树莓派Pico的USB虚拟串口进阶:用select实现稳定数据通信
2026/9/10 3:21:09 网站建设 项目流程

如果你接过树莓派Pico刷MicroPython,大概率经历过这个场景:板子插上电脑,设备管理器里多出一个串口,PuTTY一开就是MicroPython的REPL提示符。这个串口其实就是USB-CDC虚拟串口,但它默认不是给你做数据通信的,而是被MicroPython固件拿来当控制台用了。想真正拿它跑业务,比如PC发指令控制舵机、Pico回传传感器数据,你就得先弄明白USB-CDC、REPL、select和MicroPython这四者之间的关系。我这篇就把整条链路拆开,从USB协议层到MicroPython代码,再到PC端的Python脚本,完整走一遍。

这篇内容适合两类人:一是已经会用MicroPython点灯,但对USB虚拟串口只停留在“能看REPL”阶段的入门者;二是想在Pico上做一个稳定、可扩展的串口通信节点,但不想用while True硬扛读写的开发者。全文不求面面俱到,重点放在“为什么这么做”,以及“实操里到底会踩哪些坑”。

1. USB-CDC在Pico上的真实身份:虚拟串口到底虚在哪

1.1 从USB设备类说起:CDC-ACM是怎么变成COM口的

USB协议里有一类设备叫“通信设备类”,CDC(Communications Device Class)就是其中一个子集。树莓派Pico的RP2040芯片内置了USB 1.1设备控制器,MicroPython固件通过TinyUSB协议栈把它枚举成一个CDC-ACM设备。CDC-ACM在电脑上呈现出来的结果,就是Windows里的“COM端口”或者Linux里的/dev/ttyACM0

很多人以为这就是一个物理串口,实际上USB-CDC和传统的UART串口完全是两回事。传统UART是两根线(TX/RX)按照约定波特率一比特一比特地传。USB-CDC走的则是USB总线协议,数据被拆成USB包,通过端点(Endpoint)传输。CDC-ACM设备通常有中断端点和批量端点,中端点负责通知“线路状态”这类控制信息,批量端点才是真正传数据的管道。

这带来的直接好处是:虚拟串口的“波特率”是假的。你在PC端串口工具里设9600还是115200,对USB-CDC这条链路没有任何实际影响,它只是USB设备描述符里的一组参数。真正决定速度的是USB端点的带宽。这也是为什么Pico虚拟串口跑起来比普通UART顺滑得多,传输大块数据基本不用考虑波特率校准。

1.2 虚拟串口的数据链路:PC到Pico之间那几步

当PC打开这个虚拟串口时,操作系统会向设备发送Set Line Coding、Set Control Line State等控制请求,USB设备端(Pico上的固件)回应这些请求后,通道才算建立。之后PC上任何串口写入,都会变成USB批量传输包到达Pico;Pico想回数据,只要向USB IN端点写入,电脑就会在串口缓冲区里读到。

整个过程看起来和打开一个物理串口一样,但底层完全是USB协议。理解这一点很重要,因为后面所有MicroPython编程都建立在这个基础上:你的sys.stdinsys.stdout,实际就是USB-CDC端点对外的流接口。

2. MicroPython的USB-CDC只听REPL的话:先把通道让出来

2.1 默认固件里USB-CDC和REPL是绑定关系

树莓派Pico刷了官方MicroPython固件后,USB-CDC默认被REPL(Read-Eval-Print Loop,也就是你看到的>>>交互解释器)占用。板子上电,固件初始化TinyUSB,枚举出CDC设备,然后把stdin/stdout映射到这个CDC上。你每敲一行Python代码,实际上就是从USB-CDC读进去,解释器执行完,再把结果打印回USB-CDC。

