实现独有的硬件通信总线
WangGaojie Lv4

背景

前段时间开发了一个嵌入式软件,其中有一个功能是使用PWM驱动一个无源的蜂鸣器,根据以往的经验来看,这是一个非常简单的功能,不过在实际操作时遇到了一个很多人往往都忽略的问题。我们硬件选择的单片机没有硬件PWM功能,只有普通的定时器,这就意味着我们只能使用定时器+IO口模拟PWM。通过定时器中断操作IO口能够模拟一个较为准确的PWM信号,把这个信号输出到音频放大电路并驱动蜂鸣器后,蜂鸣器能够发出期望的声音,但是其中存在一点噪音,起初以为是驱动电路存在问题,但是经过排查确认是单片机输出的PWM信号频率成分不干净,除了期望的频率信号外,还存在其它频率的信号。如果使用频谱仪进行分析,就能发现其中的杂散成分。导致这个问题的原因是依靠因为CPU控制IO口的时间是不准确的。单片机存在其它高优先级的中断任务,导致定时器的中断被延时触发,进而导致PWM信号生成不准确。这种误差非常的小,大多数人可能都没有注意到模拟硬件IO时序上引入的误差,但它是客观存在的。

使用软件模拟IO时序总体来说是不常见的,因为软件通常只能模拟一些简单的IIC、SPI、PWM等速度较低的信号。如果你想使用GPIO模拟几百兆时钟频率的USB信号,大多数CPU都是无法完成的。因为CPU侧重于计算而不是这种重复、繁琐的IO控制。当CPU的时钟频率越高,模拟的时序越复杂,CPU的负担也就越重,这种额外的开销对于CPU来说是非常不划算的,所以后面就诞生了FPGA之类的芯片。FPGA芯片几乎能够模拟出任何你想要的硬件时序,但是嵌入式开发中使用FPGA会带来额外的高昂成本,同时限制开发人员使用FPGA的另外一个难题是它复杂的开发方式。

一种全新的方案-PIO

2021年树莓派基金会推出了一款新的微控制器-RP2040,关于这款单片机的详细介绍可以从官网了解。它和一般的ARM微控制器大体上差不多,但是它存在一个特殊的外设-PIO,这是在以往的单片机中从未出现过的,通过PIO可以由硬件自动完成IO口的时序操作而无需CPU的干预。PIO能够独立运行一段简单的程序来控制IO从而实现特定的硬件时序。通过它控制IO是非常高效的,它甚至比CPU直接控制IO还要快。

PIO的硬件结构

RP2040单片机内有两个独立的PIO模块,每个PIO模块的结构如下:
PIO模块
每个PIO有4个状态机,状态机是执行指令的实体。4个状态机共享32字的指令存储空间。每个状态机上连接了TXRX FIFO用于与CPU交互数据。FIFO事件和状态机的事件能够触发两个不同的中断,这两个中断是两个PIO共享的。状态机还能够绑定IO列表,用于控制IO口的输入和输出。

状态机
每个状态机上有两个位移寄存器(ISR、OSR),主要用于数据输出到IO上或者从IO上读取数据。还有两个缓存寄存器(X、Y),用于保存临时数据和一些中间状态。PC寄存器记录当前的状态机执行到了那一条指令。此外每个状态机都能独立配置时钟,16位的整数分频加8位的小数分频能满足大多数时钟要求。

PIO编程指南

类似于C语言,PIO编程也是先编写PIO代码然后使用PIOASM编译器生成目标文件。生成的目标文件文件格式可以选择,为了方便集成到C-SDK中,PIOASM工具默认生成.h格式的目标文件。

PIO源文件的组成

  • 1.注释
    代码中的注释可以使用分号;或者//作为注释内容的开头。按照官方示例的传统,建议使用分号。

  • 2.伪指令
    伪指令主要是用于描述一些配置的代码,伪指令以.开头,需要记住的伪指令有这些

指令 描述
.define ( PUBLIC ) <symbol> <value> 定义一个符号,类似于C语言中的define,附加public关键字后会在目标文件中也生成同步的宏定义。
.program <name> 声明程序的名称,并表示后续的代码即为程序代码,一个文件中可以包含多个不同名称的代码。
.origin <offset> 用于指定PIO程序必须加载到指定的存储空间地址下,这对于使用了绝对地址跳转的功能是有用的。
.side_set <count> (opt) (pindirs) 声明指令中允许使用侧设置指令来控制io,count表示io的数量,opt意味这代码中不需要每条指令都进行侧设置,pindirs表示设置的是io的方向而不是io的电平状态。

