PIO 底层原理硬核详解:从 side-set 到指令执行全指南

PIO 功能和特性详解

1.Side-set 侧集特性

Side-set 侧集特性用于在执行主指令的同时,同步修改指定 GPIO 的电平(默认)或方向,实现主操作与 GPIO 控制的并行执行(如 SPI 中 “数据输出” 与 “时钟翻转” 同步、I2C 中 “数据传输” 与 “SCL 时钟” 同步)。

ScreenShot_2026-06-08_172453_083.png

Side-set 的行为由硬件底层机制决定,是其与 SET/OUT 指令的核心区别,具体如下:

  • ​编码位置:​复用指令的 Delay/side-set 字段
    • PIO 的每条指令都包含 16 位编码,其中 12~8 位(5 位) 为 Delay/side-set 复用字段:
    • 若配置为延时(Delay):该字段表示指令执行后的延时周期数(0~31);
    • 若配置为侧集(Side-set):该字段表示要写入 GPIO 的侧集数据(0~31,对应最多 5 位 GPIO);
    • 字段分配由 PINCTRL_SIDESET_COUNT 控制(如设置为 1,则占用 1 位,剩余 4 位可用于延时;设置为 5,则全部 5 位用于侧集,无法使用延时)。
  • ​管脚映射:​独立于 SET/OUT,支持重叠
    • Side-set 有专属的 GPIO 映射配置(PINCTRL_SIDESET_BASE),指定侧集控制的起始 GPIO 编号,与 SET 的 PINCTRL_SET_BASE、OUT 的 PINCTRL_OUT_BASE 相互独立;
    • 侧集的 GPIO 映射可与 SET/OUT 的映射重叠(如同一 GPIO 既被 OUT 用于数据输出,又被 Side-set 用于时钟控制),此时 Side-set 的优先级高于 SET/OUT(若同时写入,以 Side-set 的数据为准)。
  • ​生效时机:​立即生效,不受指令等待状态影响
    • 无论主指令是否进入等待状态(如 PULL 指令等待 TX FIFO 数据、PUSH 指令等待 RX FIFO 空位),Side-set 的数据都会在指令执行的第一个时钟周期立即生效;
    • 这保证了 GPIO 控制的时序不会因主指令的等待而延迟,是高速通信中时序稳定性的关键。
      兼容性​: 支持所有 PIO 指令,任何 PIO 指令(OUT、IN、JMP、MOV、SET、PULL、PUSH 等)都可与 Side-set 组合使用,侧集数据通过指令后的 side <value> 标识指定。

使用 Side-set 需通过 PIO 的配置寄存器设置 4 个关键选项,决定侧集的位数、生效规则、GPIO 映射和控制类型,具体如下:

ScreenShot_2026-06-08_172545_322.png

以下是 spi_tx_fast 程序的逐行解析,结合配置说明 Side-set 的实际使用:

.program spi_tx_fast ; 定义了一个名为 spi_tx_fast 的程序
.side_set 1 ; 设置侧集数据位数为1
  
loop: 
    out pins, 1 side 0 ;将1个数据位传输到GPIO引脚1上,同时使用Side-set操作设置将其设置为低电平
    jmp loop side 1 ; 这行指令跳转回 loop 标签,同时 Side-set 操作设置将其设置为高电平

结合程序的 .side_set 1,该示例的 4 个配置项设置如下:

ScreenShot_2026-06-08_172803_776.png

spi_tx_fast 示例中:

  • 执行时序​:每条指令占用 1 个系统时钟周期,out pins,1 side 0 执行时,数据输出与 CLK 拉低同步完成;jmp loop side 1 执行时,跳转与 CLK 拉高同步完成。整个循环仅 2 个时钟周期,即可输出 1 位 SPI 数据,实现高频数据传输。
  • 优势体现​:无需额外的 SET 指令控制 CLK 引脚,代码精简;数据与时钟同步执行,时序无偏差,SPI 最大频率显著提升。

spi_tx_fast 示例展示了 side-set 的两个优势:数据和时钟可以更精确地对齐,并且程序整体上可以更快,本例中每两个系统时钟周期就能输出一个数据位。程序也可以更小。

在 I2C 通信中,Side-set、OUT、SET 的 GPIO 映射分工如下,体现侧集的灵活性:

  • ​Side-set:​I2C 的 SCL 时钟引脚(负责时钟翻转,同步数据传输);
  • ​OUT:​I2C 的 SDA 数据引脚(负责移出数据位);
  • ​SET​:I2C 的 SDA 数据引脚(负责产生 Start/Stop 等特殊序列的脉冲);通过这种映射分工,状态机可在 SCL 提供时钟的同时,灵活控制 SDA 的数据输出或特殊脉冲,且 SDA/SCL 可映射到任意 GPIO,无需受硬件引脚限制。

在使用时需要注意:

  • Delay 与 Side-set 的资源竞争​:Delay/side-set 字段是复用的,侧集占用的位数越多,可用于延时的位数越少(如侧集占用 5 位,则无法使用延时;占用 1 位,延时最多为 15(4 位))。
  • 优先级规则​:若 Side-set 与 SET/OUT 同时写入同一 GPIO,Side-set 的数据会覆盖 SET/OUT 的数据,需根据业务场景合理规划 GPIO 映射。
  • 有效位的使用场景​:EXECCTRL_SIDE_EN 设为真时,仅部分指令的 Side-set 生效,适用于需要选择性控制 GPIO 的场景(如仅在数据传输时翻转时钟,空闲时不操作)。
  • 方向控制的使用​:EXECCTRL_SIDE_PINDIR 设为真时,Side-set 修改的是 GPIO 方向,适用于需要动态切换 GPIO 输入 / 输出的场景(如 I2C 的 SDA 引脚双向通信)。

2.程序折返

程序折返(Wrap)是 PIO 状态机的硬件级循环机制,其本质是通过配置专用寄存器(EXECCTRL_WRAP_BOTTOMEXECCTRL_WRAP_TOP),让程序计数器(PC)在执行到指定地址(WRAP_TOP)时,自动跳转到预设的起始地址(WRAP_BOTTOM),而非执行软件层面的 JMP 跳转指令。该特性专门解决传统 JMP 循环带来的指令内存占用和执行周期损耗问题,是提升 PIO 程序执行效率、节省指令内存的核心硬件优化手段。

