前言:为什么要专门聊 RP2040 的 PIO

很多单片机项目做到后期,真正难的往往不是“能不能点亮一个外设”,而是“能不能在系统负载变化、多个中断同时发生、DMA 正在搬运数据时,仍然把一个苛刻的波形发得足够稳”。比如 WS2812 灯带要求高低电平宽度落在比较窄的窗口里;航模电调常见的 DShot 协议要求固定周期内编码 0 和 1;一些老式传感器或私有总线没有标准外设可用,只能靠 GPIO 翻转和定时器模拟。传统做法通常有三种:用位带或寄存器裸写“硬抠”延时,用定时器 PWM 加 DMA 拼波形,或者干脆换一颗带专用外设的 MCU。前两种方案可以工作,但维护成本高,移植性差,稍微加入日志、通信栈或 RTOS 后就容易出现抖动;第三种方案则会推高物料和板级改版成本。

Raspberry Pi Pico 使用的 RP2040 在这个问题上给出了一个很有意思的答案:PIO,Programmable I/O。它不是普通 GPIO,也不是传统意义上的串口、SPI、I2C 控制器,而是放在 IO 边上的一组小型可编程状态机。每个 PIO 模块有 4 个 State Machine,芯片上一共有 2 个 PIO 模块,也就是最多 8 个状态机。状态机可以执行简短的 PIO 汇编指令,配合独立的 TX/RX FIFO、移位寄存器、side-set 引脚、可编程时钟分频器,把“协议时序”从 CPU 主循环和中断里剥离出来。CPU 只需要装载程序、配置频率、把数据塞进 FIFO;确定性边沿由 PIO 在硬件节拍下产生。

这篇文章不打算只停留在“PIO 很强”这种结论上,而是从工程角度拆解一个可落地的工作流:怎样理解状态机,怎样写一个 WS2812 波形程序,怎样用 C SDK 装载和启动 PIO,怎样把 FIFO 与 DMA 接起来,怎样调试时序误差,以及什么时候不要滥用 PIO。读完之后,你应该能把 PIO 当作一个小型协议引擎,而不是一个只能跑官方示例的“黑魔法外设”。

RP2040 PIO 协议引擎工作流

一、PIO 适合解决什么问题

PIO 最适合解决的是“时序明确、状态简单、数据流连续、CPU 参与会带来抖动”的 IO 问题。它不适合做复杂计算,也不适合处理需要大量分支、动态内存、复杂协议栈的任务。把它理解成一个极简但确定性很强的协处理器,会比把它理解成“万能外设”更准确。

典型适用场景包括:第一,单线或少线制编码协议,例如 WS2812、SK6812、DShot、1-Wire 的部分时序;第二,需要多个同步 GPIO 同时翻转的场景,例如简易并口、摄像头采样、LED 矩阵扫描;第三,标准外设数量不够时,用 PIO 补 UART、SPI 或 I2S;第四,需要在后台持续收发数据,但主 CPU 还要运行 USB、文件系统或控制算法的项目。

PIO 不适合的场景也要提前说清楚。比如协议需要长时间等待并根据复杂帧内容做决策,状态机 32 条指令的容量会很快不够;又比如协议时序并不严格,普通 UART、SPI、I2C 已经能满足,硬上 PIO 只会让调试门槛变高;再比如需要高级错误恢复、重传、加密校验,这些仍然应该交给 CPU 或上层驱动。一个成熟的设计通常是:PIO 做纳秒到微秒级的边沿控制,CPU 做毫秒级的策略和协议语义,DMA 负责把连续数据流送到 FIFO。

二、PIO 的核心模型:指令、FIFO、移位寄存器和 GPIO

理解 PIO,先抓住四个对象:Instruction Memory、State Machine、FIFO、GPIO 映射。每个 PIO 模块有一段 32 条指令的存储器,多台状态机可以共享这段程序。状态机有自己的程序计数器、X/Y 暂存寄存器、输入移位寄存器 ISR、输出移位寄存器 OSR、TX FIFO、RX FIFO。PIO 指令很少,常用的有 pullpushoutinsetjmpwaitmovirq。指令少并不是缺点,因为它的定位就是把短小的时序循环跑得稳定。