其中最难理解的也许是side_set(侧设置),它是用于控制IO状态的指令,要理解它,我们就将另外三种控制IO的指令拿出来一起说明。PIO有4中方式控制IO口,输入(IN),输出(OUT),设置(SET),侧设置(SIDE_SET)。

每种控制方式都需要配置一个连续的引脚范围用于控制,各个控制方式的引脚范围是可重叠的,这意味着一个引脚可以同时用4种方式进行控制。引脚数量除了SET、SIDE_SET控制外,其它三种方式都没有引脚数量的限制,SET能最多映射5个引脚,side_set受限与指令编码的位宽,最多5个IO口能够控制。

输入用于读取IO的值到ISR寄存器中,通过位移的方式读取。输出是将OSR寄存器中的值输出到IO上,同样也是位移的方式。当位移寄存器满了或者空了之后,经过适当的配置,位移寄存器能够自动和FIFO交互数据。SET能够直接控制引脚的电平状态而不依赖位移寄存器。SIDE_SET是附加在一般指令中的指令,它与被附加的指令一起执行,通过它也能够直接设置IO的电平状态。

此外还有JMP指令能够读取引脚状态,它可以单独配置一个引脚用于条件跳转。

  • 3.标签
    指令前可以使用一个命名标签用于表示代码段的位置,类似这样的:
    1
    2
    标签:
    指令
    通过这样标签,使用JMP指令就能实现分支流程处理。

指令

所有的指令结构都为如下形式:

1
<指令> (side <侧设置电平状态>) ([<延时周期>])

每条指令都可以附加侧设置指令以在执行指令的过程中设置电平状态。延时周期则用于在指令执行完成后延时一段时间再执行下一条指令,如果指令导致状态机阻塞,延时的时间是从结束阻塞开始计算的。PIO支持的9条指令都是单周期指令,这是非常准确的,如果两个状态机设置为相同的时钟频率一起执行同一段代码,它们能够保证同步运行。

侧设置电平状态和延时周期所占的编码位宽是共享的,如果配置了.side_set 2,意味着可以同时设置两个IO,但是延时时间就只能占用3个位了,即最长只能延时7个时钟周期。此外如果加入了opt选项.side_set 2 opt,意味着opt还要单独占用一位,延时时间只能使用2个位,最长的延时时间就是3个时钟周期。

9条指令有 JMP、WAIT、IN、OUT、PUSH、PULL、MOV、IRQ、SET。
每条指令的细节说明应当去看芯片手册,这里就不需要复述了。这些指令非常的灵活,有很多让人意外的用法,例如将FIFO中的数据取出后把它当作指令执行。

中断

前面提到了PIO具有两条中断线(irq0和irq1),两个PIO模块是共享的。PIO模块的中断是系统级别的,和SPI等外设的中断在一个层次上,也是由NVIC进行管理的。PIO有12个事件能够触发中断,其中0 ~ 3是来自状态机内部的中断,4 ~ 11是4个状态机共8个FIFO的满空事件中断。

PIO模块有两条中断线到NVIC,且每个中断事件都是可以单独路由的。举个例子方便理解,PIO0的中断0和中断2事件发送到irq0,PIO0的中断1和中断3事件发送到irq1,PIO1的中断0和中断1发送到irq0,这是可以混搭的。但实际上不会这么使用,因为要在中断处理中去分析PIO0和PIO1的具体中断事件比较麻烦,需要检查每个中断状态寄存器。

除了PIO具有中断外,每个状态机也具有中断,或者说是中断标志位,PIO的4个状态机共享8个中断标志位,也就是说状态机可以使用8个中断。比如说可以在一个状态机中设置中断标志,在另一个状态机中等待中断标志,这样就实现了状态机的交互。这应该是状态机间进行直接交互的唯一方式了,因为状态机之间是不能传输数据的。8个中断标志位中的前4个可以上报到PIO并触发PIO的中断事件,后4个中断标志仅仅用于状态机内部。处理器通过PIO模块能够直接清除这8个中断标志。

如果PIO打开了内部0 ~ 3的中断使能,那么任意一个状态机设置了0 ~ 3的中断标志位,中断都会上报到PIO中。由于状态机和CPU的NVIC都能处理一些相同的中断源,所以在使用时,需要注意等待中断中的竟态条件。

