刚接触树莓派 Pico 的时候,我碰到的最烦心的事情不是引脚不够,也不是 Flash 不够用,而是想和电脑通信总觉得别扭。点个灯、跑个循环都简单,一旦想把传感器数据发到电脑上看看曲线,或者想用电脑按下按钮远程控制 Pico,问题就来了:传统 UART 串口要接 USB 转 TTL 小板,要接 TX、RX、GND 三根线,要确认电平,还要看驱动脸色,折腾半天可能就卡在“串口打不开”上。后来我把主力方案切到了 USB-CDC 虚拟串口,配合 MicroPython 的 select 机制做非阻塞通信,整个体验舒服了不止一个档次。这篇文就完整拆解一下这套方案:虚拟串口在 Pico 上到底怎么运作,select 在通信代码中解决什么问题,以及一份可以直接抄作业的实战代码。
1. 传统串口让我头疼的三个场景,以及 USB-CDC 如何把它们干掉
1.1 场景一:一根杜邦线引发的“血案”
早期调试 Pico 时,我想把温度数据发到电脑的串口助手看波形。于是找了块 CH340 USB 转 TTL 小板,接上 Pico 的 GP0、GP1 和 GND,打开串口助手,反复确认波特率是 115200,结果屏幕上要么是空白,要么是乱码。排查了半天,最后发现是杜邦线虚接——那根线的插头稍微动一下就断开接触。那种问题你很难定位,因为你无法判断是代码的问题、线的问题还是驱动问题。
类似的情况还包括:你用的是 5V 供电的 USB 转 TTL 板子,直接连到 3.3V 逻辑的 Pico,虽然大部分时候电平兼容能扛过去,但一些特殊板子会把引脚烧掉。所以传统 UART 虽然简单,但在工作台上插来拔去,踩坑概率确实不低。
1.2 场景二:驱动和端口号像开盲盒
不同厂家的 USB 转串口芯片,驱动策略完全不同。CH340 在 Windows 下经常要手动装驱动,CP2102 大概率免驱,PL2303 老版本芯片在新系统上可能直接无法识别。就算驱动装好了,通信还要注意共地、波特率、停止位、流控这些细节。很多初学者在第一步就被劝退了。
1.3 USB-CDC 是什么,为什么它省心
USB-CDC 全称是 USB Communications Device Class,也就是 USB 协议里定义的“通信设备类”。当你的 MCU 内置了 USB 控制器,它可以在枚举 USB 设备时伪装成一个“串口设备”,让电脑操作系统自动创建一个虚拟 COM 口。树莓派 Pico 用的 RP2040 芯片自带 USB 控制器,板上那个 micro-USB 接口不只是供电和下载固件用,它还能给你提供一个即插即用的虚拟串口。
这意味着什么?意味着你只需要一根 USB 数据线,把 Pico 插到电脑上,设备管理器里就会多一个 COM 口。不需要任何 USB 转串口芯片,不需要接杜邦线,不存在电平匹配,甚至连波特率的概念都变得无所谓——USB-CDC 的传输速率由 USB 协议决定,你配置的 115200 波特率在虚拟串口上只是个“装饰”,实际并不会影响数据收发的真实性。
1.4 什么场景最适合用它
USB-CDC 虚拟串口最适合的场景,就是“板子和电脑主机”之间的双向通信。比如:
- 把 Pico 采集到的温度、光照、加速度数据实时上传到电脑画曲线;
- 电脑上的 Python 脚本通过网络或界面按钮,给 Pico 下发控制指令;
- 把 Pico 当成一个小型数据采集卡,对接上位机软件。
但要注意,USB-CDC 是主机和设备之间的通信模型,Pico 和 Pico 之间没法直接拿 USB-CDC 互连,因为双方都没有主机角色。板子之间通信还是老老实实用 UART、SPI、I2C 更合适。这一点在选型时要想清楚,不然你会在“两根 USB 线怎么对接”这种问题上卡很久。
2. MicroPython 把虚拟串口藏得很深:REPL、sys.stdin 与数据通道的三角关系
2.1 Pico 开机的默认行为:虚拟串口被 REPL 占用了
Pico 刷入 MicroPython 后,默认情况下,USB-CDC 虚拟串口是被交互式解释器 REPL 占用的。你用任何串口终端软件打开那个 COM 口,按一下回车,就能看到>>>提示符,可以直接敲 Python 代码。这一点对调试来说很爽,但当你打算用这个虚拟串口跑自己的业务协议时,REPL 就成了一个干扰项——你发过去的命令可能被 REPL 解释,终端里也会回显一些>>>之类的字符。
很多人在这一步就被卡住了:他们打开串口助手,发命令给 Pico,结果收到的响应里总夹着 Python 提示符,甚至根本收不到。这就是因为没有厘清 REPL 和用户数据通道的关系。
实际上,MicroPython 运行时的sys.stdin和sys.stdout默认就指向这个 USB-CDC 通道。也就是说,你在代码里调用sys.stdin.buffer.read()或sys.stdout.buffer.write(),数据也会从这个虚拟串口走。REPL 只是一个“挂在同一个通道上”的解释器前端。当你执行 main.py 里的死循环时,REPL 其实不会同时运行,但它的影子还在——串口打开时启动信息、>>>提示符、程序抛异常时的回溯信息,都会混进你的串口数据里。
2.2 把虚拟串口改成纯数据通道
要彻底把 USB-CDC 变成“你自己的串口”,需要在 boot.py 里做一步操作:把 REPL 从 USB-CDC 通道上剥离。
MicroPython 的os.dupterm()函数就是干这个的。它的作用是给 MicroPython 挂接或移除“重复终端”。默认情况下 REPL 会挂到一个终端上,USB-CDC 就是编号为 1 的那个终端。执行:
import os os.dupterm(None, 1)这行代码放在boot.py里,Pico 每次开机启动后,USB-CDC 就不会再启动 REPL 提示符了。此时sys.stdin/sys.stdout依然指向 USB-CDC 通道,你可以自由收发数据,不再担心 Python 提示符混进来。
2.3 这个方案的风险与恢复手段
这里必须提醒一句:一旦你这么做了,串口终端打开后就看不到>>>了,相当于你失去了远程交互调试的能力。如果 main.py 里代码有 bug,Pico 开机后不执行到你的业务逻辑,你甚至会觉得它“坏掉了”。
恢复方法也很简单:按住 Pico 板子上的 BOOTSEL 按键不放,再插 USB 线,这时 Pico 会进入 USB 大容量存储模式(也就是出现一个 U 盘)。你可以直接打开那个 U 盘,把boot.py或main.py删掉或改名,再重新上电,REPL 就会回来。这个恢复手段我已经用了很多次,属于必知技能。
2.4 新固件的另一个选择:双 CDC 双通道
MicroPython 1.20 之后的 RP2040 固件提供了一个叫usb_cdc的模块,可以开启第二个 CDC 接口。也就是说,你可以让 Pico 枚举出两个虚拟串口:一个给 REPL 做调试,另一个给你的业务数据用。这样两边互不干扰,比禁用 REPL 更优雅。
在boot.py里这样写:
import usb_cdc usb_cdc.enable(console=True, data=True)重新上电后,电脑上会多出两个 COM 口(Linux 下则是/dev/ttyACM0和/dev/ttyACM1)。前一个是 REPL,后一个是数据口。你在代码里可以用usb_cdc.data这个流对象来收发数据。
不过我实测下来,双 CDC 方案在 Windows 下的表现偶尔会因为驱动枚举顺序出现端口错乱,需要看设备描述符来区分,对小白不是特别友好。如果你只想快速跑通项目,禁用 REPL 的单通道方案反而更直接。
3. select 到底解决什么问题:阻塞读、轮询与非阻塞 IO 的取舍
3.1 不用 select 时,串口代码会有哪些坑
大多数人第一次写 MicroPython 串口读取,写出来的代码大概是这样的:
while True: data = sys.stdin.buffer.read(1) # 处理 data这种写法有个致命问题:read(1)在 USB-CDC 没有数据时会一直阻塞等待。也就是说,只要电脑端不发数据,Pico 的程序就卡死在那一行,什么其他事都干不了。如果你还需要同时处理按键、刷新屏幕、控制 PWM,那这个方案直接让你寸步难行。
换用readline()也一样,甚至更危险。因为你必须等到一个换行符\n出现,函数才会返回。万一电脑端发来的命令不完整,比如只发了"PING"没发\n,你的 Pico 就会永远卡在readline()上,跟死机一模一样。
一种常见的“土办法”是轮询:
while True: try: data = sys.stdin.buffer.read(1) if data: pass except OSError: pass time.sleep_ms(10)但这种方式要么依赖超时异常,要么靠 sleep 碰运气,CPU 占用高,而且数据半包、粘包问题处理起来非常难受。
3.2 select 的核心思想:让系统告诉你“有货了”
select这个模块在 Python 里是用来做 I/O 多路复用的,MicroPython 也实现了它,虽然功能裁剪过,但核心够用。它做的事情很朴素:你给它一组你关心的输入源,它帮你盯着,只要其中任何一个输入源有数据可读,它就返回告诉你“有货了”。
在 Pico 上,我们只需要关心一个输入源:sys.stdin。
import select import sys r, w, e = select.select([sys.stdin], [], [], 0.5)- 第一个参数是“可读”列表,放你要监听的文件对象或流对象;
- 第二个参数是“可写”列表,一般用不到,传空列表;
- 第三个参数是“异常”列表,MicroPython 支持有限,通常也传空列表;
- 第四个参数是超时时间,单位是秒。
返回值有三个列表:有数据可读的对象列表、可写对象列表、异常对象列表。我们只需要判断r里有没有sys.stdin即可。
用生活类比:你点了一份外卖,与其每隔十秒跑到门口看一次,不如给门卫打个招呼,等外卖到了让门卫通知你。select就是这个门卫,它知道数据什么时候到达。
3.3 超时参数是这套方案的精髓
select的第四个参数直接决定了你的主循环“响应风格”:
timeout=0:立即检查一次,没有数据立刻返回。适合在事件密集的循环里快速扫描。timeout=None:一直等,直到有数据才返回。这又变成阻塞了,不到万不得已不要用。timeout=0.01:最多等 10 毫秒,有数据立刻返回,没数据等够 10 毫秒也返回。
实际项目中我比较喜欢把超时设成 0 到 5 毫秒之间,配合一个主循环的 sleep 控制整体节拍。这样既能在数据到达时快速响应,又不会把 CPU 全部烧在空转轮询上。低功耗场景则可以把超时设长一点,让 Pico 在 select 等待期间进入休眠,真正省电。
3.4 在 MicroPython 中使用 select 的注意事项
MicroPython 对select.select的实现是依赖底层硬件的流对象支持的。RP2040 的 USB-CDC 驱动是支持 select 监听的,实测没有问题。但要注意:select只告诉你“这个流可读”,它不保证一次能读完所有数据,也不保证你读到的正好是完整的一帧命令。数据可能是一次性到的,也可能是分几段到达的。所以写完 select 之后,还需要配合“字节积累 + 按行拆包”的缓冲逻辑,这部分我在下一节的完整代码里会展开。
另外,MicroPython 也提供了select.poll接口,在某些端口上比select.select更高效,支持的事件类型更丰富。但select.select的可读性最好,作为教学和理解模型也最清晰,所以下面的项目我用select.select作为主实现。
4. 从零写一个 Pico 命令应答机:USB-CDC + select 完整工程
4.1 项目需求与协议设计
为了让方案完整落地,我设计了一个非常典型的小项目:Pico 通过 USB-CDC 虚拟串口与电脑通信,上位机发送文本命令,Pico 解析命令并返回响应。这个项目覆盖了“数据接收 + 命令解析 + 状态控制 + 数据上报”四个最常见的串口需求。
命令协议我设计成最简单的 ASCII 行协议,每条命令以\r\n或\n结尾,Pico 收到完整一行后应答。命令表如下:
| 命令 | 功能 | 返回示例 |
|---|---|---|
PING | 握手测试 | PONG |
LED_ON | 点亮板载 LED | OK:LED_ON |
LED_OFF | 熄灭板载 LED | OK:LED_OFF |
TEMP? | 读取 Pico 内部温度传感器 | TEMP:28.5 |
HELP | 列出所有命令 | CMDS: PING, LED_ON... |
为什么用文本命令而不是二进制协议?因为文本协议可以直接在串口助手里手动测试,不需要专业的协议分析工具,对初学者最友好。等你能把文本协议跑通,再换成二进制帧结构只是换一个解析函数的事。
4.2 Pico 端代码:boot.py 与 main.py
先看boot.py:
import os # 把 REPL 从 USB-CDC 通道上移除,让虚拟串口变成纯数据通道 os.dupterm(None, 1)再看main.py:
import select import sys import time import machine led = machine.Pin(25, machine.Pin.OUT) adc_t = machine.ADC(4) # RP2040 内置温度传感器 def read_available_data(): """把当前缓冲区里所有可读数据一次性读出来,返回 bytes。""" data = b'' while True: r, _, _ = select.select([sys.stdin], [], [], 0) if not r: break b = sys.stdin.buffer.read(1) if not b: break data += b return data def read_temperature(): """读取 RP2040 内部温度传感器数值,返回摄氏度。""" v = adc_t.read_u16() * 3.3 / 65535 temp_c = 27 - (v - 0.706) / 0.001721 return temp_c def handle_command(cmd): """根据命令返回响应字节串。""" if cmd == b'PING': return b'PONG\r\n' elif cmd == b'LED_ON': led.value(1) return b'OK:LED_ON\r\n' elif cmd == b'LED_OFF': led.value(0) return b'OK:LED_OFF\r\n' elif cmd == b'TEMP?': temp = read_temperature() return f'TEMP:{temp:.1f}\r\n'.encode() elif cmd == b'HELP': return b'CMDS: PING, LED_ON, LED_OFF, TEMP?, HELP\r\n' else: return b'ERR:UNKNOWN\r\n' # 行缓冲器,用来积累不完整的半包数据 line_buf = b'' sys.stdout.buffer.write(b'USB-CDC App Started, select mode\r\n') while True: # 1. select 检查数据是否到达 data = read_available_data() # 2. 如果读到了数据,合并进行缓冲器 if data: line_buf += data # 3. 按换行符拆出完整的行,循环处理每条命令 while b'\n' in line_buf: line, line_buf = line_buf.split(b'\n', 1) line = line.strip() if line: response = handle_command(line) sys.stdout.buffer.write(response) # 4. 主循环节拍,避免空转 time.sleep_ms(2)4.3 逐段拆解:这段代码为什么这样写
先从read_available_data()说起。这个函数内部是一个零超时的 select 循环:先调用select.select([sys.stdin], [], [], 0),如果没有数据可读,r就是空列表,直接跳出 while 循环;如果有数据,就read(1)读一个字节。为什么每次只读一个字节?因为这样可以确保我们不会因为尝试读太多数据而阻塞。读完一个字节后再回到 select 判断,如果缓冲区里还有数据,select 会立刻再次返回,相当于把所有到达的数据全部“捞”干净。
这种写法的好处是:即使一次命令被拆成几段到达(也就是半包),我们也能把每段数据都收到,不会漏。坏处是单字节读取在数据量极大时效率不算最高,但对于 Pico 这种规模的嵌入式应用完全够用。
line_buf是一个累积缓冲器。为什么需要它?看看这个场景:电脑端发来的是"TEMP?\r\n",但 USB 传输可能把这条命令拆成两个 TCP 段(类比)到达,第一次只到了"TEM",第二次才到"P?\r\n"。如果收到"TEM"就尝试解析,显然会识别成未知命令。正确的做法是把所有字节先攒在line_buf里,然后不停用b'\n' in line_buf判断有没有完整的一行出现。出现一条,就split出一条,处理完再检查下一条。这就是拆包和粘包处理的核心思想。
handle_command()函数是命令路由,用最简单的一串 if-elif 完成。实际项目里如果命令多,可以用字典映射{命令: 处理函数}来做,逻辑更清晰。我为了减少代码概念负担,保留了 if 结构。
注意所有响应后面都跟了\r\n,这是因为上位机读取时通常用行模式,看到行结束符才能判断一条消息结束。如果不加,PC 端用readline()会一直等不到结果。
4.4 上位机端:用 Python pyserial 配合测试
Pico 端代码烧录好之后,在电脑上打开虚拟串口。Windows 下可以用设备管理器确认串口号,比如COM14;Linux 下一般是/dev/ttyACM0;macOS 下是/dev/tty.usbmodem*。
我写了一个小测试脚本,用 pyserial 做交互验证:
import serial import time # 端口号按实际情况修改 ser = serial.Serial("COM14", 115200, timeout=0.2) time.sleep(0.5) # 等 Pico 重启和 USB 枚举完成 def send_command(cmd): ser.reset_input_buffer() ser.write((cmd + "\r\n").encode()) time.sleep(0.1) return ser.read_all().decode(errors="ignore").strip() if __name__ == "__main__": print(send_command("PING")) print(send_command("TEMP?")) print(send_command("LED_ON")) print(send_command("HELP"))这里的几个细节很有价值:
timeout=0.2让read_all()最多等待 0.2 秒,避免数据未到达时无限阻塞;reset_input_buffer()清空上一次可能残留的数据,保证这次读到的响应是当前命令的;- 每次命令之间
sleep(0.1),是为了照顾 Pico 端的主循环节拍,给它的time.sleep_ms(2)留出处理时间。
实际运行后,能看到这样一串输出:
PONG TEMP:27.6 OK:LED_ON CMDS: PING, LED_ON, LED_OFF, TEMP?, HELP到这里,一条“电脑 → Pico → 电脑”的完整通信链路就跑通了。
5. 实测中的坑与排查建议:设备枚举、REPL 残留、粘包与“失联”
5.1 电脑找不到虚拟串口,先查这三件事
如果你插上 Pico,设备管理器里完全没有 COM 口,不要急着怀疑代码。第一步检查 USB 线是不是只带充电不带数据的“电源线”,这在移动电源附赠线里非常常见,很多人因此排查了一下午。第二步按住 BOOTSEL 再插线,看系统是否出现一个 U 盘。如果出现 U 盘,说明板子和 USB 控制器都正常,问题出在固件或者驱动上。第三步如果 Windows 下设备管理器里出现一个带黄色感叹号的设备,去下载 RP2040 的官方驱动安装即可,绝大多数情况免驱,少数精简系统会缺驱动。
5.2 串口能打开但没有任何输出,如何判断是哪个环节的问题
能打开串口,说明设备枚举正常。如果打开后既没有 REPL 提示符,也没有应用的欢迎信息,最可能的原因是 boot.py 里的os.dupterm(None, 1)生效了,而 main.py 因为某种异常没跑起来。此时按住 BOOTSEL 重新插入,删除或改名boot.py和main.py,再重新上电,先恢复成干净的默认 MicroPython 环境,一步一步排查。
另一个极易忽略的点是:USB-CDC 虚拟串口本质上和波特率无关。串口终端软件里选 9600 还是 115200,对数据收发没有任何影响,因为 USB 全速传输并不需要真实波特率。很多终端软件默认的“打开串口”会强制应用波特率,你只需要选择一个能和软件兼容的值,比如 115200,就行,不需要纠结是否匹配。
5.3 半包和粘包问题,运算符代码里最容易翻车的地方
不少人在没有行缓冲的情况下直接用readline(),结果程序卡死。这就是半包问题:电脑端发了部分数据,换行符还没到达,readline()就傻等着。解决办法就是我前面代码里展示的line_buf累积法。把所有数据都先攒下来,直到缓冲区里出现\n,才把整行拿出来处理。这个方法本质上就是把“你等我”变成“我先记账,凑够一整行再干活”,应对半包和粘包都有效。
如果要跑二进制协议,建议不要按\n做分隔,而是定义固定帧头、帧长、校验字节。解析逻辑从“找换行”变成“先收够 N 个字节再解包”,思路是一样的,只是缓冲区逻辑更复杂一点。实际用 select + 逐字节累积的方式完全能做到。
5.4 select 超时的经验值,和主循环节拍怎么配合
我实际项目里常用的组合是:select.select的超时设为 0,因为read_available_data()本身就在循环里反复调用,零超时能让数据读取非常灵敏;而整个 while 循环末尾用一个time.sleep_ms(2)控制主循环频率。这样 Pico 在 2 毫秒内能响应上位机命令,同时 CPU 又不会满载。
如果应用对功耗敏感,比如用电池供电,可以把方案改成:
r, _, _ = select.select([sys.stdin], [], [], 0.05) if r: # 有数据时读取 pass这样 select 会进入等待,50 毫秒内没有数据就继续执行后面的逻辑。在等待期间 MicroPython 可以让 CPU 进入更省电的状态,和单纯 sleep 轮询相比,响应更快也更省电。
5.5 使用双 CDC 通道时的端口混淆问题
如果你选择了新固件的usb_cdc.enable(console=True, data=True)方案,电脑上会同时出现两个 COM 口。很多时候你会忘记哪个口是 REPL、哪个口是数据口,结果代码往 REPL 口发命令,当然得不到业务响应。区分方法很简单:用串口助手挨个打开,能看到>>>提示符的是 REPL 口,另一个就是数据口。你在上位机脚本里写死端口号时要特别谨慎,拔插一次 USB 后,Windows 下端口号可能发生变化,最好不要硬编码,而是用 pyserial 的serial.tools.list_ports按 VID/PID 或者描述符来匹配。
另外,usb_cdc.enable()的配置必须在 boot.py 里执行,并且要重新上电才会生效,热插拔不会触发重新枚举。如果你在 main.py 里临时调用,往往会发现串口没有反应,这也是很多人踩过的一个坑。
最后说一个小技巧:实际调试时,我会在handle_command里的每个分支都额外写一行sys.stdout.buffer.write返回明确的错误信息——比如收到未知命令时返回ERR:UNKNOWN。千万不要小看这一步,它让你在处理复杂协议时能精准定位是“命令没收到”还是“命令解析失败”。从我被串口问题折磨到现在用 USB-CDC 一路通畅,这套组合拳(虚拟串口 + select + 行缓冲 + 明确的错误返回)已经成了我做 Pico 上位机通信的标准模板,你可以直接拿去改造。