树莓派Pico用PIO模拟UART:从原理到MicroPython实现

📅 发布时间:2026/9/11 15:11:38
树莓派Pico用PIO模拟UART:从原理到MicroPython实现
Raspberry Pi Pico严格说是RP2040芯片最容易被低估的功能就是PIO。很多人把它当成“能跑MicroPython的开发板”直到某天项目里同时接了GPS模块、串口屏和一个PM2.5传感器才发现RP2040的硬件UART就两个根本不够用。PIO模拟UART就是这时候救场的方案不占用硬件UART任意GPIO都能变成串口引脚而且时序由硬件状态机自己控制CPU一点负担都没有。这篇文章我打算从原理到MicroPython实现再到回环实测和踩坑记录把这条链路完整讲清楚适合手里有Pico、被串口数量卡住、或者单纯想把PIO搞明白的朋友。1. 为什么放着现成UART不用偏要用PIO去模拟1.1 两个硬件UART在真实项目里怎么不够用RP2040内部其实集成了两个UART外设UART0和UART1。每个UART都支持把TX/RX映射到多组引脚上听起来很灵活但实际问题在于“同时使用的通道数”。一个UART同一时刻只能跑一路收发两个UART就是两路。你接了一个北斗/GPS模块占一路再挂一个串口屏或传感器另一路也用完了想留一路给调试日志打印对不起没了。我见过不少人在这个点上硬扛用软串口库在用户态自己翻转引脚也就是bit-banging。这种方式不是不能跑但坑很深。最高频率上不去CPU忙到没法做别的事稍微有个中断延迟波形就乱了9600波特率下还能勉强用到了115200就开始偶发乱码。归根结底普通GPIO翻转是“软件时序”靠的是CPU能不能准时执行下一行代码这在现代操作系统或者MicroPython的调度器面前是非常脆弱的事。有人会说STM32有USART多路复用还有FDCAN之类的资源比RP2040宽裕。这话没错但RP2040的设计思路本来就不同它把各种外设固化成了有限的几组然后用PIO这种可编程硬件去补足数量。理解这一点再看PIO模拟UART就是个很自然的操作了。1.2 PIO不是软件模拟而是“给GPIO写固件”PIO全称Programmable I/O可编程I/O。你可以把它想象成一组专门负责“按指定时序操作GPIO”的迷你硬件处理器。RP2040有2个PIO块每个块里有4个状态机合计8个状态机。每个状态机都能独立跑一小段指令程序操作自己绑定的GPIO同时通过FIFO和CPU交换数据。这和软件bit-banging最大的区别是PIO程序一旦跑起来CPU就只需要往FIFO里扔数据、从FIFO里取数据剩下所有电平翻转、延时、循环、等待信号全部由状态机硬件完成。状态机的指令周期是固定的不受中断、调度器影响所以波形稳定性比软件模拟高好几个量级。很多朋友是从STM32转过来的习惯性去找GPIO的8种工作模式什么输入、输出、上拉、下拉、开漏、复用推挽这些RP2040当然也有。但PIO是另一个维度的东西它不是在配置某一根引脚而是把引脚的控制权直接交给一个“协议引擎”。对应到RP2040的GPIO mux里就是把GPIO功能选择到PIO外设上之后这根引脚到底干什么由PIO程序说了算。WS2812灯带、DShot电机协议、步进电机脉冲、甚至自定义的传感器时序PIO都能做UART只是其中最常见的一种。2. 先拆透UART时序和PIO指令再写状态机2.1 一帧UART数据到底长什么样写PIO程序之前必须把UART协议还原成最原始的电平时序否则后面全是瞎调。最常用的8N1格式长这样空闲状态TX引脚保持高电平。起始位发送第一个低电平宽度是1个bit时间接收端靠这个下降沿完成同步。数据位8个bit低位在前LSB first。停止位最后拉高1个bit时间。所以关键点有三个空闲必须高、起始位是下降沿、数据是低位先走。任何模拟方案都得满足这三点尤其是第一点和第三点很多人写发送端时容易漏掉。波特率决定了每个bit的时间宽度。比如9600波特率1位就是约104.2微秒115200波特率1位就是约8.68微秒。接收端没有独立的时钟线只能靠起始位的下降沿作为参照然后按双方的波特率约定在每个bit的中心附近采样。2.2 状态机里最常用的几条指令RP2040 PIO的指令集很小数来数去就是JMP、WAIT、IN、OUT、PUSH、PULL、MOV、IRQ、SET、NOP这十来条每条指令还可以带一个0到31的延时参数。这个延时参数很关键它让单条指令可以占1到32个PIO时钟周期这样安排时序就不用每件事都写循环了。模拟UART发送端核心用的是SET、OUT、PULL、JMPSET直接让某个引脚输出高或低适合控制起始位、停止位。OUT从发送移位寄存器OSR里移出数据到引脚适合逐位发送数据。PULL从TX FIFO里取一个新字节到OSR如果FIFO空状态机就阻塞在这里。JMP配合X寄存器做循环控制发送8位。接收端还会用到WAIT和IN。WAIT可以阻塞等待引脚变成指定电平IN从引脚采一位数据放进接收移位寄存器ISR等ISR攒够8位会自动推到RX FIFO里给CPU读。2.3 为什么每个bit按16个PIO周期来算时钟设计是整个状态机最核心的决策。PIO有独立的时钟分频器可以让PIO跑在比系统时钟低很多频率上。MicroPython里创建StateMachine时有个freq参数直接设置PIO时钟频率。我习惯把每个bit固定成16个PIO周期。这样波特率和PIO频率的换算非常简单PIO频率 波特率 × 16比如115200波特率PIO频率就是115200 × 16 1843200Hz约1.84MHz。写出来只需要freqBAUD*BIT_CYCLES很直观。为什么选16而不是8或32因为PIO指令的延时字段最大只能到31如果每个bit是8个周期留给半位采样点是4个周期可操作空间变小如果是16个周期半位就是8个周期正好能让状态机做一次跳转和延时指令安排上舒服很多。而且16位周期意味着采样点的容差大约有±7个周期就算收发双方时钟有点误差也不容易采到bit边沿上去。3. 发送端拉低起始位、移出8位、拉高停止位3.1 TX状态机一条一条指令拆解完整的MicroPython发送端程序如下from machine import Pin import rp2 BAUD 115200 BIT_CYCLES 16 rp2.asm_pio(set_initrp2.PIO.OUT_HIGH, out_shiftdirrp2.PIO.SHIFT_RIGHT) def uart_tx(): pull() # 从TX FIFO取一个字节到OSR set(x, 7) [6] # X寄存器赋值为7后面循环用顺便耗掉7周期 set(pins, 1) [15] # 停止位/空闲位拉高16周期 set(pins, 0) [15] # 起始位拉低16周期 label(bitloop) out(pins, 1) [7] # 输出OSR最低位持续8周期 jmp(x_dec, bitloop) [7] # X减1非0则循环耗8周期这段程序要配合StateMachine绑定引脚才能跑sm_tx rp2.StateMachine( 0, uart_tx, freqBAUD * BIT_CYCLES, set_basePin(2), # SET指令操作的引脚这里必须指向TX引脚 out_basePin(2), # OUT指令操作的引脚也必须指向TX引脚 ) sm_tx.active(1)很多人第一次写PIO发送端都会踩同一个坑SET和OUT不是天然操作同一个引脚的。SET PINS映射到set_base指定的引脚OUT PINS映射到out_base指定的引脚。如果你只设置了set_base忘了out_base那么起始位和停止位跑在GPIO2上数据位却跑到GPIO0上去了波形完全错乱。所以这里两个参数都要指向同一个TX引脚。Windows下如果你习惯用FT231x或者FT232R这类USB转串口模块观察PC上的串口记得先装好驱动否则Pico这边发得再好PC端也看不到。这类模块本质就是把PC的USB口转成UART电平和Pico的PIO模拟UART并不冲突用起来就是一条虚拟串口。3.2 关键机制为什么这里不开autopullMicroPython的PIO支持autopull选项意思是OSR里的32位移位寄存器在数据被移空之后硬件自动从TX FIFO装载新数据不需要程序里显式写PULL。这个选项在官方示例里经常出现确实能简化代码。但如果你像我这样用循环一位一位发数据建议不要开autopull或者至少搞清楚它的触发时机。autopull是在OUT指令执行且OSR为空时触发的。当你发送完一个字节最后一次OUT执行完之后OSR空了硬件可能自动把下一个字节装进去。这时候如果程序又跳回开头执行PULL就会再取一个新字节等于跳过了一个数据。表现就是连续发送时每两个字节丢掉一个而且是稳定丢非常隐蔽。所以我在这里关闭autopull只在程序开头用一次显式PULL。这样逻辑最直观PULL取一个字节循环把它发完再从头PULL取下一个OSR不会在中间被硬件偷偷填上数据程序行为完全可控。3.3 怎么确认发送波形是对的调试PIO UART发送端最快的方法是发送0x55。0x55的二进制是01010101最低位先发实际线上就是1、0、1、0、1、0、1、0交替也就是标准方波。如果你有逻辑分析仪接上TX引脚看波形应该能看到一个低起始位后面跟四个完整方波最后高电平停止位。每个bit的宽度应该完全一致如果哪个bit明显宽了或窄了说明状态机循环里的延时参数没有配平。另外一个更实用但经常被忽视的方法是回环测试把TX引脚和RX引脚直接短接发送端发出的数据由接收端自己收。这样不需要额外设备就能验证整条链路。后面第5部分我会给完整的回环测试代码。4. 接收端等下降沿然后在每个bit中点采样4.1 为什么采样点必须放在bit中心接收端的难点比发送端高不少。发送端是自己控制电平翻转时机只要按时间拉高拉低就行接收端却完全不知道对方什么时候发数据只能被动监听引脚。一旦检测到起始位的下降沿接收端就有了时间参考接下来需要在每个数据位的“中间”去采样。选中间而不是边沿的原因很简单电平刚跳变之后不稳定线上有电容、有噪声、有振铃这时候采样最容易误判。等半个bit周期之后再读电平早就稳定了读到的值可靠得多。4.2 RX状态机的1.5 bit延时数据位是8个而起始位是“发令枪”本身不是数据。为了让起始位不进ISR我采用一个很直白的思路检测到下降沿后先延时大约1.5个bit周期让采样点直接落在第一个数据位的中心然后开始8次采样。rp2.asm_pio(in_shiftdirrp2.PIO.SHIFT_RIGHT, autopushTrue, push_thresh8) def uart_rx(): wait(0, pin, 0) # 阻塞等待起始位下降沿 set(x, 7) [7] # 延时8周期约0.5个bit nop() [15] # 延时16周期约1个bit合计1.5 bit label(bitloop) in_(pins, 1) [7] # 在bit中心采样存入ISR jmp(x_dec, bitloop) [7] # 循环8次配合接收引脚的绑定sm_rx rp2.StateMachine( 1, uart_rx, freqBAUD * BIT_CYCLES, in_basePin(3), # RX引脚 ) sm_rx.active(1)为什么不能省掉那个NOP如果你在下降沿之后只等半位就开始采样那么第一次IN采到的是起始位本身低电平这个0会被当成数据最低位装进ISR采样完8次后整个字节就错位了。所以必须等满1.5个bit周期让起始位从ISR前面“滑过去”。很多人写PIO接收端踩到“收到的字节完全不对”就是卡在这里。4.3 波特率误差能容忍多少任何两个设备的时钟都不可能完全一致Pico用的晶振和PC端USB转串口模块的晶振总有一点偏差。PIO分频器本身支持小数值所以理论上Pico这边可以很精确地产生波特率但另一端不一定那么准。实际工程中UART接收端采样点在bit中心附近允许的误差大概是半位周期左右。对于16个PIO周期一个bit的设计等于有差不多±7个周期的余量对应到偏差比例超过40%。当然这是理论极值真正还要考虑起始位采样误差、边沿抖动、噪声所以一般要求两端时钟误差控制在±2%到±3%以内。这个要求日常使用完全够用常见的晶振误差都在±0.1%级别基本不会因为时钟偏而乱码。5. 回环实测、逻辑分析仪和几个绕不开的坑5.1 一分钟回环测试把TX引脚和RX引脚直接用杜邦线短接然后跑下面的测试代码import rp2 from machine import Pin import time BAUD 115200 BIT_CYCLES 16 # TX / RX定义省略见上面 sm_tx.active(1) sm_rx.active(1) test_data [0x00, 0x55, 0xAA, 0xFF, 0x0F, 0xF0, 0x31, 0x41] errors 0 for i in range(100): for b in test_data: sm_tx.put(b) r sm_rx.get() if r ! b: errors 1 print(error: send, hex(b), recv, hex(r)) print(done, errors , errors)如果一切正常errors为0。如果你看到某些数据错位比如发送0x55收到0xAA大概率是采样点位置不对起始位被算进了数据里或者LSB/MSB方向搞反了。5.2 用逻辑分析仪看波形逻辑分析仪是排查PIO时序问题最好的工具比盲目改代码高效得多。把TX引脚接到分析仪通道上触发方式设为下降沿就能抓到完整的一帧。正常情况下应该看到一个低电平起始位8个数据位最后高电平停止位。用分析仪自带的协议解析功能选UART设置好波特率能直接解出数据。如果解析出的数据和发送端一致说明发送端没问题如果接收端解出来不对再看采样点位置是否在每位中间。回环测试是最廉价的验证方式但逻辑分析仪能给你“眼见为实”的波形尤其当你怀疑接收端时序时它能帮你快速定位到底是发送端的问题还是接收端的问题。USB转串口模块也能做类似的事不过只能看到数据内容看不到波形细节。5.3 踩坑清单我在PIO模拟UART上踩过不少坑挑几个最容易遇到的列出来现象原因解决办法起始位和数据位出现在不同引脚set_base和out_base没指向同一个引脚创建StateMachine时两者统一指向TX引脚发送速度慢了一半freq被直接设成了波特率如115200freq必须是波特率×16连续发送丢字节开启了autopull循环末尾又手动PULL二选一要么用autopull并调整循环结构要么关闭它并显式PULLRX永远收不到数据忘记调用active(1)创建完StateMachine后务必active(1)收到的字节错位接收端采样点太早起始位混入了ISR确认等待了1.5个bit周期后再开始采样高波特率下误码多杜邦线太长、环境干扰大降低波特率、缩短线缆、必要时用屏蔽线或电平转换芯片还有一个容易被忽略的坑是FIFO满了以后状态机会被阻塞。接收端如果CPU不按时读取RX FIFOFIFO满了之后状态机就没法继续PUSH后续采样全部作废甚至会导致下一次起始位检测出问题。所以在实际项目里接收数据建议用IRQ或者在线程里及时读取不要在FIFO上等太久。6. 从一对串口到八路串口PIO还能玩出什么6.1 一份程序多个状态机复用RP2040的PIO指令存储空间是所有状态机共享的但每个状态机有自己独立的程序计数器、FIFO和引脚绑定。这意味着你可以把同一份uart_tx程序加载到多个状态机上让它们同时运行每个状态机只操作自己的引脚。一块Pico有两个PIO每个PIO四个状态机。一路全双工UART需要两个状态机一个TX、一个RX所以理论上最多可以同时跑四路全双工UART如果只需要单向发送可以一路占一个状态机最多八路。这对各种物联网网关、多传感器采集场景非常实用。6.2 多路串口注册示例多路注册并不复杂用一个循环就能搞定tx_pins [2, 4, 6, 8] rx_pins [3, 5, 7, 9] for i in range(4): rp2.StateMachine( i * 2, uart_tx, freqBAUD * BIT_CYCLES, set_basePin(tx_pins[i]), out_basePin(tx_pins[i]), ).active(1) rp2.StateMachine( i * 2 1, uart_rx, freqBAUD * BIT_CYCLES, in_basePin(rx_pins[i]), ).active(1)这样四路UART就全部跑起来了每路之间互不干扰CPU还是只负责往FIFO里读写数据。这个扩展方式比我最初一台板子折腾一晚上引脚映射舒服太多。6.3 顺着PIO再往前走学完PIO模拟UART你会发现它的应用边界其实远不止串口。WS2812灯带驱动需要极其严格的时序DShot电机协议需要微秒级脉冲步进电机需要精确数量和高低电平占空比这些场景全都能用PIO做。很多人用树莓派Pico控制舵机也是先生成特定频率和脉宽的PWM用PIO来做的话精度和稳定性都比软件定时器高得多。我在实际项目里的体会是PIO真正的价值不是“模拟某个外设”而是让你摆脱“外设数量有限”这个限制。只要协议能拆解成电平翻转和等待PIO就有机会搞定。学PIO不需要一次性把所有指令背下来从UART这种成熟协议入手搭一个回环测试再改改波特率、换换引脚很快就能建立手感。后面再遇到I2C、SPI甚至自定义协议你只会觉得又多了一个可玩的积木。