pull 会从 TX FIFO 取一个字到 OSR,out pins, 1 可以把 OSR 中的一位输出到引脚,in pins, 1 可以把引脚采样到 ISR。set pins, 1set pins, 0 用于直接设置引脚电平。side-set 是 PIO 的一个非常实用的机制:在执行某条指令的同时,顺便给一组 side-set 引脚赋值。很多波形的关键在于“执行数据操作的同时改变电平”,side-set 可以让代码更短、周期更可控。

每条 PIO 指令通常消耗 1 个 PIO 时钟周期,指令后面还可以带 delay 槽,例如 [3] 表示额外等待 3 个周期。状态机的时钟由系统时钟通过分频得到,所以最终脉宽可以通过 clkdiv、指令数量和 delay 槽一起计算。工程里不要凭感觉改这些数字,应该先把协议时序换算成周期预算,再写程序,最后用逻辑分析仪确认。

下面是一个非常简化的 PIO 思维模型:CPU 把一串像素或控制帧写进 TX FIFO,PIO 状态机 pull 取数据,OSR 按位移出,side-set 控制引脚高低,delay 控制每个高低电平持续时间。CPU 不需要在每个 bit 上参与,自然就不会因为中断或缓存失效引入抖动。

三、从 WS2812 开始:把协议时序翻译成状态机

WS2812 是理解 PIO 的好例子。它用一根数据线串接很多 RGB LED,每个 bit 都是固定总周期,只是 0 和 1 的高电平时间不同。常见 800 kHz 模式下,一个 bit 周期大约 1.25 微秒,0 码高电平短、低电平长,1 码高电平长、低电平短。用 CPU 精确翻转 GPIO 当然可以做,但只要系统里还有 USB、串口打印或其他中断,就很容易把波形拉坏。

PIO 的写法是把一个 bit 拆成几个固定阶段。例如进入循环时先把引脚拉高,同时从 OSR 取出下一位;根据这一位决定跳到 0 码路径还是继续走 1 码路径;然后在合适的周期把引脚拉低;最后跳回循环。官方示例中的写法非常精炼,核心思想可以抽象成下面这样:

.program ws2812
.side_set 1

.wrap_target
bitloop:
    out x, 1        side 0 [2]
    jmp !x do_zero  side 1 [1]
    jmp bitloop     side 1 [4]
do_zero:
    nop             side 0 [4]
.wrap

这段代码并不是为了直接复制到所有项目,而是展示“指令周期即波形”的思路。side 1 表示这一条指令执行期间数据线为高,side 0 表示为低,[2][4] 这些 delay 槽用于补齐高低电平宽度。状态机频率设定后,每个 bit 的总周期就是几条指令周期之和。你会发现,PIO 程序里几乎没有业务含义,只有“取位、分支、保持高、保持低”。这正是它稳定的原因。

在实际项目中,颜色数据通常按 GRB 顺序发送,而不是 RGB。CPU 侧可以先把每个像素打包成 24 位数据,再写入 FIFO。如果灯带较长,建议不要在主循环里阻塞写 FIFO,而是配合 DMA 或至少做双缓冲,否则刷新大面积灯效时仍然会占用大量 CPU 时间。

(第一部分完,约2300字)

四、在 C SDK 中装载 PIO 程序

PIO 汇编通常放在 .pio 文件里,由 Pico SDK 的 pioasm 在构建阶段生成头文件。C 代码并不直接解释汇编文本,而是拿到编译后的 instruction array,并调用 SDK 函数把程序加入 PIO 指令存储器。一个典型初始化流程包括:选择 PIO 模块和状态机,调用 pio_add_program,配置引脚方向和初始电平,设置状态机的 wrap、side-set、out 移位方向、FIFO join、时钟分频,最后 enable 状态机。

下面是一段接近真实工程的初始化代码骨架,省略了错误处理和部分宏定义:

