
如果你的项目用到了树莓派 Pico又在某个深夜发现手里那颗传感器非要第二个 UART 口不可——恭喜你要么挪线、要么换片子、要么开始研究 PIO 模拟 UART。PIO 这个外设在 RP2040 上相当有名WS2812 灯带、DVI 输出、高速数字采集全靠它而用 PIO 状态机在任意 GPIO 上实现串口通信几乎是每个玩 Pico 的人早晚都会撞上的必修课。这篇文章没什么弯弯绕直接把我调试 PIO 模拟 UART 的过程、代码、参数计算和踩过的坑全部摊开。你不需要懂汇编甚至不需要先看 RP2040 数据手册跟着文章走一遍就能在自己的板子上把任意 GPIO 变成串口 TX、RX。适合刚接触 PIO 的新手也适合在项目里被多路串口逼到墙角、想快速确认“PIO 到底能不能干这活”的开发者。1. 为什么好好的 UART 要拿 PIO 模拟1.1 Pico 的硬件 UART 资源到底紧张在哪树莓派 Pico 用的 RP2040 芯片内部其实有两个完整 UART 外设UART0 和 UART1。每个外设支持标准 8N1、奇偶校验、硬件流控等功能上完全够用问题出在引脚复用和项目需求上。一个项目里如果同时接 GPS、指纹模块、RS485 转接板和调试串口两个 UART 很容易不够分。更麻烦的是RP2040 的 UART 引脚不是随便选的每组 UART 只有固定的几个引脚复用位置比如 UART0 通常只能走 GPIO0/1、GPIO12/13 或 GPIO16/17UART1 只能走 GPIO4/5、GPIO8/9 或 GPIO20/21。当你的 I2C 占用了一组引脚、SPI 占用了一组引脚之后剩下能留给 UART 的物理位置往往会把你逼到重新画 PCB 的边缘。这也是 PIO 方案最吸引人的地方PIO 状态机的输出引脚映射是可以任意指定的。只要 GPIO0 到 GPIO28 之间没有硬件占用你就能把它配置成串口引脚。线怎么走方便就怎么走不需要为 UART 的固定引脚让路。对做原型验证和复杂接线的项目来说这个自由度非常值钱。1.2 PIO 模拟 UART 适合什么场景不建议什么场景先说适合的场景。第一类就是“串口不够用”需要跑三四个串口、每个串口速率不高的场景比如接传感器、蓝牙模块、串口屏。第二类是“引脚被钉死”硬件 UART 的可用引脚位置和你的实际布线冲突但项目又不能改板子这时用 PIO 把串口放到空闲引脚上是最快的出路。第三类是我个人比较喜欢的学习 UART 协议本身。用 PIO 亲手把起始位、数据位、停止位一条条做出来你对串口时序的理解会比单纯调用库函数深刻得多。不建议的场景也客观说清楚。PIO 终究不是硬件 UART没有内置的 FIFO 溢出错误标志、帧错误标志、break 检测等高级功能。如果要做工业级长距离 RS485、需要严格硬件流控、或者波特率要跑到 3Mbps 以上并长时间拷机我建议还是用硬件 UART 或者外部 UART 芯片。PIO 模拟 UART 最适合的是设备间短距离的 TTL 电平通信实用上限一般是 921600 或 1.5Mbps 左右再往上就是给自己找不痛快了。1.3 MicroPython 和 C SDK 我应该用哪个看你的项目阶段。MicroPython 写起来快几行代码就能让状态机跑起来适合验证方案、学习原理、做原型。C SDK 更适合产品化可以精确控制时钟分频、用 DMA 搬运 FIFO 数据、注册中断性能和可靠性上限更高。这篇文章主体用 MicroPython 讲原因很简单代码短容易复现不用搭 pioasm 工具链。但我会在关键地方把 C SDK 的配置思路也讲清楚因为 PIO 的工作机制是同一套你只要理解了状态机在干什么换语言只是换层皮。2. 拆开 PIO 状态机它凭什么能干串口的活2.1 PIO 指令集和“每个时钟周期干一件事”的执行模型PIO 全称 Programmable I/O是 RP2040 上两颗专门处理数字信号的协处理器。每个 PIO 里有 4 个状态机每个状态机是一个简单的、确定性的单周期处理器跑在独立的时钟分频上。状态机上有两个通用寄存器 X 和 Y还有两个关键移位寄存器OSR输出移位寄存器和 ISR输入移位寄存器以及 TX FIFO 和 RX FIFOFIFO 的深度默认是 4 字节也可以合并成 8 字节。状态机的指令集小得可怜常用的就是 PULL、PUSH、OUT、IN、SET、WAIT、JMP、MOV、NOP。每一条指令的执行时间是确定的还可以在指令末尾加一个 0 到 31 的延时后缀比如set(pins, 0) [3]表示这条指令除了自身占用的 1 个周期外再额外延时 3 个周期总共占 4 个周期。对所有支持延时后缀的指令来说这种“指令周期 显式延时”的组合就是精确控制时序的基础。对串口来说这套精简指令其实刚刚好。UART 的时序本质上就是按波特率把电平切成一格一格的PIO 状态机只要能在正确的时刻把引脚电平拉高拉低、在正确的时刻读取引脚电平就能完成串口收发。不用关心 CPU 主循环在干什么状态机自己按照配置好的时钟频率跑天然就是“硬件实时”。2.2 从字节到波形TX 状态机怎么把串口帧发出去一个标准 8N1 串口帧长这样空闲时引脚保持高电平发送时先把引脚拉低一个位时间作为起始位然后从最低位开始依次输出 8 个数据位最后把引脚拉高一个位时间作为停止位。接收方就是靠这个“高到低”的跳变找准帧的开始的。TX 侧 PIO 程序做的事情非常直白从 TX FIFO 拉一个字节到 OSR暂停一下准备移位计数把引脚拉低持续一个位时间输出起始位循环 8 次每次从 OSR 移出一位到引脚持续一个位时间数据位按 LSB first 顺序输出把引脚拉高持续一个位时间输出停止位回到第 1 步等下一个字节。把这段逻辑写成 PIO 汇编最核心的部分就是用延时后缀把每个状态机时钟周期凑成固定的“每 bit 周期数”。我在代码里把每个 bit 设置为 4 个状态机周期也就是说状态机运行频率等于波特率的 4 倍。这个倍数不需要太大因为 TX 只是输出电平不存在采样误差问题4 倍已经足够。2.3 从波形到字节RX 状态机怎么把起始位和 8 个数据位捞回来RX 侧要麻烦一些。UART 没有时钟线接收方必须自己判断每一帧从哪里开始、每个数据位的中点大概在什么位置。硬件 UART 的典型做法是用 16 倍波特率过采样先检测到下降沿再在数据位中点附近采样。PIO RX 程序的核心思路和硬件 UART 没有本质区别用 WAIT 指令阻塞等待引脚出现低电平也就是起始位的下降沿等到下降沿之后延时 1.5 个位时间让采样点正好落在第一个数据位的正中间之后每间隔一个位时间采样一次引脚连续采 8 次8 个 bit 收齐后通过 PUSH 指令自动推进 RX FIFO应用层直接读 FIFO 就能拿到一个完整字节。为了让采样点尽量远离数据位的跳变沿我这里把 RX 的每个 bit 周期设置成 16 个状态机周期比 TX 多了不少余量。下降沿检测本身存在最多一个状态机周期的同步误差16 个周期一平均误差占比只有 6.25% 左右对 UART 通信来说是完全可以接受的。2.4 波特率是怎么一步一步算出来的PIO 状态机的实际运行频率由系统时钟和状态机时钟分频器共同决定。RP2040 正常跑在 125MHz状态机分频器的格式是 16.8 定点整数部分 8 位、小数部分 8 位最小分频步进是 1/256。TX 侧每 bit 占 4 个状态机周期所以波特率的计算公式是baud 125000000 / (clkdiv * 4)反推 clkdiv当 baud 115200 时clkdiv 125000000 / (115200 * 4) ≈ 271.267如果分频器粗配成 271实际波特率大约 115313误差只有 0.098%在 UART 通常允许的 ±2% 以内完全没问题。RX 侧每 bit 占 16 个状态机周期公式变成baud 125000000 / (clkdiv * 16)同样以 115200 为例clkdiv 大约等于 67.82配置误差同样远低于 UART 容限。在 MicroPython 里你其实不用手算 clkdiv初始化 StateMachine 时直接给freq115200*4或者freq115200*16就行固件会自动换算。但我强烈建议你自己算一遍否则调试时遇到波特率偏差你连该往哪个方向调都不清楚。2.5 PIO 是“软串口”但比中断收发稳在哪很多人一听 PIO 模拟 UART第一反应是“这不就是软件模拟串口吗”。其实完全不是一回事。Arduino 上常见的 SoftwareSerial 靠的是定时器中断和 CPU 在中断服务程序里逐 bit 翻转引脚CPU 负载高时序受中断响应延迟影响很大波特率一高就容易乱码。PIO 状态机是独立于 CPU 的硬件单元程序按固定的时钟周期执行不依赖中断、不占用主循环时序完全确定。一个状态机在跑 UART 的时候CPU 可以同时做别的事比如处理传感器数据、刷新屏幕。而且一个 PIO 有 4 个状态机两块 PIO 就是 8 个状态机在不和其他 PIO 程序冲突的情况下最多可以虚拟出多路 UART这是普通软件模拟方案完全做不到的。3. 实战让任意 GPIO 变成串口引脚3.1 接线和准备准备好一块树莓派 Pico刷好 MicroPython 固件。你可以用 USB 转 TTL 模块把电脑串口和 Pico 连起来测试也可以直接把 Pico 的两个 GPIO 短接做环回测试。我用 GPIO4 作为 TX、GPIO5 作为 RX。接线非常简单Pico GPIO4 接 USB 转 TTL 模块的 RXPico GPIO5 接 USB 转 TTL 模块的 TXPico 的 GND 接 USB 转 TTL 模块的 GND如果做环回测试直接把 GPIO4 和 GPIO5 用一根杜邦线连起来就行。注意 UART 空闲时引脚是高的给 RX 引脚打开内部上拉会更稳后面我会解释为什么。3.2 状态机初始化代码先定义 PIO 程序再创建状态机。MicroPython 的rp2.asm_pio装饰器负责把 Python 风格的 PIO 汇编编译成状态机程序。TX 状态机完整代码如下from machine import Pin from rp2 import PIO, StateMachine, asm_pio asm_pio( out_shiftdirPIO.SHIFT_RIGHT, autopullTrue, pull_thresh8, ) def uart_tx(): pull() # 从 TX FIFO 拉一个字节到 OSR set(x, 7) # 预置 8 位计数注意这里是空闲期不影响波形 set(pins, 0) [3] # 起始位拉低 4 个时钟周期 label(bitloop) out(pins, 1) [2] # 从 OSR 移出 1 位到引脚占 3 个周期 jmp(x_dec, bitloop) # 再占 1 个周期每个 bit 共 4 个周期 set(pins, 1) [3] # 停止位拉高 4 个时钟周期RX 状态机完整代码如下asm_pio( in_shiftdirPIO.SHIFT_RIGHT, autopushTrue, push_thresh8, ) def uart_rx(): wait(0, pin, 0) # 等待起始位下降沿 set(x, 7) [23] # 延时 24 个时钟周期到 bit0 中心同时预置计数 label(bitloop) in_(pins, 1) [14] # 采样 1 位占 15 个周期 jmp(x_dec, bitloop) # 补 1 个周期每个 bit 共 16 个周期RX 的时序我前面讲过等待下降沿之后延时 1.5 个位时间就是 1.5 × 16 24 个周期正好落在第一个数据位的中点后面每个 bit 固定 16 个周期采样点会依次落在 bit1、bit2……bit7 的中心这是整个程序里最核心的细节。3.3 发送端代码与验证初始化 TX 状态机BAUD 115200 tx_pin Pin(4, Pin.OUT, value1) sm_tx StateMachine( 0, uart_tx, freqBAUD * 4, out_basetx_pin, set_basetx_pin, ) sm_tx.active(1)注意这里同时传了out_base和set_base都指向同一个 GPIO4。原因很简单起始位和停止位是固定电平用 SET 指令产生数据位是动态的必须用 OUT 指令从 OSR 移位输出。如果你只配了其中一个PIO 执行另一条指令时会找不到引脚映射大概率直接报错。发送一个字节sm_tx.put(0x55)0x55 的二进制是 01010101在示波器上能看到非常漂亮的方波序列。发送字符串可以直接用sm_tx.write(bhello pio uart)write方法内部会循环把每个字节塞进 FIFO适合发短串。如果发长串FIFO 只有 4 字节深度write会阻塞到 FIFO 有空位这一点要心里有数。3.4 接收端代码与验证初始化 RX 状态机rx_pin Pin(5, Pin.IN, Pin.PULL_UP) sm_rx StateMachine( 1, uart_rx, freqBAUD * 16, in_baserx_pin, ) sm_rx.active(1)读取一个字节if sm_rx.rx_fifo() 0: data sm_rx.get() print(hex(data))rx_fifo()返回当前 RX FIFO 里可读的字节数get()会取走一个字节。如果你想阻塞等数据直接sm_rx.get()也可以但实际项目里一般不会让主循环干等还是轮询 FIFO 更常见。3.5 收发互通测试最简单的验证方式是环回测试。把 GPIO4 和 GPIO5 短接然后执行sm_tx.put(0x55) time.sleep_ms(10) if sm_rx.rx_fifo() 0: print(hex(sm_rx.get()))如果一切正常应该打印0x55。如果你手头有 USB 转 TTL 模块用电脑串口助手向 Pico 发数据或者让 Pico 往电脑发数据也能验证双向通信。注意两边波特率必须一致我这边统一用的 115200。如果收回来发现字节好像反了比如发 0x55 收到 0xAA问题出在移位方向把in_shiftdir从PIO.SHIFT_RIGHT换成PIO.SHIFT_LEFT再试。4. 时序细节与参数控制4.1 采样点为什么必须落在数据位中心UART 没有独立时钟线接收方唯一的参考点就是起始位的下降沿。从下降沿开始起始位占 1 个 bit、bit0 占 1 个 bit所以 bit0 的中心点就在下降沿之后 1.5 个 bit 时间的位置。采样点越靠近数据位中心对信号抖动、边沿毛刺、波特率细微偏差的容忍度就越高。如果采样点偏到数据位边缘哪怕是一丁点噪声都可能导致这一位读错整帧字节就乱了。硬件 UART 普遍用 16 倍过采样本质原因就在这里。PIO RX 状态机虽然没有硬件 UART 那么多级过采样逻辑但通过 WAIT 加延时的方式效果是一样的。我把 RX 每个 bit 设成 16 个状态机周期下降沿检测最多误差 1 个周期占比 6.25%足够覆盖绝大多数线缆和电平转换场景。4.2 TX 为什么需要同时配置 out_base 和 set_base这是新手最容易卡住的一个点。MicroPython 的 StateMachine 构造函数里有多个引脚相关参数out_base决定 OUT 指令的目标引脚set_base决定 SET 指令的目标引脚它们可以不一样也可以一样。TX 程序里起始位和停止位用的是set(pins, 0)和set(pins, 1)这属于 SET 指令引脚来自set_base。8 个数据位用的是out(pins, 1)这属于 OUT 指令引脚来自out_base。如果我只传set_base不传out_base程序跑到out指令时就没有合法的引脚映射状态机跑不起来。所以必须两个参数一起传指向同一个物理引脚。这也是我在调试时第一次报错的地方当时日志提示得非常模糊排查了好一会儿才意识到是两个 base 的问题。4.3 分频误差对波特率的影响状态机分频器是 16.8 定点格式不是任意小数都能精确表示所以多数波特率配置下来都会有一点误差。关键是误差到底有多大以及收发双方加在一起的误差会不会超过 UART 容限。我实际算过几个常用波特率按系统时钟 125MHz、TX 每 bit 4 周期、RX 每 bit 16 周期来算波特率TX 理论 clkdivTX 实际误差RX 理论 clkdivRX 实际误差96003255.21约 0.006%813.80约 0.02%115200271.27约 0.098%67.82约 0.27%92160033.91约 0.27%8.48约 0.55%就算是最高的 921600单边误差也只有 0.55%两个设备加起来一般不会超过 1.1%仍然在 UART 的 ±2% 容限内。MicroPython 固件在配置 freq 时会自动选择最接近的分频值实际误差会比表格值更小一些。如果你的应用特别在意误差可以用 C SDK 手动指定分频到最接近的小数再把系统时钟倍频到能被波特率整除的频率。4.4 FIFO 水位与流控PIO 状态机的 FIFO 深度只有 4 字节这是整个方案的天然短板。TX 侧如果程序往 FIFO 里塞数据的速度超过 UART 发送速度put或write会阻塞主循环可能被卡住。RX 侧如果应用层读取速度慢于对方发送速度RX FIFO 满之后新来的字节会直接丢弃而且没有类似硬件 UART 的溢出标志可以查询。实战里我一般这么处理RX 侧在主循环里高频轮询rx_fifo()把数据及时搬走如果数据量大且突发性强就会考虑用 C SDK 加 DMA让 DMA 直接把 PIO RX FIFO 的数据搬到内存缓冲区。MicroPython 目前对 PIO 的 DMA 支持不够方便所以这个方案更适合产品化阶段。对大多数传感器数据采集场景4 字节 FIFO 加轮询读取已经完全够用。5. 踩坑记录与问题速查5.1 接收端首字乱码我在第一次跑通环回测试时前几个字节总是收到 0x00 或者莫名其妙的数据后面才恢复正常。排查下来有两个原因一是上电瞬间 TX 引脚默认状态不确定如果 TX 引脚在初始化前被拉低RX 的 WAIT 指令会立刻把它当成起始位开始采样一帧全零数据二是 RX 引脚没有上拉空闲电平时因为悬空导致误触发。解决方法是把 TX 引脚初始化为高电平Pin(4, Pin.OUT, value1)同时给 RX 引脚开内部上拉Pin(5, Pin.IN, Pin.PULL_UP)。如果你在发送设备初始化之前就打开了 RX 状态机可以在sm_rx.active(1)之后清一次 FIFOwhile sm_rx.rx_fifo() 0: sm_rx.get()5.2 高波特率偶发错位921600 以下基本稳定但我试着把波特率拉到 2Mbps 时开始出现偶发错位现象是一整串数据里偶尔有个字节对不上。检查后发现两个问题一是杜邦线太长信号边沿劣化二是 RX 每 bit 只有 16 个周期下降沿同步误差占比被放大。处理方式是缩短接线、降低波特率或者把 RX 的过采样倍数提高。PIO 的延时后缀最大是 31所以如果想把 RX 改成每 bit 32 个周期需要在延时上稍微拆一下比如把 1.5 个位时间的 48 个周期拆成set(x, 7) [31]加一条nop() [15]。提高过采样倍数之后同样的下降沿同步误差占比从 6.25% 降到 3.125%稳定性明显改善。5.3 两个状态机同时用同一 PIO 时的坑一个 PIO 有 32 条指令空间TX 程序和 RX 程序加起来也就 10 条指令左右完全放得下。但要注意如果你还在同一个 PIO 里跑了 WS2812 驱动、DVI 输出等程序指令空间可能不够。另外同一个 PIO 的 4 个状态机共用一个时钟源吗不是每个状态机有独立的分频器所以可以让不同状态机跑不同波特率互不影响。真正容易踩的坑是 GPIO 映射冲突。同一个 GPIO 可以被多个状态机引用但如果你在一个状态机里把 GPIO4 设为输出又在另一个状态机里把 GPIO4 设为输入两边就会打架。我在调试时曾经把 TX 和 RX 配到了同一个引脚上状态机“看起来”都能跑但信号互相干扰数据完全不对。所以配置之前先列一张引脚占用表确认所有 PIO 程序和硬件外设的引脚不重叠。5.4 卡在 wait 指令上排查RX 程序的wait(0, pin, 0)是阻塞型指令意思是“没等到引脚变低就一直停在这里”。如果对端一直没有发送数据比如 TX 线空闲保持高电平这个状态机停住是完全正常的。很多人误以为程序死锁了其实不是。如果你怀疑真的卡住了用万用表或示波器量一下 RX 引脚电平。如果 RX 引脚一直是低电平说明对端可能把 TX 拉死了或者波特率不匹配导致波形异常。如果 RX 引脚一直高电平说明对方还没开始发数据或者接线没接对。这个思路虽然简单但能把排查范围缩小一大半。5.5 问题排查速查表现象可能原因排查与解决完全收不到数据接线错误、TX/RX 接反、未共地检查接线确认 GND 相连收到 0x00 或乱码引脚初始电平异常、波特率不匹配TX 引脚初始化成高RX 开上拉首字节正常后续丢字节RX FIFO 溢出读取不够快提高轮询频率或改用 C SDK 加 DMA字节位序反转移位方向配置错误把 in_shiftdir 或 out_shiftdir 换方向高波特率偶发错位线缆过长、采样点误差偏大缩短接线提高 RX 过采样倍数状态机初始化报错out_base / set_base 没有同时配置确认 TX 的 out_base 和 set_base 都指向同一个 GPIO我实际用这套 PIO UART 方案在项目里同时跑了三路串口一路接 GPS一路接电子罗盘一路做调试输出系统时钟还是默认的 125MHz状态机之间互不干扰跑了几天没有出现过丢帧。方案本身不复杂真正难的是把采样点、分频和引脚映射这些细节抠明白。如果你也在这条路上踩到新的坑或者发现某些波特率下有更好的参数组合欢迎回来交流。