PIO 程序若通过 JMP 指令实现外层循环(如方波生成、数据传输的重复步骤),会存在以下两个显著缺陷,也是程序折返特性要解决的核心痛点:

ScreenShot_2026-06-08_172821_416.png

以输出方波信号为例,代码如下:

.program squarewave 
    set pindirs, 1 ; 步骤1:设置引脚为输出模式(1个周期)
again: 
    set pins, 1 [1] ; 步骤2:引脚置高,延时1周期(共2个周期:指令1个+延时1个)
    set pins, 0 ; 步骤3:引脚置低(1个周期)
    jmp again ; 步骤4:跳转回again(1个周期)

这段代码实现了一个简单的方波信号生成器。它通过在高电平和低电平之间切换,并加入一个延迟,来生成方波信号。jmp 跳转指令本身需要一个周期,每个 set 指令也需要一个周期, 所以为了保证高低电平信号保持的时间相同,set pins, 1 指令增加了一个周期的延时,使状态机在执行 set pins, 0 指令之前等待一个周期。每次循环总共需要 4 个周期。

这里,有两个问题:

  • JMP 占用了本可用于其他程序的指令内存
  • 执行 JMP 所需的额外周期导致最大输出速度降低了一半

由于程序计数器(PC)超过 31 时会自动折返为 0,因此将整个指令内存填充为 set pins, 1set pins, 0,就能解决第二个问题,但这样很浪费内存。

PIO 汇编器(pioasm)提供了两个专用的汇编指示符,用于标记折返的起始和结束位置,最终会映射到硬件寄存器(EXECCTRL 控制寄存器),就能解决这两个问题:

ScreenShot_2026-06-08_172840_145.png

下面是升级版本:

.program squarewave_wrap ; 折返版方波程序
    set pindirs, 1 ; 初始化:设置引脚为输出模式(1个周期)
.wrap_target ; 标记折返起始地址(WRAP_BOTTOM = 1)
    set pins, 1 [1] ; 引脚置高,延时1周期(共2个周期)
    set pins, 0 [1] ; 引脚置低,延时1周期(共2个周期)
.wrap ; 标记折返结束地址(WRAP_TOP = 2)

以上代码可以去掉延时周期,这样输出的速度就是以前的两倍,同时还能维持高电平和低电平的时间相等:

  1. 执行 set pins,1 [1](PC=1),完成后 PC 加 1(PC=2);
  2. 执行 set pins,0 [1](PC=2),完成后 PC 等于 WRAP_TOP(2),自动跳转到 WRAP_BOTTOM(1),而非 PC 加 1;

在执行完程序内存中的一条指令后,状态机会使用以下逻辑更新 程序计数器 PC

  1. 如果当前指令为 JMP,且 Condition 为真,则设置 PCTarget
  2. 否则,如果 PC 等于 EXECCTRL_WRAP_TOP,则设置 PCEXECCTRL_WRAP_BOTTOM
  3. 否则,PC 加一,但如果当前值为 31,则设置为 0

pioasm 中的 .wrap_target.wrap 汇编指示实际上是标签。它们会导出常量,以写入到 WRAP_BOTTOMWRAP_TOP 中。

以上示例代码经过 PIO 汇编器 pioasm 编译后会产生如下文件:

// -------------------------------------------------- // ​
// This file is autogenerated by pioasm; ​do​ not edit! // ​
// -------------------------------------------------- // ​
  
#pragma once ​

# 如果没有定义 PICO_NO_HARDWARE,则会包含 hardware/pio.h 头文件,它定义了 PIO 相关的数据结构和函数
#if !PICO_NO_HARDWARE ​
#include "hardware/pio.h" ​
#endif ​
  
// --------------- // ​
// squarewave_wrap // ​
// --------------- // ​

# 两个宏定义了程序中 .wrap_target 和 .wrap 指令的位置
#define squarewave_wrap_wrap_target 1 ​
#define squarewave_wrap_wrap 2 ​

# 数组包含了 squarewave_wrap 程序的指令序列
static const uint16_t squarewave_wrap_program_instructions[] = { 
    0xe081, // 0: ​set​ pindirs, 1  ​
    // .wrap_target ​
    0xe101, // 1: ​set​ pins, 1 [1] ​
    0xe100, // 2: ​set​ pins, 0 [1]  ​
    // .wrap ​
 }; 

# 如果没有定义 PICO_NO_HARDWARE
#if !PICO_NO_HARDWARE ​
# 定义一个 squarewave_wrap_program 结构体,其中包含了程序的指令序列和长度等信息
static const struct pio_program squarewave_wrap_program = { 
    .instructions = squarewave_wrap_program_instructions, 
    .length = 3, 
    .origin = -1, 
}; 

# 返回一个默认的状态机配置,其中设置了 .wrap_target 和 .wrap 的位置
static inline pio_sm_config squarewave_wrap_program_get_default_config(uint offset) { 
    pio_sm_config c = pio_get_default_sm_config(); 
    sm_config_set_wrap(&c, offset + squarewave_wrap_wrap_target, offset + 
    squarewave_wrap_wrap); 
    return c; 
 } 
#endif

我们可以看到,PIO 汇编器(pioasm)会将 .wrap_target.wrap 转换为常量,并生成对应的配置函数,具体包括:

  • 定义 xxx_wrap_targetxxx_wrap 宏,存储折返的相对地址;
  • 生成 pio_program 结构体,包含指令序列、长度等信息;
  • 生成 xxx_program_get_default_config 函数,自动将折返地址写入 pio_sm_config(调用 sm_config_set_wrap 函数)。

