1. 为什么 Pico 的串口值得单独写一篇
树莓派 Pico 这块小板子,很多人买回来第一件事就是点灯,第二件事大概是让板子跑个 MicroPython 然后打印Hello World。但真正到了要跟传感器通信、接显示屏、调试物联网设备,甚至做 Server 端联调的时候,绕不开的就是 UART 串口。可以说,串口是你在 Pico 上做一切“板外交互”的地基。
我最初接触 Pico 的时候,也天真地以为串口就是两根线、一收一发,能通就行。结果真上手后发现,硬件引脚怎么复用得想清楚,MicroPython 里UART()的参数怎么配才不丢数据,调试时是看print还是用逻辑分析仪,这中间全是隐形门槛。再加上很多人习惯拿它跟 Arduino 比,Pico 的串口数量、引脚映射、中断收发机制又不一样,抄 Arduino 的代码过来大概率直接翻车。
这篇文章不打算泛泛讲串口理论,我会直接拆成四块:硬件特性是什么、MicroPython 里怎么写最稳、调试工具有哪些怎么选、以及我实际踩过的一些坑。适合刚入手 Pico 的玩家,也适合用 Pico 做了几个小项目、但想进一步提升串口通信稳定性的朋友。
2. Pico 串口硬件特性盘点
2.1 两颗 UART 到底够不够用
RP2040 芯片上集成了两个硬件 UART:UART0 和 UART1,每个都支持标准的全双工收发。全双工意思就是可以同时发送和接收数据,这对做控制类场景很重要,我们后面讲的舵机控制和读取传感器反馈,很多时候需要“边发命令边听应答”。
不过,“有 UART0 和 UART1”这件事只是第一步,真正的限制在于引脚映射。每个 UART 都有一组默认引脚,同时也允许映射到板上其他引脚。对新手来说,熟悉的往往是默认那组:
- UART0 TX 默认 GP0,RX 默认 GP1
- UART1 TX 默认 GP4,RX 默认 GP5
这两个默认组合已经覆盖绝大多数场景,比如接 GPS 模块、接蓝牙模块、接 ESP8266 透传。但如果你做的项目里,GP0 已经被 PWM(比如驱动舵机)占了,或者 GP1 要留着做按键扫描,此时就得用 UART0 的备用引脚映射:TX 映射到 GP12,RX 映射到 GP13。
于是我给新手的第一个建议:拿到板子后不要直接接线,先想清楚每个 GPIO 的职责。引脚复用是 Pico 做多路串口设计的核心思路,但也容易让你布线时踩进冲突的坑里,后面我们会专门讲怎么排查这种问题。
2.2 电平标准与接线的物理常识
Pico 的 GPIO 电平是 3.3V,这决定了它的 UART 引脚不能直接接 5V 的设备(比如旧款 Arduino 的 5V 串口输出、某些 5V 供电的 GPS 模块)。
很多新手在接线时最大的误区是:只要 TX 接 RX、RX 接 TX、GND 共地就行,忽略了电平匹配。如果对方设备标明是 5V 逻辑,你还需要在通信线上加电平转换模块。不是所有板子“稍微过压”都能扛住,RP2040 的引脚不是完全耐 5V 的。
另外,接线长度也要控制。串口的波特率越高,对线材和布线越敏感。我测试过在 115200 波特率下,用 20 厘米长的杜邦线连接传感器,基本没问题;一旦把线换成 1 米以上,就会出现偶发乱码。所以布线原则是:能短则短,尽量用双绞线或者屏蔽线,特别是走长距离通信时,这个细节能省掉很多排查时间。
2.3 从单片机串口到板级串口的区别
这里还想做一个概念上的对比。当我们说“Pico 串口通信”,既指 RP2040 芯片引出的 UART 引脚(单片机串口),也指板载 USB 转串口(MicroPython 的 REPL 终端)。两者是完全不同的链路:
- 芯片 UART:通过 GP0/GP1 等引脚对外通信,接传感器、接 PC 串口助手(需要 USB 转 TTL 工具)
- USB 串口:Pico 板载的 microUSB 连接到电脑后,会被识别成一个虚拟串口设备,MicroPython 的交互式解释器(REPL)就走这个通道
很多人刚接触 Pico 时,会在 Thonny 的 Shell 窗口里看到 print 输出,就以为那就是串口调试。严格说,这是 USB CDC 虚拟串口,并不是芯片 UART 引脚上的数据。理解这两者的区别,是后面进行真正串口设备调试的基础。
3. MicroPython 串口编程从入门到顺手
3.1 初始化参数的“灵魂三问”
MicroPython 的machine.UART接口写起来不复杂,但初始化时那几个参数如果不理解含义,很容易写出“能跑但是不稳定”的代码。
以一个典型的传感器交互为例:
from machine import UART, Pin import time # 使用 UART1,默认引脚 TX=GP4, RX=GP5 uart1 = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5), bits=8, parity=None, stop=1)这里主要解释两个容易被忽略的点。第一是波特率,它决定了每秒传输多少 bit。9600 和 115200 是嵌入式里最常见的两个值,传感器模块一般默认 9600,像 ESP8266 AT 固件、多数 GPS 模块都是这个值;第二是tx和rx引脚参数,在高版本 MicroPython 固件里,可以不传,直接走默认引脚;但如果你希望用备用引脚(比如前面提到的 GP12/GP13),必须显式指定。
bits=8、parity=None、stop=1这三个组合是通用的 8N1 格式,绝大多数串口设备都认这个格式。除非你明确知道模块手册里写的是 7 位数据位或带校验位,否则不需要改动。
初始化之后,只需注意一点:MicroPython 不会自动在初始化时把 TX 置为高电平。如果你接的设备对串口空闲电平有要求(比如某些 RS485 模块),可能需要在初始化后手动拉高,这个细节在调试中比较隐蔽。
3.2 收发数据:读和写的三种姿势
写数据这件事,MicroPython 提供了两种常用方法:write()和writeinto()。区别不大,实际项目里用write()最多,因为它直接接受字符串或者字节串:
uart1.write(b'AT\r\n') # 发送 AT 指令,例如接 ESP8266 uart1.write('Hello Pico\n') # 直接传字符,固件会自动编码推荐在通信时统一用 bytes 类型,也就是前面加b前缀。如果你用字符串传中文,MicroPython 会用 UTF-8 编码,部分下位机对这种编码不敏感,但部分模块需要的是纯 ASCII,直接传字符串可能出乱码。
读数据则有几种思路。最简单的是轮询式,用any()判断缓冲区是否有数据:
if uart1.any(): data = uart1.read() print(data)这种方式适合测试脚本,逻辑清晰,也不容易出错。缺点是如果数据不是一次到齐的,你读到的一半可能是个不完整的协议帧。更稳的做法是把读数据放到while循环里,定义一个接收缓冲区,先把滚动数据存起来,等收到结束符或完整帧长度后再统一解析。
MicroPython 也支持 UART 中断接收,通过irq()方法绑定处理函数:
def uart_handler(uart): data = uart.read() print('rx:', data) uart1.irq(trigger=UART.IRQ_RX, handler=uart_handler)但这套机制在 MicroPython 固件版本里支持程度不太一致,实测在 Pico 上某些固件更新后中断回调会偶发失效。如果你项目的实时性要求很高,建议直接用 C/C++ SDK 写接收中断;如果只是用 MicroPython 做快速原型验证,轮询加缓冲区方式其实足够稳。
3.3 发送十六进制数据的正确姿势
很多传感器模块的指令是十六进制数组,比如“读取版本号”对应的指令可能是0xAA 0x55 0x01 0x00。新手容易犯的错误是直接把字符串'AA550100'写进uart.write(),结果模块完全没反应。
正确做法是构造字节数组:
cmd = bytes([0xAA, 0x55, 0x01, 0x00]) uart1.write(cmd)如果你需要动态拼包,可以这样:
cmd = bytearray() cmd.append(0xAA) cmd.append(0x55) cmd.append(0x01) cmd.append(0x00) # 计算校验和 checksum = (0xAA + 0x55 + 0x01 + 0x00) & 0xFF cmd.append(checksum) uart1.write(bytes(cmd))要点是:模块要什么格式,你就按什么字节结构给它,不要在中途做编码转换。很多人卡在“模块没反应”这类问题上,最后排查出来就是编码不对。
3.4 一个可复用的串口收发小框架
下面是我整理的一个通用框架。每次接新模块时,我习惯先套这个框架,验证能不能通信,再往里面填业务逻辑:
from machine import UART, Pin import time class SerialManager: def __init__(self, uart_id=1, tx_pin=4, rx_pin=5, baud=9600): self.uart = UART(uart_id, baudrate=baud, tx=Pin(tx_pin), rx=Pin(rx_pin)) self.rx_buffer = bytearray() def send_raw(self, data: bytes): self.uart.write(data) def send_str(self, s: str): self.uart.write(s.encode('utf-8')) def read_available(self): while self.uart.any(): self.rx_buffer.extend(self.uart.read()) return bytes(self.rx_buffer) def clear_buffer(self): self.rx_buffer.clear() # 使用示例 ser = SerialManager() ser.send_str('AT\r\n') time.sleep(0.1) resp = ser.read_available() print('Response:', resp) ser.clear_buffer()这个框架的好处是把收发分离,你只需要关心send_*和read_available两个接口,不用每次都重复写any()判断逻辑。实测下来调试效率提升明显。
4. 串口调试工具实战对比与选型
4.1 五类工具的定位与适用场景
串口调试这件事,单靠一个“串口助手”并不能覆盖所有场景。我按用途把常用工具分成了五类:
- 基础串口助手,用来发指令、看 ASCII/HEX 返回
- USB 转 TTL 模块,用来把 Pico 的 UART 引脚接到电脑
- 逻辑分析仪,用来观察波形和时序,确认数据是否真的发出去了
- 串口监听/分析软件,比如 Python pyserial 脚本、VSPD 虚拟串口
- 显示器类工具,像 Pico 自带的 REPL 终端(Thonny、minicom)
先区分一个基本概念:如果你是用 Pico 的 USB 口(microUSB)连电脑,那么在设备管理器里看到的 COM 口是 USB CDC 虚拟串口,只能用于 MicroPython REPL 交互和打印输出。如果你想跟 Pico 的 UART 引脚通信(也就是 GP0/GP1 那一路),需要用 USB 转 TTL 模块把 Pico 的 TX/RX 接到电脑。
4.2 一些主流工具的使用心得
先说说挺常用的 USB 转 TTL 模块。市面上最常见的方案是 CP2102 和 CH340,前者稳定一点,后者便宜大碗。Mac 用户要注意 CH340 在 macOS 上偶尔掉驱动,出现断连时先看ls /dev/tty.*有没有设备。Windows 上一般装好驱动后在“设备管理器”里看 COM 口号就行。
再比如用 PySerial 做压力测试,是最灵活的组合。普通串口助手手动点按钮发数据,效率太低,真实项目里往往要连续发几百条指令验证稳定性。我经常写这样的脚本:
import serial import time ser = serial.Serial('COM10', 115200, timeout=1) cmd = bytes([0xAA, 0x55, 0x03, 0x00]) ok_count = 0 total = 200 for i in range(total): ser.write(cmd) resp = ser.read(10) if len(resp) > 0: ok_count += 1 time.sleep(0.02) print(f'{ok_count}/{total} received response') ser.close()这对判断模块的响应率、丢包率非常直观。
然后是逻辑分析仪。如果你怀疑数据根本没从 Pico 引脚出来,或者波形乱七八糟,逻辑分析仪是定位问题最快的工具。逻辑分析仪把 TX/RX 信号按时间轴显示成高/低电平序列,你可以肉眼看到波特率是否匹配、起始位是否正常。
需要强调的是,当信号完全无响应时,建议先用逻辑分析仪观测硬件波形,而不是反复调软件。因为串口通信“无声无息”的问题,多半是物理连接、电平、引脚映射或波特率,调软件很难定位。
4.3 从热词中延伸的两个经典调试场景
我注意到最近搜索“unity pico会出现indexoutofrangeexception: renderpassindex”以及“宿主机 windows 如何通过串口与 vmware 中 linux 通信”的人越来越多,这两个其实都是串口联调的典型例子。
先说 Unity 和 Pico。很多人做 VR 项目,用 Unity 作为上位机界面,用 Pico 当硬件控制器。Unity 通过串口发数据给 Pico,Pico 解析之后再执行动作,比如开灯或者调电机。此时 Unity 里用的 SerialPort 类底层调用的也是系统串口 API,思路和单片机串口完全一致,差异只是解析高频数据时注意丢包。反正我在 Unity 里串口通信时,第一件事就是设置较短的 ReadTimeout,避免主线程卡死。
再说 Windows 宿主机和 VMware 里 Linux 的串口共享。这个问题常见于嵌入式开发:编译和烧录在 Windows 上完成,但运行环境在虚拟机里的 Linux 上。最简单的方式是让 Windows 和 Linux 共享同一个 COM 口,VMware 支持“Connect”这个串口设备给虚拟机,但注意物理串口同一时间只能被一个系统占用,你需要关闭宿主机的串口监视程序。
更稳的方案是宿主机跑一个 TCP 转串口服务,虚拟机里用 socat 把 TCP 端口映射成虚拟串口。这样两边都能访问同一个物理串口,不冲突。这个方案我实际用下来,稳定性尚可,特别适合需要两边同时观察日志的场景。
5. 串口通信典型应用实战:舵机控制
5.1 通过串口命令控制舵机的整体思路
串口通信的经典应用之一,就是 Pico 作为舵机控制板,接收上位机(PC 或手机)发来的角度指令,然后产生 PWM 信号控制舵机。
舵机控制使用的是 50Hz 的 PWM,也就是周期 20ms,占空比在 2.5%~12.5% 之间对应 0°~180°。RP2040 的 PWM 精度足够高,MicroPython 里可以很方便地控制。
串口在这套系统里的角色是“指令入口”。上位机发来类似S90这样的文本指令,Pico 解析出角度值后映射到 PWM 占空比,这就是一个完整的串口控制链路。
5.2 从串口指令到 PWM 输出的完整代码
下面这段代码实现了一个通过 UART 控制舵机的完整流程。接收串口字符串,解析角度值,然后设置 PWM 占空比:
from machine import UART, Pin, PWM import time # 初始化 UART1:TX=GP4, RX=GP5 uart1 = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5)) # 初始化舵机 PWM 引脚 GP15 servo = PWM(Pin(15)) servo.freq(50) # 50Hz = 20ms 周期 def angle_to_duty(angle): # 0度 -> 2.5%占空比,180度 -> 12.5%占空比 # 填入 0~65535 的占空比范围 min_duty = int(65535 * 0.025) max_duty = int(65535 * 0.125) return int(min_duty + (max_duty - min_duty) * angle / 180) buffer = b'' while True: if uart1.any(): data = uart1.read() if data: buffer += data # 按换行符分割完整指令 if b'\n' in buffer: lines = buffer.split(b'\n') for line in lines: line = line.strip() if not line: continue try: # 指令形如 S90 if line[0] == ord('S'): angle = int(line[1:]) angle = max(0, min(180, angle)) servo.duty_u16(angle_to_duty(angle)) uart1.write(f'OK {angle}\n'.encode()) except ValueError: uart1.write(b'ERR\n') buffer = b''这段代码虽然借鉴了网上流行思路,但我做了一些改进:用缓冲区而不是每次收到一个字节就执行一次;用换行符作为指令结束标志,比固定长度解析更健壮;增加角度限幅保护舵机。
接线要注意:舵机信号线接 GP15,电源线接 5V,地线接 GND。注意不要直接用 3.3V 给舵机供电,多数舵机在负载状态下 3.3V 会掉压导致抖动。
5.3 为什么这种架构适合“上位机 + 下位机”模式
这种“串口指令 + 下位机执行”的架构,在机器人项目里几乎是标配。上位机(PC、手机蓝牙串口助手、Unity 程序)负责复杂的运算和界面交互,下位机 Pico 只做实时性强的底层层操作,比如 PWM 输出和 IO 控制。
我这段时间把 Pico 接入 Unity,也就是用类似方式实现:Unity 端通过SerialPort.Write("S90\n")发送指令,Pico 端解析并控制舵机。实测在 19200 波特率下,指令发送到舵机转动的延迟可以控制在 20ms 以内,对大部分交互场景完全够用。
如果你也打算把 Pico 接到 Unity 或者其他上位机,建议在协议设计上加入起始符和结束符。比如S90\n,S是起始符,90是数据,\n是结束符。这样即使偶尔出现粘包,也能利用起始符分割指令,避免错误执行。
6. 常见问题与排查技巧
6.1 为什么收不到数据
这是串口调试里遇到频率最高的问题,没有之一。常见原因按概率排序:
- 接线没共地。UART 是异步串行通信,收发双方必须共地,否则参考电压不一致。
- TX 和 RX 接反了。对调一下就能解决。
- 波特率不一致。模块是 9600,你这边 115200,收到的全是乱码或者什么都没显示。
- 引脚占用冲突。GP0/GP1 可能已经被别的功能占用,此时数据自然不会从你期望的引脚出来。
- 电平不匹配。3.3V 接收方接了 5V 信号,虽说不至于立刻烧毁,但可能无法稳定识别。
排障顺序建议:先看硬件(接线共地否、TX/RX 是否对调),再用逻辑分析仪测波形,最后才怀疑代码。很多新手顺序反了,代码改了大半天,最后发现是杜邦线松了。
6.2 为什么会乱码
乱码几乎都是波特率不匹配。还有少数情况是双方格式不一致:你是 8N1,对面模块是 7E1(7位+偶校验),结果就是每个字节都错位。
如果你用 USB 转 TTL 模块,确认模块上的跳线或拨码开关是否配置正确。有些模块默认 5V 模式,没有跳成 3.3V,这也会导致波形畸变。
另一个冷门但常见的原因是“接地环路”。如果上位机 PC 和 Pico 两边都接了不同的电源适配器,且两个适配器的地之间存在电压差,串口线上会有电流流动,导致信号畸变。解决方法是只用单边供电,或者用隔离模块。
6.3 为什么打印显示正常但 UART 引脚没输出
这个问题在 Pico 上很典型,原因我刚才提过:MicroPython 的 print 打印走的是 USB 虚拟串口,跟 UART 引脚是两码事。很多人写了几行 print 发现 Thonny 窗口有输出,就以为串口通了,实际 GP0/GP1 上什么信号都没有。
想验证 UART 引脚是否在发数据,最简单的方法是把 TX 引脚接地,然后用 USB 转 TTL 模块连接,在电脑上用串口助手看有没有数据。如果你手头有逻辑分析仪,把这个抓手挂上去看波形是更直观的方案。
由于 REPL 默认占用 UART0 的引脚,你如果初始化 UART0 就会发现 REPL 没输出了。解决办法是换用 UART1,或者调整 REPL 的映射(需要在固件层操作,比较麻烦,不推荐新手直接上)。
6.4 串口调试问题速查表
| 现象 | 最可能原因 | 优先排查项 |
|---|---|---|
| 完全无输出 | 接线错误或未共地 | 检查 TX/RX/GND 连接 |
| 收到数据全是乱码 | 波特率不一致 | 两边设成完全相同 |
| 偶尔丢字节 | 缓冲不足或线过长 | 加长延时/缩短线缆 |
| 执行了两次指令 | 上位机多发或粘包 | 在指令中加结束符 |
| print 有输出但引脚没信号 | REPL 和 UART 混为一谈 | 检查是否在 UART 引脚上接设备 |
| 引脚电平异常 | 外设电压不匹配 | 加电平转换模块 |
这张表的经验来自我实际调试过的多个场景。个人感觉,串口问题看起来五花八门,但根因不外乎电平、接线、波特率、引脚冲突四类。先把这四类排除掉,再去折腾协议和代码,能少走一半弯路。
6.5 电磁干扰与信号完整性
最后补一个前面提过但值得专门强调的点:线缆长度对高速串口的影响。理论上 UART 在 115200 波特率下,几十厘米线缆都问题不大;但如果你用的是面包板加长杜邦线,又跟电机驱动线走在一起,还是会偶发干扰。
对策有两种。一是降低波特率到 9600 或 19200,代价是传输速度变慢,但稳定性显著提升;二是使用带屏蔽的线缆或者双绞线,减少外部电磁干扰的影响。
我在驱动舵机时,PWM 信号线和串口线保持了一定距离,避免舵机 PWM 对串口 RX 线产生串扰。这个细节在电机类项目中尤其要注意。
7. 我的一点体会与扩展建议
说实话,Pico 的串口通信并不复杂,但它在整个嵌入式项目里起到的作用,经常被低估。串口是你对外的眼睛,也是你给他的命令。学会正确配置 UART、理解引脚复用、掌握收发逻辑、选对调试工具,这套组合拳打下来,基本能应对 90% 的 Pico 外设通信需求。
我实际做过的项目里,Pico 串口最稳的一个场景是长期运行的环境监测节点。节点通过 UART 接二氧化碳传感器,每 5 秒读一次数据,再用板载 WiFi 模块通过串口把数据传到 MQTT broker。连续运行了两周,串口链路没有丢过一次数据。能做到这一点,靠的不是运气,而是初始化时把超时、缓冲区、电平都处理好。
如果你后面打算深入这块,有几个方向值得继续玩:用 Pico 做串口转 WiFi 网关,让老旧传感器联网;用两个 Pico 做全双工串口通信测试,自己写一份简单的通信协议;或者在 CircuitPython 和 MicroPython 之间切换,对比两者 UART 行为的差异。串口是不起眼的基础功,但基础功越扎实,后续做复杂项目的翻车概率就越低。