news 2026/9/8 10:20:21

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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/ttyACM0(Linux),看起来和传统串口一模一样,但底层的物理链路其实是 USB,不是 UART。

用大白话说,USB-CDC 就像是一个“穿着旧串口外衣的 USB 快递员”。对 PC 上的老程序来说,它就是一个标准串口,收发数据、设置波特率、控制流控都是同一套逻辑;实际上数据全走 USB 通道。这个特性对嵌入式调试特别友好,因为现在很多 MCU 板子(比如树莓派 Pico 上的 RP2040)原生支持 USB,硬件上省掉一颗 USB-TTL 转接芯片,一根 Type-C 线就能同时搞定供电和数据通信。

在树莓派 Pico 上,MicroPython 固件默认把 USB-CDC 通道作为 REPL 的输入输出。也就是说,sys.stdinsys.stdoutsys.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 固件,第一次使用需要手动烧录。流程很成熟:

  1. 按住 Pico 板子上的 BOOTSEL 按钮不放。
  2. 用 Type-C 线连接 Pico 和电脑。
  3. 松开 BOOTSEL,电脑上会出现一个名为“RPI-RP2”的 U 盘。
  4. 去 MicroPython 官方下载 rp2 目录下最新的.uf2固件文件。注意 Pico 和 Pico W 是不同文件,别下错。
  5. .uf2文件直接拖进 RPI-RP2 磁盘里。
  6. 文件拷贝完成后 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 命令。命令行党也可以装 mpremote:pip 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(b"echo: " + 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 的输出有缓冲,不手动冲刷,数据可能攒在缓冲区里半天发不出去,看起来就像“发了但没反应”。

这段代码跑起来后,在串口工具里随便敲几个字符,比如hello,Pico 会回复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(b"pong\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 数值
500us1638
45°1000us3277
90°1500us4915
135°2000us6554
180°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(b"ACK:" + str(angle).encode() + b"\r\n") sys.stdout.buffer.flush() elif line == "Q": sys.stdout.buffer.write(b"CUR:" + str(current_angle).encode() + b"\r\n") sys.stdout.buffer.flush() else: sys.stdout.buffer.write(b"ERR\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, timeout=0.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/ttyACM0,macOS 下是/dev/cu.usbmodem*。运行后输入S0,舵机转到 0°,Pico 返回ACK:0;输入S90,舵机转到中间;输入Q,Pico 返回当前角度。整个过程在主循环里完全无阻塞,哪怕舵机正在转动,你也可以继续接收新的指令,这才是 select 方案带来的真正体验。

5. 避坑指南:虚拟串口与 select 的踩坑实录

5.1 串口消失、闪断与 REPL 冲突

虚拟串口最大的特点同时也是最大的负担:它依赖 USB 枚举,一旦 Pico 复位、进入 bootrom、或者程序跑飞重启,PC 端的 COM 口会瞬间消失,再重新出现。这对调试影响很大,尤其程序里出现未捕获异常时,MicroPython 会自动复位,串口就会闪断。

解决思路分两层。程序层面,while True主循环里尽量用try/except包住可能出错的逻辑,避免异常导致复位;就算真的遇到严重错误,也应该进入一个“提示错误并等待”的死循环,而不是直接 reset。上位机层面,要做断线重连逻辑,检测到端口消失后轮询等待端口重新出现再自动打开,否则每次都要手动断开重连。

另外还要注意 REPL 和业务代码抢通道的问题。如果main.py里写了无限循环,MicroPython 默认的 REPL 就不会再响应;反过来,如果你手动运行了一个死循环程序,按 Ctrl+C 可以中断并回到 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.loadstry/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 方案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 10:20:13

MATLAB_KUKA6D项目包解析:六轴机器人运动学与轨迹规划实践

简介:面向机器人运动学学习者的MATLAB源码包,围绕KUKA六自由度机械臂的正逆解计算展开,适合机械工程、自动化、机器人技术相关专业学生及刚接触运动学的开发者参考。压缩包内共12个m文件,整个资源仅6KB,按功能划分为正…

作者头像 李华
网站建设 2026/9/8 10:17:25

计算机网络安全基础学习路线:从协议原理到实战入门

搞了这么多年安全,我见过太多人一上来就问“计算机网络安全基础应该怎么学”,然后转头就去背漏洞列表、刷渗透工具,结果三个月下来连一个端口扫描的原理都说不清楚。这个路子从一开始就跑偏了。网络安全的门槛不在工具多炫,而在基…

作者头像 李华
网站建设 2026/9/8 10:17:10

Android Init进程全解析:启动流程、rc文件与属性服务

1. Init 进程到底解决了什么问题做 Android 系统开发这些年,如果要挑一个最容易被忽略却又绕不开的起点,我会选 Init 进程。它不像 Binder、AMS 那样经常被挂在嘴边,但只要是涉及“开机”“服务拉起”“属性读写”“异常重启”的问题&#xf…

作者头像 李华
网站建设 2026/9/8 10:16:58

Python类型提示完全指南:从变量注解到泛型的工程实践

1. 类型提示不是给解释器看的,是给未来的自己看的 很多刚接触Python的人第一次看到类型提示,脑子里冒出来的问题基本都一样:Python不是动态语言吗?我明明可以不写类型,为什么还要多此一举?甚至有人觉得这是…

作者头像 李华
网站建设 2026/9/8 10:13:23

C#异步编程核心:async/await与Task机制详解及实战

干C#这行这么多年,我最大的一个感触就是:真正让程序“快起来”的,往往不是把某个算法优化几毫秒,而是把线程资源用在刀刃上。异步编程模式配合async/await和Task,正好是C#里解决I/O密集型任务(网络请求、文…

作者头像 李华
网站建设 2026/9/8 10:13:17

Ray深度解析:从Spark/Celery对比到分布式任务与Actor实战

先说结论:Ray不是要替代谁,而是把“分布式”这件事下沉成了一个Python原生的运行时。它既没有像Spark那样把一切都抽象成RDD/DataFrame的批计算模型,也没有像Celery那样把任务做成一条投递队列就完事。它站在两者之间的空白地带——让普通的P…

作者头像 李华