这个设计对开发调试很友好,但对做通信项目是灾难。因为你没法把REPL“关掉”,只要USB-CDC存在,固件就默认它是控制台。更麻烦的是,一旦PC端误发了0x03(Ctrl-C)字节,MicroPython会立刻中断当前正在运行的main.py,把你打回>>>交互提示符。我在一次做设备测试时,写了个脚本往串口发十六进制命令,一个不小心把\x03混在帧里发过去,现场直接回到REPL,前面的运行状态全丢了。

所以,在默认固件上做虚拟串口通信,本质上就是在“跟REPL抢这个通道用”,必须自己做好协议和容错。

2.2 让main.py接管stdin/stdout:把控制台变成数据管道

虽然REPL占用USB-CDC,但你仍然可以在main.py里通过sys.stdin读取从PC串口发来的数据,通过sys.stdout往PC回数据。也就是说,只要你的程序进入主循环,系统接收到的USB数据就会进入stdin流,而不是直接丢给REPL解释。

这里有个关键操作:PC端打开串口后,Pico的启动过程会把MicroPython版本信息、>>>提示符等一并推到USB-CDC。所以通信前必须先清空输入缓冲区,并且做一个“握手”动作——PC等Pico主动发一条READY之类的标志,确认链路可用,再开始发正式指令。

import sys def send_bytes(data: bytes): sys.stdout.buffer.write(data) sys.stdout.buffer.flush()

注意,print()会额外追加换行,而且走的是文本流,在某些固件上对汉字等非ASCII字符还会有编码问题。往PC回数据时,建议直接用sys.stdout.buffer.write(),它写的是原始字节,干净利落。读取也一样,用sys.stdin.buffer.read(1)按字节读,别用readline()去赌对方一定会发换行符。

2.3 第一批该处理的问题:乱入的版本信息和残缺指令

实际跑起来后,你会遇到两个绕不开的问题。第一个是启动噪音:Pico每次上电,CDC里都会出现一段MicroPython版本号和>>>提示符。如果PC脚本没等一段时间就直接发指令,这些噪音会和正常应答混在一起。解决办法就是我在上一节说的握手标志,PC端先循环读取,直到读到READY\r\n再进入指令交互。

第二个是半包问题。USB虚拟串口虽然速度很快,但PC端一次write()的数据,到达Pico时可能被拆成多个小包;反过来,Pico一次发出去的数据,PC也可能分段收到。如果你写死readline(),只要对方没发换行符,程序就会一直阻塞在那里,整个循环停摆。这个问题会在第3节用select解决。

3. select在MicroPython里的正确用法:让一个循环同时盯住多路输入

3.1 为什么不能只用readline硬扛

很多人写了这样的代码:

line = sys.stdin.readline() # 处理line

如果PC一直不发数据,这个函数就会一直卡住。卡住期间,Pico既没法去读UART上的传感器数据,也没法驱动舵机做平滑PWM更新,整个系统变成单通道阻塞模型。

还有一种写法是不断sys.stdin.buffer.read()去轮询,但这是忙等待,CPU空转,浪费电,而且read没有数据时到底返回什么,不同固件行为还不一样。MicroPython里解决这种多路输入问题最优雅的工具就是select,它能让一个循环同时等待多个流对象,谁有数据谁先被处理。

打个不太恰当但好懂的比方:单线程readline就像一个店员只盯着一个电话铃响,其他电话打进来全被忽略。select则像总机接线员,把所有电话线都放在面前,哪条线来电接哪条,没有来电就等一小会儿,顺便干点别的。

3.2 select.select和select.poll:MicroPython里的差异化选择

MicroPython移植版对select的支持不完全一样,但Pico的官方固件里,select模块主要提供两个接口:select.selectselect.poll。两者的目的都是监听流对象是否可读/可写,但使用方式有差别。

select.select(rlist, wlist, xlist, timeout)一次接收三个列表,分别装“想读”“想写”“想处理异常”的流对象,返回三个列表。它更适合一次等多个对象的场景,写法直观。

