第一次在 RP2040 上看到“PIO”这个词时,我就知道这东西不简单:它能让普通 GPIO 变成任意时序发生器,点个 LED 闪烁对它来说确实有点“杀鸡用牛刀”。但正因为点灯足够简单,你才能把 PIO 的指令流、状态机、side-set、时钟分频这些最基础的概念一次搞明白。这篇文章就把板载 LED 闪烁这个实验拆开,从原理到 MicroPython 和 C SDK 两套代码,完整走一遍。
1. 为什么点个灯要用 PIO
1.1 先分清“闪烁”的几种实现路径
很多人的第一反应是:点个 LED 闪烁,直接写一段while True: GPIO.on(); sleep(); GPIO.off(); sleep()不就行了吗?确实行,但这种方式里 CPU 全程在管理引脚翻转。点一个灯无所谓,如果同时要刷屏幕、读传感器、驱动电机,CPU 就会被这些“简单任务”反复打断,时序也不好控。
第二种常见做法是定时器中断。设置一个 500ms 的定时器,中断里翻转一次引脚。这个思路比阻塞式好很多,但每个引脚的动作仍要由 CPU 参与,中断多了同样会互相抢资源。
PIO 是第三种做法。它是一套独立于 CPU 的硬件状态机,你把“拉高、等一会儿、拉低、等一会儿”这段流程写进 PIO 程序,然后启动状态机,之后 CPU 就可以完全不管,PIO 会自己按照固定节奏把波形输出去。
| 实现方式 | CPU 占用 | 时序精度 | 多路并发 | 适合场景 |
|---|---|---|---|---|
| GPIO + sleep | 高 | 差 | 难 | 学习、一次性测试 |
| 定时器中断 | 中 | 中 | 中 | 简单周期性任务 |
| PIO 状态机 | 低 | 高 | 强 | 严格时序、多路信号、协议模拟 |
用 PIO 点灯,就是把这个状态机跑通的最小验证。理解之后,再去驱动 WS2812 全彩灯串、DHT11 传感器、甚至模拟 VGA 信号,逻辑上就是同一个思路。
1.2 PIO 设计初衷和它在 RP2040 里的位置
RP2040 芯片里放了两个 PIO 模块,每个模块有 4 个状态机,共 8 个状态机。这些状态机共享一块 32 条指令的存储区,每条指令占一个地址。它们不像 CPU 那样能跑复杂分支和大量变量,主打的是“短小、固定、高速、精准”的 I/O 时序。
PIO 不是普通外设,它是可编程外设。你可以把“SPI 读 Sensor”“LED 灯串写码”“串口发送”这些协议写成一小段 PIO 汇编,然后让它跑起来。点灯是最简单的一种“协议”:高电平保持多长时间,低电平保持多长时间,就这样。
对于没有用过 FPGA、第一次接触状态机的人来说,PIO 的最大价值不是“能做什么”,而是它把“时序控制”从 CPU 里解放了出来。板载 LED 接到 GPIO25,写一段 4 条指令的 PIO 程序,就能看到一个非常精准的方波在引脚上输出。
1.3 通过点灯你能学到什么
这个实验虽然结果很朴素,但涉及的知识点很集中:
- 状态机是怎么启动和运行的;
- 指令里的普通执行周期和 delay 周期怎么算;
- PIO 的引脚方向、输出状态怎么初始化;
- 时钟分频对实际频率的影响;
- MicroPython 和 C SDK 两种开发方式的差别。
把这几个问题搞明白,后面看任何 PIO 相关代码都不容易发怵。我不会一上来就扔一堆协议,而是先让你手里有一个能亲眼看到结果的实验,再围绕它解释原理。
2. PIO 工作原理:动手前必须知道的事
2.1 状态机、指令存储器和执行流程
PIO 的每个状态机就像一个小工人,它按顺序读指令、执行指令。指令存在哪?存在 PIO 模块的共享指令存储器里,一共 32 条。所有状态机都可以从同一块指令区取指令,但各自有独立的程序计数器(PC),互不干扰。
关键点在于“自动回绕”。RP2040 的 PIO 支持wrap_target和wrap这两个标记。程序从wrap_target位置开始执行,执行到wrap位置后自动跳回wrap_target。这和我们熟悉的 C 语言while(1)很像,但它是硬件自动完成的,不需要额外的跳转指令。
比如一个最简单的闪灯程序:
set(pins, 1) [31] set(pins, 0) [31]状态机会先拉高引脚,保持 32 个周期,再拉低引脚,保持 32 个周期,然后自动回绕到第一条指令继续跑。
除了指令存储器和程序计数器,每个状态机还配了几样东西:一个入口 FIFO、一个出口 FIFO、一个输入/输出移位寄存器,以及 X、Y 两个通用寄存器。入门点灯用不到太多,但 FIFO 和寄存器会让 PIO 变得非常灵活,后面我会给一个通过 FIFO 控制闪烁时长的扩展例子。
2.2 指令里的 delay 周期到底怎么算
PIO 指令不是每条都恰好执行一个时钟周期。一条普通指令本身占 1 个周期,后面还可以额外加 delay,最多加 31 个周期。所以指令总周期数 = 1 + delay。
set(pins, 1) [31]的意思是:执行SET pins, 1这条指令用 1 个周期,然后空转 31 个周期,总共占用 32 个状态机时钟周期。如果状态机频率是 64Hz,那高电平和低电平各持续 32 / 64 = 0.5 秒,LED 就会以 1Hz 的频率闪烁。
很多人第一次计算闪烁频率时容易忘记那个“1 + delay”,直接把 delay 当作总周期数。记住,即使指令后面不写[...],它至少也要占 1 个周期。这里有个简单的参考公式:
状态机输出翻转频率 = 状态机频率 / (2 × (1 + delay))
如果你用[31]且状态机频率是 2000Hz,那翻转频率就是 2000 / (2 × 32) = 31.25Hz,也就是每秒钟亮灭 31 次。这个速度人眼看着就是连续闪烁的比较快,所以想要慢一点,要么加大 delay,要么降低状态机频率。
2.3 side-set:边执行指令边改引脚
PIO 里有两套控制引脚的常用手段:普通SET和side-set。普通SET是专门拿出一条指令去修改引脚状态;而side-set是“搭便车”,可以让你在每一条指令执行的同时,顺带设置 1 到 5 个引脚上的电平。
用 side-set 点灯可以写成这样:
@asm_pio(sideset_init=PIO.OUT_LOW) def led_blink_sideset(): nop().side(1) [31] nop().side(0) [31]这里nop()本身什么都没做,真正改变引脚电平的是.side(1)和.side(0)。好处是引脚翻转不占用额外的指令周期,一段程序里可以在做数据移位、等待、跳转的同时把波形输出出去。官方很多协议示例里都会用 side-set,理解它对后面看源码很重要。
不过需要提醒一下:side-set 的引脚范围和普通SET的引脚范围是在状态机初始化时分别配置的,并不是同一个寄存器。点灯这个场景里,用普通SET就够了,side-set 属于锦上添花。我在第 3 章会分别给出两版代码,你自己烧进去对比一下节奏和效果。
2.4 PIO 指令集速览
PIO 的完整指令集只有 9 条,任何复杂协议都是这些指令组合出来的:
| 指令 | 作用 | 本实验是否用到 |
|---|---|---|
| JMP | 跳转,可配合条件判断或寄存器递减 | 扩展实验用到 |
| WAIT | 等待 GPIO 电平或 IRQ 条件 | 暂不用 |
| IN | 从引脚或移位寄存器读入数据 | 暂不用 |
| OUT | 把移位寄存器数据输出到引脚或寄存器 | 暂不用 |
| PUSH | 把 ISR 数据写入 RX FIFO | 暂不用 |
| PULL | 从 TX FIFO 取数据到 OSR | 扩展实验用到 |
| MOV | 寄存器间传数据 | 扩展实验用到 |
| IRQ | 触发中断信号,可以阻塞或非阻塞 | 暂不用 |
| SET | 直接把立即数写到引脚或寄存器 | 核心使用 |
点灯只需要SET和 JMP,最多加一个PULL和MOV。所以新手不用担心指令太多,先把SET和时钟分频搞明白,这个实验就完成一大半了。
2.5 时钟分频:控制闪烁频率的真正开关
每个 PIO 状态机都有一个独立的时钟分频器,组成是 16 位整数加 8 位小数。系统时钟(通常是 125MHz)经过分频后,才是状态机实际运行的频率。状态机频率越高,单位时间执行的指令越多,引脚翻转就越快;频率越低,整个程序跑得越慢。
如果你想让 LED 一个亮灭周期刚好是 1 秒,可以把状态机频率设置为 64Hz。为什么是 64?因为亮和灭各占 32 个周期,两个阶段加起来是 64 个周期,1 秒除以 64,每个周期就是 15.625ms,32 个周期就是 500ms。
在 MicroPython 里,创建状态机时传入freq=64就完成了分频设置。在 C SDK 里则是通过sm_config_set_clkdiv配置,或者直接用pio_sm_init的默认配置。理解分频后,你会发现 PIO 的延时范围和精度都相当可观:如果你想做高频信号,可以把状态机频率调到几百兆分之一附近的数值;如果你想让 LED 慢慢呼吸,把分频调低即可。
3. 手把手实现:板载 LED 闪烁
3.1 硬件准备与固件选择
这次实验用的是经典款 Raspberry Pi Pico,板载 LED 连接在 GPIO25。断开所有接线,只插一根 USB 数据线到电脑就够了。如果你想用 Pico W,要特别注意一点:Pico W 的板载 LED 不是直接挂在 RP2040 的 GPIO 上,而是由无线模组 CYW43439 控制的,PIO 没法直接驱动它。所以 Pico W 用户要么把代码里的引脚改到外接 LED,要么干脆外接一个 LED 到 GP16。
软件开发方式我推荐两条路:
- MicroPython:上手快,代码短,适合先理解 PIO 概念。
- C SDK:更接近寄存器底层,适合后面做正式项目。
我自己的经验是:先用 MicroPython 跑通,再用 C SDK 复现一遍,两次对比下来,对 PIO 的认识会深很多。
3.2 MicroPython 版:核心代码与逐段解析
先看完整代码:
from machine import Pin from rp2 import asm_pio, StateMachine, PIO @asm_pio(set_init=PIO.OUT_LOW) def led_blink(): set(pins, 1) [31] set(pins, 0) [31] sm = StateMachine(0, led_blink, freq=64, set_base=Pin(25)) sm.active(1)这段代码看似只有几行,但每一行都有讲究。
@asm_pio(set_init=PIO.OUT_LOW)是 MicroPython 的 PIO 汇编装饰器。set_init=PIO.OUT_LOW告诉状态机:启动时,把用作SET输出的引脚方向设为输出,并初始化为低电平。这样 LED 在上电后是灭的,不会先闪一下高电平再进入我们的节奏。
函数体内的两行就是实际运行的 PIO 汇编:
set(pins, 1) [31] set(pins, 0) [31]set(pins, 1)的意思是拉高当前set_base指定的引脚,[31]表示这条指令执行完后再空转 31 个额外周期。set(pins, 0)同理拉低。状态机自动回绕到前面继续执行,所以程序会一直循环闪烁。
接下来是关键的一行:
sm = StateMachine(0, led_blink, freq=64, set_base=Pin(25))这里的状态机编号是 0,程序映射的是我们刚才定义的led_blink。freq=64把状态机时钟压到 64Hz,这样每条指令总周期是 32,高和低各 500ms,整体闪烁频率正好 1Hz。set_base=Pin(25)把SET指令操作的引脚绑定到 GPIO25,也就是板载 LED。
最后sm.active(1)是启动状态机。PIO 状态机默认是停着的,不写这行,LED 永远不会亮。如果想让 LED 在 10 秒后停止:
import time sm.active(1) time.sleep(10) sm.active(0)有几点值得记一下:
sm.active(1)之后不要再用普通Pin对象去操作同一个 GPIO,否则会跟 PIO 抢控制权。- 程序停止后,引脚会停在最后一个状态,可能是高也可能是低,这是正常的。
- MicroPython 每次上电都要重新运行这段代码,PIO 程序本身不会保存到芯片内部。
3.3 用 side-set 写出另一版闪灯
为了把 side-set 练熟,我建议你改成这样:
from machine import Pin from rp2 import asm_pio, StateMachine, PIO import time @asm_pio(sideset_init=PIO.OUT_LOW) def led_blink_sideset(): nop().side(1) [31] nop().side(0) [31] sm = StateMachine(0, led_blink_sideset, freq=64, sideset_base=Pin(25)) sm.active(1)区别在装饰器参数换成了sideset_init,初始化引脚用到sideset_base,程序体里用nop().side(1)和nop().side(0)去切换电平。.side()和指令是并行发生的,所以状态机执行到nop().side(1) [31]时,指令本身没什么动作,但引脚立刻拉高了,然后空转 31 个周期,再执行下一条。
从 LED 现象来看,这版和上一版几乎一样。但如果你用逻辑分析仪看波形,会发现用 side-set 时,翻转动作发生在指令周期开头,普通SET也一样。两者真正的差别在复杂协议里:side-set 可以让“改引脚”这件事跟“数据处理”同步完成,减少占用指令条数。
3.4 通过 FIFO 控制亮灭时长
点灯入门到这里基本够了,但我想再往深处走一步:让 PC 端运行时决定 LED 该亮多久。PIO 状态机可以从 TX FIFO 里拿数据,这个动作由PULL指令完成。下面这段代码会从 FIFO 读一个数,高电平持续这个数对应的周期,再读下一个数,低电平也持续对应周期:
from machine import Pin from rp2 import asm_pio, StateMachine, PIO @asm_pio(set_init=PIO.OUT_LOW) def led_blink_fifo(): pull() mov(x, osr) set(pins, 1) label("high_loop") jmp(x_dec, "high_loop") pull() mov(x, osr) set(pins, 0) label("low_loop") jmp(x_dec, "low_loop") sm = StateMachine(0, led_blink_fifo, freq=1000, set_base=Pin(25)) sm.active(1) while True: sm.put(1000) sm.put(1000)解释一下执行过程:
pull()从 TX FIFO 取一个 32 位数,放进 OSR;mov(x, osr)把 OSR 的数据复制到 X 寄存器;set(pins, 1)拉高引脚;jmp(x_dec, "high_loop")每执行一次 X 就减 1,减到 0 之后不再跳回,继续往下走。
这里状态机频率是 1000Hz,每次 put 进去的 1000,就对应 1 秒。所以主程序每次放两个 1000,LED 就会亮 1 秒、灭 1 秒。你可以改成sm.put(500)让它快速闪,或者sm.put(2000)让它慢下来。
这个例子的价值在于:PIO 不再是一个写死的程序,它可以和 CPU 配合,随时调整时序参数。之后学 PIO 驱动 WS2812 灯带时,就是通过 FIFO 不断把颜色数据喂给状态机,原理和这里一脉相承。
3.5 C SDK 版:更接近底层的实现
如果你想做正式一点的嵌入式项目,C SDK 是绕不开的。PIO 的 C 开发通常要写一个.pio文件,然后用 pioasm 工具生成头文件,再在 C 代码里调用生成的函数。
先新建一个led_blink.pio:
.program led_blink set pindirs, 1 .wrap_target set pins, 1 [31] set pins, 0 [31] .wrap注意这里我用set pindirs, 1在程序开头把 set 引脚配置为输出,然后进入主循环。set pindirs操作的是引脚方向寄存器,和set pins操作的电平寄存器不同,但都通过 SET 指令的引脚映射来定位。
然后是main.c:
#include "pico/stdlib.h" #include "hardware/pio.h" #include "led_blink.pio.h" int main(void) { const uint LED_PIN = 25; PIO pio = pio0; uint offset = pio_add_program(pio, &led_blink_program); uint sm = pio_claim_unused_sm(pio, true); pio_sm_config c = led_blink_program_get_default_config(offset); sm_config_set_set_pins(&c, LED_PIN, 1); pio_gpio_init(pio, LED_PIN); pio_sm_set_consecutive_pindirs(pio, sm, LED_PIN, 1, true); pio_sm_init(pio, sm, offset, &c); pio_sm_set_enabled(pio, sm, true); while (true) { tight_loop_contents(); } }pio_add_program负责把.pio里的程序加载进 32 条指令存储区,返回程序第一条指令的偏移量。pio_claim_unused_sm自动找一个空闲状态机,避免你自己管理编号。pio_gpio_init把 GPIO25 切换到 PIO 功能,pio_sm_set_consecutive_pindirs把从 GPIO25 开始的 1 个引脚设置为输出。
sm_config_set_set_pins很重要,它告诉 PIO:SET指令里的引脚 0 对应物理 GPIO 的哪一位。这里把引脚 0 映射到 GPIO25,所以汇编里的set pins, 1就会拉高 GPIO25。
CMake 里要做两件事:添加pico_stdlib和hardware_pio库,并调用pico_generate_pio_header生成头文件:
add_executable(led_blink main.c) pico_generate_pio_header(led_blink ${CMAKE_CURRENT_LIST_DIR}/led_blink.pio) target_link_libraries(led_blink pico_stdlib hardware_pio)编译烧录后,效果和 MicroPython 版完全一致。如果你之前没接触过 pioasm,第一次看到“自动生成头文件”可能会觉得封装得太黑盒。其实.pio.h里就是一些数组、宏和 init 函数,本质上把我们手写的那几行汇编指令翻译成机器码常量,再提供安装配置的函数接口。
4. 换成外部 LED 或 RGB LED 时怎么改
4.1 外接 LED 的接线和限流电阻
PIO 点板载 LED 不用管电路,因为板子上已经把串联电阻做好了。但如果你外接 LED,一定要记得加限流电阻,不然很容易烧坏引脚或 LED。
常见的红色 LED 正向压降约 2.0V,Pico 的 GPIO 高电平是 3.3V。如果要限制电流在 10mA 左右,电阻大约为:
R = (3.3V - 2.0V) / 0.01A = 130Ω
实际工程里取常见的 220Ω 或者 330Ω 都可以,LED 亮度略低但更安全。绿色和蓝色 LED 的正向压降更高,阻值按同样的公式算出来会更小一些,但通常也用 100Ω 到 220Ω 之间的标准值。
接线非常简单:GPIO 串联电阻再接 LED 正极,LED 负极接 GND。注意极性不要接反,反接时 LED 不会亮,但不至于损坏,只是菜鸟常见问题。
大功率 LED 需要注意:PIO 只是产生控制信号,GPIO 输出电流很有限,不能直接驱动 1W、3W 这类大功率 LED。要么用 MOSFET 开关电路,要么用专门的 LED 驱动芯片,PIO 只管提供精确的 PWM 或闪灯信号。这也回应了很多人在网上搜“led闪灯驱动芯片”“led驱动电路”的需求——控制逻辑和功率电路要分开。
4.2 改引脚和多路闪烁
把代码里的set_base从 GPIO25 改成 GPIO16,不需要动 PIO 程序本身,就能让同一个状态机驱动外部 LED:
sm = StateMachine(0, led_blink, freq=64, set_base=Pin(16))如果你想同时点两个 LED,可以申请第二个状态机:
sm0 = StateMachine(0, led_blink, freq=64, set_base=Pin(25)) sm1 = StateMachine(1, led_blink, freq=64, set_base=Pin(16)) sm0.active(1) sm1.active(1)因为两个状态机可以共享同一段指令程序,所以led_blink函数只用定义一次。RP2040 有 8 个状态机,理论上可以同时维护多路不同频率或不同波形的输出,这也是 PIO 的强项。
4.3 PIO 点灯和 PWM 点灯怎么选
我在网上看到很多“PWM 驱动 LED RGB”的热搜词。如果你只是想让 LED 实现呼吸灯效果,或者调亮度,那用 RP2040 自带的 PWM 外设更合适。PIO 适合的场景是“精确控制时序”和“模拟通信协议”,比如 WS2812 灯带的单总线协议,每个 bit 的高低电平时间要精确到几百纳秒,用普通 GPIO 翻转很难稳定,用 PIO 就是官方推荐方案。
打个比方:PWM 是自带调光功能的电源开关,PIO 是能自己写剧本的编排师。呼吸灯用 PWM 很顺手,但你要给一串 60 个 WS2812 灯珠按时序喂数据,PIO 才是正解。
5. 常见问题与排查实录
5.1 程序上传后 LED 完全不亮
这是所有人都可能遇到的第一道坎。先确认你的板子是原版 Pico 还是 Pico W。W 版板载 LED 不在 GPIO25,而是由无线芯片控制,所以 MicroPython 里set_base=Pin(25)无效。最直接的验证办法:外接一个 LED 到 GPIO16,看看程序是不是能工作。
如果确认是原版 Pico,再看代码里有没有sm.active(1)。PIO 状态机不是通电就开始跑的,必须显式启动。很多人在 REPL 里只定义了sm,没有执行sm.active(1),LED 自然不亮。
还有一个容易忽略的点:烧录后如果程序立即崩溃,状态机可能已经被启用但引脚的初始方向被你后面代码改掉了。MicroPython 下尽量不要在同一引脚上再创建普通Pin对象,否则谁最后设置方向,谁就会覆盖 PIO 的配置。
5.2 状态机好像没跑起来?
建议先做一个最笨的验证:把freq调低到 2Hz,程序改成只拉高引脚然后跳转到自身,LED 应该保持常亮而不是闪烁。如果常亮也做不到,问题大概率在引脚方向或状态机启动。
MicroPython 里可以检查sm.active()的返回值,但不能直接查看 PIO 内部的 PC 指针。没有调试器时,最快的手段是外接一个逻辑分析仪,直接看引脚波形。没有逻辑分析仪的,就用“眼睛看频率”来判断,只要把频率调到 0.5Hz 到 2Hz 之间,LED 状态变化非常明显。
5.3 闪烁频率和预期不一致怎么办
先对照公式:输出翻转频率 = 状态机频率 / (2 × (1 + delay))。如果状态机频率是 2000Hz,delay 是 31,那么亮灭一次需要 64 个周期,实际闪烁频率约 31.25Hz,而不是 2000Hz。很多人把 delay 当成“额外延时”,却忘了每条指令本身占用 1 个周期。
另一个坑是系统时钟频率。C SDK 里如果你在启动代码中调用了set_sys_clock_48mhz()或超频,PIO 的分频基准会变。MicroPython 一般默认 125MHz,但某些固件或主频设置也可能不同。算频率时要基于实际clk_sys,而不是拍脑袋按 125MHz 算。
如果 LED 只是微弱地抖动,说明频率太高,人眼能捕捉到变化但无法区分亮灭。把freq降到 64Hz 或者更大 delay,现象就明显了。
5.4 C SDK 编译常见坑
C 开发最常见的错误是找不到led_blink.pio.h。这个文件不是手动写的,而是 CMake 里通过pico_generate_pio_header自动生成的。漏掉这个函数,或者路径写错,编译就会失败。
还有一个坑是库没有链接。PIO 相关函数都在hardware_pio库里,CMake 里如果只链接了pico_stdlib,编译时通常不会报错,链接时就会疯狂报 undefined reference。正确写法:
target_link_libraries(led_blink pico_stdlib hardware_pio)此外,pio_claim_unused_sm(pio, true)的第二个参数true表示如果找不到空闲状态机就硬断言报错。如果之前有程序在运行没有释放状态机,这一步可能卡住。嵌入式开发里最好养成“复位后重新加载”的习惯,状态机和外设状态才会回到默认。
5.5 调试 PIO 的几个土办法
没有示波器的时候,我最常用的方法是“频率降级”。把状态机频率一路降到 1Hz 到 4Hz,用肉眼看一下现象是否符合预期。如果低频下程序正确,高频下出问题,那通常是频率计算错误或引脚驱动能力不足,而不是 PIO 程序本身有错。
同时建议准备一个逻辑分析仪,几十块钱的 8 通道逻辑分析仪就够用。把通道夹在 LED 引脚和 GND 上,可以直接看到波形周期和高低电平占比。调试 PIO 的最大痛苦在于“看到了现象,但不知道状态机执行到哪一步”,有了波形图,很多问题一眼就能定位。
最后分享一个小经验:PIO 的 32 条指令存储区看起来很小,但入门点灯这种任务完全足够。不要一上来就想用 PIO 写很长的程序,模块化和复用才是它的生存方式。把“点灯”作为第一个烧进去的 PIO 实验,后面再去啃那些官方协议示例,你会发现所有复杂的东西,都是从这条最简单的指令流生长出来的。