ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

树莓派Pico虚拟串口通信:USB-CDC与select机制实战解析

树莓派Pico虚拟串口通信:USB-CDC与select机制实战解析 在实际调试一块传感器板卡时我遇到了一个很常见的问题手边只有一块树莓派 Pico但电脑上要同时监控三路串口信号——传感器数据、调试日志、还有上位机的控制指令。Pico 的硬件 UART 只有两路其中一路还要留着下载程序用根本不够分。后来我把 USB-CDC 虚拟串口和 MicroPython 的 select 机制组合起来用板子自带的 USB 口模拟出一个额外的串口才真正把这件事理顺。这篇文章不是讲怎么点灯而是把这套东西从底层原理、环境搭建到 select 非阻塞收发、再到双向透传的完整流程拆开揉碎记录我在 Pico 上做虚拟串口通信的全过程给同样对 USB-CDC 感兴趣、想省掉一颗 USB 转 TTL 芯片的人一条可以直接抄作业的路线。1. 这次折腾 USB-CDC 的起因串口不够用只是表面问题1.1 从一块需要三路串口的板卡说起我当时在做的是一块工业数据采集板上面挂了一个温湿度传感器、一个 GPS 模块、还有一个步进电机驱动。调试阶段的需求很朴素传感器数据要看实时 logGPS 的 NMEA 语句要抓下来解析电机驱动要能手动发指令测试。这三个任务如果都用串口就得在 Pico 上同时开三路 UART其中 UART0 负责下载程序UART1 被腾出来给传感器剩下 GPS 和电机驱动就没地方挂了。第一反应是买 USB 转 TTL 的扩展板但手头只有一块 CH340 模块而且电脑上 USB 口也紧张。后来想到 Pico 板子上的 micro USB 口直连 RP2040 的 USB 控制器完全可以把这里变成一个虚拟串口。这样一根 USB 线既能下载程序又能当一路“额外”的串口和上位机通信硬件上一个芯片都不用加。1.2 虚拟串口解决了什么本质问题USB-CDC 的全称是 USB Communications Device Class属于 USB 设备类中的一个标准类别。它做的事情是把 USB 通道封装成一套类似传统串口的收发接口。主机侧看到的设备是一个 COM 口Windows 下或者 /dev/ttyACM0Linux 下应用程序读写这个设备节点就等价于和 Pico 交换数据。这里的关键在于传统串口走了 UART 物理协议靠波特率、起始位、停止位在 RX/TX 两根线上同步时钟和数据。而 USB-CDC 完全抛弃了这套物理层规则数据变成 USB 包在 D/D- 上跑主机端驱动负责把 USB 包重新包装成字符流。所以你会看到 USB-CDC 的“波特率”没有任何实际意义不管在电脑上设 9600 还是 115200虚拟串口收发的都是同样的数据不会因为波特率不对而出现乱码。这个特性对嵌入式开发非常有用不需要关心电平标准不需要区分 TTL 还是 RS232不需要购置额外的 USB-UART 桥接芯片直接用 Pico 自带的 USB 外设就能做数据回传和指令下发。1.3 这套方案到底适合谁我梳理了一下下面这几类需求特别适合用 USB-CDC 来做需要在上位机和板子之间建立双向通信但不想增加硬件成本的场景做数据采集和日志回传希望用一个固定设备名在电脑上读写数据做协议转换器比如 USB 虚拟串口转 UART、转 I2C、转 SPI想在一个方便调试的 REPL 环境里快速验证通信逻辑而不是每次改代码都要重新烧录固件。如果用传统方案你可能得接一个 CH340 或者 CP2102不仅占 PCB 面积还多一层驱动兼容性的问题。Pico 板载 USB-CDC 一条 USB 线全搞定这在快速原型验证阶段的价值是肉眼可见的。2. 数据是如何从 Pico 的 USB 管脚变成电脑上的 COM 口的2.1 USB-CDC 的底层零件接口、端点和控制请求要真正理解虚拟串口不能只在应用层看读写还得知道 USB 描述符那一层的结构。一个标准 USB 设备有设备描述符Device Descriptor、配置描述符Configuration Descriptor和接口描述符Interface Descriptor。对 CDC-ACM 来说一个 CDC 接口通常由两部分组成通信接口Communication Interface和数据接口Data Interface。数据接口负责实际的数据搬运里面有两类端点一个 Bulk OUT 端点接收主机发来的数据一个 Bulk IN 端点把数据发回主机。通信接口里有一个 Interrupt IN 端点专门用来上报串口的控制状态比如 DTR、RTS、Break 信号变化。端点就是 USB 通信的门牌号主机和设备通过端点号来确定数据去哪、从哪来。还有一类东西叫 CDC 控制请求走的是默认端点 EP0。比如SetLineCoding用来设置波特率、停止位、校验位SetControlLineState用来设置 DTR/RTS 的电平。主机端打开串口时会自动发这些请求这也是为什么上位机软件要求你先选一个波特率才能打开端口。Pico 端收到这些请求后可以选择直接忽略也可以根据需求通知应用层。2.2 RP2040 硬件上对 USB 的支持到底够不够RP2040 内部集成了一颗 USB 1.1 全速控制器标称速率 12Mbps。这个速率在 CDC 应用上非常够用实际实测做回环测试时吞吐量可以跑到 1MB/s 左右比传统 115200 波特率的 UART约 11.5KB/s快了接近一百倍。硬件上有几个点值得注意首先USB 数据线不是随便接在某个 GPIO 上的RP2040 对于 USB 的连接是固定引脚在 Pico 板子上直接连到了 micro USB 座用户代码不用也不应该去手动初始化这些引脚。其次RP2040 的 USB 控制器带 DMA 支持缓冲区的读写可以由硬件完成这样在做大数据吞吐时不会大量占用 CPU。最后Pico 的板载 USB 口是设备模式如果要做 USB Host比如插 U 盘、接 USB 键盘那需要额外的软件协议栈和硬件支持至少不是板子默认能直接干的事。2.3 MicroPython 实现虚拟串口的两条路线在 MicroPython 的世界里实现 USB-CDC 有两条路线我一开始也分不太清后来实践下来才弄明白。路线 A官方固件自带的默认行为。官方固件在启动时会把 USB-CDC 作为 REPL 的载体。你在 Thonny 或者串口终端里看到的 “” 提示符就是通过 USB-CDC 输出的。这时候sys.stdin和sys.stdout就绑定在这个虚拟串口上用户可以用 select 来监听sys.stdin是否有数据进来。这个方案的好处是完全不用管 USB 描述符细节缺点是 REPL 和业务通信混用一个口收到非预期字符时可能会触发 REPL 的快捷键逻辑比如 Ctrl-C 中断程序。路线 B使用 machine 模块中的 USBDevice 类或者 micropython-lib 里配套的 usb.device 库在设备上创建自定义的 USB-CDC 接口。这样做的好处是可以严格区分 REPL 和业务数据通道甚至可以在同一个 USB 设备上挂两个 CDC 接口一个留给 REPL一个专门跑数据。代价是需要较新的固件版本而且 API 相对实验性不同版本之间会有兼容性调整你必须以自己下载的固件内置示例为准。两条路线我都跑过这篇文章里会全部记录因为它们的适用场景确实不同。3. 环境搭建固件、驱动、主机工具一个都不能少3.1 固件差异怎么选官方固件 vs 自定义 USB 固件如果只是想用 select 监听官方固件默认的 USB 虚拟串口直接去 MicroPython 官网下载针对 Pico 的.uf2固件就可以。下载后按住 Pico 上的 BOOTSEL 键插入 USB 线会看到一个名为 RPI-RP2 的磁盘把.uf2文件拖进去板子会自动重启并运行新固件。检查固件版本可以打开 REPL输入import sys print(sys.implementation) print(sys.platform)如果你的项目需要自定义 USB-CDC 接口那就要考虑构建或者下载带 USBDevice 支持的固件。现在比较新的 MicroPython 版本在 RP2040 端口里已经加入了 USBDevice 支持可以直接在 REPL 里查看from machine import USBDevice print(USBDevice)如果提示找不到说明固件版本太老需要更新。另外micropython-lib 仓库里有一套 usb-device 库可以从那里把 usb/device 相关代码拷贝到板子文件系统里使用。我的建议是先跑通官方固件的路线 A确认你的使用场景确实需要独立 CDC 通道后再折腾自定义固件。3.2 主机侧识别虚拟串口的检查方法主机端识别虚拟串口Linux 和 Windows 略有不同。Linux 下插入 Pico执行dmesg | tail能看到类似cdc_acm 1-2:1.0: ttyACM0: USB ACM device的日志设备节点就是/dev/ttyACM0或者/dev/ttyACM1。如果遇到权限问题多半是当前用户不在dialout用户组里执行sudo usermod -a -G dialout $USERWindows 下打开设备管理器展开“端口 (COM 和 LPT)”会看到类似USB Serial Device (COM5)。Windows 10 以上系统对标准 CDC 设备通常自带驱动不需要额外装 CH340 那种厂家驱动。如果设备识别成了未知设备优先检查 USB 线是不是只供电没数据线的那种劣质线以及 Pico 的 BOOTSEL 是否还处于刷机模式没退出来。验证通道是否打通最直接的办法是打开串口工具发送几个字符看 REPL 是否有反应。Linux 下我喜欢用minicom -D /dev/ttyACM0 -b 115200Windows 下用 MobaXterm 或者 Putty 选 Serial 都可以。3.3 最容易被忽略的 USB-CDC 特性波特率其实不重要这个坑我一直觉得值得单独拿出来说。很多初学者在虚拟串口上会纠结“该选多少波特率”实际上USB-CDC 是虚拟串口波特率对数据完整性没有任何影响。不管上位机设 9600 还是 921600Pico 和主机之间走的都是 USB 协议完全没有 UART 那种时钟同步问题。那为什么还要选波特率因为部分串口工具在打开端口时会发送 SetLineCoding 请求如果值不合法驱动可能会拒绝打开。所以我们在使用 minicom 或 pyserial 时按惯例选一个常用的值比如 115200意义只是让驱动开心不代表数据速率。具体到代码如果收到主机的 SetLineCoding 请求合理做法是解析出波特率存到变量里但不必真的去改任何硬件寄存器。4. select 非阻塞编程告别忙等轮询的关键4.1 为什么说 while True read 是噩梦如果你直接在 MicroPython 里写sys.stdin.read()而这个缓冲区里没有任何数据程序就会卡在这一行直到有数据过来。这在简单回显场景下没问题但一旦你想在等待串口数据的同时去干别的事比如驱动电机、读取另一个 UART、刷新 LED 状态就会立刻感受到痛苦。常见的初级写法是 while 循环里不断read(1)读到就处理读不到就继续。这在 MicroPython 下看起来没什么问题实际却是个忙等循环CPU 被拉满其他任务响应变差电池设备的功耗也会明显升高。你还需要自己去管理每个数据源的读取状态一旦有几个串口和定时任务代码会迅速变得没法维护。select 机制解决的核心问题是同时监听多个数据源当且仅当某个数据源可读或可写时才去操作它没有数据时直接休眠不占 CPU。4.2 uselect 的 API 与可监听对象MicroPython 的select模块实际对应的是底层uselect模块控制台里通常直接执行import select就能用。它提供两类接口一类是select.select(rlist, wlist, xlist[, timeout])另一类是面向对象的poll()。select.select一次调用传入三个列表rlist 里放“我想读”的对象wlist 里放“我想写”的对象xlist 里放“我想监控异常”的对象可选参数 timeout 是等待超时毫秒数。返回值是三个列表分别包含就绪的读对象、写对象、异常对象。poll的写法更现代一些先创建一个 poller然后register(obj, eventmask)注册关注的对象循环里调用poll(timeout)拿到就绪的事件列表。每个事件是一个(object, event_mask)二元组。MicroPython 里可以被 select/poll 监听的对象一般需要支持底层 stream 协议常见的有对象类型说明machine.UART硬件串口判断是否收到数据sys.stdin官方固件默认的 USB-CDC 虚拟串口输入sys.stdout虚拟串口输出可判断是否可写socket网络套接字自定义的流式对象比如实现了 ioctl 和读写方法的外设驱动4.3 阻塞、非阻塞与超时三种模式怎么取舍select 的超时参数决定了三种行为模式第一种是超时设为负数或者不传select 会无限等待直到有事件发生这是阻塞模式。第二种是超时设为 0select 立即返回等同于做一次非阻塞轮询。第三种是给一个大于 0 的毫秒值select 等待这个时间期间有事件立刻返回没有事件到点返回空结果。在嵌入式场景里我通常用第三种超时设在 50ms 到 200ms 之间。这样既能及时响应虚拟串口和 UART 的数据又能在无数据时进入低功耗的空闲状态。如果某些场景对数据完整性的实时性要求很高可以把超时降到 10ms 甚至 0代价是 CPU 占用和功耗都会上升。不要无脑把 timeout 调成 0 然后“忙轮询”那样跟 while True read 没有本质区别。5. 代码实战从最小收发到 USB-CDC 与 UART 双向透传5.1 最小示例在官方固件上用 select 处理虚拟串口输入第一种方法是完全依赖官方固件自带的 USB-CDC。把下面代码保存为main.py上传到 Pico 后板子在 REPL 提示符下运行这个程序虚拟串口收到的每一行都会被原样回显import select import sys spoll select.poll() spoll.register(sys.stdin, select.POLLIN) print(USB-CDC select demo started.) while True: events spoll.poll(100) for fd, event in events: if fd sys.stdin: data sys.stdin.read(64) if data: sys.stdout.write(echo: data) sys.stdout.write(\r\n)运行后用任意串口工具打开 Pico 对应的 COM 口发送hello应该能看到返回echo: hello。如果打开串口时提示端口被占用多半是 Thonny 还连着 REPL先断开即可。这个例子的核心在于spoll.poll(100)。每次循环等待 100ms如果虚拟串口没有数据程序就在那边空等不会误读取其他串口也不会把 CPU 消耗在无意义的 read 尝试上。5.2 自定义 USB-CDC 接口的方案与注意点如果不想让 REPL 和业务数据挤在同一个虚拟串口里就需要走自定义 USB-CDC 路线。这里用 micropython-lib 的 usb-device 库来举例核心思路是创建一个 CDC 接口对象注册到 USBDevice 上然后启动设备from machine import USBDevice from usb.device import CDC def on_connect(usb): print(host connected) def on_disconnect(usb): print(host disconnected) cdc CDC() USBDevice.config(productPico CDC) USBDevice.init(cdc)这个库的 API 在不同固件版本和不同库版本之间会有调整比如有的版本用USBDevice.add_interface()有的用USBDevice.init()有的还要手动指定vid和pid。我踩过的一个具体坑是固件没有预编译 usb.device 模块时需要自己把库文件传到板子上但库里的核心代码可能因为 MicroPython 版本差异报AttributeError。没把握的情况下先去 pip 或者 GitHub 上拉最新的 micropython-lib再对着固件版本把示例代码跑通再改自己的逻辑。自定义 CDC 的优势在于数据通道更干净。你可以让 REPL 保留在默认的 USB 口也可以完全禁用 REPL让这个虚拟串口只做通信。对产品原型来说这个“干净”非常重要因为上位机不需要处理 REPL 的提示符和其他输出内容。5.3 双向透传桥完整代码与实测数据下面这个例子是官方固件路线下比较实用的一个场景把 USB-CDC 虚拟串口和 UART0 双向打通。接线方面把 Pico 的 GP0/GP1 分别作为 UART0 的 TX/RX接到外部的串口设备上。比如一个 GPS 模块GPS 的 TX 接 Pico 的 RXGP1GP0 接 GPS 的 RX。from machine import Pin, UART import select import sys uart0 UART(0, baudrate115200, txPin(0), rxPin(1)) spoll select.poll() spoll.register(sys.stdin, select.POLLIN) spoll.register(uart0, select.POLLIN) print(Bridge: USB-CDC - UART0) while True: events spoll.poll(100) for fd, _ in events: if fd sys.stdin: data sys.stdin.read(64) if data: uart0.write(data) elif fd uart0: data uart0.read(64) if data: sys.stdout.write(data)实测时我从电脑串口发送一段二进制指令给 PicoPico 通过 UART0 转发给一个步进电机驱动器电机立刻响应GPS 模块的 NMEA 数据不断通过 UART0 进入 Pico再原样转发到电脑上的串口助手能看到周期性冒出的$GPRMC语句。整个过程没有任何丢字节也没有出现数据错位。要注意的是这个方案中 REPL 仍然会监听虚拟串口的输入如果你发送的是特殊控制字符比如 Ctrl-CMicroPython 的 REPL 会尝试终止当前程序。所以在实际项目上做透传要么在业务层过滤掉这些控制字符要么像前面说的自定义 CDC 那样把通道分开。6. 踩坑记录与优化建议把这些坑提前排掉6.1 数据粘包与半包USB 也是流协议串口通信里最经典的问题就是粘包。USB-CDC 对上层呈现的是字节流不保证你一次read(64)拿到的正好是一个完整的业务帧。比如上位机发送了三个 20 字节的数据帧底层 USB 可能一次性到达 60 字节你的程序可能一次read(64)就全部取走了也可能是分两次取走 40 和 20。如果业务逻辑简单到把一次 read 当成一帧数据处理必然会出现错位。解决办法是定义帧边界。最简单的方式是用\r\n作为每帧结束标志程序里按行读取。MicroPython 里可以这样处理buf b while True: events spoll.poll(100) if not events: continue for fd, _ in events: if fd sys.stdin: chunk sys.stdin.read(64) buf chunk while b\r\n in buf: line, buf buf.split(b\r\n, 1) process_frame(line)如果是二进制协议建议在帧头加固定魔数和长度字段收到数据后先解析长度再根据长度拼接完整帧。这个问题不解决之后跑复杂协议一定会反复出 bug。6.2 select 轮询中的 CPU 占用与功耗把 timeout 设得过短会导致 poll 不断空转CPU 占用率飙升对低功耗场景非常不友好。我在一次电池供电的项目里测试过timeout 设成 10ms 和 100ms整机功耗差了不少。原因是每次 poll 超时返回之后主循环还要继续执行其他代码。所以我的建议是主循环里所有任务都是事件驱动的把 timeout 尽量拉大比如 200ms这样大部分时间系统都处于休眠等待状态。MicroPython 目前无法在 poll 等待时进入真正的最低功耗模式但至少可以减少无谓的指令执行。如果你手里有一个示波器或者功率计可以实测不同 timeout 值对功耗的影响选择性价比最高的那个数。6.3 几个提升稳定性的小调整第一个调整是读取使用更大的缓冲区。sys.stdin.read(64)一次最多取 64 字节如果 USB 缓冲区里一次涌进来几百字节频繁调用 read 反而容易产生间隙。改成sys.stdin.read(256)或者循环读取直到没有数据为止可以减少丢包概率。第二个调整是主循环里增加异常兜底。REPL 场景下主机端可能随时发送奇怪的控制字符MicroPython 解释器在某些情况下会抛出异常。在主循环外层套一个 try/except捕获后打日志并继续运行比直接死机好得多。第三个调整是注意 USB 线的供电能力。虚拟串口在做大数据传输时Pico 瞬时电流会升高劣质 USB 线可能导致电压跌落出现 USB 设备反复掉线。如果板子上还接了舵机这类大电流外设最好外接独立电源。另外如果你计划控制舵机现在手头已经有 USB-CDC 通道了只需要在上层解析出角度指令再用 MicroPython 的 PWM 库驱动舵机即可。这样上位机通过虚拟串口发一个set_angle:90字符串Pico 就能完成全部动作整个链路不需要额外硬件。我个人在实际项目里的体会是USB-CDC 配合 select 这套组合真正把“在板子上开一个稳定的通信口”这件事变得又快又省。你不需要在电脑上装驱动不需要采购转接芯片也不需要背 USB 协议栈的细节把精力集中在业务逻辑上就行。如果后续想进一步扩展可以考虑在同一个 USB 设备上挂多个 CDC 接口做多路虚拟串口或者把自定义 CDC 和现有 UART 数据合并成一个统一的数据网关这些都是同样的原理在更大场景下的复用。
返回列表