news 2026/9/11 15:11:18

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
树莓派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、JMP:

  • SET:直接让某个引脚输出高或低,适合控制起始位、停止位。
  • 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。写出来只需要freq=BAUD*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_init=rp2.PIO.OUT_HIGH, out_shiftdir=rp2.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, freq=BAUD * BIT_CYCLES, set_base=Pin(2), # SET指令操作的引脚,这里必须指向TX引脚 out_base=Pin(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 关键机制:为什么这里不开autopull

MicroPython的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_shiftdir=rp2.PIO.SHIFT_RIGHT, autopush=True, push_thresh=8) 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, freq=BAUD * BIT_CYCLES, in_base=Pin(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被直接设成了波特率(如115200)freq必须是波特率×16
连续发送丢字节开启了autopull,循环末尾又手动PULL二选一:要么用autopull并调整循环结构,要么关闭它并显式PULL
RX永远收不到数据忘记调用active(1)创建完StateMachine后务必active(1)
收到的字节错位接收端采样点太早,起始位混入了ISR确认等待了1.5个bit周期后再开始采样
高波特率下误码多杜邦线太长、环境干扰大降低波特率、缩短线缆、必要时用屏蔽线或电平转换芯片

还有一个容易被忽略的坑是FIFO满了以后状态机会被阻塞。接收端如果CPU不按时读取RX FIFO,FIFO满了之后状态机就没法继续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, freq=BAUD * BIT_CYCLES, set_base=Pin(tx_pins[i]), out_base=Pin(tx_pins[i]), ).active(1) rp2.StateMachine( i * 2 + 1, uart_rx, freq=BAUD * BIT_CYCLES, in_base=Pin(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甚至自定义协议,你只会觉得又多了一个可玩的积木。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 15:09:25

Cursor接入Figma MCP:从设计稿到代码的自动化流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 15:07:19

线性回归损失函数详解:MSE、MAE与Huber Loss的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 15:00:42

ArgoCD 镜像拉取慢怎么办:三级加速方案与内网缓存避坑指南

ArgoCD 镜像拉取慢怎么办:三级加速方案与内网缓存避坑指南 【免费下载链接】public-image-mirror 很多镜像都在国外。比如 gcr 。国内下载很慢,需要加速。致力于提供连接全世界的稳定可靠安全的容器镜像服务。 项目地址: https://gitcode.com/GitHub_T…

作者头像 李华
网站建设 2026/9/11 14:54:37

RP2040低功耗实战:时钟树与电源域寄存器级控制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华