RP系列PIO完全指南:从入门到精通#
PIO(Programmable Input/Output,可编程输入输出)是Raspberry Pi RP2040和RP2350芯片上最独特、最强大的功能之一。它不是普通的GPIO外设,而是一组独立于主CPU运行的专用协处理器,专门用于处理各种I/O操作。
本文将系统性地讲解PIO的原理、编程方法和高级应用,帮助你从零开始掌握这项技术。
一、PIO是什么?为什么需要它?#
1.1 传统MCU的困境#
在传统微控制器上实现非标准通信协议时,通常只有两种选择:
- 软件模拟(Bit-banging):由CPU手动翻转GPIO引脚。这种方式消耗大量CPU资源,且当中断发生时容易产生时序抖动,对于时序要求严格(如WS2812要求±150ns精度)的场景基本不可用。
- 外接专用芯片:增加BOM成本、PCB面积和功耗。
PIO正是为解决这两个问题而生的。
1.2 PIO的本质#
PIO是芯片内部独立于CPU的小型处理器,拥有自己的专用指令集和寄存器。它的核心特征可以概括为:
| 特征 | 说明 |
|---|---|
| 独立运行 | 不占用CPU资源,CPU可以专注于应用逻辑 |
| 纳秒级精度 | 时序控制精确到时钟周期级别 |
| 高度灵活 | 可以模拟任何数字协议——从WS2812到VGA信号 |
二、PIO硬件架构#
理解PIO的硬件结构是编程的基础。整个架构分为三个层级:
2.1 PIO Block(PIO块)#
PIO Block是最高层级。RP2040有2个PIO块(PIO0和PIO1),而RP2350升级到了3个(PIO0、PIO1、PIO2)。每个PIO块都是独立的外设子系统。
2.2 State Machine(状态机,SM)#
每个PIO块包含4个独立的状态机。状态机是执行PIO代码的主体——每个状态机都像一个迷你的CPU,可以独立运行程序。
- RP2040总计:2 × 4 = 8个状态机
- RP2350总计:3 × 4 = 12个状态机
同一个PIO块内的4个状态机共享32条指令的指令内存。这意味着你可以让多个状态机运行相同的程序(比如控制多路WS2812灯带),也可以运行完全不同的程序。
2.3 核心寄存器#
每个状态机都拥有以下寄存器:
| 寄存器 | 全称 | 功能 |
|---|---|---|
| OSR | Output Shift Register | 输出移位寄存器,存放下一步要发出的数据 |
| ISR | Input Shift Register | 输入移位寄存器,存放接收到的数据 |
| X, Y | 通用寄存器 | 用于计数、暂存、循环控制 |
| PC | Program Counter | 程序计数器,跟踪当前执行位置 |
2.4 FIFO(数据队列)#
每个状态机都有独立的TX FIFO(发送)和RX FIFO(接收),深度为4个32位字。它们是连接主CPU和PIO状态机的数据管道:
- CPU通过TX FIFO向PIO发送数据
- PIO通过RX FIFO向CPU返回数据
- FIFO可以配合DMA传输大量数据
三、PIO编程模型#
3.1 指令集:只有9条指令#
PIO的汇编语言极其精简,仅有9条核心指令:
| 指令 | 功能 | 典型用途 |
|---|---|---|
| JMP | 条件/无条件跳转 | 循环、状态切换 |
| WAIT | 等待条件满足 | 同步、边沿检测 |
| IN | 从源移位数据到ISR | 读取引脚状态 |
| OUT | 从OSR移位数据到目的地 | 写入引脚 |
| PUSH | 将ISR内容送到RX FIFO | 向CPU发送数据 |
| PULL | 将TX FIFO内容送到OSR | 从CPU接收数据 |
| MOV | 寄存器间复制数据 | 数据处理 |
| IRQ | 设置或清除中断标志 | 通知CPU |
| SET | 立即设置引脚或寄存器 | 引脚控制、初始化 |
每条指令恰好执行1个时钟周期(加上可选的延迟周期)。这种确定性的时序是PIO能够实现精确协议控制的核心。
3.2 指令修饰符:Side-set和Delay#
为了让9条指令发挥出更大的威力,PIO提供了两个强大的修饰符:
-
Side-set(侧置):在执行指令的同一个时钟周期内,额外控制其他引脚。比如在移位输出数据的同时,同步翻转时钟引脚——这在不增加指令数量的情况下实现了类似SPI的行为。
-
Delay(延迟):每条指令可以附加0-31个周期的延迟。这让你可以精细调整时序,而无需浪费指令空间写NOP操作。
3.3 伪指令#
PIO汇编还支持几个伪指令来控制程序流程:
.wrap_target/.wrap:定义程序的循环体,执行到.wrap时会自动跳回.wrap_targetlabel():定义跳转标签.origin:指定程序在指令内存中的起始位置
四、开发环境与编程方式#
4.1 C/C++ SDK方式#
对于生产环境或需要最大控制权的场景,Pico C/C++ SDK提供了完整的PIO支持。
PIO程序通常写在.pio文件中,包含汇编程序部分和C辅助函数部分。构建系统会自动处理编译并生成头文件。
关键SDK函数:
pio_add_program() // 加载程序到PIO指令内存
pio_sm_init() // 初始化状态机
pio_sm_set_enabled() // 启动/停止状态机
pio_sm_put() // 写数据到TX FIFO
pio_sm_get() // 从RX FIFO读数据c4.2 Rust + Embassy方式#
对于Rust开发者,Embassy框架提供了最完整的PIO支持。Embassy是一个异步嵌入式框架,让Rust的async/await语法在MCU上成为可能。
项目依赖:
在Cargo.toml中添加:
embassy-rp = { version = "0.3", features = ["defmt", "unstable-pac", "time-driver"] }
pio-proc = "0.2" # PIO汇编宏toml核心宏:pio_asm!
在Embassy中,通过pio_asm!宏可以直接在Rust代码中嵌入PIO汇编:
use embassy_rp::pio::pio_asm;
let prg = pio_asm!(
"set pindirs, 1",
".wrap_target",
"out pins, 1 [19]",
".wrap",
);rust这个宏会在编译时将PIO汇编转换为PIO可执行的机器码,并生成对应的程序对象。
中断绑定:
Embassy使用bind_interrupts!宏将PIO中断绑定到异步处理器:
bind_interrupts!(struct Irqs {
PIO0_IRQ_0 => InterruptHandler<PIO0>;
});rust其他语言支持:
- TinyGo:提供专门的
pio包,支持RP2040/RP2350 - Arduino:通过
arduino-pico核心支持PIO
4.3 Rust完整工程示例:三状态机协同工作#
下面是一个完整的Rust + Embassy工程,展示了三个PIO状态机同时并行运行的经典用法——分别实现串行数据发送、串行数据接收和周期触发中断。
完整的工程代码#
//! This example shows powerful PIO module in the RP235x chip.
#![no_std]
#![no_main]
use defmt::info;
use defmt_rtt as _;
use embassy_executor::Spawner;
use embassy_rp::peripherals::PIO0;
use embassy_rp::pio::program::pio_asm;
use embassy_rp::pio::{Common, Config, InterruptHandler, Irq, Pio, PioPin, ShiftDirection, StateMachine};
use embassy_rp::{Peri, bind_interrupts};
use fixed::traits::ToFixed;
use fixed_macro::types::U56F8;
use panic_probe as _;
// 1. 绑定PIO中断
bind_interrupts!(struct Irqs {
PIO0_IRQ_0 => InterruptHandler<PIO0>;
});
// 2. 配置状态机0:串行数据发送
fn setup_pio_task_sm0<'a>(pio: &mut Common<'a, PIO0>, sm: &mut StateMachine<'a, PIO0, 0>, pin: Peri<'a, impl PioPin>) {
let prg = pio_asm!(
".origin 16",
"set pindirs, 1",
".wrap_target",
"out pins,1 [19]",
".wrap",
);
let mut cfg = Config::default();
cfg.use_program(&pio.load_program(&prg.program), &[]);
let out_pin = pio.make_pio_pin(pin);
cfg.set_out_pins(&[&out_pin]);
cfg.set_set_pins(&[&out_pin]);
cfg.clock_divider = (U56F8!(125_000_000) / 20 / 200).to_fixed();
cfg.shift_out.auto_fill = true;
sm.set_config(&cfg);
}
// 3. 状态机0的异步任务
#[embassy_executor::task]
async fn pio_task_sm0(mut sm: StateMachine<'static, PIO0, 0>) {
sm.set_enable(true);
let mut v = 0x0f0caffa;
loop {
sm.tx().wait_push(v).await;
v ^= 0xffff;
info!("Pushed {:032b} to FIFO", v);
}
}
// 4. 配置状态机1:串行数据接收(内部生成测试数据)
fn setup_pio_task_sm1<'a>(pio: &mut Common<'a, PIO0>, sm: &mut StateMachine<'a, PIO0, 1>) {
let prg = pio_asm!(
".origin 8",
"set x, 0x15",
".wrap_target",
"in x, 5 [31]",
".wrap",
);
let mut cfg = Config::default();
cfg.use_program(&pio.load_program(&prg.program), &[]);
cfg.clock_divider = (U56F8!(125_000_000) / 2000).to_fixed();
cfg.shift_in.auto_fill = true;
cfg.shift_in.direction = ShiftDirection::Right;
sm.set_config(&cfg);
}
// 5. 状态机1的异步任务
#[embassy_executor::task]
async fn pio_task_sm1(mut sm: StateMachine<'static, PIO0, 1>) {
sm.set_enable(true);
loop {
let rx = sm.rx().wait_pull().await;
info!("Pulled {:032b} from FIFO", rx);
}
}
// 6. 配置状态机2:周期触发IRQ中断
fn setup_pio_task_sm2<'a>(pio: &mut Common<'a, PIO0>, sm: &mut StateMachine<'a, PIO0, 2>) {
let prg = pio_asm!(
".origin 0",
".wrap_target",
"set x,10",
"delay:",
"jmp x-- delay [15]",
"irq 3 [15]",
".wrap",
);
let mut cfg = Config::default();
cfg.use_program(&pio.load_program(&prg.program), &[]);
cfg.clock_divider = (U56F8!(125_000_000) / 2000).to_fixed();
sm.set_config(&cfg);
}
// 7. 状态机2的异步任务:等待IRQ触发
#[embassy_executor::task]
async fn pio_task_sm2(mut irq: Irq<'static, PIO0, 3>, mut sm: StateMachine<'static, PIO0, 2>) {
sm.set_enable(true);
loop {
irq.wait().await;
info!("IRQ trigged");
}
}
// 8. 主函数:初始化并启动所有任务
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let pio = p.PIO0;
let Pio {
mut common,
irq3,
mut sm0,
mut sm1,
mut sm2,
..
} = Pio::new(pio, Irqs);
setup_pio_task_sm0(&mut common, &mut sm0, p.PIN_0);
setup_pio_task_sm1(&mut common, &mut sm1);
setup_pio_task_sm2(&mut common, &mut sm2);
spawner.spawn(pio_task_sm0(sm0).unwrap());
spawner.spawn(pio_task_sm1(sm1).unwrap());
spawner.spawn(pio_task_sm2(irq3, sm2).unwrap());
}rust代码解读:三个状态机各司其职#
状态机0(SM0)——串行数据发送
PIO汇编程序将OSR中的数据逐位通过out指令发送到GPIO引脚,每条指令附带19个周期的延迟来控制位速率。CPU通过sm.tx().wait_push(v).await向FIFO推送数据,当FIFO满时异步等待。程序不断将0x0f0caffa推入FIFO,然后取反再推入,循环往复。
状态机1(SM1)——串行数据接收(模拟)
PIO程序将X寄存器中固定的0x15(二进制10101)通过in指令反复移入ISR。当ISR满32位时自动推入RX FIFO。CPU通过sm.rx().wait_pull().await异步等待并读取数据。虽然这里没有连接外部引脚,但它模拟了从传感器连续读取数据流的过程——CPU无需轮询,PIO在后台自动完成数据采集。
状态机2(SM2)——周期触发中断
PIO程序用X寄存器做倒计时(初始值10),每次循环递减一次并延迟。当X减到0时执行irq 3触发IRQ中断。CPU通过irq.wait().await等待中断,触发后打印日志。这相当于一个完全由硬件实现的、不占用CPU定时器资源的“心跳时钟”。
三个状态机并行运行,互不干扰——这正是PIO的精髓:用硬件并行代替软件串行。
五、基础应用示例#
5.1 WS2812 NeoPixel灯带控制#
这是PIO最经典的”Hello World”应用。NeoPixel需要精确到±150ns的单线协议时序,传统软件模拟极易被中断破坏。
PIO用仅4条指令的程序就能生成所需波形,CPU可以在PIO发送数据的同时准备下一帧像素数据,驱动数百个LED而CPU占用率为0%。
5.2 模拟标准通信协议#
PIO可以创建无限个额外的SPI、UART、I2C接口:
| 协议 | PIO优势 |
|---|---|
| SPI | 创建超出硬件外设数量的SPI接口 |
| I2S音频 | 生成精确音频时钟,无CPU抖动 |
| VGA/DVI视频 | 像素级精确的视频信号输出 |
| CAN总线 | 无需外部收发器芯片 |
5.3 并行数据接口#
PIO可以同时采样8个引脚,并将数据自动打包成32位字。这对于连接并行Flash、旧式显示器或高速ADC非常有用。
六、高级应用:用PIO实现高速DAC#
RP2350虽然没有内置DAC,但利用PIO可以轻松实现高速任意波形发生器——这是PIO在信号生成领域最令人兴奋的应用。
6.1 方案一:R-2R电阻网络(低成本方案)#
原理:用一个R-2R电阻网络构成简易DAC。PIO高速并行输出数字量,R-2R网络将其转换为模拟电压。
一个n位的R-2R网络需要n-1个R电阻和n+1个2R电阻。输出端需接电压跟随器(运算放大器)来驱动负载。
性能:有项目采用10位R-2R网络,实现了100Hz-200kHz频率范围、0-3Vpp幅度可调的正弦波、三角波、方波输出。
Rust实现要点:使用PIO的out pins, N指令并行输出N位数据到GPIO,配合DMA从预计算波形表中持续搬运数据。
6.2 方案二:外部高速DAC芯片(高性能方案)#
原理:PIO直接驱动高速并口DAC芯片(如DAC904),CPU或DMA将波形数据持续喂给PIO,PIO以精确时序并行发送给DAC。
性能:
- RP2040超频到250MHz时,可实现125MSa/s的采样率
- RP2350方案更可达150MS/s采样率
- 输出指标(PiWave DAC参考设计):300kHz正弦波6.3Vpp,2MHz正弦波5.9Vpp,9MHz正弦波3Vpp,16MHz正弦波1Vpp
Rust实现要点:使用embassy_rp::pio配置状态机为并行输出模式,通过DMA通道将波形数据从内存持续搬运到PIO的TX FIFO。
6.3 方案三:Σ-Δ调制(单引脚高精度方案)#
原理:PIO运行Σ-Δ调制算法,根据数据值输出密度不同的1/0脉冲序列。外部仅需简单的RC低通滤波即可恢复模拟信号。
优势:仅需1个GPIO即可实现高精度DAC,适合引脚受限的场景。
Rust实现:在PIO程序中实现Σ-Δ调制逻辑,CPU只需将目标电压值写入TX FIFO,PIO自动完成脉冲密度调制。
6.4 三种方案对比#
| 方案 | GPIO需求 | 精度 | 速度 | 成本 | 适用场景 |
|---|---|---|---|---|---|
| R-2R网络 | N个(N位精度) | 8-10位有效 | 可达125MS/s | 极低 | 学习、原型验证 |
| 外部DAC芯片 | 8-16个(并行) | 10-14位 | 150MS/s | 中等 | 高性能信号源 |
| Σ-Δ调制 | 1个 | 16位+(过采样) | 音频级 | 极低 | 引脚受限、音频 |
七、DMA + PIO:零CPU占用的数据传输#
PIO的真正威力在与DMA结合时充分展现。你可以配置DMA通道自动向PIO的FIFO填充数据或从中取出数据,整个过程完全无需CPU参与。
典型应用场景:
- 向I2S DAC流式传输音频,同时CPU处理下一缓冲区
- 高速采集并行数据到内存
- 驱动复杂LED显示屏,使用预计算的帧数据
DMA控制器会监视FIFO的水位,按需传输数据,形成一个完全自动化的I/O流水线。
社区已有许多基于”DMA+PIO”的高性能项目:
- 步进电机驱动:用PIO状态机和DMA通道驱动两个步进电机,完全卸载CPU负载
- HUB75 LED矩阵:实现高刷新率、真彩色、零CPU开销
- I2C控制器:使用DMA后性能翻倍
八、性能优化与调试技巧#
8.1 时序计算#
理解时序是PIO编程的关键:
指令执行时间 = (1 / 状态机频率) × (1 + 延迟周期数)plaintext例如,125MHz系统时钟、无分频时,每条指令耗时8ns。加上[31]延迟后变为256ns(32周期 × 8ns)。
PIO状态机可以最高以系统时钟的一半频率翻转GPIO——默认125MHz下为62.5MHz,超频后更快。相比之下,软件模拟通常只能达到10-15MHz。
8.2 指令内存优化#
每个PIO块只有32条指令的共享内存,需要精打细算:
- 使用
.wrap实现免费的循环跳转 - 利用side-set将多个操作合并到一条指令
- 多个状态机共享同一个程序
- 必要时使用第二个PIO块
8.3 调试方法#
PIO程序无法像普通代码那样单步调试,可以借助以下工具:
- 逻辑分析仪:验证实际信号时序
- PIO仿真器(rp2040pio-docs):在开发机上离线测试
- IRQ指令:在PIO程序中插入中断,触发主程序回调,帮助理解程序流向
- 从简单开始:逐步增加复杂度
九、RP2350的PIO增强#
RP2350在RP2040的基础上对PIO进行了显著升级:
| 特性 | RP2040 | RP2350 |
|---|---|---|
| PIO块数量 | 2个 | 3个 |
| 状态机总数 | 8个 | 12个 |
| 新增指令 | 无 | JMP的新条件等 |
| FIFO模式 | 基础 | 新增TXGET(4)、TXPUT(8)、PUTGET(12)模式 |
这些增强让RP2350在处理DSHOT电调协议、CRSF接收机协议等复杂时序任务时更加游刃有余。
十、常见问题#
Q: PIO和FPGA有什么相似和不同?
PIO与FPGA在概念上有相似之处——两者都允许通过编程实现自定义的硬件行为。但PIO要简单得多:FPGA可以实现任意数字逻辑,而PIO状态机执行的是专注于I/O操作的顺序程序。可以认为PIO是软件模拟和定制硅片之间的中间地带。
Q: 多个状态机能控制同一个GPIO吗?
每个GPIO引脚同时只能被一个状态机控制输出。但多个状态机可以同时读取同一个输入引脚。对于需要多引脚协调的复杂协议,通常使用一个状态机或仔细划分引脚分配。
Q: PIO能跑多快?
PIO状态机可以以最高系统时钟频率的一半运行GPIO——默认125MHz下为62.5MHz,超频后更快。相比软件模拟10-15MHz的上限,这是一个巨大的提升。更重要的是,PIO的时序是确定性的,不受中断或其他CPU活动影响。
十一、总结#
PIO是RP系列芯片最独特的设计,它将”硬件级的I/O处理能力”带入了微控制器领域。通过PIO,你可以:
- 模拟任何数字协议,从WS2812到VGA、CAN总线
- 实现纳秒级精度的时序控制
- 将CPU从繁重的I/O任务中解放出来
- 用软件的方式实现硬件级别的并行处理
- 构建高性能任意波形发生器(R-2R网络或外部高速DAC)
无论是在Rust+Embassy异步框架下开发,还是使用C/C++ SDK构建量产产品,PIO都为你提供了一个前所未有的灵活性和性能组合。掌握PIO,就等于掌握了RP系列芯片最强大的”王牌”。
参考资源#
- RP2040数据手册 ↗(第3章PIO详解)
- RP2350数据手册 ↗(第11章PIO详解)
- Pico C/C++ SDK文档 ↗
- Embassy RP系列文档 ↗
- PIO仿真器 ↗
- pico-examples仓库 ↗
- PiWave DAC参考设计 ↗