1
2
3
IRQ 1         ;表示设置一个中断标志并继续运行,1表示中断标志位的序号
IRQ WAIT 1 ;表示设置一个中断标志并等待该标志被清除
IRQ CLEAR 1 ;表示清除一个中断标志

中断标志位的序号有两种表示方式,绝对值和相对值,前面的三个写法都是绝对序号,举例说明一下相对序号的写法:

1
2
; 状态机序号为 1
IRQ 5 rel ;表示设置的中断序号为6,6 = (设置序号 & 4) + (设置序号 + 状态机序号) & 3

相对序号的具体细节还是要参考芯片手册。

DMA

CPU和PIO的数据交互主要是FIFO,但该FIFO的容量有限,且需要通过轮询或者中断来触发数据传输,这对CPU来说是额外的负担,PIO提供了数据请求机制,通过该机制并结合DMA子模块,数据能够无需CPU干预从而在RAM和PIO状态机之间传输大量数据,数据传输速度为每个时钟周期1字节。

PIO编程中的一些细节

PIO状态机总是要一直运行的,可以通过JMP指令进行无条件跳转达到循环的目的,但是PIO提供了一种边界机制,当程序运行到边界后能够自动恢复到起始位置,这个边界默认是程序的起始和结束位置,当然也能够通过.wrap_target指定循环的起始位置,并用.wrap指定循环的结束位置。

每个状态机都有输入和输出FIFO,用于数据的交互,但是某些应用仅仅输出数据或者读取数据,这样我们可以将它们的FIFO进行合并以扩大FIFO的使用空间。

再说明一些数据方向的问题,OUT指令是将OSR寄存器的数据输出到PIN,当配置了autopull,OSR位移完成后,TXFIFO中的数据会转移到OSR中。PULL指令是主动将TXFIFO中的数据转移到OSR寄存器中。PULL指令默认是阻塞的,意味着TXFIFO中没有数据时状态机会阻塞运行。

PIO代码经过PIOASM编译后生成的是一个.h文件,其中有些细节需要知道。如果我们声明的程序名为xx,即.program xx,那么.h文件中会生成一个类型为pio_program的变量,变量名为xx_program。这是用来加载程序的。此外在.program内定义的public符号在生成的.h文件中也会附件xx_的前缀。PIO中的相关配置会转化为一个配置获取函数xx_program_get_default_config()。PIO代码源文件代码是不区分大小写的。

侧设置的执行和指令执行是同时发生的,在同一个时钟周期内完成。但是需要注意的是,许多指令能够导致状态机阻塞而暂停指令的执行,例如WAIT、PUSH、PULL等,当状态机阻塞时,侧设置是不会被影响的,它总能在第一个时钟周期内完成。

AUTOPULL是怎么实现的呢?假如将OSR配置为8位,这就意味着当OSR移动了8位后,就需要执行PULL操作,状态机内部记录了OSR位移的量,所以才能够自动触发PULL。

JMP指令跳转的目标地址在编译后都是相对0的绝对地址,当使用pio_add_program载入程序时,如果载入的偏移地址不是0,那么JMP指令中的目标地址会自动进行调整以保证程序正确运行。这是唯一一个会被重定位的指令。

C-SDK编程

为了用好PIO,还需要大量C-SDK中的接口来配合,实现配置、数据交互、程序加载等。下面说明一些常用的C函数的用法和功能。

  • 1.PIO程序加载类

uint pio_add_program(PIO pio, const pio_program_t *program);
加载一个程序,并将程序加载到的位置返回。

void pio_remove_program(PIO pio, const pio_program_t *program, uint loaded_offset);
从指令存储空间中卸载程序,需要提供加载地址。

void pio_clear_instruction_memory(PIO pio);
清空PIO的全部指令存储空间。

  • 2.状态机类

int pio_claim_unused_sm(PIO pio, bool required);
请求一个空闲的状态机。

void pio_sm_init(PIO pio, uint sm, uint initial_pc, const pio_sm_config *config);
初始化一个状态机,initial_pc就是程序的加载地址,config可以使用.h文件中生成的一个方法得到。

void pio_sm_set_enabled(PIO pio, uint sm, bool enabled);
启动或者关闭状态机。

void pio_set_sm_mask_enabled(PIO pio, uint32_t mask, bool enabled);
使用掩码同时启动或者关闭多个状态机。

void pio_sm_restart(PIO pio, uint sm);
状态机复位。