使用程序折返特性时,需要注意:

  • 地址的绝对性​:WRAP_BOTTOMWRAP_TOP 是 PIO 指令内存的绝对地址,而非相对地址;

  • 偏移量调整​:若程序加载到指令内存的非 0 偏移位置(如 offset=5),需将折返的相对地址加上偏移量,得到绝对地址后再写入寄存器;

  • 直接寄存器配置​:若不使用默认配置函数,可直接通过 pio_sm_set_wrap 函数或修改 EXECCTRL 寄存器的 WRAP_BOTTOM/WRAP_TOP 字段配置折返地址。

    同时还需要注意:

  • 折返地址的范围限制​:WRAP_BOTTOM 必须小于或等于 WRAP_TOP,且两者都不能超过 PIO 指令内存的最大地址(31),否则会导致折返逻辑失效。

  • 与 ​JMP​ ​指令​​的兼容性​:程序折返机制不会影响 JMP 指令的执行(JMP 优先级更高),因此可在折返循环内部使用 JMP 实现条件分支,兼顾循环效率和逻辑灵活性。

  • 天然折返的优先级​:若未配置程序折返(WRAP_BOTTOM=0 且 WRAP_TOP=31),PC 在达到 31 时会自动重置为 0,这是 PIO 指令内存的天然折返特性,可视为程序折返的默认情况。

  • 延时与侧集的兼容性​:折返逻辑仅影响 PC 的更新,不会影响指令的延时([n])或侧集(side n)特性,延时和侧集仍会正常生效。

3.FIFO 合并

每个 PIO 状态机的默认 FIFO 是 “双向双缓冲区”:

ScreenShot_2026-06-08_172856_401.png

但是,很多程序并不需要系统和状态机之间的双向数据传输, 而更长的 FIFO 却很有用,特别是在 DPI 图像传输 这种高带宽的接口中。在这些情况下,可以通过 SHIFTCTRL_FJOIN 选项将两个 4 字长的 FIFO 合并为一个 8 字长的 FIFO。

b90a0051-7a33-41c1-9d8b-e2b4cd758913.png

FIFO 合并的本质是将 2 个 4 字的 FIFO 资源整合为 1 个 8 字的单向 FIFO,核心价值有 2 点:

  • ​提升 FIFO 深度:​从 4 字提升到 8 字,可暂存更多数据,减少系统中断频率(比如 UART 接收时,FIFO 越深,越不用频繁触发中断取数据);
  • ​避免资源浪费:​单向传输场景下,空闲的 FIFO 资源可被充分利用(比如仅用 TX 的状态机,可将 RX FIFO 也转为 TX FIFO)。

FIFO 合并的硬件结构包含数据解码器和多路复用器(对应你提供的 “合并两个 FIFO 为一个单向 FIFO” 图),工作流程如下:

  1. ​数据写入:​系统通过 “TX Write” 或 “RX Write” 向 FIFO 写数据时,数据解码器会将数据分配到对应的原始 FIFO(原 TX 或原 RX FIFO);
  2. ​数据读取:​合并后的 FIFO 通过多路复用器,将原 TX 和原 RX FIFO 的数据 “合并” 为一个数据流,供状态机通过 PULL 读取(合并为 TX FIFO 时),或供系统读取(合并为 RX FIFO 时);
  3. ​单向复用:​合并后只能选择 “全 TX” 或 “全 RX” 模式,此时另一个方向的 FIFO 资源会被复用为当前方向的缓冲区,最终形成 8 字的单向 FIFO。

合并后的 FIFO 提供了 1:4 数据解码器和 4:1 多路复用器,使得我们可以在 FIFO 的 TX 和 RX 通道之间进行写数据和读数据操作,这样所有 32 位 x 8 字 的数据都可以从两个端口进行访问。

UART 是典型的 “单向分状态机实现” 场景(TX 和 RX 分别用独立的状态机):

  • ​默认情况:​TX 状态机用自己的 4 字 TX FIFO,RX 状态机用自己的 4 字 RX FIFO,双方的另一个 FIFO 都空闲;
  • 合并后:
    • TX 状态机可将自己的 TX+RX FIFO 合并为 8 字的 TX FIFO,能暂存更多待发送数据,减少系统写 FIFO 的中断次数;
    • RX 状态机可将自己的 RX+TX FIFO 合并为 8 字的 RX FIFO,能暂存更多接收数据,系统无需频繁中断取数据;最终,8 字 FIFO 的 UART 处理中断的频率仅为 4 字 FIFO 的一半,提升了系统效率。

合并 FIFO 后,状态机的 FIFO 会变为 “单向 8 字 + 0 字” 的结构,对应的状态和指令行为会发生变化:

  • ​FIFO 状态:​FIFO 状态由 FSTAT寄存器(你提供的第二张图)标识,合并后 “未被使用的 FIFO” 会同时处于满(FULL)和空(EMPTY)状态:
    • 若合并为 TX ​FIFO(8 字):
      • TX FIFO 正常工作,FSTAT.TXEMPTY/TXFULL 显示实际状态;
      • RX FIFO 被禁用,FSTAT.RXEMPTYRXFULL 会同时置 1(表示 RX FIFO 既满又空,无法使用);
    • 若合并为 RX FIFO(8 字):
      • RX FIFO 正常工作,FSTAT.RXEMPTY/RXFULL 显示实际状态;
      • TX FIFO 被禁用,FSTAT.TXEMPTYTXFULL 会同时置 1。
  • 状态机指令​的影响:​合并后,被禁用的 FIFO 对应的指令会陷入永久等待状态:
    • ​合并为 TX FIFO 时:​状态机执行 PUSH 指令(往 RX FIFO 写数据)会一直等待(因为 RX FIFO 深度为 0,无法写入);
    • ​合并为 RX FIFO 时:​状态机执行 PULL 指令(从 TX FIFO 读数据)会一直等待(因为 TX FIFO 深度为 0,无数据可读)。

需要注意的是,当合并 FIFO 或将合并的 FIFO 独立后,会抛弃当前状态机的 FIFO 中的一切数据。如果数据不能恢复,那么必须事先清空 FIFO 队列。

203be88a-5eb6-4ce5-88a9-6468fc2fbc4b.png

8 字深度的 FIFO 足够配合 RP2040 的 DMA 实现 “每个时钟周期传输 1 字节” 的高速率(只要 DMA 未被其他任务阻塞)。