#include "pico/stdlib.h"
#include "hardware/pio.h"
#include "hardware/clocks.h"
#include "ws2812.pio.h"

#define WS_PIN 2
#define WS_FREQ 800000

static inline void ws2812_program_init(PIO pio, uint sm, uint offset, uint pin) {
    pio_sm_config c = ws2812_program_get_default_config(offset);

    sm_config_set_sideset_pins(&c, pin);
    sm_config_set_out_shift(&c, false, true, 24);   // 左移 / autopull / 24 bit
    sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);  // 扩大 TX FIFO 深度

    float div = (float)clock_get_hz(clk_sys) / (WS_FREQ * 10.0f);
    sm_config_set_clkdiv(&c, div);

    pio_gpio_init(pio, pin);
    pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
    pio_sm_init(pio, sm, offset, &c);
    pio_sm_set_enabled(pio, sm, true);
}

static inline void put_pixel(PIO pio, uint sm, uint32_t grb) {
    pio_sm_put_blocking(pio, sm, grb << 8u);
}

这里有几个细节值得注意。第一,sm_config_set_out_shift 的第三个参数表示 autopull 阈值,发送 24 位像素时可以设为 24,这样 OSR 中的 24 位移完后会自动从 FIFO 拉取下一组数据。第二,PIO_FIFO_JOIN_TX 会把 RX FIFO 合并给 TX,适合纯输出协议,可以减少 FIFO 饿死概率。第三,div 的计算要和 PIO 程序每 bit 消耗的周期一致。上例假设一个 bit 使用 10 个 PIO 周期,所以状态机频率应为 800 kHz × 10。

五、数据格式与颜色打包:别让软件层拖后腿

很多灯带问题看起来像 PIO 时序错,实际是颜色打包、亮度缩放或内存布局错。WS2812 常见顺序是 GRB,SK6812 RGBW 又会多一个白光通道。如果上层逻辑使用 RGB 结构体,发送前必须转换。建议在驱动层提供明确的接口,例如 rgb_to_grb()ws2812_write_pixels(),不要让业务代码到处手写位移。

static inline uint32_t urgb_u32(uint8_t r, uint8_t g, uint8_t b) {
    return ((uint32_t)g << 16) | ((uint32_t)r << 8) | b;
}

void ws2812_show(PIO pio, uint sm, const uint32_t *pixels, size_t count) {
    for (size_t i = 0; i < count; ++i) {
        pio_sm_put_blocking(pio, sm, pixels[i] << 8u);
    }
    sleep_us(80); // reset latch,实际可按灯珠手册留余量
}

如果项目里还有动画、调光和功耗限制,建议使用 16 位或 32 位中间亮度,再在发送前做 gamma 校正和全局限流。不要简单把 RGB 按比例线性缩小,因为人眼对亮度不是线性响应,低亮度区域容易出现跳变。对于电池供电项目,全白满亮可能瞬间拉出很大的电流,驱动层最好提供最大电流估算或全局亮度限制。

六、用 DMA 喂 FIFO:从“能跑”到“跑得省心”

当灯珠数量只有十几个时,pio_sm_put_blocking() 足够简单。但如果有几百颗灯,或者系统还要同时处理 USB、蓝牙模块、传感器采样,阻塞式写 FIFO 就会变成 CPU 占用大户。RP2040 的 DMA 可以直接把内存缓冲搬到 PIO TX FIFO,状态机按固定节拍消耗,DMA 按 DREQ 请求补数据,CPU 只在一帧开始和结束时参与。

DMA 配置的关键是 DREQ。每个 PIO 状态机都有对应的 TX DREQ,当 FIFO 需要数据时 DMA 才搬运,避免过快写入。代码骨架如下:

#include "hardware/dma.h"

int dma_chan;

