USB-CDC与select轮询:树莓派Pico非阻塞虚拟串口通信实战

USB-CDC与select轮询:树莓派Pico非阻塞虚拟串口通信实战 前阵子调试一个树莓派 Pico 的小项目PC 上位机要实时下发指令、收回传感器反馈。我偷了个懒没用外接的 USB-TTL 转串口模块而是直接拿 Pico 自带的 USB 口当虚拟串口配合 MicroPython 里的 select 轮询机制做了一套非阻塞通信。实测下来这套 USB-CDC select MicroPython 的组合相当顺手代码量不大理解起来也不难但里面的细节坑真不少。这篇文章就把我的完整拆解和踩坑记录整理出来给准备在 Pico或者其他带 USB 的板子上做虚拟串口通信的朋友做个参考。无论你是刚接触 MicroPython 的新手还是想从阻塞式读串口升级到事件驱动写法的老玩家应该都能从中捞到点东西。1. 项目拆解这套组合到底要解决什么问题1.1 虚拟串口到底是什么——从 USB-CDC 说起USB-CDC 的全称是 USB Communications Device Class也就是 USB 标准里专门给通信设备定义的一类设备规范。最常见的呈现形式就是“虚拟串口”电脑这边识别成一个 COM 口Windows或者 /dev/ttyACM0Linux看起来和传统串口一模一样但底层的物理链路其实是 USB不是 UART。用大白话说USB-CDC 就像是一个“穿着旧串口外衣的 USB 快递员”。对 PC 上的老程序来说它就是一个标准串口收发数据、设置波特率、控制流控都是同一套逻辑实际上数据全走 USB 通道。这个特性对嵌入式调试特别友好因为现在很多 MCU 板子比如树莓派 Pico 上的 RP2040原生支持 USB硬件上省掉一颗 USB-TTL 转接芯片一根 Type-C 线就能同时搞定供电和数据通信。在树莓派 Pico 上MicroPython 固件默认把 USB-CDC 通道作为 REPL 的输入输出。也就是说sys.stdin、sys.stdout、sys.stderr这三个标准流对象默认都指向这个虚拟串口。你在 PC 串口工具里敲字符Pico 上的sys.stdin就能读到Pico 用print()输出PC 端就能显示。这本身就是一套开箱即用的 USB 串口通信方案我们只需要在上面加一点“组织数据”的逻辑。1.2 为什么是 select MicroPython阻塞与轮询的博弈如果只是想做一次性命令交互直接写几行readline()也够用。但一旦涉及真实项目比如通信的同时还要控制舵机、读传感器、刷新 LED 状态阻塞式读取的毛病就立刻暴露出来了。# 常见但容易翻车的写法 line sys.stdin.readline() # 如果上位机一直不发数据这行会一直卡住这句readline()是典型的阻塞调用USB-CDC 上没有数据时它会一直挂起等在那里后面的代码全部停摆。舵机不能动了传感器不采样了整个程序就像死了一样。解决思路通常有两个方向。一个方向是用 MicroPython 的asyncio做协程调度把读串口包成一个异步任务另一个方向就是这里的主角——select模块里的poll()轮询机制。我选择后者原因很实在select.poll在 MicroPython 的 RP2040 移植版里内置支持不引入额外依赖逻辑也直观——每次循环去查询“USB 虚拟串口有没有可读数据”有就处理没有就超时返回主循环继续跑其他任务。这种非阻塞的“事件驱动”写法正是单芯片上同时干多件事的常规解法。2. 准备工作硬件连接、固件烧录与终端工具2.1 硬件与软件清单做这套实验需要的材料不多很多玩嵌入式的人手头都有。我把清单列一下顺带标注用途。类型项目说明硬件树莓派 Pico 或 Pico WRP2040 芯片带 USB 口W 版本多无线但通信逻辑一致硬件Type-C 数据线必须是数据线不是充电线否则无法枚举 USB 设备硬件SG90 舵机第 4 节扩展项目使用也可以用其他 50Hz PWM 舵机硬件面包板 杜邦线舵机信号线、电源线连接用软件MicroPython 固件rp2 系列 .uf2从 MicroPython 官方下载页获取软件Thonny IDE 或 mpremote烧录调试和 REPL 交互软件PuTTY / minicom / 任意串口助手用于连接虚拟串口收发数据软件Python3 pyserial写上位机测试脚本用2.2 烧录 MicroPython 固件的完整流程树莓派 Pico 默认出场不带 MicroPython 固件第一次使用需要手动烧录。流程很成熟按住 Pico 板子上的 BOOTSEL 按钮不放。用 Type-C 线连接 Pico 和电脑。松开 BOOTSEL电脑上会出现一个名为“RPI-RP2”的 U 盘。去 MicroPython 官方下载 rp2 目录下最新的.uf2固件文件。注意 Pico 和 Pico W 是不同文件别下错。把.uf2文件直接拖进 RPI-RP2 磁盘里。文件拷贝完成后 Pico 会自动重启RPI-RP2 磁盘消失系统里会多出一个虚拟串口设备。这里有个细节容易被忽略如果你之前烧过别的固件比如 CircuitPython 或者 C 语言程序不用先做任何“清除”操作直接按住 BOOTSEL 插线进入 UF2 模式再拖入新固件即可覆盖。如果插上电脑后始终没出现 RPI-RP2 磁盘大概率是线的问题或者 BOOTSEL 没按紧先把 USB 线拔掉按住 BOOTSEL 重新插入基本都能解决。2.3 怎么打开 Pico 的虚拟串口固件烧好以后Pico 上电就会被电脑识别成一个虚拟串口。不同系统查看方式不一样Windows打开设备管理器展开“端口COM 和 LPT”能看到一个类似USB Serial Device (COMx)的条目记住这个 COM 号。Linux执行ls /dev/ttyACM*一般会看到/dev/ttyACM0。如果没有权限需要把当前用户加入dialout组并重新登录。macOS查看/dev/cu.usbmodem*通常是一个类似/dev/cu.usbmodem1101的设备。连接串口工具时推荐 PuTTY 或者 MobaXterm选择 Serial / Serial 连接方式端口填刚才看到的设备名波特率随意填个 115200 即可。这里需要注意USB-CDC 虚拟串口的波特率其实不参与实际通信USB 协议里没有“波特率”的概念电脑端只是需要一个波特率值才能把串口打开。所以哪怕你填 9600 或者 921600两边照样能通信。如果你更喜欢图形化环境Thonny 右下角解释器选 MicroPython (Raspberry Pi Pico)它会自动找到虚拟串口并打开 REPL直接在下方输入框写 Python 命令。命令行党也可以装 mpremotepip install mpremote然后mpremote connect /dev/ttyACM0进交互式控制台。3. USB-CDC 通信实战从回显程序到命令框架3.1 第一版最简单的非阻塞回显程序先写一个 USB-CDC 回显程序来验证通道。PC 端发什么Pico 原样返回什么但用的是 non-blocking 的 select 轮询方式。import select import sys # 创建 poll 对象并注册 USB-CDC 的标准输入流 sp select.poll() sp.register(sys.stdin, select.POLLIN) print(USB-CDC echo ready) sys.stdout.flush() while True: events sp.poll(100) # 超时 100ms if not events: continue # 没有数据就继续跑循环 data sys.stdin.buffer.read() if data: sys.stdout.buffer.write(becho: data) sys.stdout.buffer.flush()解释几个关键点。select.poll()在 MicroPython 中返回一个 poller 对象register(sys.stdin, select.POLLIN)表示“我要监听标准输入是否有可读事件”。sp.poll(100)里的 100 单位是毫秒表示最多等待 100 毫秒有数据来就立刻返回没数据就等满 100 毫秒返回空列表。因为设置了超时这个循环永远不会被卡死主流程每 100 毫秒“瞟一眼”串口其余时间可以处理别的事情。读取时用sys.stdin.buffer.read()是因为 USB-CDC 里的底层数据是字节流直接读.buffer能拿到bytes类型避免文本编码的干扰。输出同样用sys.stdout.buffer.write()写完之后务必要flush()。USB-CDC 的输出有缓冲不手动冲刷数据可能攒在缓冲区里半天发不出去看起来就像“发了但没反应”。这段代码跑起来后在串口工具里随便敲几个字符比如helloPico 会回复echo: hello基本通信链路就通了。3.2 select.poll 的机制与 MicroPython 特有细节很多从 CPython 转过来的朋友第一次用 MicroPython 的 select 会被返回结果搞懵。CPython 里poll.poll()返回的是一个(fd, event_mask)元组列表fd 是文件描述符数字但在 MicroPython 的 RP2040 移植版里返回元素的第一个值是你注册时传入的对象本身不是数字。也就是说判断有事件后直接拿这个对象去读就行。events sp.poll(100) for obj, mask in events: # obj 是注册时传入的 sys.stdin data obj.buffer.read()另外还有一个常见困惑register到底应该传sys.stdin还是sys.stdin.buffer我的经验是传sys.stdin最稳因为 poll 需要的是一个带完整读写接口的流对象读取数据时统一用sys.stdin.buffer.read()这样两个关注点分开不容易踩坑。如果某些移植版在register(sys.stdin.buffer)上报错换成register(sys.stdin)基本都能解决。事件掩码方面MicroPython 支持POLLIN可读、POLLOUT可写、POLLERR错误、POLLHUP挂断等常用标志。做 USB-CDC 接收时主要关心POLLIN发送时一般不阻塞所以POLLOUT用得少。3.3 升级可扩展的行命令解析框架实际项目里不会只做回显更常见的需求是“上位机发一条命令下位机解析并执行”。这种场景建议维护一个字节缓冲区把收到的数据攒起来遇到换行符再整行解析避免半包、粘包问题。import select import sys BUFFER b def handle_command(line: str): # 这里放具体的命令处理逻辑 if line ping: sys.stdout.buffer.write(bpong\r\n) sys.stdout.buffer.flush() sp select.poll() sp.register(sys.stdin, select.POLLIN) print(command shell ready) sys.stdout.flush() while True: events sp.poll(50) if not events: continue data sys.stdin.buffer.read() if not data: continue BUFFER data while b\n in BUFFER: line, BUFFER BUFFER.split(b\n, 1) handle_command(line.strip())这个框架的核心在于while b\n in BUFFER这个内层循环。串口数据到达的时机不可预测可能一次只来半个命令也可能一次堆了好几条命令通过缓冲区按换行符切分能保证每条命令都是完整的一行同时不丢失多余数据。这个思路不仅适用于 USB-CDC也适用于 UART、socket、甚至文件流属于嵌入式通信里的通用基本功。4. 延伸项目用虚拟串口控制舵机4.1 舵机 PWM 参数推算与角度映射指令通了之后我顺手把虚拟串口和控制舵机结合起来这也是很多玩树莓派 Pico 控制舵机的人想做的一个场景。SG90 舵机是一个典型的 PWM 伺服电机它需要接收周期为 20ms频率 50Hz的方波信号脉冲宽度决定舵机角度0.5ms 脉宽对应 0°2.5ms 对应 180°中间基本线性。而 Pico 的machine.PWM模块设置占空比用的是 16 位数值也就是duty_u16取值范围 0 到 65535代表 0% 到 100% 的占空比。要计算某个角度对应的duty_u16公式是duty_u16 (脉宽 / 20000us) * 65535以 SG90 的 0.5ms 到 2.5ms 范围为例角度脉宽duty_u16 数值0°500us163845°1000us327790°1500us4915135°2000us6554180°2500us8192实际工程中建议把脉宽限制在 0.5ms 到 2.5ms 之间避免堵转电流损坏舵机。有些舵机线性范围略有差异第一次接好后先手动给中间值 90° 测试如果舵机会“吱吱”叫或者抖动再微调范围。4.2 完整舵机控制代码下面把第 3 节的行命令框架和舵机控制合成一个完整程序。协议定得很简单上位机发Sxx设置舵机角度发Q查询当前角度。这里“S”代表 set“Q”代表 query按自己的项目改即可。import select import sys from machine import Pin, PWM SERVO_PIN 0 pwm PWM(Pin(SERVO_PIN)) pwm.freq(50) # 舵机要求 50Hz current_angle 90 def angle_to_duty(angle: float) - int: # 角度范围 0~180脉宽范围 500us~2500us pulse_us 500 angle / 180 * 2000 duty int(pulse_us / 20000 * 65535) # 限制输出范围防止超出舵机机械极限 duty max(1638, min(8192, duty)) return duty def set_servo(angle: int) - int: angle max(0, min(180, int(angle))) pwm.duty_u16(angle_to_duty(angle)) return angle def handle_command(line: str): global current_angle if line.startswith(S): angle set_servo(line[1:]) current_angle angle sys.stdout.buffer.write(bACK: str(angle).encode() b\r\n) sys.stdout.buffer.flush() elif line Q: sys.stdout.buffer.write(bCUR: str(current_angle).encode() b\r\n) sys.stdout.buffer.flush() else: sys.stdout.buffer.write(bERR\r\n) sys.stdout.buffer.flush() BUFFER b sp select.poll() sp.register(sys.stdin, select.POLLIN) set_servo(90) # 上电先回到中间位置 print(servo ready, send S0~S180 or Q) sys.stdout.flush() while True: events sp.poll(50) if not events: continue data sys.stdin.buffer.read() if not data: continue BUFFER data while b\n in BUFFER: line, BUFFER BUFFER.split(b\n, 1) handle_command(line.strip())接线也很简单SG90 的红线接 Pico 的 5V 或者外部 5V 电源棕线接 GND橙黄线接 GPIO0。如果用的是 USB 供电带一个舵机一般没问题同时带动多个舵机的话建议外部电源供电并把外部电源的 GND 和 Pico 的 GND 接在一起也就是俗称的“共地”否则信号参考电位不一致舵机会乱跳。这个坑我踩过一次折腾半天才发现是 GND 没接。4.3 PC 上位机脚本与联调过程Pico 端程序跑起来后用 Python 写个简单上位机来联调。安装 pyserial 后脚本如下import serial import time ser serial.Serial(COM13, 115200, timeout0.2) time.sleep(0.1) ser.reset_input_buffer() while True: cmd input( ).strip() if cmd exit: break ser.write((cmd \r\n).encode()) time.sleep(0.05) while ser.in_waiting: line ser.readline().decode().strip() print(Pico:, line) ser.close()注意把COM13换成你自己的端口号Linux 下是/dev/ttyACM0macOS 下是/dev/cu.usbmodem*。运行后输入S0舵机转到 0°Pico 返回ACK:0输入S90舵机转到中间输入QPico 返回当前角度。整个过程在主循环里完全无阻塞哪怕舵机正在转动你也可以继续接收新的指令这才是 select 方案带来的真正体验。5. 避坑指南虚拟串口与 select 的踩坑实录5.1 串口消失、闪断与 REPL 冲突虚拟串口最大的特点同时也是最大的负担它依赖 USB 枚举一旦 Pico 复位、进入 bootrom、或者程序跑飞重启PC 端的 COM 口会瞬间消失再重新出现。这对调试影响很大尤其程序里出现未捕获异常时MicroPython 会自动复位串口就会闪断。解决思路分两层。程序层面while True主循环里尽量用try/except包住可能出错的逻辑避免异常导致复位就算真的遇到严重错误也应该进入一个“提示错误并等待”的死循环而不是直接 reset。上位机层面要做断线重连逻辑检测到端口消失后轮询等待端口重新出现再自动打开否则每次都要手动断开重连。另外还要注意 REPL 和业务代码抢通道的问题。如果main.py里写了无限循环MicroPython 默认的 REPL 就不会再响应反过来如果你手动运行了一个死循环程序按 CtrlC 可以中断并回到 REPL。调试时的建议是先不着急写main.py把代码放在 Thonny 里直接运行调试等稳定了再固化成main.py这样能少踩很多坑。5.2 半包问题与缓冲解析串口通信里最经典的问题就是“半包”。上位机发送S90\r\n但数据在 USB 包里可能被拆成两次到达第一次到S9第二次到0\r\n。如果收到一次数据就立刻当完整命令解析必然出错。这也是我坚持用“缓冲区 换行符切分”模式的原因。BUFFER data while b\n in BUFFER: line, BUFFER BUFFER.split(b\n, 1) handle_command(line.strip())这套逻辑的巧妙之处在于split(b\n, 1)只分割第一个换行符剩下的留在 BUFFER 里继续累计。比如一次收到S90\r\nQ\r\n第一次循环切出S90第二次循环切出Q一条不漏。如果收到S9没有换行符那就一直攒着等0\r\n到了再一起处理。很多刚接触串口的朋友觉得这代码绕实际上这是最可靠的行解析法强烈建议直接抄作业。5.3 select.poll 的常见使用误区最后整理几个我实际踩过、或者帮别人排查时见过的高频坑。第一个坑是把poll.poll()返回值当成文件描述符数字。MicroPython 里返回的其实是你注册的对象直接调它的read方法就行别用 CPython 的习惯去对数字做处理。第二个坑是注册对象选错。有些固件版本对register(sys.stdin.buffer)支持不好会抛异常或者事件不触发。统一用register(sys.stdin)最省心读取时再用.buffer。第三个坑是忘掉flush()。USB-CDC 输出数据会先进缓冲区如果你写了sys.stdout.buffer.write(...)但不flush()数据可能很久都不出现在电脑端串口工具里。一个保险的做法是在敏感输出后都跟一句flush()。第四个坑是超时时间设置太短。poll(0)表示非阻塞立刻返回不带超时参数或传负数则一直等待这两者容易混淆。实际主循环里建议poll(50)到poll(200)之间既保证串口响应足够及时又给其他任务留出 CPU 时间。6. 从这套方案延伸出来的想法USB-CDC select MicroPython 这个组合能玩的方向其实不少。我做完舵机控制后又顺手往协议里加了简单的 CRC 校验和 JSON 行协议把上位机数据包做成{cmd:servo,angle:90}这种格式解析时用ujson.loads加try/except通信可靠性提升一个档次调试时也直观很多。如果想做更复杂的并发任务可以把 select 和 MicroPython 的asyncio结合用协程同时管理 USB-CDC、硬件 UART 和定时器事件。另外树莓派 Pico 如果刷支持 USB Host 的 MicroPython 固件还能把板子反过来当 USB 主机外接 USB 键盘、U 盘之类的设备那就是另一条更有意思的扩展路线了。最后再分享一点使用体会。我以前习惯用time.sleep(0.1)配readline()调串口程序一卡就怀疑硬件后来换成 select 轮询之后思路一下子清晰了串口只是众多事件源之一主循环的节奏完全由自己掌控。这套结构不只在 Pico 上适用ESP32、其他 RP2040 开发板甚至桌面端 Python 脚本里都可以用同样的逻辑属于那种“学会一次用遍全厂”的通用技能。你要是还在为串口阻塞发愁不妨把代码里的readline()换掉试试这套 select 方案。