4.自动推出和自动加载

先回顾两个核心寄存器的 “移位与数据补充” 逻辑:

  • OSR(输出移位寄存器):状态机通过 OUT 指令移出数据,移出后 OSR 会 “变空”,需要从 TX FIFO(系统 → 状态机的缓冲区)补充新数据(手动用 PULL 指令);
  • ISR(输入移位寄存器):状态机通过 IN 指令移入数据,移入后 ISR 会 “变满”,需要把数据推到 RX FIFO(状态机 → 系统的缓冲区)(手动用 PUSH 指令)。

自动推出 / 加载的核心是:让硬件自动完成 “补充 OSR 数据” 和 “推送 ISR 数据” 的操作,无需手动写 PULL/PUSH 指令,既节省指令内存,又提升数据传输效率。

先看手动加载 OSR 数据的程序示例,分析其缺陷:

.program manual_pull 
.side_set 1 opt ; 设置 Side-set 侧集操作位数为1,opt 以指定 side <value> 对于指令是可选的
  
.wrap_target ; 标记了 .wrap 指令的目标位置
    set x, 2 ; 将寄存器 x 初始化为 2,这个寄存器会用于控制循环次数
    pull side 1 [1] ; 执行手动拉取操作,如果没有 TX 数据,则会在这里等待
bitloop: ; 这里定义了一个标签,用于循环输出数据位
    out pins, 1 side 0 [1] ; 将 1 位数据移位输出到 GPIO 引脚,同时将 Side-set 设置为 0,用于控制时钟
    jmp x-- bitloop side 1 [1] ; 递减寄存器 x,如果不为 0,则跳转回 bitloop 标签
    out pins, 1 side 0 ; 输出最后一位数据,Side-set 设置为 0
.wrap ; 无条件跳转回 .wrap_target 标记的位置

该程序按照每 4 个时钟周期传输 1 数据位的速率,从每个 FIFO 字中移出 4 数据位,同时输出时钟信号。当 TX FIFO 为空时,程序在时钟高电平处暂停 (注意在指令暂停的周期上 side-set 依然会生效)。

c5f4dd5b-3a59-4a30-837c-cfd392801d97.png

该程序有一些限制:

  • 占用指令​​槽多​:5 个指令槽中仅 2 个用于实际数据输出,其余用于计数器初始化、手动 PULL、跳转;
  • 吞吐量低​:每 4 个系统时钟周期才移出 1 位数据,速度受手动指令的周期损耗限制;
  • 灵活性差​:移出位数固定(依赖 X 计数器),无法动态适配不同数据长度。

此时,我们可以启用 autopush 数据自动推出和 autopull 数据自动加载:

  • ​自动加载的核心是:​硬件跟踪 OSR 的 “移位计数器”,当移出位数达到配置阈值时,自动从 TX FIFO 加载数据到 OSR,无需手动 PULL
  • ​自动推出的逻辑与自动加载对称:​硬件跟踪 ISR 的 “移位计数器”,当移入位数达到配置阈值时,自动将 ISR 数据推到 RX FIFO,无需手动 PUSH

启用 autopush 数据自动推出,在达到设定的移位计数器的计数阈值时,状态机会自动执行 IN 指令从 ISR 寄存器中加载数据推出到 RX FIFO 中,同时清除 ISR 寄存器中数据:

  1. 状态机执行 OUT 指令,OSR 移位计数器累加移出的位数;
  2. 当计数器达到 SHIFTCTRL_PULL_THRESH(配置的阈值),硬件自动触发 PULL
  3. 从 TX FIFO(发送 FIFO 存储器)读取 1 个 32 位数据,加载到 OSR 输出移位寄存器;
  4. OSR 移位计数器被清零,等待下一轮移出。

f8f511ca-da02-4a10-a426-b217ab90b6e5.png

启用自动推出后,代码如下:

.program autopull
.side_set 1 ; 侧集位数1
  
.wrap_target
    out pins, 1 side 0 [1] ; 移出1位数据,侧集时钟置0
    nop side 1 [1] ; 空操作,侧集时钟置1(替代手动跳转)
.wrap

这个程序的优势:

  • 指令更精简​:仅 2 条指令,无计数器 / 跳转逻辑;
  • 速度提升 1 倍​:去掉手动 PULL / 跳转的周期损耗,若去掉延时,吞吐量可达 “每 2 个系统时钟移出 1 位”;
  • 灵活性高​:移出位数由硬件阈值(而非代码)控制,可动态配置为 16/32 位。

启用 autopull 数据自动加载,在达到设定的移位计数器的计数阈值时,状态机会自动执行 OUT 指令从 FIFO 中加载数据,通过启动数据自动加载,相对于手动执行 OUT 指令来说,可以节省加载 OUT 指令的时间同时实现更高的数据吞吐量:

  1. 状态机执行 IN 指令,ISR 移位计数器累加移入的位数;
  2. 当计数器达到 SHIFTCTRL_PUSH_THRESH(配置的阈值),硬件自动触发 PUSH
  3. ISR 输入移位寄存器的 32 位数据被推到 RX FIFO(接收 FIFO 存储器);
  4. ISR 移位计数器被清零,等待下一轮移入。

77384f56-f8da-444a-a47b-67f7035e86da.png

利用自动加载(autopull)功能,manual_pull 示例可以重写如下:

.program autopull
.side_set 1

.wrap_target
    out pins, 1    side 0      [1]
    nop            side 1      [1]
.wrap

这个程序比原版本更短、更简单而且如果去掉延时的话,运行速度是原版本的两倍,因为通过硬件自动加载 OSR 是无需消耗执行指令时消耗的时钟周期的。注意程序并不知道下一次加载之前需要移位多少数据位;硬件会在达到配置好的阈值(SHIFTCTRL_PULL_THRESH)后自动加载, 所以示例程序也可以从每个 FIFO 字中移出 16 数据位或 32 数据位。