void ws2812_dma_init(PIO pio, uint sm) {
    dma_chan = dma_claim_unused_channel(true);
    dma_channel_config cfg = dma_channel_get_default_config(dma_chan);
    channel_config_set_transfer_data_size(&cfg, DMA_SIZE_32);
    channel_config_set_read_increment(&cfg, true);
    channel_config_set_write_increment(&cfg, false);
    channel_config_set_dreq(&cfg, pio_get_dreq(pio, sm, true));

    dma_channel_configure(
        dma_chan,
        &cfg,
        &pio->txf[sm],
        NULL,
        0,
        false
    );
}

void ws2812_dma_show(PIO pio, uint sm, const uint32_t *buf, size_t count) {
    dma_channel_set_read_addr(dma_chan, buf, false);
    dma_channel_set_trans_count(dma_chan, count, true);
    dma_channel_wait_for_finish_blocking(dma_chan);
    sleep_us(80);
}

工程化时还要考虑缓存一致性吗?RP2040 没有复杂的数据 Cache,这一点比很多高性能 MCU 简单。但仍然要注意缓冲区生命周期:DMA 还没搬完时不要改写发送缓冲;如果采用双缓冲,应该明确 front/back buffer 的切换时机;如果 DMA 结束中断里只置标志位,不要在中断里做复杂动画计算。

七、把思路迁移到 DShot:周期、编码和校验

DShot 是另一类适合 PIO 的协议。它常用于飞控给无刷电调发送油门值,相比传统 PWM,DShot 是数字协议,不需要电调校准行程。一个 DShot 帧通常包含油门值、遥测位和校验,共 16 bit。不同速率如 DShot150、DShot300、DShot600 对 bit 周期要求不同,编码方式同样是 1 和 0 的高电平占空比不同。

PIO 实现 DShot 的方法与 WS2812 类似:CPU 先把 16 bit 帧计算好,PIO 逐位输出,side-set 负责拉高拉低。差别在于 DShot 通常需要按固定帧率周期发送,而且多个电机通道最好同步。RP2040 有多个状态机,可以为每个电机分配一个状态机,也可以用一个状态机同时驱动多根引脚,把多个通道的 bit 预先打包成并行输出。后者更节省资源,但数据打包更复杂。

DShot 帧的校验计算可以放在 CPU 侧:

uint16_t dshot_make_frame(uint16_t throttle, bool telemetry) {
    uint16_t frame = (throttle << 1) | (telemetry ? 1 : 0);
    uint16_t csum = 0;
    uint16_t csum_data = frame;
    for (int i = 0; i < 3; ++i) {
        csum ^= csum_data;
        csum_data >>= 4;
    }
    csum &= 0x0f;
    return (frame << 4) | csum;
}

在飞控这类系统里,PIO 的价值不只是“能输出 DShot”,而是把输出抖动从主控制环中隔离出去。主循环可以专注 IMU 融合和 PID 计算,DMA/PIO 在固定时间窗口把上一轮算好的油门帧发出去。调试时要特别关注帧间隔、多个通道的同步误差,以及电调对空闲电平和启动序列的要求。

(第二部分完,约2500字)

八、调试 PIO 时序:逻辑分析仪比猜测可靠

PIO 代码短,但短代码不代表容易一次写对。最常见的问题是周期预算错误:以为一条路径和另一条路径总周期一样,实际某个 jmp 分支少了一个 delay;以为系统时钟是 125 MHz,实际项目为了 USB 或超频改成了 120 MHz、133 MHz;以为协议容差很大,实际某批器件对 reset latch 或高电平窗口更敏感。

建议每个 PIO 协议都保留一套最小测试程序:只初始化 PIO,不跑复杂业务,循环发送固定图案,例如 0x0000000xffffff0xaaaaaa0x555555。用逻辑分析仪抓取波形后,先看 bit 周期是否正确,再分别测 0 码和 1 码的高低电平,最后看帧间 reset 时间。不要一开始就接整条灯带或电调排查,因为上层数据、电源质量、接地和线缆反射都会混进来。

