ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

树莓派Pico USB外设开发:从硬件原理到MicroPython HID实战

树莓派Pico USB外设开发:从硬件原理到MicroPython HID实战 玩树莓派 Pico 的人多数是从点灯开始的然后读几个传感器接着就会有人盯上它那个 USB 口这玩意儿除了下载程序和跑 REPL能不能当键盘、鼠标、游戏手柄甚至跟电脑跑自定义数据通道答案是能而且这套 USB 外设的玩法远比你想象的多。今天就把树莓派 Pico 的 USB 从硬件原理、RP2040 外设架构到 MicroPython 软件控制完整过一遍既有寄存器层面的背景也有能直接跑起来的代码适合正在用 Pico 做 USB 小项目的开发者也适合刚入手想搞懂 USB 枚举的新手。1. 树莓派 Pico 的 USB 硬件从引脚到协议1.1 先看懂 Pico 上的 USB 是怎么接的Pico 开发板上唯一的通信口就是那个 Micro-USB 座子很多人以为它只是供电兼下载口实际上板上的 USB_DM / USB_DP 两根数据线是直接连到 RP2040 芯片专用引脚的不经过 GPIO也不和你剩下的排针冲突。这意味着你即使把 26 个 GPIO 全部用满USB 依然可以正常工作这是它做外设设备时很方便的一点。RP2040 芯片内部集成了完整的 USB 收发器PHY外部不需要额外挂 USB 转串口芯片或者 PHY 芯片。芯片支持的是 USB 1.1 全速Full Speed12 Mbps 设备控制器和早期优盘、鼠标键盘用的协议是同一代。全速设备在协议上要求在 D 线上拉一个 1.5 kΩ 电阻告诉主机我是一台全速设备RP2040 把这个电阻也做进了芯片内部软件里可以通过寄存器使能或关闭上拉这也就是为什么你能在软件层让设备动态断开和重新枚举。如果你打算照着自己画一块 RP2040 底板别直接把 USB_DM / USB_DP 两根线拉出去就完事。按常规做法应该在 D / D- 上各串联一个 22 Ω 电阻再在靠近座子处加 TVS 管做静电保护VBUS 检测脚也要通过电阻分压再去检测否则插拔瞬间容易把芯片打坏。Pico 原厂板已经处理好了但自制板这些细节省不得。1.2 为什么 RP2040 只做设备不做主机很多人第一次被坑就是这里Pico 的 USB 控制器是 device-only也就是设备模式它只能作为外设去应答电脑、手机或树莓派主板发来的请求没法主动枚举插入的 U 盘、鼠标、键盘。换句话说USB Host 控制器需要的 root hub、事务调度、SOF 生成等硬件能力RP2040 里都没有。那网上说的PIO 模拟 USB Host是怎么回事这确实是社区里有人做过的实验用 RP2040 的 PIO 状态机在 GPIO 引脚上模拟低速 USB 1.0 主机协议能读部分低速 HID 设备。但代价很大只能支持低速设备时序全靠 PIO 程序硬扣兼容性看人品而且 MicroPython 固件里没有现成 API你得用 C SDK 重新撸一套。我的建议很直接——如果项目明确要读取 U 盘或外接键盘鼠标作为输入趁早换有 USB OTG / Host 功能的芯片比如 STM32F4 系列、ESP32-S3或者给 Pico 外挂一个 CH559 / MAX3421E 主机控制器芯片别在 RP2040 的 PIO 上死磕。1.3 USB 枚举与标准请求设备是怎么被发现的在进入 MicroPython 编程之前建议先把 USB 枚举这套流程过一遍因为后面调试自定义 HID 设备时八成问题都出在这个阶段。USB 的通信模型是严格的主机问、设备答设备永远不会主动说话。插上 Pico 之后主机那边会发生这么几步主机检测到 D 线上的上拉电平变化知道有新设备接入随后主机发送总线复位信号并给地址 0 的设备发一个 GET_DESCRIPTOR(Device) 请求设备得在 18 字节的设备描述符里报上自己的 VID、PID、USB 版本号等信息主机再发 SET_ADDRESS给设备分配一个可用地址之后用新地址请求配置描述符、接口描述符、端点描述符这些描述符合在一起告诉主机我有几个接口每个接口是什么类型需要哪些端点最后主机加载对应驱动发 SET_CONFIGURATION设备进入正常工作状态。这中间任何一步返回的数据格式不对主机就会在设备管理器或 dmesg 里报设备描述符请求失败或未知 USB 设备。我之前调试自定义设备时就因为配置描述符里的 bNumInterfaces 和实际接口数不一致导致 Windows 直接放弃枚举这类问题靠肉眼检查非常累后面我会专门讲怎么用 USB 抓包工具定位。2. RP2040 USB 外设架构端点、DPRAM 与传输类型2.1 端点和缓冲区数据具体存在哪里USB 设备内部的核心概念是端点Endpoint你可以把它理解成设备和主机之间的一个数据管道。每个端点有方向IN 方向表示设备向主机发送数据OUT 方向表示接收主机发来的数据。RP2040 提供 16 个端点编号 0 到 15每个端点都有独立的 IN / OUT 方向缓冲区实际哪些端点在描述符里公开由你的固件决定。端点 0 是控制端点专门用于枚举和标准请求普通应用基本碰不到它。数据缓冲区放在一块 4 KB 的 DPRAM双端口 RAM里。这块内存一端由 USB 硬件读写另一端由 CPU 通过 APB 总线访问所以叫双端口。每个端点要先把缓冲区地址、大小、是否启用双缓冲等参数配置到对应寄存器里USB 硬件收到数据后自动写入 DPRAM 中你分配的区域然后触发中断通知 CPU 来取。MicroPython 用户通常感知不到这层细节但当你发现批量传输速度上不去、或者数据偶尔错位时回到这层检查缓冲区大小和双缓冲配置往往是关键。双缓冲是个很实用的机制硬件正在发送第一个缓冲区时CPU 可以同时往第二个缓冲区写数据两个 buffer 交替使用省去等待时间。全速 USB 带宽本来就不高开启双缓冲对传输吞吐的提升非常明显。2.2 四种传输类型控制、批量、中断、等时USB 协议把数据传输分成四种类型它们的带宽保证、错误处理和应用场景完全不同。做 Pico 外设设计时选错传输类型是最常见的坑。传输类型典型应用全速下最大包大小特点控制传输枚举、标准请求、少量配置数据64 字节有强制握手和重试机制可靠性高但速度慢批量传输USB 转串口、大块数据采集64 字节数据量大且可靠但只在帧空闲时传输没有带宽保证中断传输键盘、鼠标、游戏手柄64 字节保证最大延迟每帧最多一包适合交互类设备等时传输音频、摄像头、传感器流1023 字节全速保证带宽但不保证可靠丢包不重传RP2040 的 USB 控制器对四种类型都支持但在 MicroPython 环境下你接触最多的是中断传输和批量传输。HID 键盘鼠标走的是中断端点CDC 虚拟串口走的是批量端点。选择传输类型的依据很简单需要低延迟就选中断需要大批量可靠搬运就选批量需要音视频流且能容忍偶发丢包就选等时。2.3 帧、SOF 和带宽预算USB 全速总线的时间被分成一个个 1ms 的帧Frame主机在每个帧开始时会发送一个 SOFStart of Frame包用于全线设备时间同步。RP2040 的中断系统里有 SOF 中断你可以拿它做一个 1kHz 的时基。不过我用下来不太推荐在 MicroPython 里依赖这个做精确定时Python 解释器执行延迟不稳定时基抖动会很严重真需要 1ms 级定时还是用 PIO 或硬件定时器更靠谱。带宽预算是做组合设备时容易忽略的问题。12 Mbps 是全速总线的极限但它是共享的控制传输和中断传输每帧都要占用固定时隙批量传输只能在剩余时间窗口里挤。实测下来Pico 走批量传输的理想吞吐量大概在 1 MB/s 附近MicroPython 环境下通常只有几百 KB/s。如果你要做类似连续高速数据采集的项目先算算这带宽够不够别等代码写完才发现数据搬不完。3. MicroPython 如何接管 USB从 REPL 到自定义设备3.1 默认状态一个 USB CDC 串口拿到一块全新的 Pico里面是空的按住 BOOTSEL 键再插 USB 线电脑会出现一个叫 RPI-RP2 的 U 盘。把 MicroPython 固件.uf2 文件拖进这个盘符Pico 自动重启之后 USB 就被固件枚举成一个 CDC 虚拟串口了。Windows 设备管理器里会多出一个 COM 口Linux 下则是 /dev/ttyACM0用 Thonny、minicom、PuTTY 连上就能看到 REPL 提示符。这个默认的虚拟串口在 MicroPython 里其实是一个内置驱动builtin driver。它不只是串口还承担了 REPL 交互、print 输出、文件系统挂载等一大堆功能对日常开发来说非常方便。很多人在这一步就停住了以为 Pico 的 USB 只能当串口用其实这只是它能力的起点。3.2 usb.device 模块Python 层动态注册 USB 设备从官方固件 1.23 版本开始MicroPython 为 RP2040 移植版加入了 usb.device 模块允许你在 Python 代码里动态注册自定义 USB 设备。核心入口是usb.device.init()你准备一个或多个设备对象传进去函数执行后 USB 会重新枚举电脑上立刻出现你定义的新设备。拿最基础的键盘举例子import time import usb.device import usb.hid keyboard usb.hid.Keyboard() usb.device.init(keyboard, builtin_driverTrue) time.sleep(1) keyboard.send(usb.hid.Keyboard.B_KEY_A) keyboard.release()这段代码会注册一个 USB HID 键盘设备然后向电脑发送一个按键A。注意里面builtin_driverTrue这个参数为 True 时原来的 REPL 虚拟串口会保留你会同时看到一个 COM 口和一个键盘设备为 False 时串口被替换掉Pico 就变成一个纯粹的键盘设备。这里有个容易被忽略的行为执行usb.device.init()的瞬间USB 会断开并重新枚举已经打开的 REPL 串口会断一次等 1 到 2 秒后会重新出现。这不是故障是正常现象。测试代码时我习惯先time.sleep(1)等枚举稳定否则第一批按键包偶尔会被主机丢弃。3.3 官方支持的设备类和自定义边界usb.device 模块目前覆盖的设备类型主要有 HID 键盘、HID 鼠标、MIDI 设备等。具体到某个固件版本支持哪些类最靠谱的办法是在 REPL 里直接查import usb.device print(dir(usb.device)) import usb.hid print(dir(usb.hid))如果你需要的是完全自定义的 USB 设备比如带私有协议的采集卡MicroPython 也提供了底层 USBDevice 和 Endpoint 类允许手动构造描述符并挂载端点。但说实话这条路比较难走因为描述符细节多、回调函数处理繁琐Python 层的性能也有限。我的经验是能用 HID 标准类解决就用标准类非要私有协议优先考虑 C SDK TinyUSBMicroPython 适合做快速原型验证。4. 三个可直接抄的 USB 实操键盘、鼠标与组合设备4.1 固件准备与验证动手前先把固件升级到较新版本usb.device 是后面加的 API老固件没有折腾半天查不出问题。去 MicroPython 官网下载 rp2-pico 或 rp2-pico-w 固件按住 BOOTSEL 插线拖入 uf2 文件完成升级。连上 REPL 后输入下面的代码确认模块存在try: import usb.device print(usb.device OK) except ImportError: print(usb.device missing, need newer firmware)如果输出 missing别继续往下写代码先升级固件。这一步能省掉后面至少半小时的排查时间。4.2 例一USB HID 键盘自动输入一段文字下面这段代码会让 Pico 变成一个键盘每 3 秒自动输入一遍 HELLO。把光标放在记事本或聊天输入框里运行效果立竿见影。import time import usb.device import usb.hid keyboard usb.hid.Keyboard() usb.device.init(keyboard, builtin_driverTrue) time.sleep(1) def press_and_release(*keycodes): keyboard.send(*keycodes) time.sleep(0.05) keyboard.release() time.sleep(0.05) while True: press_and_release(usb.hid.Keyboard.B_KEY_H) press_and_release(usb.hid.Keyboard.B_KEY_E) press_and_release(usb.hid.Keyboard.B_KEY_L) press_and_release(usb.hid.Keyboard.B_KEY_L) press_and_release(usb.hid.Keyboard.B_KEY_O) time.sleep(3)有几个细节值得注意。第一keyboard.send()可以传多个键码表示同时按下多个键比如send(B_LEFT_SHIFT, B_KEY_A)输出大写 A释放是keyboard.release()一定记得调用否则电脑会认为你一直按着那个键打出来的字会不停重复。第二每次按下之间加一个小延时给主机处理事件留时间太快会丢数据。第三千万别把这代码直接跑在密码输入框旁边别问我怎么知道的。4.3 例二USB HID 鼠标相对移动与点击鼠标和键盘类似核心 API 是move()和按钮控制下面这段让鼠标每 0.5 秒右移再左移import time import usb.device import usb.hid mouse usb.hid.Mouse() usb.device.init(mouse, builtin_driverTrue) time.sleep(1) while True: mouse.move(80, 0) # 向右移动 80 个相对单位 time.sleep(0.5) mouse.move(-80, 0) # 向左移动 80 个相对单位 time.sleep(0.5)注意这里的坐标是相对位移不是绝对坐标。鼠标在屏幕上移动多少还取决于操作系统的指针速度设置单位是count不是像素。测试时别把鼠标往屏幕边缘猛甩很容易飞出去。点击的 API 在不同固件版本里略有差异有的版本提供mouse.buttons(state)有的提供mouse.click(button)不确定的时候先执行一下help(mouse)看看方法列表比盲猜快得多。4.4 例三键盘鼠标二合一组合设备实际项目里经常需要一个设备既是键盘又是鼠标比如演示翻页笔、远程会议遥控器。usb.device.init 支持同时传入多个设备对象import time import usb.device import usb.hid keyboard usb.hid.Keyboard() mouse usb.hid.Mouse() usb.device.init(keyboard, mouse, builtin_driverTrue) time.sleep(1) # 每 5 秒按一次 F5浏览器刷新随后模拟鼠标中键点击 while True: keyboard.send(usb.hid.Keyboard.B_KEY_F5) keyboard.release() time.sleep(0.2) mouse.buttons(usb.hid.Mouse.BUTTON_MIDDLE) time.sleep(0.1) mouse.buttons(0) time.sleep(5)组合设备在 Windows 设备管理器里会显示为多个USB 输入设备同时保留一个 COM 口这是正常的。如果你的需求是翻页笔这类应用这个结构就是很好的底座加一个按键输入左边按钮发右键翻页右边按钮发左键翻页硬件成本不到二十块。4.5 HID 报告描述符自定义设备的灵魂如果你想做的不是标准键盘鼠标而是自定义 HID 设备比如一个带旋钮的音量控制器、一个自定义触摸板那就绕不开报告描述符Report Descriptor。这是 HID 设备最核心的数据结构它用一段特定格式的字节码告诉操作系统设备能上报哪些数据、每个字段多少位、是输入还是输出。标准键盘的报告报文固定是 8 字节第 1 字节是修饰键Bit0 左 CtrlBit1 左 ShiftBit2 左 Alt...第 2 字节保留必须为 0第 3~8 字节是最多 6 个同时按下的按键键码。鼠标通常是 4 字节1 字节按钮状态1 字节 X 方向位移1 字节 Y 方向位移1 字节滚轮。当你打算自定义设备时HID 报告描述符往往是一长串十六进制字节举一个简化鼠标的例子05 01 09 02 A1 01 09 01 A1 00 05 09 19 01 29 03 15 00 25 01 95 03 75 01 81 02 95 01 75 05 81 01 05 01 09 30 09 31 09 38 15 81 25 7F 75 08 95 03 81 06 C0 C0翻译成人类语言就是这是一个鼠标应用集合有 3 个 1 位按钮字段后面补 5 位填充然后 X、Y、滚轮三个 8 位相对值字段。写这种描述符千万别凭感觉要对照 USB HID Usage Tables 文档一个字节一个字节地推。描述符长度、字段位宽、Usage Page 任何一个地方写错轻则设备不识别重则直接蓝屏别问我为什么知道。4.6 实操里最容易翻车的几个点刚上手时我建议你严格按这几条来能少走不少弯路。第一注册设备后加time.sleep(1)让 USB 枚举彻底完成再发数据第二定义好按键的按下-释放流程不能只按不放第三调试时始终保留builtin_driverTrue这样你能一边看 REPL 日志一边测试等逻辑稳定了再决定要不要去掉串口第四一旦设备卡死导致电脑键盘失控直接拔 USB 线把代码里的死循环改成有限次数测试不要一上来就while True。5. 常见问题与调试工具实录5.1 枚举失败设备描述符请求失败Windows 设备管理器出现黄色感叹号或者 Linux 下用 dmesg 看到 device descriptor read/64, error -71这应该是玩 USB 自定义设备时最打击人的报错。我的排查顺序永远是固定的。先换线。Micro-USB 线是最容易翻车的环节很多线只有供电线没有数据线或者线芯太细导致信号衰减严重。我一般手边备两三根数据线专门用来排除这类问题。然后排除供电问题电脑前置 USB 口供电经常不稳改成后置口或者用带外部供电的 USB Hub 再试。接着检查代码把自定义设备相关代码全部注释掉只保留最基础的注册逻辑跑一遍确认裸注册能否枚举成功。最后如果还不行按住 BOOTSEL 重刷固件回到出厂状态再逐步加代码。5.2 用好 USB 抓包工具USB 协议是黑盒通信出错时靠猜效率太低抓包才是正解。Windows 下推荐 Wireshark USBPcap 组合安装 USBPcap 驱动后打开 Wireshark 选择 USBPcap 接口插拔 Pico 就能抓到完整的枚举过程。重点看几个位置GET_DESCRIPTOR 阶段设备返回的字节数对不对、SET_ADDRESS 之后设备有没有正确应答、SET_CONFIGURATION 之后设备有没有继续传输数据。Linux 下更简单直接用 usbmonsudo modprobe usbmon sudo cat /sys/kernel/debug/usb/usbmon/4u抓包的目的不是让你读懂每一字节而是快速判断问题在哪一层。如果主机根本没发 GET_DESCRIPTOR说明物理层就有问题换线查供电如果发了但设备没应答或者返回短包说明描述符或者固件状态有问题。我每次改完描述符都会抓包确认一下比自己盯着十六进制数组猜快太多了。5.3 串口断开或设备突然消失自定义设备跑着跑着REPL 串口突然不见了很多人第一反应是板子坏了。大部分情况不是。调用 usb.device.init 后 USB 必然重新枚举串口断开是正常现象等 1 到 2 秒会自己回来。如果长时间不回来先看代码是不是进入了死循环把 CPU 占满了。MicroPython 里while True:加time.sleep()通常问题不大但如果你在循环里做了大量字符串拼接、文件操作CPU 忙不过来时 USB 中断响应会变慢串口看起来就像死了一样。这种情况最简单的恢复办法按住 BOOTSEL 重新上电直接重刷固件。不用担心你的 Python 文件固件重刷不会动 Flash 文件系统里的 .py 文件除非你顺手点了擦除。5.4 带宽与数据稳定性问题如果你用 Pico 做数据采集最怕的其实是数据传不完和数据传错。全速 USB 批量传输每包最大 64 字节主机每帧最多给你 19 个批量事务还要扣除协议开销理论极限大约 1.2 MB/s实际 MicroPython 环境通常只有几百 KB/s。想提高吞吐优先用 C SDK 而不是 MicroPythonPython 层的逐包处理开销太明显。数据稳定性方面我强烈建议在应用层做简单的分包协议比如固定 4 字节帧头 长度 校验别裸发原始字节流。MicroPython 的 USB 在长时间高负载传输后偶尔会出现数据包乱序或丢包和 USB 本身的 ACK 重传机制无关更多是上位数和解释器调度造成的。一个简单的校验字段能帮你快速辨别是设备端问题还是上位机问题。6. 继续折腾前我给你几条实在建议先分清设备和主机再选型。很多网友在问Pico 能不能直接读 U 盘答案是不能RP2040 的 USB 控制器是 device-only想读 U 盘得换平台或者外挂 Host 芯片。这个坑我见过太多次选型错了后面所有工作都是白费。固件尽量保持在最新。usb.device 这类新 API 只在较新固件里有老固件连模块都导入不了。MicroPython 升级固件不影响 Flash 里已有的 .py 文件所以放心升不需要备份你的代码。调试工具一定要配齐一根确定能传输数据的 USB 线、一个 USB 电流表或者隔离器、一台装了 Wireshark 的电脑。USB 调试里七成问题出在物理层三成在描述符没有抓包工具只能靠猜效率极低。从最简单的Hello USB开始做。第一次做自定义设备时不要一上来就搞组合键盘鼠标加自定义 HID 报告描述符那只会让你同时面对太多变量。先只注册一个键盘设备发一个按键抓包确认主机收到了再逐步加复杂度。这样每一步都能定位问题在哪而不是最后面对一堆报错无从下手。最后分享一个我自己的习惯所有 USB 设备测试代码我都会在开头放一个两三秒的延时利用这段时间准备好接收中断按住 BOOTSEL 随时能进 UF2 模式避免设备一直抢占键盘导致救不回来。另外没事多翻翻 RP2040 数据手册的 USB 章节开头看着像天书但等你在 DPRAM 和端点之间绕上一圈很多莫名其妙的 bug 其实答案早就在手册里等着你了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进