最后,注意上述程序与原本并非完全相同:因为它会在时钟信号(注意,不是系统时钟,而且通信时钟)低时暂停,而不是高的时候,这种行为主要是由于 ​PIO​ 状态机的设计特点造成,PIO 状态机是基于时钟脉冲边沿来执行指令,在 out pins, 1 side 0 [1] 指令中,数据输出发生在时钟信号的下降沿,而 nop side 1 [1] 指令则是在时钟信号的上升沿执行。因此,当 TX FIFO 为空时,状态机会在时钟信号的低电平阶段暂停,等待新的数据进入 TX FIFO 后再继续执行。

我们可以通过 PULL IFEMPTY 指令改变暂停位置,该指令使用与自动加载(autopull)同样的可配置阈值,使用该指令后,它可以像自动加载一样简化一些程序来控制暂停的发生位置,当 OSR 和 TX FIFO 均为空时,状态机会在执行 ​OUT​ 时进入暂停状态。

.program somewhat_manual_pull
.side_set 1

.wrap_target
    out pins, 1    side 0      [1]
    pull ifempty   side 1      [1]
.wrap

下面是完整的示例,包括 PIO 程序以及一个用于加载它并运行的 C 程序,演示了如何在同一个状态机上同时启用自动加载(autopull)和自动推出(autopush)。 状态机 0 的功能是将数据从 TX FIFO 传输至 RX FIFO,吞吐量为每两个时钟周期一个字节。

PIO 程序如下:

.program auto_push_pull ; 定义了一个名为 auto_push_pull 的程序

.wrap_target
    out x, 32 ; 将 32 位数据从寄存器 x 自动推送(auto-push)到 TX FIFO
    in x, 32 ; 从 RX FIFO 自动拉取(auto-pull) 32 位数据到寄存器 x
.wrap

用于加载并运行 PIO 程序的的 C 程序

#include "tb.h" // TODO this is built against existing sw tree, so that we get printf etc

#include "platform.h"
#include "pio_regs.h"
#include "system.h"
#include "hardware.h"

#include "auto_push_pull.pio.h"

int main()
{
    // 用于初始化测试环境
    tb_init();

    // 加载并配置 PIO 状态机 0 来运行 auto_push_pull 程序
    // 将 auto_push_pull_program 数组中的指令加载到 PIO 指令存储器中
    for (int i = 0; i < count_of(auto_push_pull_program); ++i)
        mm_pio->instr_mem[i] = auto_push_pull_program[i];
    
    // 配置状态机 0 的 SHIFTCTRL 寄存器
    // 启用自动推送和自动拉取功能,设置推送和拉取阈值为 32
    mm_pio->sm[0].shiftctrl =
            (1u << PIO_SM0_SHIFTCTRL_AUTOPUSH_LSB) |
            (1u << PIO_SM0_SHIFTCTRL_AUTOPULL_LSB) |
            (0u << PIO_SM0_SHIFTCTRL_PUSH_THRESH_LSB) |
            (0u << PIO_SM0_SHIFTCTRL_PULL_THRESH_LSB);
    
    // 配置状态机 0 的 EXECCTRL 寄存器,设置 .wrap_target 和 .wrap 指令的位置
    mm_pio->sm[0].execctrl =
            (auto_push_pull_wrap_target << PIO_SM0_EXECCTRL_WRAP_BOTTOM_LSB) |
            (auto_push_pull_wrap << PIO_SM0_EXECCTRL_WRAP_TOP_LSB);

    // 启动状态机 0
    hw_set_bits(&mm_pio->ctrl, 1u << (PIO_CTRL_SM_ENABLE_LSB + 0));

    // 向 TX FIFO 中写入 5 个数据,并从 RX FIFO 中读取并打印出 5 个数据
    for (int i = 0; i < 5; ++i)
        mm_pio->txf[0] = i;
    for (int i = 0; i < 5; ++i)
        printf("%d\n", mm_pio->rxf[0]);

    return 0;
}

下图展示了状态机执行的整个流程:

d876263d-5f4a-4f55-8ec1-03c953997b80.png

为了在正确的时间触发自动推出或自动加载,状态机会使用一对 6 个数据位的移位计数器跟踪 ISR 和 OSR 的总移位数:

  • 复位后,或在 CTRL_SM_RESTART 时,ISR 移位计数器设置为 0 (尚未移入任何数据),OSR 移位计数器设置为 32 (没有任何待移出数据);
  • OUT 指令会给 OSR 移位计数器增加 Bit count
  • IN 指令会给 ISR 移位计数器增加 Bit count
  • PULL 指令或自动加载会将 OSR 计数器清为 0;
  • PUSH 指令或自动推出会将 ISR 计数器清为 0;
  • MOV OSR, xMOV ISR, x 会将 OSR 或 ISR 移位计数器清为 0;
  • OUT ISR, n 指令会将 ISR 移位计数器设置为 n

在执行任何 OUTIN 指令时,状态机都会将移位计数器与 SHIFTCTRL_PULL_THRESHSHIFTCTRL_PUSH_THRESH 比较,来决定是否要采取动作。自动加载和自动推出分别由 SHIFTCTRL_AUTOPULLSHIFTCTRL_AUTOPUSH 字段启用。

4.1 自动推出详解 autopush

下面是启用了自动推出(autopush)的 IN 指令的伪代码:

// 读取数据并将其使用in指令位移到isr移位寄存器中
isr = shift_in(isr, input())
// 更新isr移位计数器的值,读取了几个数据递增几个数
// 当 isr count 大于32时,记为0
isr count = saturate(isr count + in count)

// 检查接收数据的位数统计 rx count 是否达到了预设的阈值
if rx count >= threshold:
    // 如果接收 FIFO 已满,则暂停程序执行(stall)
    if rx fifo is full:
        stall
    // 如果接收 FIFO 未满
    else:
        // 将 isr 中的数据推出到 RX FIFO
        push(isr)
        // 重置 isr 和 isr count 为 0,准备接收下一个数据包
        isr = 0
        isr count = 0

注意硬件平台执行上述步骤只需要一个机器时钟周期(除非状态机出现暂停)。

4.2 自动加载详解 autopull

在没有执行 OUT 指令的周期,硬件执行以下伪代码:

// 若是执行了 MOV 或 PULL 指令
if MOV or PULL:
    // 将输出移位寄存器 (OSR) 的位数统计 osr count 重置为 0
    osr count = 0