如果抓到的波形整体偏快或偏慢,优先检查 clkdiv。如果只有某个编码的高电平不对,检查 delay 槽和分支路径。若波形偶尔中断或出现长低电平,通常是 TX FIFO 饿死,说明 CPU 或 DMA 没有及时喂数据。此时可以启用 FIFO join、提高 DMA 优先级、缩短临界区,或者降低刷新频率。若波形边沿过冲、远端灯珠异常,问题可能在硬件层:数据线太长、没有串联电阻、地线回流不好、电平转换器速度不够。

九、PIO 资源规划:不要把 8 个状态机一下用光

RP2040 一共 8 个状态机,看起来不少,但项目复杂后会很快紧张。比如一个机器人项目可能需要一路 WS2812 状态灯、两路 DShot、一路软件 UART、一组超声波测距触发/捕获,再加一个并口屏幕时序,PIO 资源就会被瓜分。因此在设计早期要做资源表:每个协议占用哪个 PIO、哪个 SM、哪些 GPIO、是否需要 DMA、是否需要 IRQ、指令存储器占几条。

多个状态机共享一个 PIO 指令存储器时,要注意程序 offset。SDK 的 pio_add_program 会返回程序加载位置,初始化时必须使用对应 offset。如果多个协议都塞进同一个 PIO 模块,32 条指令空间可能比状态机数量更早成为瓶颈。此时可以把相似协议合并,或把一个简单协议退回普通外设。不要为了“展示 PIO 技巧”牺牲系统可维护性。

引脚规划也很重要。PIO 可以映射到很多 GPIO,但 side-set、out pins、set pins 等配置通常要求连续引脚或至少在程序里按某种位宽操作。如果后期 PCB 已经定版,再发现并行输出的 8 根数据线不是连续 GPIO,软件会变得别扭。对需要多路同步输出的项目,硬件设计阶段就应该让固件工程师参与引脚分配。

十、工程封装:给 PIO 驱动留清晰边界

我建议把 PIO 驱动封装成三层。第一层是 .pio 汇编和自动生成的头文件,只描述时序;第二层是硬件适配层,负责选择 PIO、SM、GPIO、DMA、时钟分频;第三层是业务接口,例如 led_strip_set_pixel()dshot_set_throttle()custom_bus_transfer()。这样做的好处是,业务代码不需要知道 side-set 和 FIFO 细节,协议时序修改也不会扩散到整个项目。

一个简化的 WS2812 驱动头文件可以这样设计:

#pragma once
#include <stddef.h>
#include <stdint.h>
#include "hardware/pio.h"

typedef struct {
    PIO pio;
    uint sm;
    uint pin;
    int dma_chan;
    size_t count;
    uint32_t *front;
    uint32_t *back;
} ws2812_strip_t;

bool ws2812_strip_init(ws2812_strip_t *s, PIO pio, uint sm, uint pin, size_t count);
void ws2812_strip_set_rgb(ws2812_strip_t *s, size_t index, uint8_t r, uint8_t g, uint8_t b);
void ws2812_strip_set_brightness(ws2812_strip_t *s, uint8_t brightness);
bool ws2812_strip_show(ws2812_strip_t *s);
bool ws2812_strip_busy(const ws2812_strip_t *s);

接口里显式保留 busy 状态,是为了避免业务层在 DMA 传输中修改缓冲。初始化函数返回 bool,是为了处理 PIO 程序加载失败、DMA 通道不足、内存分配失败等情况。很多示例代码默认所有资源都能申请成功,真实产品里最好不要这么乐观。

十一、常见坑与排查清单

第一类坑是电源。WS2812 灯带经常被误判为软件问题,其实是电流不足或地线不好。灯带电源入口要有足够电容,长线要考虑压降,MCU 与灯带必须共地。数据线上串一个几十到几百欧姆的小电阻,常常能改善边沿反射。

第二类坑是电平。RP2040 是 3.3 V IO,部分 5 V 灯带在 5 V 供电时对 3.3 V 数据电平不够宽容,尤其线长和噪声变大后更明显。稳妥做法是使用 74AHCT 系列电平转换,或者降低灯带供电到合适范围但要确认亮度和稳定性。