select.poll()返回一个轮询器,用register()把流对象和关心的事件注册进去,然后调用poll(timeout)。它更灵活,可以动态注册/注销流对象,在嵌入式环境里性能也更好一些。

我这篇里的例子用select.select,因为它语义清晰,新手容易看懂。实际产品里如果你要动态添加通道,建议用poll

3.3 带超时的select:顺便做心跳

select的timeout参数是灵魂。如果传None,表示无限等待,那就和readline block没区别;如果传一个数字,单位是秒(小数也行),表示最多等这么久,超时后即使没有数据也返回空列表。这样你就能在主循环里利用超时时间做心跳、LED闪烁、看门狗喂狗这类维护任务。

import select import sys from machine import UART, Pin, PWM uart = UART(0, baudrate=9600) led = Pin(25, Pin.OUT) rlist = [sys.stdin, uart] while True: readable, _, _ = select.select(rlist, [], [], 0.05) if not readable: led.toggle() # 50ms超时,趁机闪灯 continue for stream in readable: # 处理数据 pass

timeout取多少要看业务。0.05秒对PC指令控制来说够灵敏,又不会太耗CPU。如果你要求舵机响应非常跟手,可以调到0.01,但代价是循环更频繁,机器功耗略升。

4. 实战链路:USB-CDC指令控制舵机 + UART传感器数据回传

4.1 材料清单和接线:舵机供电别走3.3V引脚

我先交代一下要做的实验。PC通过USB虚拟串口向Pico发送文本指令,最常见的指令是servo:90\n,让Pico把舵机转到90度;Pico同时监听一个UART口上的串行传感器,把数据通过USB-CDC回传PC。

材料很简单:

组件说明
树莓派Pico任意版本,建议玩之前先确认固件能跑select
舵机SG90这种9g舵机就够演示
USB线数据线,不是纯充电线
串行传感器例如GPS模块、TOF激光测距,或另一块MCU
电源舵机需要外部5V供电

接线关键点:SG90的红线接外部5V正极,棕色线接GND,橙色信号线接Pico的GP15。Pico的3.3V引脚和GND必须与外部5V电源共地,否则信号线电平无法形成回路。千万别把舵机直接接在Pico的3.3V引脚上,SG90堵转时电流能到几百毫安甚至1A,Pico的3.3V稳压器根本扛不住,轻则舵机抽搐,重则烧稳压器。

如果手边没有独立电源,至少要在5V供电线和GND之间并一个470uF左右的电解电容,利用电容储能缓解舵机启动瞬间的压降。

4.2 MicroPython端完整代码逐段拆解

import select import sys from machine import Pin, PWM, UART # ---------- 舵机PWM初始化 ---------- servo = PWM(Pin(15)) servo.freq(50) # SG90舵机标准周期20ms def set_angle(angle): # 以0°=0.5ms脉宽、180°=2.5ms脉宽、周期20ms计算 min_duty = int(65535 * 0.0005 * 50) # 约1638 max_duty = int(65535 * 0.0025 * 50) # 约8191 duty = int(min_duty + (angle / 180.0) * (max_duty - min_duty)) servo.duty_u16(duty) # ---------- 外部UART传感器 ---------- uart = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1)) # ---------- 指令解析 ---------- rx_buffer = b"" def handle_line(line: bytes) -> bytes: line = line.strip() if line == b"ping": return b"pong\r\n" if line.startswith(b"servo:"): try: angle = int(line.split(b":")[1]) angle = max(0, min(180, angle)) set_angle(angle) return b"servo-ok:" + str(angle).encode() + b"\r\n" except ValueError: return b"servo-err\r\n" return b"unknown\r\n" # ---------- 主循环:select多路监听 ---------- send_ready = False while True: if not send_ready: sys.stdout.buffer.write(b"READY\r\n") sys.stdout.buffer.flush() send_ready = True readable, _, _ = select.select([sys.stdin, uart], [], [], 0.05) for stream in readable: if stream is sys.stdin: data = sys.stdin.buffer.read(1) if data: if data == b"\n": if rx_buffer: resp = handle_line(rx_buffer) sys.stdout.buffer.write(resp) sys.stdout.buffer.flush() rx_buffer = b"" elif data != b"\r": rx_buffer += data else: data = uart.read() if data: sys.stdout.buffer.write(b"[uart] " + data + b"\r\n") sys.stdout.buffer.flush()