// 检查 OSR 的位数统计 osr count 是否达到了预设的阈值
if osr count >= threshold:
    // 如果发送 FIFO 不为空
    if tx fifo not empty:
        // 发送 FIFO 中拉取 (PULL) 数据到 OSR 中
        osr = pull()
        // 将 osr count 重置为 0,准备下一次发送
        osr count = 0

因此,自动加载可以在两个 OUT 指令之间的任何点发生,取决于数据何时到达 FIFO。

在执行 OUT 指令的周期,步骤略有不同:

// 检查输出移位寄存器 (OSR) 的位数统计 osr count 是否达到了预设的阈值
if osr count >= threshold:
    // 如果发送 FIFO 不为空
    if tx fifo not empty:
        // 从发送 FIFO 中拉取 (PULL) 数据到 OSR 中
        osr = pull()
        // 将 osr count 重置为 0,准备下一次发送
        osr count = 0
    // 无论TX FIFO是否为空,只要到达预设的阈值,暂停程序执行
    stall
// 如果 osr count 未达到阈值
else:
    // 将 OSR 中的数据输出
    output(osr)
    // 将 OSR 向左移位 out count 位,并将新数据填充到低位
    osr = shift(osr, out count)
    // 更新 osr count , 当 osr count 大于32时,记为0
    osr count = saturate(osr count + out count)
    
    // 再次检查 osr count 是否达到阈值
    if osr count >= threshold:
        // 如果发送 FIFO 不为空
        if tx fifo not empty:
            // 从发送 FIFO 中拉取数据到 OSR,并将 osr count 重置为 0
            osr = pull()
            osr count = 0

硬件能够在移出全部数据的同时填充 OSR,因为这两个操作可以并行执行。但是,硬件无法在同一个周期执行填充 OSR 并 'OUT' 同一份数据,因为这样做会造成很长的逻辑链。可以认为,对于程序而言,填充操作是异步的,但 'OUT' 就像一个数据屏障,状态机永远不能 OUT 尚未写入 FIFO 的数据。

注意,在自动加载启用时,​从 OSR 'MOV' 复制数据的操作是未定义的;根据与系统 DMA 的竞合状态,你可能会读到尚未移出的残留数据,或读到 FIFO 的新数据。​与此类似,'MOV' 到 OSR 的操作可能会覆盖刚刚自动加载进来的数据。 但是,'MOV' 进 OSR 的数据永远不会被覆盖,因为 'MOV' 会更新移位计数器。

如果你确实需要读取 OSR 的内容,应当显式地执行某种 'PULL'。上面描述的不确定性是由硬件自动进行加载的代价。启用自动加载会改变 'PULL' 的行为:如果 OSR 为满,则 PULL 为误操作。 这样做是为了避免与系统 DMA 之间的竞合冲突。它就像一个屏障:或者自动加载已经开始执行,此时 'PULL' 操作无效;或者程序会在 'PULL' 指令处等待,直到 FIFO 有数据。

5.PIO 特定的时钟分频器

PIO 依靠系统时钟运行,但对于绝大多数接口来说,系统时钟太快了,而可插入的 Delay 周期数是有限的。像 UART 等设备需要精确地控制并改变信号的速率,最好是运行同一个程序的多个状态机也能够独立地改变速率。 因此,每个状态机都有一个时钟分频器。

时钟分频器不会降低系统时钟速率,而是定义了多少个系统时钟周期等于执行 PIO 程序的“一个周期”。它会产生一个时钟允许信号,该信号能够在每个系统时钟周期暂停或恢复执行。时钟分频器的本质是产生 “Clock Enable(时钟允许)信号”,控制状态机的执行节奏:

  • 当 Clock Enable 为高时,状态机执行 1 条指令;
  • 当 Clock Enable 为低时,状态机暂停执行(但系统仍能访问其 FIFO、修改配置)。

时钟分频器可以简化状态机与系统之间的接口,降低延迟,而且占用的芯片面积很小。​在时钟允许信号为低电平时,状态机完全处于空闲状态​,不过系统仍然可以访问状态机的 FIFO 并改变其配置。

时钟分频器支持 16 数据位整数、8 数据位小数的分频,小数分频器采用一阶积分-微分(first-order delta-sigma)。时钟分频比例可以在 1 ~ 65536 之间以 1/256 的增量改变。

如果时钟分频比例设置为 1,则状态机在每个时钟周期都会执行,即全速执行:

8dc6e680-f858-4643-8998-32db2c9a62bc.png

通常,仅使用整数分频器会让状态机以每 n 个时钟周期等于 1 个执行周期的速率执行,因此有效时钟速率为 f(sys) / n。

e710fcaa-703f-4523-8a99-7bd4695e9d8b.png

当需要更精确的速率(比如 2.5 倍分频),仅用整数不够,这时候用小数分频,通过 “整数部分 + 小数部分” 的组合,实现 “非整数个系统时钟对应 1 个执行周期”,小数分频器的数值等于 n + f / 256 ,其中 nf 是该状态机的 CLKDIV 寄存器的整数和小数部分。它会有选择地在 n 个周期到 n+1 个周期之间延长某个分频期间。

26157772-f439-4098-9c36-0b8300961010.png

f6d733d1-a4fc-40be-b374-86f1bad795b6.png

我们可以看到:

  • 分频器的实际分频比 = 整数部分(INT) + 小数部分(FRAC/256)(FRAC 是 8 位,范围 0~255,对应 0~1 的小数);
  • 采用 “一阶积分 - 微分(delta-sigma)” 算法:在大部分周期用 n 个系统时钟,偶尔用 n+1 个系统时钟,平均下来实现小数分频比。
  • 图中:
    • 整数部分 INT=2,小数部分 FRAC=128(因为 0.5=128/256);
    • 分频器会交替用 2 个和 3 个系统时钟产生允许脉冲,平均下来是 2.5 个系统时钟对应 1 个执行周期;

当整数分频值 n 较小时,小数分频比例导致的抖动可能是无法接受的。但是,当整数分频值 n 很大时,抖动几乎可以忽略不计。