第三类坑是 FIFO 饿死。阻塞写 FIFO 在小数据量下没有问题,但一旦刷新时间长,CPU 被高优先级中断打断,PIO 可能等待新数据,输出波形就会出现异常长低电平。使用 DMA 后也不是万无一失,要确认 DMA DREQ 配置正确,传输宽度与 FIFO 写入宽度一致,缓冲区在传输期间没有被覆盖。

第四类坑是 PIO 程序路径不等长。写 PIO 汇编时,每条分支路径的周期必须人工核算。建议在注释里写出周期表,例如 0 码路径高电平几周期、低电平几周期,1 码路径高电平几周期、低电平几周期。后续维护者改 delay 时,至少知道自己在破坏什么。

第五类坑是把 PIO 当成脚本语言。PIO 指令空间很小,也没有复杂栈和函数调用。如果发现一个 PIO 程序越来越像协议解析器,说明边界划错了。把复杂判断挪回 CPU,把 PIO 保持为“确定性位流执行器”,通常会更稳。

十二、从原型到产品:测试和量产建议

原型阶段只要示例能跑,大家容易低估后续验证工作。若项目要进入小批量或量产,建议至少做四类测试。第一是时序边界测试:在最低和最高系统时钟、不同温度、不同电源电压下抓波形,确认仍在协议容差内。第二是压力测试:同时打开 USB 通信、日志输出、传感器采样、DMA 刷新,看 PIO 输出是否断流。第三是异常恢复:拔插外设、电源跌落、缓冲区为空、DMA 中断丢失时,驱动能否回到安全状态。第四是兼容性测试:不同批次灯珠、电调、线缆长度和电平转换器都要覆盖。

量产固件里还可以加入轻量诊断。比如记录 DMA 超时次数、PIO FIFO underflow 迹象、刷新耗时最大值;在调试版本中提供命令输出当前 PIO/SM/DMA 分配表;为关键协议保留一个测试模式,方便产线用逻辑分析仪或夹具确认波形。PIO 的稳定性很高,但前提是上层资源管理不要混乱。

十三、什么时候选择 PIO,什么时候不用

如果某个协议已经有硬件外设支持,比如标准 SPI 驱动一个普通屏幕,优先使用硬件 SPI。硬件外设有成熟驱动、DMA 支持和社区经验,维护成本低。PIO 的优势在于“标准外设做不到或做不好”的位置:非标准编码、特殊相位、多个引脚同步、外设数量不够、需要把 bit 级时序从 CPU 中解耦。

如果只是偶尔翻转一个引脚,普通 GPIO 加定时器就够了。如果需要复杂协议栈,PIO 只做底层收发,上层仍要 CPU 处理。如果项目团队没有逻辑分析仪,也没有人愿意核算周期,PIO 反而可能增加风险。好的工程选择不是“能不能用最酷的外设”,而是“这个方案是否让系统更简单、更可测、更稳定”。

总结

RP2040 的 PIO 是一个非常适合嵌入式工程师深入掌握的外设。它把许多原本需要 CPU 忙等、定时器拼接或额外逻辑芯片才能完成的协议时序,变成了短小、可复用、确定性强的状态机程序。用得好时,CPU 只负责准备数据和处理业务,PIO 负责精确边沿,DMA 负责持续供给,整个系统既稳定又节省资源。

实践时要记住几条原则:先把协议时序换算成周期,再写 PIO 汇编;先用最小程序和逻辑分析仪验证波形,再接入完整业务;能用 DMA 就不要长期阻塞 CPU 喂 FIFO;资源规划要覆盖状态机、指令空间、GPIO 和 DMA;封装接口时让业务层远离 PIO 细节。这样做下来,PIO 不再是 Pico 示例里的神秘技巧,而会成为你手里一把可靠的协议工程工具。

下一次遇到“这个传感器时序有点怪”“这个灯带刷新会卡主循环”“这个电调输出不能被中断打断”这类问题时,不妨先画出周期表,再考虑是否让 RP2040 的 PIO 接管底层波形。很多看似棘手的 IO 问题,本质上只是缺一个稳定的小状态机。

(全文完,约7200字)