void pio_restart_sm_mask(PIO pio, uint32_t mask);
复位多个状态机。

void pio_enable_sm_mask_in_sync(PIO pio, uint32_t mask);
将多个状态机的时钟分频同步。

uint8_t pio_sm_get_pc(PIO pio, uint sm);
获取当前状态机执行的位置。

  • 3.状态机配置类

void sm_config_set_out_pins(pio_sm_config *c, uint out_base, uint out_count);
配置OUT引脚的范围,起始引脚和引脚数量。

void sm_config_set_set_pins(pio_sm_config *c, uint set_base, uint set_count);
配置SET引脚的范围,起始引脚和引脚数量。

void sm_config_set_in_pins(pio_sm_config *c, uint in_base);
配置IN引脚的起始引脚。

void sm_config_set_sideset_pins(pio_sm_config *c, uint sideset_base);
配置SIDE引脚的起始引脚。

void sm_config_set_jmp_pin(pio_sm_config *c, uint pin);
配置JMP引脚,JMP条件指令能够选择一个引脚的值作为条件,当引脚为高电平时,条件成立。

void sm_config_set_clkdiv(pio_sm_config *c, float div);
配置状态机的时钟分频。

void sm_config_set_wrap(pio_sm_config *c, uint wrap_target, uint wrap);
配置状态机的程序边界。

void sm_config_set_in_shift(pio_sm_config *c, bool shift_right, bool autopush, uint push_threshold);
配置IN指令,shift_right为真时OSR为右移模式,autopush为真时,意味着OSR达到阈值后自动执行PUSH,从TXFIFO中取出数据到OSR中。push_threshold表示OSR的位宽。

void sm_config_set_out_shift(pio_sm_config *c, bool shift_right, bool autopull, uint pull_threshold);
配置OUT指令。

void sm_config_set_fifo_join(pio_sm_config *c, enum pio_fifo_join join);
配置FIFO的用法,有三种,0均匀分配,1将FIFO全部用于TXFIFO,2将FIFO全部用于RXFIFO。

  • 4.状态机IO配置

void pio_gpio_init(PIO pio, uint pin);
将IO口绑定到指定的PIO上。不允许多个PIO控制同一个引脚。

void pio_sm_set_pins(PIO pio, uint sm, uint32_t pin_values);
使用特定的状态机将PIO所属的IO全部设为指定值。

void pio_sm_set_pins_with_mask(PIO pio, uint sm, uint32_t pin_values, uint32_t pin_mask);;
使用掩码初始化IO。

void pio_sm_set_pindirs_with_mask(PIO pio, uint sm, uint32_t pin_dirs, uint32_t pin_mask);
设置IO的方向。

void pio_sm_set_consecutive_pindirs(PIO pio, uint sm, uint pin_base, uint pin_count, bool is_out);
设置一组连续IO的方向。

  • 5.数据交互类

void pio_sm_clear_fifos(PIO pio, uint sm);
清空状态机的FIFO。SDK14.0存在BUG。

void pio_sm_drain_tx_fifo(PIO pio, uint sm);
丢掉TXFIFO中的所有数据。

void pio_sm_put(PIO pio, uint sm, uint32_t data);
写入一个数据到TXFIFO中。如果FIFO满了则丢掉数据。

void pio_sm_put_blocking(PIO pio, uint sm, uint32_t data);
向TXFIFO中写入数据,如果TXFIFO已经满了则等待,直到能够写入数据。

uint32_t pio_sm_get(PIO pio, uint sm);
从RXFIFO中读取一个数据并返回。如果RXFIFO是空的,那么返回的值没有意义。

uint32_t pio_sm_get_blocking(PIO pio, uint sm);
从RXFIFO中读取数据,如果RXFIFO为空则等待,直到RXFIFO中存在新的数据。

bool pio_sm_is_rx_fifo_full(PIO pio, uint sm);
断言接收FIFO已满。

bool pio_sm_is_rx_fifo_empty(PIO pio, uint sm);
断言接收FIFO已空。

uint pio_sm_get_rx_fifo_level(PIO pio, uint sm);
读取RXFIFO中的数据个数,都是按字计算的。

bool pio_sm_is_tx_fifo_full(PIO pio, uint sm);
断言TXIFO已满。

bool pio_sm_is_tx_fifo_empty(PIO pio, uint sm);
断言TXFIFO为空。

uint pio_sm_get_tx_fifo_level(PIO pio, uint sm);
读取TXFIFO中的数据个数。

  • 6.中断管理类