需要注意的是,对于高速异步串口来说,最好使用偶数分频比例,或 1M 波特率的整数倍,而不要使用传统的 300 的倍数,以避免不必要的抖动。

6.GPIO 映射

GPIO 映射是 PIO 状态机与外部 GPIO 引脚之间的硬件连接规则,分为 “输出映射(状态机 →GPIO)” 和 “输入映射(GPIO→ 状态机)” 两类,决定了状态机指令(OUT/SET/side-set/IN)具体控制或读取的 GPIO 引脚,是 PIO 适配不同外设引脚布局的核心灵活特性。

​在内部,PIO 有一个 32 位寄存器,表示它能驱动的每个 GPIO 管脚的输出电平,以及另一个寄存器表示每个输出允许的情况(高低阻抗);​在每个系统时钟周期,每个状态机都可以写入这些寄存器中的部分或全部 GPIO。

4096dfe9-1f92-49d2-8c24-e1eaf0ea705b.png

输出电平和输出允许寄存器的写数据和写掩码来自以下来源:

  • 一条 OUT 指令,最多能写 32 数据位。根据指令的 Destination 字段,该指令可以应用于管脚或管脚方向。OUT 数据的最低位映射到 PINCTRL_OUT_BASE,向后依次映射 PINCTRL_OUT_COUNT 个数据位,在到达 GPIO31 后折返。
  • 一条 SET 指令,最多能写 5 数据位。根据指令的 Destination 字段,该指令可以应用于管脚或管教方向。SET 数据的最低位映射到 PINCTRL_SET_BASE,向后依次映射 PINCTRL_SET_COUNT 个数据位,在到达 GPIO31 后折返。
  • 一个 side-set 操作,最多能写 5 数据位。根据 EXECCTRL_SIDE_PINDIR 寄存器字段的值,该操作可以应用于管脚或管教方向。side-set 数据的最低位映射到 PINCTRL_SIDESET_BASE,向后依次映射 PINCTRL_SIDESET_COUNT 个数据位,在到达 GPIO31 后折返。

每个 OUT/SET/side-set 操作都会写连续的管脚区间,但每个区间都在 32 数据位的 GPIO 空间内有独立的大小和位置。对于大多数应用来说,这已经足够灵活了。例如,如果一个状态机在一组管脚上实现了像 SPI 等接口,那么另一个状态机可以运行同一个程序,映射到另一组管脚上,提供第二个 SPI 接口。

在任意时钟周期,状态机可以执行一次 OUTSET,并且可以同时执行一次 side-set。管脚映射逻辑会生成一个 32 数据位的写掩码,并根据请求内容和管脚映射配置,将输出电平和输出允许寄存器写入数据总线。

如果同一个状态机在同一个时钟周期内的 side-set 操作与 OUT/SET 操作重叠,那么在重叠的区域中 side-set 优先。

6.1 输出优先级

输出映射控制 OUT/SET/side-set 指令对应的 GPIO 引脚,核心规则如下:

  • 独立的映射区间配置​:OUT/SET/side-set 各自通过专属寄存器配置映射范围:
    • OUT:由 PINCTRL_OUT_BASE(起始 GPIO 编号)和 PINCTRL_OUT_COUNT(引脚数量)决定,数据最低位对应 OUT_BASE,依次映射后续 GPIO(到 GPIO31 后折返);
    • SET:由 PINCTRL_SET_BASEPINCTRL_SET_COUNT 决定,规则同 OUT;
    • side-set:由 PINCTRL_SIDESET_BASEPINCTRL_SIDESET_COUNT 决定,规则同 OUT;
  • 同一周期的操作优先级​:若状态机在同一时钟周期内执行 side-setOUT/SET,且两者映射的 GPIO 重叠,则 side-set 的操作优先生效(覆盖 OUT/SET 的对应引脚值);
  • 写数据与掩码​:每个输出操作会生成 32 位 “写数据” 和 “写掩码”,仅掩码覆盖的 GPIO 会被修改,未覆盖的 GPIO 保持原状态。

6.2 输出优先级

每个状态机对于每个 GPIO 通过写掩码选择优先级。每个 GPIO 会考虑来自四个状态机的写入电平和方向,然后应用编号最大的状态机的值。

14120eac-34ce-459f-9555-a5ed73e22640.png

每个状态机在每个周期会通过其管脚映射硬件执行一次 OUT/SET 和 side-set。这样,每个状态机都会为 GPIO 输出电平和输出允许寄存器生成 32 数据位的写数据和写掩码。

对于每个 GPIO,PIO 会考虑来自所有四个状态机的写操作,然后应用编号最大的状态机的写操作。这一步骤是针对输出电平和输出值分别进行的,所以可能出现在同一个周期内一个状态机同时改变了同一个管脚的电平和方向(例如通过同时发出的 SET 和 side-set),或一个状态机改变了 GPIO 方向,另一个状态机改变了同一个 GPIO 的电平。如果没有状态机写入 GPIO 的电平或方向,则值保持不变。

6.3 输入映射

IN 指令收到的数据的方式是,LSB 映射到 PINCTRL_IN_BASE 设置的 GPIO,后续的更高位来自后续更高编号的 GPIO,到达 31 时折返。

换句话说,IN 总线是 GPIO 输入值根据 PINCTRL_IN_BASE 的向右移位结果。如果不够 32 个 GPIO,则 PIO 输入会在相应位置上填充 0, 以补齐 32 数据位。

WAIT GPIO 等指令会使用绝对 GPIO 编号,而不是 IN 数据总线中的索引。此时不会进行向右移位。

6.4 输入同步器

为了保证 PIO 的稳定性,每个 GPIO 输入都有两个标准 2-fliplop 同步器。这会导致输入采样延迟两个周期,但好处是,状态机可以在任何时刻执行 ​IN PINS,而且只会看到干净的高低电平,而不会看到可能会影响状态机电路的中间值。 对于 UART RX 等异步接口来说这一点非常重要。