这段代码有几个细节值得说。

主循环启动后先发送READY,PC端收到这个标志才开始后续交互。rx_buffer用来累积串口数据,直到收到\n才当成完整一行处理。这样即使PC端一个命令分多个USB包到达,也不会被拆碎。舵机角度范围被限制在0到180度,防止解析异常导致PWM输出越界。

select.select的rlist里同时放了sys.stdinuart。这意味着USB指令和UART传感器数据可以并发到达,主循环按事件逐个处理,不会因为等待一个通道而饿死另一个通道。Pico上如果有复杂任务要做,可以在select超时的0.05秒里插进去。

4.3 PC端Pyserial脚本:握手、发指令、收应答

PC端我用Python的pyserial库。先装依赖:

pip install pyserial

然后完整脚本:

import serial import time ser = serial.Serial() ser.port = "COM3" # Windows下写COM口,Linux写/dev/ttyACM0 ser.baudrate = 115200 # 虚拟串口下这个值不影响传输 ser.timeout = 2 ser.open() # 清空启动噪音,等待握手 start = time.time() while time.time() - start < 3: line = ser.readline() if b"READY" in line: print("link ready") break else: raise RuntimeError("Pico no response") # 发个ping测试 ser.write(b"ping\n") resp = ser.readline().strip() print(resp) # 控制舵机 ser.write(b"servo:125\n") resp = ser.readline().strip() print(resp) # 继续读UART回传数据 end_time = time.time() + 5 while time.time() < end_time: data = ser.readline() if data: print("recv:", data.decode(errors="ignore").rstrip())

这里把波特率设为115200纯粹是给操作系统一个“心理安慰”,USB-CDC传输不依赖它。但串口工具如果不设置波特率,连打开都不让,所以照填就行。

PC端读数据同样会遇到半包问题,所以ser.readline()+ timeout的组合更稳。它内部有缓冲区,读到\n才返回;如果2秒没读到,就返回目前已有的数据,不会死等。控制舵机是短指令交互,这种模式完全够用。

5. 跑通之后必须补的课:协议设计、异常恢复和经验坑

5.1 行协议 vs 二进制帧:为什么先从行协议开始

上面的例子里,所有指令都是key:value\n这种文本行协议。行协议最大的优点是肉眼可读,调试时你直接打开串口助手敲一行servo:90就能看到效果。PC端解析也简单,split(":")就完事。

但行协议有个致命弱点:数据里不能随意出现\n。如果以后要传图像、二进制传感器数据,就得换成二进制帧协议,比如“帧头+长度+数据+校验”的结构。我的建议是:项目初期先用行协议把功能跑通,再在需要时升级成带帧头0xAA 0x55和CRC16的二进制协议。协议设计前期不要过度设计,不然排错成本会翻倍。

5.2 三张表看清高频故障

我把实际调试中遇到过的高频问题整理出来,按现象、原因、处理方式三列列出。

现象原因处理方式
PC打开串口后Pico无响应USB线是纯充电线;端口被其他工具占用换数据线;关闭串口助手等占用程序
收到MicroPython版本号和>>>没做启动握手,直接读了启动噪音PC端等待READY标志后再发指令
发送servo:90后没反应主机端没有发\n,Pico的PUSB缓冲区半包等待指令统一在末尾加\n;Pico端用累积缓冲处理
舵机一直抖动或不动供电不足;PWM频率不对;角度脉宽范围不匹配外部5V供电+共地+电容;确认servo.freq(50);按舵机说明书校准脉宽
PC误发0x03导致程序退回REPLUSB-CDC同时是REPL控制台,Ctrl-C会中断main.py协议数据里避免0x03;或折腾双CDC固件
select.select报不支持stream固件版本太老或个别UART对象没实现poll升级MicroPython固件;改用select.poll试试

