☰
用PIO为RP2040扩展UART:树莓派Pico多路串口模拟实践
2026/11/4 10:00:02 网站建设 项目流程

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
软件模拟 UART(bit-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_init=rp2.PIO.OUT_HIGH) def uart_tx(): # 伪代码,实际循环逻辑需要按 PIO 指令写 pull(block) set(pins, 0) # ... 发送 8 个数据位 set(pins, 1) sm = StateMachine(0, uart_tx, freq=8_000_000, set_base=Pin(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 状态机自带 FIFO,TX 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 本身的问题,而是接线、电平或者分频的小细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询