用PIO为RP2040扩展UART:树莓派Pico多路串口模拟实践

发布时间:2026/9/8 18:49:07
用PIO为RP2040扩展UART:树莓派Pico多路串口模拟实践 1. 为什么好好的 UART 要用 PIO 去“模拟”1.1 原生串口资源到底紧在哪里树莓派 Pico 用的 RP2040 芯片实际上集成了两个硬件 UART 外设也就是 UART0 和 UART1。硬件 UART 的好处是自带 FIFO、支持中断、配置起来也简单普通开发完全够用。但真把它放到实际项目里问题马上就来了需要挂的串口设备往往不止两个。我之前的项目里Pico 要同时接一个 GPS 模块、一个带 AT 固件的 ESP-01S、还有一个调试传感器数据的蓝牙透传模块。三个设备全是 UART 口Pico 原生只有两个硬件 UART这就很尴尬。更麻烦的是硬件 UART 的引脚并不是任意 GPIO 都能映射的每个 UART 外设的 TX/RX 只能出现在固定的几组引脚上。假设我的 PCB 布局里GPIO0 和 GPIO1 已经被 I2C 占用了但 UART0 最顺手的引脚恰好就是 GPIO0/GPIO1那就只能绕远路去选其他组的映射甚至要改板子。这种时候PIO 的价值就体现出来了。RP2040 内部有两块 PIO可编程 IO模块每块有 4 个状态机合起来一共 8 个状态机。PIO 不是固定功能的硬件外设它本质上是一组可以执行微型程序的 IO 状态机你可以让任意一个状态机去模拟 UART 的时序而且输出引脚不依赖固定的功能映射几乎可以接到板子上的任意 GPIO。我第一次听到这个方案时第一反应也是有必要吗直接用软件延时来模拟串口不行吗真做起来才发现软件模拟串口的毛病太多了CPU 被占得死死的动不动就乱码而 PIO 模拟是硬件状态机自己在跑时序CPU 只需要往 FIFO 里写数据或者从 FIFO 里读数据就行本质上和用硬件 UART 的体验非常接近。如果你手上已经有 Pico 开发板也想在项目里多接几个串口设备或者单纯想搞懂 PIO 到底能干什么这篇文章就是从时序原理、状态机骨架、引脚选型到实测踩坑的完整梳理。1.2 三条扩展串口路线的取舍遇到原生串口不够用的情况其实不止 PIO 模拟 UART 一条路。我当时列过一张对比表挨个评估过可行性。方案额外成本最大路数CPU 占用开发难度适用场景外接 UART 扩展芯片如 SC16IS752约 10-20 元/片由芯片决定常见 2 路很低通过 I2C/SPI 读写低有现成库需要稳定多路串口、不想折腾 PIO软件模拟 UARTbit-banging零理论上无限实际上受定时器限制极高低但调试痛苦只做低速率、短时间测试PIO 模拟 UART零最多 8 路受状态机数量限制低中需要理解 PIO 指令想省钱、想学 PIO、需要灵活选引脚外接扩展芯片是最省心的但多一块芯片就要多占 PCB 面积还要额外写 I2C/SPI 通信代码总感觉不够“优雅”。软件模拟只能用来验证 idea跑个 9600 波特率都容易丢数据更别说项目里可能要上 115200 甚至更高。PIO 模拟 UART 更像是“硬核玩家”的选择不用花一分钱全靠芯片内部的 PIO 状态机硬解时序多出来的串口路数还都带 FIFO比软件模拟靠谱太多。所以最后我选择了 PIO而且折腾完这一轮之后我对整个 PIO 工作机制的理解也上了一个台阶。2. 先读懂 UART 的时序再谈状态机2.1 UART 一帧真正的电平序列很多人写串口程序的时候根本不关心底层电平长什么样因为硬件 UART 都帮你处理好了你只要往寄存器里丢一个字节就行。但做 PIO 模拟没有硬件帮你兜底你必须把 UART 协议每一根电平的变动都看清楚。UART 空闲时TX 引脚保持高电平。发送一帧数据时第一件事是拉低电平这个低电平持续一个 bit 的时间叫起始位。接收端看到这个下降沿就知道后面要开始传数据了。紧接着是 8 个数据位按 LSB first 的顺序逐个发送也就是先发字节的最低位。数据位结束后TX 引脚恢复高电平这个高电平持续一个 bit 时间叫停止位。所以一个典型的 8N1 帧8 个数据位、无校验、1 个停止位总共包含 1 个起始位 8 个数据位 1 个停止位一共 10 个 bit 时间。如果波特率是 115200那么一个 bit 的持续时间就是 1/115200 秒大约 8.68 微秒。PIO 要模拟 UART本质上就是让状态机在高电平和低电平之间严格按照这个时间轴去切换。发数据的时候什么时候拉低、什么时候移位输出、什么时候拉高一个都不能错收数据的时候要能识别起始位的下降沿然后在每个数据位的中间时刻去采样电平把采样结果拼成一个字节。这里有一个经常被忽略的关键细节发送和接收的时序基准其实不是同一个时钟。两个设备各自用自己的时钟去计算 bit 周期只要波特率误差在一定范围内通信就能成立。UART 协议没有单独的时钟线靠的就是收发双方对波特率的约定和容错。PIO 模拟 UART 时也是一样你要给状态机配一个合适的时钟频率并且接受一定的分频误差。2.2 RP2040 PIO 挑哪几条指令来搭状态机PIO 的编程模型和普通 MCU 很不一样它没有复杂的算术指令也没有中断向量表状态机本质上在做一件很简单的事按顺序执行指令每条指令都可以操作引脚、移位寄存器、FIFO并且可以带延迟周期。模拟 UART 发送最核心的是这几条指令pull从 TX FIFO 里取出一个 32 位数据放入输出移位寄存器 OSR。如果 FIFO 为空可以阻塞等待这就是 PIO 的“自动流控”。out把 OSR 里的数据按位移出可以一次移出 1 位到引脚上。这是发送数据位的核心操作。set直接把引脚设为高电平或低电平用来产生起始位和停止位。jmp条件跳转配合x--这类操作实现循环比如循环 8 次发送 8 个数据位。接收方向还需要用到wait等待某个引脚变为指定电平。这是检测起始位下降沿的关键。in从引脚上采集 1 位数据移入输入移位寄存器 ISR。push把 ISR 里的数据推入 RX FIFO等待 CPU 取走。这些指令单独看都不复杂难的是把它们组合成一个能在正确时间点执行操作的循环并且通过指令延迟和分频器把每个 bit 的时长卡准。PIO 不是通用处理器它没有除法指令也没有浮点运算所以“延时”这件事需要用状态机时钟和指令周期来精确控制。一个很容易踩的坑是PIO 的指令本身需要 1 个或 2 个周期才能执行完指令后面的[n]只是额外延迟不是总延迟。写程序时要先算清楚每条指令的基础周期数再决定额外延迟填多少。比如你要等 10 个周期如果某条指令本身就占 2 个周期那你额外延迟 8 个周期就够了。3. TX 和 RX 两条状态机的骨架与分频计算3.1 发送状态机骨架PIO 模拟 UART 的发送端逻辑其实很直白就是循环做四件事等数据、发起始位、发 8 个数据位、发停止位。下面这段是 pioasm 风格的逻辑骨架不是完整可编译文件重点看结构.program uart_tx .wrap_target pull block ; 从 TX FIFO 取数据没有就阻塞等 set pins, 0 ; TX 引脚拉低产生起始位 set x, 7 ; 循环计数准备发 8 个数据位 bitloop: out pins, 1 ; 把 OSR 最低位移到 TX 引脚先发 LSB jmp x-- bitloop ; 循环 8 次 set pins, 1 ; TX 引脚拉高产生停止位 .wrap直接看这段代码你会发现它好像没有处理“每个 bit 持续多长时间”。这是因为每个 bit 的时长分布在所有指令的执行周期和额外延迟里。set pins, 0和out pins, 1这些指令后面都可以加延迟比如set pins, 0 [CYCLES_PER_BIT_MINUS_1]在真正的 PIO 程序里需要根据状态机时钟频率和波特率算出一个 bit 对应多少个状态机时钟周期然后把这个周期数分配到指令延迟中。官方 pico-examples 仓库里的pio/uart目录有完整的uart_tx.pio文件写法比我这个骨架要严谨会用到 side-set 来让时序更紧凑。为什么建议你优先看官方代码而不自己从零写因为发送状态机有一个很容易翻车的点out pins, 1和循环跳转指令jmp x-- bitloop的执行周期必须严格控制否则循环最后一轮和前面几轮的时间就不一致了。官方的写法经过大量测试时序是均匀的。3.2 接收状态机骨架与 oversample 思路接收比发送复杂一个量级。发送端知道自己什么时候该拉低、什么时候发数据位但接收端是完全异步的只能靠检测起始位的下降沿来判断“现在开始有一帧数据了”。最简单的接收状态机骨架长这样.program uart_rx .wrap_target wait 0 pin RX ; 等待 RX 引脚变低即检测到起始位下降沿 set x, 7 ; 准备收 8 位 bitloop: in pins, 1 ; 采样 RX 引脚移入 ISR jmp x-- bitloop push block ; 把收到的字节推入 RX FIFO .wrap这段逻辑有个致命问题它一检测到低电平就立刻采样但低电平出现的位置是起始位的开头后面第 1 个数据位才刚开始没多久电平可能还没稳定。真正可靠的做法是检测到起始位之后先等半个 bit 的时间让采样点对准第 1 个数据位的正中间然后再开始采样。官方实现里会引入 oversample 的概念也就是让状态机跑在波特率的整数倍频率上常见的是 8 倍过采样。状态机时钟如果是波特率的 16 倍或者 8 倍就可以用更细的时间粒度去控制采样时机。检测到起始位后先跳过若干个时钟周期再逐个采样数据位。为什么采样点要对准数据位中间因为 UART 通信双方存在时钟误差如果正好在数据位跳变的边缘采样哪怕一点点抖动都会导致误判。而在数据位中间采样即使收发双方的时钟差了一点也不太会采到边界上。这就像拍照要对焦在物体中心而不是边缘。3.3 分频值、波特率误差的 Python 口算方法PIO 状态机有一个可配置的时钟分频器可以把系统时钟分到任意频率实际上是 16 位整数分频加 8 位小数的分频器。你只需要算出“状态机时钟频率 波特率 × 每个 bit 需要的时钟周期数”然后设置分频器让状态机尽量接近这个频率。这里直接用 Python 算最方便def calc_divider(sys_clk_hz, baud, cycles_per_bit): divider sys_clk_hz / (baud * cycles_per_bit) div_int int(divider) div_frac round((divider - div_int) * 256) actual_div div_int div_frac / 256 actual_baud sys_clk_hz / (actual_div * cycles_per_bit) error (actual_baud - baud) / baud * 100 print(f目标波特率: {baud}) print(f分频器: {div_int}.{div_frac}/256) print(f实际波特率: {actual_baud:.2f}) print(f误差: {error:.4f}%)举个例子Pico 默认系统时钟是 125MHz我想让状态机以“每个 bit 对应 8 个时钟周期”的方式跑 115200 波特率那么状态机频率需要是 115200 × 8 921600Hz。125000000 除以 921600得到约 135.63。这个数不能整除分频器取整之后实际波特率会有约 0.47% 的偏差。串口协议本身对波特率误差有容忍度一般不超过 2% 都能正常通信所以这个误差完全没问题。但要注意误差是累积的。如果一帧数据有 10 个 bit最后一个 bit 的采样时机偏差会被放大。这也是为什么接收端要尽量用 oversample 而不是只采一次。oversample 倍数越高对误差的容忍度就越好但状态机能跑到的最高波特率也会下降因为单位 bit 内能执行的指令数变少了。工程上需要做取舍。4. 挂在任意 GPIO 上引脚选型和工程接线4.1 “任意 GPIO”在实践中要打的折扣广告语说“任意 GPIO 实现串口通信”这话没毛病但实际用的时候还是要稍微留个心眼。PIO 的输出/输入引脚确实可以通过引脚的 function select 映射到几乎所有 GPIO不像硬件 UART 那样被固定在特定引脚组里。所谓“几乎”是因为 Pico 板上有几个引脚不建议拿来当串口。GPIO24 和 GPIO25 连接着板载外部 Flash 的 SPI 接口GPIO26 到 GPIO28 是 ADC 功能引脚如果只是当普通串口用其实可以但会浪费模拟采集通道项目里如果同时要用 ADC 和串口就尽量避免占用。还有USB 的 D 和 D- 占用的是 GPIO0 和 GPIO1如果你要用 USB 通信或者刷机就别让 PIO 程序去抢这两个引脚。GPIO29 在官方 Pico 板上连了 VSYS 检测相关的电路用来做数字 GPIO 也要谨慎。所以准确的说法是除了板级功能占用的那几根特殊引脚其余 GPIO 你基本随便挑。这个“灵活选引脚”的价值在实际布线的时候非常大。我之前做一块扩展板原生 UART0 的引脚刚好被一个传感器占用如果不用 PIO就得绕一大圈飞线用了 PIO 之后直接把 TX/RX 挪到板子边缘的两个空引脚上干净利落。4.2 电平、上下拉和对端模块匹配PIO 模拟 UART 只是把 RP2040 的 GPIO 当成普通数字引脚来驱动所以有一个硬性限制所有 GPIO 都是 3.3V 电平而且不是 5V 容忍引脚。如果你的对端模块是 5V TTL 电平直接把对方的 TX 接到 Pico 的 RX 引脚上时间长了大概率会烧引脚。处理办法很简单5V 设备要接 Pico 时中间加一个电平转换模块或者用电阻分压。很多 USB 转 TTL 模块上都有跳线可以切 3.3V/5V切到 3.3V 再接线就安全了。3.3V 设备之间直连没问题但要注意共地——两个设备的地必须连在一起否则串口信号根本没有参考电平。上拉电阻也值得说一句。UART 空闲时是高电平但 PIO 的输入引脚在浮空状态下可能被噪声干扰。我习惯在 RX 引脚上加一个 10kΩ 左右的上拉电阻确保空闲时稳定在高电平避免噪声毛刺被误判成起始位。很多模块本身已经带上下拉电阻了如果没有自己加一个也不费事。4.3 C SDK 与 MicroPython 两种玩法差异用 C SDK 写 PIO 程序流程比较固定先写.pio文件用 pioasm 工具编译成 C 头文件然后在 main 函数里调用pio_add_program加载程序再配置状态机。官方 pico-examples 里已经有现成的uart_tx.pio和uart_rx.pio你的 main.c 大致长这样#include hardware/pio.h #include uart_tx.pio.h PIO pio pio0; uint offset pio_add_program(pio, uart_tx_program); uart_tx_program_init(pio, 0, offset, 4, 115200); // 之后用 uart_tx_program_put(pio, 0, A) 之类的方式发送C SDK 的优势是性能高、配置细适合做产品原型和需要深入定制 PIO 行为的场景。缺点是 pioasm 的语法不熟的话改起来像在写汇编调试也有点费劲。如果你只是想在 MicroPython 里快速验证 PIO UART 能不能用那直接用rp2模块的装饰器会更舒服from rp2 import asm_pio, StateMachine from machine import Pin asm_pio(set_initrp2.PIO.OUT_HIGH) def uart_tx(): # 伪代码实际循环逻辑需要按 PIO 指令写 pull(block) set(pins, 0) # ... 发送 8 个数据位 set(pins, 1) sm StateMachine(0, uart_tx, freq8_000_000, set_basePin(4)) sm.active(1)MicroPython 的好处是写起来快不用编译整个 SDK适合学习原理或者做短期实验。但 MicroPython 的 PIO 封装毕竟多了一层解释器时序延迟和中断响应的确定性不如 C SDK如果项目要跑比较高的波特率或者要求稳定我更推荐直接用 C SDK。5. 实测踩坑从没人通信到乱码的完整链路5.1 先做自发自收回环我第一次把 PIO UART 的 TX 和 RX 都写好之后烧录到板子上用 USB 转 TTL 模块接到电脑上结果串口助手什么都收不到。当时我第一反应是代码有问题但排查了半天最开始的错误其实特别低级我没把 PIO 的 RX 引脚和 USB 转 TTL 模块的 TX 引脚接对两个设备的 TX 接在了一起。串口接线的基本原则是交叉连接本端 TX 对远端 RX本端 RX 对远端 TX还要共地。如果你不愿意一开始就接外部设备最简单的验证方法是把 PIO 的 TX 引脚直接短接到 RX 引脚做一个自发自收回环。然后用一段代码往 TX FIFO 里写数据再从 RX FIFO 里读看能不能读到一模一样的数据。如果回环能通说明状态机本身的时序没问题如果回环不通再用逻辑分析仪看 TX 引脚的电平波形检查是不是根本没有波形输出。我后来发现很多所谓的“PIO 模拟 UART 不稳定”问题其实一开始就是接线或者电平问题跟 PIO 半毛钱关系都没有。先把回环测试跑通再连外部设备排查范围会小很多。5.2 噪声毛刺误触发起始位回环测试通过之后我接入真实的 GPS 模块发现一个很有意思的现象大部分时候通信正常但偶尔会收到一个乱码字节频率不高但时不时冒出来。用逻辑分析仪抓 RX 引脚的波形终于发现了问题。GPS 模块上电或者复位的时候TX 引脚会出现一些短促的低电平毛刺PIO 的接收状态机用wait 0 pin RX检测起始位只要看到引脚变低就认为是一帧数据的开始。如果这个低电平毛刺太短不是真正的起始位状态机还是会傻乎乎地往下采样最后把一个错误的字节推进 FIFO。这不是 PIO 的问题硬件 UART 也会有类似情况只是硬件 UART 通常有滤波和校验机制。PIO 模拟实现的时候需要在采样逻辑上做一点防护最常见的方法是检测到低电平后不要立刻开始采样数据位而是先多等一小段时间再检查一下引脚是否仍然是低电平。如果引脚已经恢复高电平说明刚才那个只是一个毛刺就应该放弃这一帧。这种逻辑在 PIO 指令里也能写只是状态机代码会变得更复杂需要多占用几个指令 slot。如果你的接收端设备和 Pico 距离比较近布线也不长通常不会频繁触发这个坑。但如果现场有电机、继电器之类的干扰源建议老老实实把毛刺防护逻辑加上或者至少保证 RX 引脚空闲时被上拉电阻牢牢拉在高电平。5.3 高波特率抖动与分频误差如何看把波特率从 9600 往上调之后最容易出问题的不是发送端而是接收端的采样时机。之前算过125MHz 时钟下跑 115200 波特率分频误差大约是 0.47%这个误差在 9600 波特率下完全无感但在 921600 甚至更高波特率下就会变得明显。接收端对误差的容忍度取决于采样点离数据位中心的距离。如果每次采样都正好落在数据位的正中间那误差容忍窗口大概是正负半个 bit实际情况当然没那么理想因为收发双方的误差方向可能一致也可能相反总的误差是两个设备时钟误差的叠加。PIO 模拟 UART 的接收状态机如果不用 oversample而是单纯靠状态机时钟去硬等那波特率越高每个 bit 里的时钟周期数越少微小的周期误差也会被放大。实测下来如果你的目标波特率在 115200 及以下PIO 模拟串口非常稳。想要跑到 1Mbps 以上就要特别注意一是分频误差尽量控制在 1% 以内二是接收端要考虑 oversample 补偿三是导线要短避免信号质量带来的额外抖动。如果项目对高速串口有硬性要求我还是建议优先考虑硬件 UART 或者外接串口扩展芯片PIO 模拟更适合中低速率的多路串口扩展场景。6. 资源上限与进阶玩法6.1 8 个状态机能开几路模拟串口RP2040 有 2 个 PIO 模块每个 PIO 有 4 个状态机总共 8 个状态机。理论上一个状态机就能实现一路 UART所以最多可以跑 8 路模拟串口前提是每个状态机只负责一个方向。如果只做单工发送比如把 PIO 纯粹当成调试日志输出口那一路 UART TX 只占一个状态机CPU 基本零负担。但如果要收发一体的双向串口一个状态机的指令槽位可能不够尤其接收逻辑还要处理毛刺防护的时候。更常见的做法是用两个状态机分别负责 TX 和 RX一路完整 UART 占两个状态机这样最多同时跑 4 路双向串口。要是只发送不接收那 8 路都行。除了状态机数量另一个资源限制是每条 PIO 程序最多占 32 条指令。UART 发送程序很短接收程序如果做得复杂一点加上毛刺判断、过采样、循环控制指令数会明显增加。好在 32 条对 UART 来说完全够用真正复杂的 PIO 应用通常是 DVI 视频输出那种指令槽位才会捉襟见肘。每个 PIO 模块可以同时加载最多 32 条不同程序准确说是一块 PIO 的所有状态机共享 32 条指令存储如果两个状态机跑不同程序它们的指令要加起来不超过 32 条。所以如果你在同一个 PIO 上开了 4 路 UART恰好这 4 路 UART 的程序结构一样它们可以共用同一段指令不重复占用。6.2 用中断和 DMA 降低 CPU 占用PIO 模拟 UART 虽然比软件模拟省心但 CPU 还是要负责处理收发数据的搬运。RP2040 的 PIO 状态机自带 FIFOTX FIFO 和 RX FIFO 各自能存几条数据。FIFO 满了之后发送端会阻塞等待FIFO 空了之后接收端也没有地方放新数据。如果 CPU 不及时读走 RX FIFO 里的数据后面的字节就可能被覆盖丢包。要解决这个问题最直接的办法是用中断。PIO 状态机可以配置在 RX FIFO 非空或者 RX FIFO 超过某个阈值时触发中断CPU 在中断回调里读取数据。这样 CPU 不用一直轮询等数据到了再去处理。在 C SDK 里可以用pio_set_irq_source_enabled和中断注册函数来接管。如果数据量很大还可以用 DMA 直接把 PIO 的 RX FIFO 数据搬到内存缓冲区完全不需要 CPU 参与每字节的搬运。官方文档里 DMA 和 PIO 联动的例子不少本质上是因为两者都有握手信号DMA 可以在 FIFO 有数据时自动发起传输。我看过有人用这种方法让 PIO 模拟串口跑到接近硬件 UART 的吞吐量CPU 占用率依然很低。不过我要泼一盆冷水如果不是数据量真的很大没必要一上来就把 DMA 加上。先用中断收发把功能跑通再考虑性能优化。因为 DMA 链路的调试复杂度和中断不在一个级别一上来就上 DMA出了问题你会怀疑人生。如果你也在某个项目里被原生 UART 数量不够卡住我的建议是先别急着下单买扩展芯片用 PIO 试一把。花一个下午把官方pio/uart例程跑通然后在自己的工程里改引脚和分频这个经历会让你对 RP2040 的认识深一大截。踩坑的时候多备一个逻辑分析仪实在不行就回环测试把问题一层层拆开看多半不是 PIO 本身的问题而是接线、电平或者分频的小细节。