资讯动态

基于USB-CDC与select的树莓派Pico上位机通信实战

发布时间:2026/9/7 11:57:38 来源:尧图企业网站定制
刚接触树莓派 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 里代码有 bugPico 开机后不执行到你的业务逻辑你甚至会觉得它“坏掉了”。恢复方法也很简单按住 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(consoleTrue, dataTrue)重新上电后电脑上会多出两个 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的第四个参数直接决定了你的主循环“响应风格”timeout0立即检查一次没有数据立刻返回。适合在事件密集的循环里快速扫描。timeoutNone一直等直到有数据才返回。这又变成阻塞了不到万不得已不要用。timeout0.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握手测试PONGLED_ON点亮板载 LEDOK:LED_ONLED_OFF熄灭板载 LEDOK:LED_OFFTEMP?读取 Pico 内部温度传感器TEMP:28.5HELP列出所有命令CMDS: PING, LED_ON...为什么用文本命令而不是二进制协议因为文本协议可以直接在串口助手里手动测试不需要专业的协议分析工具对初学者最友好。等你能把文本协议跑通再换成二进制帧结构只是换一个解析函数的事。4.2 Pico 端代码boot.py 与 main.py先看boot.pyimport os # 把 REPL 从 USB-CDC 通道上移除让虚拟串口变成纯数据通道 os.dupterm(None, 1)再看main.pyimport 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 bPING: return bPONG\r\n elif cmd bLED_ON: led.value(1) return bOK:LED_ON\r\n elif cmd bLED_OFF: led.value(0) return bOK:LED_OFF\r\n elif cmd bTEMP?: temp read_temperature() return fTEMP:{temp:.1f}\r\n.encode() elif cmd bHELP: return bCMDS: PING, LED_ON, LED_OFF, TEMP?, HELP\r\n else: return bERR:UNKNOWN\r\n # 行缓冲器用来积累不完整的半包数据 line_buf b sys.stdout.buffer.write(bUSB-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 下可以用设备管理器确认串口号比如COM14Linux 下一般是/dev/ttyACM0macOS 下是/dev/tty.usbmodem*。我写了一个小测试脚本用 pyserial 做交互验证import serial import time # 端口号按实际情况修改 ser serial.Serial(COM14, 115200, timeout0.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(errorsignore).strip() if __name__ __main__: print(send_command(PING)) print(send_command(TEMP?)) print(send_command(LED_ON)) print(send_command(HELP))这里的几个细节很有价值timeout0.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(consoleTrue, dataTrue)方案电脑上会同时出现两个 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 上位机通信的标准模板你可以直接拿去改造。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价