玩树莓派 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 Speed)12 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),主机在每个帧开始时会发送一个 SOF(Start 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_driver=True) time.sleep(1) keyboard.send(usb.hid.Keyboard.B_KEY_A) keyboard.release()这段代码会注册一个 USB HID 键盘设备,然后向电脑发送一个按键"A"。注意里面builtin_driver=True这个参数:为 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 + TinyUSB,MicroPython 适合做快速原型验证。
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_driver=True) 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_driver=True) 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_driver=True) 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 左 Ctrl,Bit1 左 Shift,Bit2 左 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_driver=True,这样你能一边看 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 下更简单,直接用 usbmon:
sudo 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 而不是 MicroPython,Python 层的逐包处理开销太明显。
数据稳定性方面,我强烈建议在应用层做简单的分包协议,比如固定 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 其实答案早就在手册里等着你了。