有时某些 GPIO 可能需要跳过同步器。这样可以减少延迟,但用户必须自行保证状态机不会在错误的时刻进行采样。通常,只有 SPI 等同步接口才能做到这一点。设置 INPUT_SYNC_BYPASS 中的相应数据位即可跳过同步器。

需要注意的是,对不稳定的输入进行采样会导致不可预测的状态机行为,应当避免这种情况。

我们可以通过读写 INPUT_SYNC_BYPASS 寄存器来设置或读取同步器:

5b1605f9-a0f7-4ad5-bb8c-f0c2f603aa3a.png

7.指令执行

除了指令内存外,状态机还可以从其他三个来源执行指令:

  • MOV EXEC 可以从 Source 指定的某个寄存器执行指令:
    • MOV EXEC, <source>source(如 X/Y/ISR/OSR)中的 16 位数据作为指令执行;
    • MOV 指令消耗 1 个周期,下一个周期执行寄存器中的指令。
  • OUT EXEC 可以执行 OSR 移出的数据:
    • OUT EXEC, <bit_count> 将 OSR 中移出的 16 位数据作为指令执行;
    • OUT 指令消耗 1 个周期,下一个周期执行该指令。
  • SMx_INSTR 控制寄存器执行指令,系统可以将指令直接写入该寄存器,以立即执行:
    • 读:获取当前程序计数器(PC)指向的指令;
    • 写:向寄存器写入 16 位指令,状态机立即执行该指令。

在下列示例程序中,我们在一个循环中从 TX FIFO 加载 32 数据位数据,然后将低 16 数据位作为指令执行(由于 32 数据位的指令会写入INSTR​ 寄存器,而INSTR​ 寄存器仅有低 16 位可访问)。

PIO 程序如下:

.program exec_example

hang:
    jmp hang ; 定义了一个标签 hang,并在这里形成一个无限循环跳转
execute:
    out exec, 32
    jmp execute ; 定义了另一个标签 execute,在这里无限循环地将寄存器中的 32 位数据输出并执行

.program instructions_to_push

    out x, 32 ; 将 32 位数据从寄存器 x 输出
    in x, 32 ; 将 32 位数据从输入端口载入到寄存器 x
    push ; 将寄存器 x 中的数据推送到 TX FIFO

调用 PIO 程序的 C 语言程序如下,该程序在运行时会输出 "12345678":

#include "tb.h" // TODO this is built against existing sw tree, so that we get printf etc

#include "platform.h"
#include "pio_regs.h"
#include "system.h"
#include "hardware.h"

#include "exec_example.pio.h"

int main()
{
    tb_init();

    // 将 exec_example_program 数组中的指令加载到 PIO 指令存储器中
    for (int i = 0; i < count_of(exec_example_program); ++i)
        mm_pio->instr_mem[i] = exec_example_program[i];

    // 配置状态机 0 的 SHIFTCTRL 寄存器,启用自动拉取功能,并设置拉取计数器阈值为 32
    mm_pio->sm[0].shiftctrl = (1u << PIO_SM0_SHIFTCTRL_AUTOPULL_LSB);

    // 启动状态机 0,使其进入 hang 死循环
    hw_set_bits(&mm_pio->ctrl, 1u << (PIO_CTRL_SM_ENABLE_LSB + 0));

    // 强制状态机跳转到 execute 标签的位置,开始执行 out exec, 32 指令
    mm_pio->sm[0].instr = 0x0000 | 0x1; // jmp execute

    // 写入 instructions_to_push_program 中的指令到TX FIFO
    // 写入一些数据(12345678)到TX FIFO
    mm_pio->txf[0] = instructions_to_push_program[0]; // out x, 32
    mm_pio->txf[0] = 12345678;                        // data to be OUTed
    mm_pio->txf[0] = instructions_to_push_program[1]; // in x, 32
    mm_pio->txf[0] = instructions_to_push_program[2]; // push

    // 等待 RX FIFO 中有数据可读,并将其打印出来
    while (mm_pio->fstat & (1u << PIO_FSTAT_RXEMPTY_LSB))
        ;

    printf("%d\n", mm_pio->rxf[0]);

    return 0;
}

C 程序将状态机设置为运行状态,然后进入 hang 循环。在状态机执行时,C 程序强制执行一条 jmp 指令,使状态机跳出循环。

当一条指令写入 ​INSTR​ 寄存器后,状态机会立即解码并执行该指令,而不会执行从 PIO 指令内存读取到的指令。 程序计数器不会增加,因此在下一个周期(假设 INSTR 寄存器强制执行的指令没有导致等待状态)状态机会从原来的位置继续执行当前程序, 除非写入的指令修改了 PC

f558ce7f-f907-4896-882b-cdc435839a7b.png

282ed844-4a76-40ae-a000-a6c7643feb60.png

写入 ​INSTR​ 寄存器的指令中的延时周期会被忽略,并立即执行,不管状态机的时钟分频器如何设置。这个接口用于执行初始化或改变流程控制, 所以指令会尽快执行,而不管状态机如何配置。

写入 INSTR 的指令可以进入等待状态,此时状态机会锁住该指令,直到其完成。出现这种情况时,会设置 EXECCTRL_EXEC_STALLED 标志。 重启状态机或向 INSTR 写入 NOP 可以清除该标志。

上述示例状态机 PIO 程序的第二阶段使用了 OUT EXEC 指令。OUT 本身需要一个执行周期,OUT 执行的指令在下一个执行周期进行。​注意被执行的指令之一也是 ​OUT—— 状态机每个周期只能执行一条 ​OUT​ 指令。

OUT EXEC 会将 OUT 移出的数据写入内部的指令寄存器。下一个周期中,状态机知道它不应该执行指令内存,而是执行该锁存器的内容,而且知道此时不应当增加 PC

注意,如果写入 ​INSTR​ 的指令造成等待状态,该指令会被写入到与 ​OUT EXEC​ 和 ​MOV EXEC​ 所用的同一个指令寄存器,并覆盖其中保存的指令。因此,如果使用了EXEC指令,那么写入 ​INSTR​ 的指令不能造成等待

posted @ 2026-09-01 15:11  FreakStudio  阅读(5)  评论(0)    收藏  举报