void pio_set_irq0_source_enabled(PIO pio, enum pio_interrupt_source source, bool enabled);
设置IRQ0的中断事件源。

void pio_set_irq1_source_enabled(PIO pio, enum pio_interrupt_source source, bool enabled);
设置IRQ1的中断事件源。

void pio_interrupt_clear(PIO pio, uint pio_interrupt_num);
清除状态机内部的中断标志。

除了C函数外,还需要Cmake来辅助实现编译PIO源文件。

总结

这只是我学习PIO的一个过程记录,到此已经能够基本读懂官方例程中的PIO例子。当然后面还需要自己大量的实践才能熟练掌握这个强大的PIO模块。


示例代码解读

一个简单的串口数据发送协议:

1
2
3
4
5
6
7
8
9
10
.program uart_tx           ; 程序名称为uart_tx
.side_set 1 opt ; 代码中可以使用侧设置指令,控制的IO数量为1,侧设置指令是可选的。

pull side 1 [7] ; 从TXFIFO缓冲区中读取一个数据到OSR寄存器中,如果没有数据就等待。并将side引脚输出高电平。最少执行8个时钟周期。
set x, 7 side 0 [7] ; 将7赋值到X寄存器。并将side引脚设为低电平。固定执行8个时钟周期。
bitloop: ; 循环入口
out pins, 1 ; 从OSR寄存器中右移一位到输出OUT引脚上。单个周期。
jmp x-- bitloop [6] ; 如果X非0则跳转到bitloop。X=X-1。固定执行7个时钟周期。
; 到达程序边界后会自动跳回程序起始处。这是0周期执行的。

与之配套的C相关函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
static inline void uart_tx_program_init(PIO pio, uint sm, uint offset, uint pin_tx, uint baud) {
/* 设置引脚的初始电平状态和引脚方向 */
pio_sm_set_pins_with_mask(pio, sm, 1u << pin_tx, 1u << pin_tx);
pio_sm_set_pindirs_with_mask(pio, sm, 1u << pin_tx, 1u << pin_tx);
/* 初始化引脚 */
pio_gpio_init(pio, pin_tx);

/* 根据Pio源码的配置生成一个默认配置 */
pio_sm_config c = uart_tx_program_get_default_config(offset);

/* OSR寄存器采用右移输出,不会自动执行PUSH,数据位宽为32位 */
sm_config_set_out_shift(&c, true, false, 32);

/* 绑定tx_pin到out输出引脚上 */
sm_config_set_out_pins(&c, pin_tx, 1);
/* 绑定tx_pin到侧设置输出引脚上 */
sm_config_set_sideset_pins(&c, pin_tx);

/* 将状态机的8个字FIFO全部分配给TXFIFO */
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);

/* 根据波特率计算时钟分频并设置状态机的分频系数,这里每输出1bit是8个周期 */
float div = (float)clock_get_hz(clk_sys) / (8 * baud);
sm_config_set_clkdiv(&c, div);

/* 初始化状态机 */
pio_sm_init(pio, sm, offset, &c);

/* 启动状态机 */
pio_sm_set_enabled(pio, sm, true);
}

static inline void uart_tx_program_putc(PIO pio, uint sm, char c) {
/* 阻塞的方式向状态机的TXFIFO中写入数据 */
pio_sm_put_blocking(pio, sm, (uint32_t)c);
}

static inline void uart_tx_program_puts(PIO pio, uint sm, const char *s) {
while (*s)
uart_tx_program_putc(pio, sm, *s++);
}

下面是一个串口数据接收协议,并包含帧错误管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.program uart_rx

start:
wait 0 pin 0 ; 等待第0个输入引脚变为低电平
set x, 7 [10] ; 设置一个临时变量X=7
bitloop: ;
in pins, 1 ; 读取输入引脚的值,并通过右移保存到ISR寄存器
jmp x-- bitloop [6] ; 如果X非0则跳转到bitloop,X=X-1
jmp pin good_stop ; 确认输入引脚为高电平,这里输入引脚也配置为了JMP引脚
; 如果为低电平则检查到帧错误
irq 4 rel ; 设置一个IRQ标志表示发生了帧错误,rel将表示每个状态机设置的标志不同
wait 1 pin 0 ; 等待输入引脚恢复到高电平
jmp start ; 返回到开始处重新接收数据

good_stop: ;
push ; 接收到了正确的数据,将该数据推入到RXFIFO中