5.3 通信稳定性设计:超时、重试和封印REPL

接上表最后一条,Ctrl-C中断问题最阴间,因为MicroPython的REPL监听机制是全局的,不是你的main.py能完全屏蔽的。我自己采用的方案有几种。

一是PC端发送层过滤,所有指令都按ASCII文本协议来,不允许出现0x03字节。数据内容如果可能包含任意二进制,就得做转义,比如把0x03转成0x03 0x01,接收端再还原。这跟通信协议里的字节填充是一个思路。

二是给Pico加看门狗,MicroPython里可以用machine.WDT,但需要注意REPL被Ctrl-C劫持后,看门狗是否还会喂。实际效果不同固件有差异,不要过度依赖。

三是如果项目真的需要同时保留调试控制台和稳定数据通道,就得考虑换用带双CDC端口或支持os.dupterm重新映射的固件。但我提醒一句:os.dupterm的通道编号在不同MicroPython版本里不是完全一样,关错通道可能把整个USB-CDC弄没,到时只能按住BOOTSEL重新刷固件。新手阶段,我更建议直接用带双USB CDC的自定义固件,一个端口留给REPL,另一个专门做数据通信,彻底隔离。

6. 再往上走一步:把虚拟串口玩成“设备网关”

6.1 用select桥接多路UART/GPIO事件

前面例子里只有一路USB-CDC和一路UART,但你完全可以在select的rlist里继续加东西。比如再加一路UART,让Pico同时监听两个串口传感器;或者定义几个GPIO引脚,用中断配合select做按键事件上报。

这里有一个通用思路:rlist里的对象越多,主循环越要谨慎处理每个通道的单次读取量。对UART对象,read()不带参数在某些固件上会一次读完所有剩余字节,如果传感器数据量很大,一次读几千字节,处理函数就长时间占用CPU。稳妥做法是每次read(64)最多读64字节,读不完的留在缓冲区,下一轮select会继续报可读。这样每个通道占用的处理时间有限,其他通道就不会被饿死。

6.2 换固件/换端口:当USB-CDC不够用时

再往上做,Pico的USB-CDC虚拟串口虽然方便,但本质上还是被REPL拴着的单通道。如果你发现Ctrl-C干扰、调试信息污染、或者PC同时开两个应用想访问同一个串口,USB-CDC的单通道就会成为瓶颈。

我实际项目里最后转向的方案是:Pico上电后把主程序跑在UART0上,USB-CDC仅保留为诊断日志端口。UART0接一个USB转TTL模块,PC上用独立串口连接,数据传输和REPL彻底分离。代价是多了硬件,但稳定性和调试体验都大幅提升。

如果你不想加硬件,还可以寻找支持双CDC的MicroPython构建版本,这种固件会枚举出两个COM口,一个仍给REPL,另一个作为独立的pyb.USB_VCP或等价对象交给应用使用。改动成本主要在固件获取和烧录,应用逻辑几乎不用改,只是把sys.stdin换成新的数据流对象。这个方向值得探索,但不同固件差异很大,选之前一定确认镜像来源和对应版本。

回到最初的问题:树莓派Pico的虚拟串口到底能不能干实事?能,而且能干得很漂亮。前提是你别把它当作一个“本来就应该是数据通道”的串口,而是把它理解成“一个默认被REPL占用的USB-CDC设备”。用select把stdin和UART纳入同一个事件循环,用行协议做指令交互,再做好握手和字节缓冲,你就能得到一个稳定、可扩展的PC与Pico通信底座。这套东西跑通之后,后面的传感器接入、多舵机控制、PC上位机开发,都只是往上堆功能的问题了。

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

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

立即咨询