
如果你折腾过树莓派 Pico应该会遇到一个挺尴尬的场景项目里同时要挂好几路串口设备——GPS 一路、LoRa 一路、调试输出一路可芯片上自带的 UART 只有两组引脚位置还被扩展板占得七七八八。最开始我习惯用软件 bit-banging 硬抠后来发现 PIO 才是这套方案的正解。这篇文章就把 Pico 的 PIO 模拟 UART 这件事完整拆开从为什么不用硬件 UART、PIO 状态机指令怎么理解到 TX/RX 两路状态机代码逐行分析、C SDK 怎么初始化最后再贴出我实际测试时踩过的坑。无论你是刚摸到 Pico 的新手还是想把手头串口资源榨干的嵌入式老手都可以直接把这套流程搬走。1. 为什么放着现成的 UART 不用偏要折腾 PIO1.1 Pico 自带 UART 的硬限制RP2040 内置的 UART 一共有两组UART0 和 UART1每组可以映射到不同的物理引脚。听起来够用但实际上做项目时串口数量经常不够。一个稍微复杂点的采集设备光调试串口、GPS 模块、RS485 收发器、蓝牙透传模块就四路了Pico 板载的 UART 数量再翻一倍都不一定够。还有引脚位置的问题。UART0 可以用 GP0/GP1也能用 GP12/GP13UART1 可以用 GP4/GP5也能用 GP8/GP9。理论上挺灵活可一旦接了某个现成扩展板排针引出的引脚组合未必是你想用的那组。比如我之前的板子GP8/GP9 已经留给一个 I2C 传感器想用 UART1 的默认引脚就得想办法改飞线。这时候你会意识到硬件 UART 并不是真的“任意引脚”它只是给了你几个预设选项。PIO 的思路完全不同它不绑死任何外设用状态机直接操作 GPIO。你可以把一个串口 TX 放到 GP2RX 放到 GP6完全没有硬件限制。对于 PCB 布线和扩展板兼容性来说这个自由度是硬件 UART 给不了的。1.2 PIO 能帮你解决的实际场景我实际用 PIO 模拟 UART 的一次典型项目是做一个多串口采集器一路硬件 UART 留给 USB 调试输出另一路硬件 UART 接 RS485 总线剩下一个 LoRa 模块的串口就只能靠 PIO 来模拟。这样整个系统有了三路独立串口而且 LoRa 那组引脚我想放哪就放哪不用去迁就 UART 复用表。类似场景还包括串口外设太多硬件 UART 数量不够需要第三路、第四路串口。引脚冲突默认 UART 引脚被其他功能占用需要换到非标准位置。需要一个“临时调试串口”不想动 PCB直接用空闲 GPIO 飞线调试。想做一个串口扩展工具把普通 GPIO 变成可配置的 TX/RX。除了 UARTPIO 还能模拟出 I2C、SPI、IR、WS2812 这类常见外设本质都是通过状态机精确控制引脚时序。学会 PIO 模拟 UART 之后再去看其他 PIO 方案会轻松很多。1.3 和软件模拟串口比差距在哪里很多人一听说“模拟串口”第一反应是软件 timing 翻转 IO也就是 Arduino 时代常见的 SoftSerial 和 bit-bang。这类方案确实能做到任意引脚但 CPU 开销巨大。串口波特率越高CPU 被占得越死一个 115200 的串口在普通单片机上满负荷收发主循环基本就别想干别的了。而且软件位拆串口极其依赖中断实时性一旦有更高优先级中断抢时间波形就会变形轻则乱码重则整个帧错位。PIO 最大的区别在于它是一块独立于 CPU 运行的硬件状态机。你写好的 PIO 程序加载进去之后状态机会按照自己的时钟周期持续输出引脚波形CPU 只需要在 FIFO 里放数据、取数据。主核就算被 USB、DMA、协议栈忙到飞起PIO 的串口波形也不会乱。方案引脚是否任意CPU 开销实时稳定性实现复杂度硬件 UART固定选项极低硬件保证低PIO 模拟 UART任意 GPIO极低硬件状态机保证中等软件 bit-bang任意 GPIO高受中断影响低把这个对比表看完你就明白PIO 更像是“自己定义一颗微型硬件外设”而不是拿 CPU 去硬顶。2. 指令不多但思路要转PIO 状态机与 CPU 的思维差异2.1 状态机的工作节奏时钟、FIFO、引脚映射PIO 模块里一共有四个独立状态机每个状态机都有自己的程序计数器、指令周期、32 位 OSR 移位寄存器、32 位 ISR 输入寄存器、X/Y 两个辅助寄存器以及一个 4 字深度的 TX FIFO 和 RX FIFO。可以这么理解状态机是一个只能执行固定几条指令的微型工人FIFO 是它手边的邮箱。CPU 往 TX FIFO 放一个字节状态机自己取走按照规定好的时序把数据一位一位输出到引脚反过来状态机从引脚采样到 8 位数据后会把它推进 RX FIFOCPU 再取走。状态机的运行节奏由分频器决定。主系统时钟通常是 125 MHz 或 133 MHz通过sm_config_set_clkdiv设置分频系数后状态机才能以符合波特率的节奏执行指令。每条指令执行需要 1 个时钟周期指令末尾还可以插入额外的延迟周期这就给了我们精确控制位宽的能力。引脚映射方面TX 程序里用到的OUT指令会从out_base指定的引脚输出SET指令从set_base指定的引脚输出。这两个 base 可以指向同一个 GPIO也可以指向不同 GPIO。只要初始化时配置到位状态机就能操作任意 GPIO。2.2 UART 会用到的核心指令PIO 指令集非常精简满打满算也就 9 条。模拟 UART 真正用到的更少核心是下面这几张牌PULL从 TX FIFO 取一个 32 位字到 OSR发送移位寄存器后面OUT才能从这里移出数据。OUT把 OSR 里指定位数的数据移到引脚或者寄存器模拟发送时的数据位输出全靠它。SET把立即数写入引脚或寄存器起始位、停止位这类固定电平用它来产生。IN从引脚采样指定位数的数据移入 ISR输入移位寄存器接收端采样数据位靠它。PUSH把 ISR 里的数据推到 RX FIFOCPU 才能读到。WAIT等待某个引脚满足电平条件接收端靠它捕捉起始位的下降沿。JMP跳转指令支持条件判断发送端用它循环发 8 个数据位。这些指令单独看都很简单关键是组合起来之后要能在每一个时钟周期都精确落在正确的位置上。这也是 PIO 编程和 C 语言编程最大的区别——你不再写“逻辑”而是在写“时序”。2.3 为什么说 PIO 是“准硬件”而不是“软件”很多初学者看到 PIO 是用汇编语言编程就误以为它和软件模拟串口是一回事只是写法更底层。这个误解会直接导致你后面调试方向跑偏。PIO 的每条指令执行时间是严格可控的指令与指令之间可以插入确定数量的延迟周期。只要你用系统时钟除以分频系数得到一个稳定的 SM 时钟那么每一拍的时间都是确定的最终引脚波形就是确定性的。这本质上就是一颗可编程状态机构成的硬件外设和 CPU 执行多少条指令、有没有中断打断完全无关。打个比方软件模拟串口是你一边接电话一边手工敲摩斯电码电话一响就敲错PIO 模拟串口是一台机械发报机你把内容塞进去它自己按固定节拍把电码发完接电话不影响它工作。搞明白这一点后面看状态机代码才不会一头雾水。3. 发送端实现用 OUT 和 SET 拼出一帧完整 UART 波形3.1 UART TX 波形的主要素先从发送端说起因为发送逻辑简单最适合练手。UART 一帧数据基本由这几部分组成空闲状态TX 引脚保持高电平。起始位一个位时间的低电平宣告一帧开始。数据位通常 8 个位LSB 先发。停止位一个位时间的高电平让总线回到空闲状态。也就是说发送一个字节至少要输出 1 个起始位 8 个数据位 1 个停止位总共 10 个位时间。后面做时钟分频的时候这个“10”就是关键除数。PIO 发送端的核心思路很简单从 FIFO 取一个字节到 OSR先输出低电平作为起始位再连续 8 次从 OSR 移出 1 位数据到引脚最后输出高电平作为停止位。整个过程每 10 个 SM 时钟完成一个位SM 时钟频率设为波特率的 10 倍。3.2 TX 状态机代码逐行拆解下面这段 PIO 程序是我实际在用的版本配合注释看很清楚.program uart_tx .wrap_target pull block ; 从 TX FIFO 取 1 字节到 OSR没有数据就阻塞等待 set x, 7 ; 循环计数设为 7配合 JMP x-- 实现 8 次循环 set pins, 0 [9] ; 输出起始位低电平持续 10 个 SM 时钟 bitloop: out pins, 1 [8] ; 从 OSR 移出 1 位到引脚执行 1 拍 延迟 8 拍 jmp x-- bitloop ; x 非零就跳回 bitloop消耗 1 拍 set pins, 1 [9] ; 输出停止位高电平持续 10 个 SM 时钟 .wrap这段代码的执行节奏是这样的SET指令本身消耗 1 拍末尾[9]再延迟 9 拍所以起始位总共占 10 拍OUT消耗 1 拍加延迟 8 拍共 9 拍紧接着JMP消耗 1 拍所以每个数据位也是 10 拍停止位同理10 拍。这样一来发送端每个位宽度完全一致非常干净。这里有个特别容易踩的计数坑为什么set x, 7能循环 8 次因为JMP x--是先判断再递减。x 从 7 开始前 7 次循环时 x 非零会跳转回去第 8 次循环开始前 x 已经是 0JMP判断为 0 后不再跳转整个循环刚好执行 8 次。如果你写成set x, 8反而会发出 9 位数据。3.3 C SDK 初始化与引脚绑定PIO 程序需要通过 pioasm 工具编译并在 CMake 里调用pico_generate_pio_header生成.pio.h头文件。工程创建这一步不展开重点看 C 代码里怎么把它用起来。#include stdio.h #include pico/stdlib.h #include hardware/pio.h #include hardware/clocks.h #include uart_tx.pio.h #define TX_PIN 4 #define BAUD 115200 int main(void) { stdio_init_all(); PIO pio pio0; uint sm 0; uint offset pio_add_program(pio, uart_tx_program); // 把 GPIO 4 交给 PIO 控制并强制设为输出方向 pio_gpio_init(pio, TX_PIN); pio_sm_set_consecutive_pindirs(pio, sm, TX_PIN, 1, true); pio_sm_config c uart_tx_program_get_default_config(offset); // OUT 指令和 SET 指令都指向同一个引脚 sm_config_set_out_pins(c, TX_PIN, 1); sm_config_set_set_pins(c, TX_PIN, 1); // 每位 10 个 SM 时钟所以分频系数要乘 10 sm_config_set_clkdiv(c, (float)clock_get_hz(clk_sys) / (BAUD * 10)); pio_sm_init(pio, sm, offset, c); pio_sm_set_enabled(pio, sm, true); while (true) { pio_sm_put_blocking(pio, sm, A); sleep_ms(500); } }初始化顺序里最容易漏的是pio_sm_set_consecutive_pindirs。pio_gpio_init只是把引脚复用功能切换到 PIO不会自动改方向。如果漏掉这一步TX_PIN 仍然是输入模式状态机里卯足劲输出示波器上也看不到任何波形。同时注意sm_config_set_out_pins和sm_config_set_set_pins都要设置因为程序里既用了OUT指令又用了SET指令。两个 base 指向同一个 GPIO 是没问题的。3.4 分频计算为什么乘 10这个问题几乎每次都会被问。Pico 的系统时钟是 125 MHzsm_config_set_clkdiv的默认参考时钟是clk_sys。UART 波特率 115200 意味着每秒要发送 115200 个位也就是每个位时间约为 8.68 微秒。但如果状态机分频只是单纯除以 115200那么一个 SM 时钟就是一位宽度。问题是我们发送一位需要执行好几个 PIO 指令周期不是一拍就能完成。为了让起始位、数据位、停止位都恰好占一个位时间我把每个位设计成固定 10 个 SM 时钟所以分频系数是系统时钟除以波特率再除以 10。实际算一下125000000 / (115200 * 10) 108.5069。分频器支持 16.8 位定点小数可以设置到 108.5069 这种精度算下来误差在 10 的负六次方量级对 UART 来说可以忽略。有一点要注意不要在主循环里频繁调用clock_get_hz(clk_sys)最好在初始化时就把这个值取出来存成变量。如果系统时钟被动态调整串口波特率会跟着变传统的锁相环式时钟配置对这种场景不友好。4. 接收端实现WAIT 下降沿 定时采样把 RX 也拿下4.1 接收难点在“知道什么时候该采样”发送端只需要按部就班输出难度一般。接收端真正的难点在于你不知道对端什么时候开始发数据起始位下降沿出现的时间是任意的而采样必须落在每个数据位的中心附近不能偏到边界上否则一旦对端波特率和你本地分频有微小误差采错位就是迟早的事。解决思路是 16 倍过采样。SM 时钟设为波特率的 16 倍这样每一个位时间被分成 16 拍。状态机先用WAIT 0指令死等下降沿一旦捕捉到起始位再用固定延迟把采样点推到第一个数据位的中心也就是下降沿之后第 24 拍的位置16 拍是整个起始位宽度再加 8 拍就是数据位中点。之后每 16 拍采一次8 个数据位全部采完。4.2 RX 状态机代码逐行拆解这是我实际调通的接收端程序.program uart_rx .wrap_target wait 0 pin, 0 ; 死等起始位的下降沿 set x, 7 ; 准备循环 8 次 nop [21] ; 补足到第一个数据位中心 bitloop: in pins, 1 [14] ; 在数据位中心采样 1 位执行 1 拍 延迟 14 拍 jmp x-- bitloop ; 加上 1 拍每次循环正好 16 拍 push block ; 把 ISR 里的 8 位数据推入 RX FIFO wait 1 pin, 0 ; 等待停止位让总线恢复空闲高电平 .wrap时间线是这样的WAIT检测到下降沿返回后系统时间大概在边沿后的 0 到 1 拍之间。随后SET消耗 1 拍NOP [21]消耗 22 拍总计时约 23 到 24 拍正好到达第一个数据位的中心。之后的循环里IN执行 1 拍加延迟 14 拍共 15 拍再加上JMP的 1 拍每次循环刚好 16 拍所以后续 7 个数据位的采样点也都在位中心。PUSH指令把 ISR 中的 8 位数据推到 RX FIFOPUSH block表示如果 FIFO 满状态机会卡住等待。这个行为其实是把双刃剑一方面防止数据丢失另一方面如果 CPU 一直不来读 FIFO状态机会卡死在 PUSH 上没法继续采样下一帧。所以接收端程序里一定要及时调用pio_sm_get_blocking或配合 DMA 搬数据。4.3 分频乘 16 的逻辑和误差容限接收端每一位的采样周期是 16 拍所以分频系数是系统时钟除以波特率再除以 16。有人会问为什么不像发送端一样用 10 拍其实 10 拍也能做但接收端需要“等一个起始位宽度再等半个数据位”才能到中心点。10 拍一位时这个补偿计算相对别扭抗抖动能力也差一些。16 是一个特别顺手的倍率指令延迟容易凑采样点也能精确落在位中心。实际工程里UART 收发双方的波特率误差只要控制在 ±2% 以内就非常稳极限情况下 ±5% 也能工作。Pico 的系统时钟是晶振派生的本身精度很高真正要盯的是分频除不尽的余数。125 MHz 时钟对 115200 波特率做 16 倍分频时结果是 67.8138小数部分可以被分频器精确表示问题不大。但如果系统时钟是 133 MHz除出来的小数更规整误差也完全不同。建议计算时直接打印出实际分频值用逻辑分析仪验证波形别只靠口算。采样位数默认是 8 位代码里没有校验位。如果你的通信协议有校验位或者 9 位数据模式需要把循环次数改成 9同时采样点位置也要重新算不能让校验位漏采。4.4 数据怎么从 FIFO 送到 CPU接收端完整 C 代码比发送端多几个配置步骤#include hardware/pio.h #include hardware/clocks.h #include uart_rx.pio.h #define RX_PIN 5 #define BAUD 115200 void uart_rx_init(PIO pio, uint sm, uint pin) { uint offset pio_add_program(pio, uart_rx_program); // 把 GPIO 功能切到 PIO默认是输入方向不用额外设方向 pio_gpio_init(pio, pin); pio_sm_config c uart_rx_program_get_default_config(offset); sm_config_set_in_pins(c, pin); // 右移、关闭自动推送、推送阈值为 8汇编里有手动 PUSH sm_config_set_in_shift(c, true, false, 8); sm_config_set_clkdiv(c, (float)clock_get_hz(clk_sys) / (BAUD * 16)); pio_sm_init(pio, sm, offset, c); pio_sm_set_enabled(pio, sm, true); } // 读取一字节阻塞 uint8_t uart_rx_getc(PIO pio, uint sm) { return (uint8_t)pio_sm_get_blocking(pio, sm); }sm_config_set_in_shift的第一个参数true表示右移也就是先采样到的位会落到 ISR 的最低位最终得到的字节顺序和 UART 的 LSB-first 规则一致。如果这里设成左移你会收到一个位序颠倒的字节排查起来特别恼火。第二个参数false表示关闭自动推送因为汇编里已经写了PUSH。如果你在这里打开 autopush同时汇编里又手动 pushFIFO 里会出现重复或异常字节。接收端 FIFO 同样只有 4 个字深如果数据流量大一个字节一个字节地pio_sm_get_blocking容易丢数据。更稳的做法是开 autopush然后用 DMA 把 RX FIFO 搬到内存缓冲区这一块展开又能写一整篇这里先记住方向。5. 实测波形与避坑记录从“能跑”到“稳定”5.1 先把电平问题解决掉3.3V 与 5V 不能硬怼Pico 的 GPIO 是 3.3V 逻辑电平很多老的 USB 转 TTL 模块为了兼容 5V 单片机输出逻辑电平是 5V。这种模块如果直接怼到 Pico 的 RX 引脚上虽然一时半会可能不烧但长期使用会导致 GPIO 输入级过压轻则收发不稳定重则直接击穿引脚。我踩过一次比较深的坑用一个 FT232R 的 USB 转串口模块调试 PIO 模拟串口输出端被我之前设成 5V 模式结果 PIO 接收端偶尔能通偶尔连续乱码。用万用表一量RX 引脚电平在 4.8V 附近晃这才发现问题。稳妥做法是选明确支持 3.3V 电平的 USB 转 TTL 模块或者模块板载电平转换芯片。非要用 5V 设备加一个双向电平转换模块或者用电阻分压把 5V 降到 3.3V 附近。始终共地。USB 转串口模块的 GND 必须和 Pico 的 GND 连在一起否则参考电平不一致波形再对也会乱码。这也是为什么热词里反复出现 FT231X 和 FT232R 驱动的原因——调试串口时驱动不稳定会让人误以为自己的 PIO 代码写错了其实问题出在调试链路本身。5.2 用逻辑分析仪确认时序而不是靠猜PIO 模拟串口属于硬件时序代码波形对不对用肉眼看不出来。建议手里常备一个逻辑分析仪哪怕是最便宜的 8 通道 24 MHz 版本也行。接线很简单逻辑分析仪的通道 0 接 TX 引脚通道 1 接 RX 引脚地线共地采样率尽量调高然后在软件里添加 UART 解码器设置对应波特率。启动发送后逻辑分析仪应该能直接解出你发送的字符。如果解码结果和你塞进 FIFO 的字符一致说明发送链路通如果不一致优先看起始位宽度和停止位宽度。PIO 代码里如果延迟值配错起始位宽度和数据位宽度会不一样逻辑分析仪解码就会在某个位开始错乱。没有逻辑分析仪时可以用另一块 Pico 的硬件 UART 做回环测试把这块 Pico 的 PIO TX 接到另一块板子的 UART RX再把另一块板子的 UART TX 接回这块 Pico 的 PIO RX做成一条全双工测试链路。两块板子的串口助手互相发数据很快能定位是哪条方向出了问题。5.3 我踩过的 4 个坑按照踩的时间顺序记一下第一个坑是分频忘了乘系数。最开始写 TX 程序分频直接clk_sys / baud没有乘 10。结果状态机每执行完一条OUT加延迟后一个位被拉得非常宽对端收到的数据全部乱码。后来用逻辑分析仪一看起始位宽度是数据位宽度的两倍多才意识到问题出在每个位时间不是 1 个 SM 时钟而是 10 个 SM 时钟。第二个坑是漏了引脚方向设置。发送程序在 C 初始化时只写了pio_gpio_init没有调用pio_sm_set_consecutive_pindirsGPIO 一直保持输入模式。那段时间折腾了很久查各种寄存器配置都没发现问题最后是拿示波器点了一下引脚才发现根本没有输出驱动。第三个坑是接收端没有等待停止位。早期版本 RX 程序在PUSH之后直接WRAP回去没有WAIT 1等待停止位高电平。结果对端连续发送两个字节时第二个字节的起始位下降沿会被当成新一帧的开始逻辑分析仪上看波形完全正确但收到的字节序列错位。第四个坑是 FIFO 溢出导致状态机卡死。某一个项目里接收频率很高主循环里有几个耗时操作导致 RX FIFO 被填满后PUSH block一直卡住。对端再发数据时状态机已经没有能力采样。后来改成中断读取 FIFO或者直接上 DMA问题才根治。5.4 接收异常排查链路如果 RX 方向出了问题我建议按下面的链路排查不要一上来就改 PIO 代码用逻辑分析仪抓 RX 引脚看有没有完整的帧波形。没有波形检查外部设备是否真的在发送TX/RX 是否交叉连接GND 是否共地。有波形但解码乱码先确认逻辑分析仪设置的波特率和实际一致再检查 PIO 接收分频有没有乘 16最后确认采样点是否落在位中心。波形正常但 PIO 读不到数据检查sm_config_set_in_shift的移位方向检查 FIFO 是否一直满检查汇编里PUSH是否被执行。确认状态机卡在哪个指令可以在调试器里读pio-sm[sm].addr看 PC 停在PUSH还是WAIT。停在WAIT说明等不到下降沿停在PUSH说明 FIFO 满了。这一套走完90% 的接收问题都能定位。PIO 模拟 UART 弄明白之后你会发现在 Pico 上聊串口数量不足是个伪问题。只要还有空闲 GPIO串口就能继续扩。最后分享一个我个人的使用习惯发送通道用 PIO 组接收通道再配上 DMA 搬运基本可以把 Pico 当成一颗灵活的串口扩展芯片来用。你如果刚开始接触先把发送跑通再碰接收不要一上来就全双工——否则一旦波形不对你根本分不清是发送错了还是接收错了。PIO 这东西思路通了以后会越用越顺手。