前阵子想做一个用鼠标控制机械臂的小项目,我抄起一块Raspberry Pi Pico,准备把USB鼠标直接插上去读坐标。原以为一小时搞定的活儿,结果被现实狠狠教了一课:RP2040内置USB控制器默认是设备模式,要让它当USB主机,中间还隔着一道“物理层PHY”的坎。这篇文章就把我从零把RP2040/RP2350跑成USB主机、成功驱动USB鼠标的完整过程拆开讲一遍,从硬件接线、TinyUSB配置,到HID报告解析和踩坑记录,所有代码和流程都是我在实际项目里验证过的,适合手里有Pico/Pico 2、想接USB外设但不知道从哪下手的读者。
1. 先搞清楚RP2040做USB主机到底卡在哪道坎
1.1 内置USB控制器的真面目
RP2040和RP2350的数据手册上都写着“USB 1.1 Host/Device Controller”,听起来好像主机模式开箱即用,但很多人没细看后半句:它内部集成的物理层PHY是给设备模式用的,也就是Pico板载那个Micro USB口直接连D+/D-,插到电脑上能被识别为一个USB设备。可一旦你想让芯片反过来当主机去接外部设备,就不能用这一套PHY了,必须通过一组叫ULPI的并行接口外接一颗USB PHY芯片,比如USB3300、TUSB1210。
我第一次看到这个结论的时候也愣了半天:同样是USB控制器,为什么主机模式不能直接用内置PHY?原因其实在USB协议本身。主机和设备最大的区别之一是谁提供VBUS电源、谁来检测设备插入和拔出、谁来处理低速/全速设备不同的信令电平。设备模式下,RP2040只要被动响应电脑发来的总线事件;主机模式下,芯片要主动管理总线状态,包括上电时序、复位信号、速度协商等,这些模拟层面的活内置PHY干不了,所以需要一颗独立PHY芯片来全部接管。
1.2 ULPI接口是什么,为什么非它不可
ULPI是USB PHY和控制器之间的一种标准并行接口协议,60MHz时钟,8位数据线,外加DIR、NXT、STP三个控制信号。简单类比:USB控制器是主机的大脑,负责协议解析和数据处理;PHY芯片是嘴巴和耳朵,负责把数字信号变成USB线上的差分模拟信号,同时负责检测设备插拔。RP2040的USB控制器在设计上就把主机模式的数据通路留给了ULPI接口,所以只有两种选择:要么接一颗ULPI PHY,老老实实走硬件主机模式;要么用PIO模拟USB低速协议,绕过内置USB控制器。前者能支持全速和低速设备,后者成本低但只支持低速设备。
这里插一句,很多初学朋友容易把“USB Host”和“OTG”搞混。OTG是手机平板那种可以切换主从角色的方案,RP2040虽然也能在设备模式之间切换,但它没有OTG控制器,也不能靠一个Micro USB口既当设备又当主机,主机模式必须物理上走外接PHY那一路。所以想用Pico接USB鼠标,第一件事就是别再盯着板载USB口看,那个口是留给电脑的。
1.3 PHY芯片选型:USB3300还是TUSB1210
市面上能做ULPI PHY的芯片不少,我目前实测过且身边同行用得最多的是这两款。
| 型号 | 接口 | 支持速度 | 常见形态 | 注意点 |
|---|---|---|---|---|
| USB3300 | ULPI | Low/Full/High Speed | 独立模块,带USB-A座 | 模块多,资料多,适合起步 |
| TUSB1210 | ULPI | Low/Full/High Speed | QFN裸片,需自行设计 | 体积小,适合做集成产品 |
| USB3320 | ULPI | Low/Full/High Speed | 裸片/定制模块 | 功耗更低,但小批量不好买 |
如果你只是验证方案,直接买USB3300模块最省事,模块上已经把晶体、LDO、USB-A座都集成好了,用杜邦线就能连Pico。我见过不少人在这一步非要自己画板焊TUSB1210,被QFN封装折磨一星期,最后又回来买USB3300模块,纯属给自己加难度。正常学习路径应该先把主机模式跑通,再考虑根据最终产品体积换更小的PHY芯片。
2. 硬件接线与供电:Pico到USB3300的每一根线
2.1 硬件清单与总体拓扑
我最终用的硬件组合非常简单:
- 1块 Raspberry Pi Pico(RP2040),后来换Pico 2(RP2350)验证过同样代码
- 1块 USB3300 模块(带USB A母座)
- 1个普通USB办公鼠标,优先选便宜的、低速的设备
- 若干杜邦线
- 1根Pico的USB线,用于给板子供电和查看UART日志
- 1个USB转TTL的调试小板(用来接收Pico打印的调试信息)
整体连接思路是:Pico通过GPIO2到GPIO14的一组引脚连接USB3300的ULPI接口,USB3300模块上的USB-A座直接插鼠标,鼠标的VBUS供电由外部5V提供,地和Pico共地,3.3V逻辑电平则由Pico的3V3引脚供给USB3300模块的IO侧。
2.2 ULPI信号线连接表
ULPI一共需要12个关键信号,8根数据线加4根控制线。我参考了Pico SDK自带的usb/host/hid示例接线,实际连接如下。
| Pico GPIO | 方向 | USB3300模块引脚 |
|---|---|---|
| GPIO2 | 双向 | DATA0 |
| GPIO3 | 双向 | DATA1 |
| GPIO4 | 双向 | DATA2 |
| GPIO5 | 双向 | DATA3 |
| GPIO6 | 双向 | DATA4 |
| GPIO7 | 双向 | DATA5 |
| GPIO8 | 双向 | DATA6 |
| GPIO9 | 双向 | DATA7 |
| GPIO10 | 输入 | CLKOUT |
| GPIO11 | 输入 | DIR |
| GPIO12 | 输出 | STP |
| GPIO13 | 输入 | NXT |
| GPIO14 | 输出 | RESET |
需要注意,USB3300模块上的CLKOUT、DIR、NXT这些方向是指向RP2040的,STP和RESET是RP2040输出的,接反了完全跑不起来。我最初就是把STP和NXT两根线接反了,结果鼠标能识别但任何数据都收不到。另外,不同商家的USB3300模块引脚标注可能不一样,务必以模块丝印上的信号名为准,不要只认引脚顺序。
2.3 供电链路:5V、3.3V、1.8V谁也别漏
供电是这个项目最容易翻车的地方。USB鼠标需要5V供电,这是无法绕开的。Pico板子可以从VSYS接外部5V,也可以直接用USB口供电,但USB3300模块的5V引脚不能从Pico的3V3取,必须单独接5V。
我采用的方案是:用一个5V电源或电脑USB口取5V,接到USB3300模块的VBUS/5V引脚,同时这个5V也接到Pico的VSYS,让整块板子共用一组5V电源,然后Pico的3V3输出再接USB3300模块的3.3V逻辑供电。这样整个系统只有一个地,不会出现GND不共地导致的信号飘移。
USB3300内部还有一个1.8V核心电压,绝大多数模块已经用板载LDO解决了,无需人为接。如果用裸片,则需要仔细看数据手册,把VDDIO、VDD_18这些引脚都处理对,少一个都可能造成PHY不工作。我的建议是新手阶段坚决用模块,这些坑模块厂商已经替你填过了。
2.4 没有ULPI PHY时该怎么办
如果你手头没有USB3300,也先别急着下单,还有一个社区方案值得考虑:用RP2040的PIO状态机直接模拟USB低速主机协议,也就是GitHub上的pico-pio-usb项目。它不需要任何外部PHY芯片,只需要把USB鼠标的D+/D-两根数据线直接接到Pico的GPIO,再接5V和GND就能工作。
这套方案在后台还是走TinyUSB主机协议栈,只是底层的“控制器”从内置USB模块换成了PIO模拟的虚拟控制器。它的限制非常明确:只能支持低速模式,也就是1.5Mbps的USB设备,绝大多数普通的办公鼠标都是低速设备,所以够用;但那些分辨率接近变态的电竞游戏鼠标基本都是全速设备,在PIO方案下会直接识别失败。我建议把它当成一个没有PHY芯片时的快速验证方案,等需要稳定支持全速设备时,再切换到USB3300硬件方案。第6章我会专门讲PIO方案的细节。
3. 软件骨架:TinyUSB主机栈的配置与主循环
3.1 为什么要用TinyUSB
Pico SDK官方默认集成的是TinyUSB协议栈,它同时支持设备模式和主机模式,配置项清晰,社区使用量大,出问题很容易搜到答案。以前有人为了省事会去找一些老的“USB Host Shield”库,但在Pico平台上完全没有必要,官方路线就是TinyUSB。只要你用Pico SDK做开发,tusb_init()和tuh_task()两个函数就能把主机栈跑起来,剩下的事情都是在回调函数里处理事件。
3.2 CMake工程配置
先看工程文件,我用的是Pico SDK的CMake构建方式。核心是打开PICO_SDK_USE_TINYUSB开关,并链接tinyusb_host库。
cmake_minimum_required(VERSION 3.13) include(pico_sdk_init.cmake) project(rp2040_usb_host_mouse C CXX ASM) set(PICO_SDK_USE_TINYUSB ON) pico_sdk_init() add_executable(usb_mouse_demo main.c ) target_link_libraries(usb_mouse_demo pico_stdlib tinyusb_host ) pico_enable_stdio_uart(usb_mouse_demo 1) pico_enable_stdio_usb(usb_mouse_demo 0) pico_add_extra_outputs(usb_mouse_demo)第二行打开TinyUSB,第5行链接主机库。注意我把UART作为stdio输出,故意关闭了USB stdio,因为Pico的板载USB口用于设备模式时会和主机模式打架。调试信息从串口打印出来,理论波特率115200。
3.3 tusb_config.h里的关键开关
TinyUSB的配置文件在所有源文件之前被包含,通常放在项目根目录。下面是我实际使用的最小配置。
#ifndef _TUSB_CONFIG_H_ #define _TUSB_CONFIG_H_ #define CFG_TUSB_OS OPT_OS_PICO // 主端口0作为主机运行,跑全速 #define CFG_TUSB_RHPORT0_MODE (OPT_MODE_HOST | OPT_MODE_FULL_SPEED) // 使能主机API #define CFG_TUH_ENABLED 1 // 使能HID类驱动 #define CFG_TUH_HID 1 // 最多挂载1个USB设备,本项目只接一个鼠标 #define CFG_TUH_DEVICE_MAX 1 // HID端点缓冲区大小 #define CFG_TUH_HID_EP_BUFSIZE 64 #endifCFG_TUSB_RHPORT0_MODE是核心,它告诉TinyUSB第一个端口的角色是主机。OPT_MODE_FULL_SPEED在这里表示主机本身跑全速,但实际接入低速鼠标时,PHY和协议栈会自动完成速度协商,不需要我们手动切换。CFG_TUH_HID打开HID类支持后,TinyUSB会自动识别鼠标键盘这类标准HID设备。
3.4 主循环:tuh_task不能缺席
现在可以写最简单的main函数了。
#include <stdio.h> #include "pico/stdlib.h" #include "tusb.h" #define LED_PIN 25 int main(void) { stdio_init_all(); gpio_init(LED_PIN); gpio_set_dir(LED_PIN, GPIO_OUT); // 初始化TinyUSB主机协议栈 tusb_init(); while (true) { // 主机协议栈的轮询函数,必须高频执行 tuh_task(); } }这段代码看起来什么都没做,但TinyUSB的所有状态机都在tuh_task内部跑,包括设备枚举、控制传输、HID轮询调度等。我在项目里把tuh_task放在主循环里一直跑,没有做任何节流,这个函数单次执行时间很短,不需要担心性能问题。如果后续想做低功耗,再考虑用tuh_task的定时调用机制,但现阶段没必要。
到这里,插上USB3300和鼠标后,理论上设备应该开始枚举。如果一切正常,板子会打印一条mount信息,证明USB链路已经通了。接下来就是处理鼠标数据。
4. 鼠标HID报告解析:从枚举回调到坐标数据的代码全解
4.1 设备挂载回调与HID接口识别
TinyUSB在检测到USB设备插入并完成枚举后,会调用tuh_mount_cb。我在里面加了一个打印,先确认最基础的主机链路是通的。
void tuh_mount_cb(uint8_t dev_addr) { printf("USB device mounted, addr = %u\n", dev_addr); } void tuh_umount_cb(uint8_t dev_addr) { printf("USB device unmounted, addr = %u\n", dev_addr); }如果你只加了这两个回调,鼠标依然不会出数据。因为鼠标是HID类设备,还需要实现HID层的回调。TinyUSB对于每个HID接口,会调用tuh_hid_mount_cb,并把该接口的HID报告描述符通过参数传出来。
void tuh_hid_mount_cb(uint8_t dev_addr, uint8_t instance, uint8_t const* desc_report, uint16_t desc_len) { printf("HID interface mounted, instance = %u, desc_len = %u\n", instance, desc_len); // 打印报告描述符原始字节,便于确认鼠标报告格式 for (uint16_t i = 0; i < desc_len && i < 64; i++) { printf("%02X ", desc_report[i]); if ((i & 0x0F) == 0x0F) printf("\n"); } printf("\n"); // 开始接收第一份HID报告 if (!tuh_hid_receive_report(dev_addr, instance)) { printf("Failed to start receiving HID report\n"); } }这个回调里我做了两件事:打印报告描述符,然后主动请求接收第一份报告。为什么要打印描述符?因为不同鼠标的报告长度和布局可能有差异,不看描述符就按固定格式解析,很容易被坑。
4.2 报告描述符与标准鼠标报告布局
一个标准的三键带滚轮鼠标,报告描述符通常定义这样一份报告:1个字节的按键位图,1个字节的X轴位移,1个字节的Y轴位移,1个字节的滚轮位移。对应实际收到的HID报告就是:
| 字节偏移 | 内容 | 类型 |
|---|---|---|
| 0 | 按键状态 | uint8,bit0左键,bit1右键,bit2中键 |
| 1 | X轴相对位移 | int8 |
| 2 | Y轴相对位移 | int8 |
| 3 | 滚轮位移 | int8,可选 |
这里的位移是相对位移,不是绝对坐标。简单理解就是“鼠标这次相对于上一次移动了多少”。我们把每次的dx和dy累加起来,就能得到从开机到现在的总位移。如果你直接拿真实鼠标坐标来做屏幕指针,那是另一个复杂话题,涉及报告描述符里的Logical Min/Max等参数。我们做嵌入式控制,用相对位移就够了。
4.3 持续接收与解析X/Y/按键/滚轮
真正接收鼠标数据的地方是tuh_hid_report_received_cb。TinyUSB每次收到一份HID输入报告,就会调用这个回调。记得在处理完报告后再次调用tuh_hid_receive_report,请求接收下一份,否则只会有第一份数据。
void tuh_hid_report_received_cb(uint8_t dev_addr, uint8_t instance, uint8_t const* report, uint16_t len) { if (len < 3) { // 报告太短,不是标准鼠标,继续接收 tuh_hid_receive_report(dev_addr, instance); return; } uint8_t buttons = report[0]; int8_t dx = (int8_t)report[1]; int8_t dy = (int8_t)report[2]; int8_t wheel = (len >= 4) ? (int8_t)report[3] : 0; static int32_t total_x = 0; static int32_t total_y = 0; total_x += dx; total_y += dy; printf("bt=%02X dx=%4d dy=%4d wh=%4d total=(%ld, %ld)\n", buttons, dx, dy, wheel, total_x, total_y); // 左键按下点亮板载LED if (buttons & 0x01) { gpio_put(LED_PIN, 1); } else { gpio_put(LED_PIN, 0); } // 必须再次请求下一份报告 tuh_hid_receive_report(dev_addr, instance); }这段代码里有几个细节值得说。第一,我把dx和dy强转成int8_t,因为报告里的位移是补码表示的有符号数,如果直接用uint8_t读,鼠标向左移动会被解析成两百多的正数,累计值直接飞掉。第二,报告长度不一定总等于描述符里定义的长度,因此我在访问report[3]滚轮前先判断len >= 4。第三,total_x和total_y要定义成累计值,这样串口上能看到鼠标从插入时刻以来的绝对位移变化。
4.4 数据用起来:PWM控制LED亮度
只打印数据不过瘾,我加了一个直接可见的反馈:把鼠标X轴位移映射到PWM占空比,控制一个外接LED的亮度。鼠标往右推,灯变亮;往左推,灯变暗。这样不用盯着串口,也能直观判断鼠标事件有没有被正确解析。
#include "hardware/pwm.h" #define PWM_GPIO 0 void pwm_init_once(void) { gpio_set_function(PWM_GPIO, GPIO_FUNC_PWM); uint slice = pwm_gpio_to_slice_num(PWM_GPIO); pwm_set_wrap(slice, 255); pwm_set_enabled(slice, true); }然后在tuh_hid_report_received_cb里加两行:
static uint32_t pwm_level = 128; if (dx > 0) { pwm_level = (pwm_level + dx * 4 < 255) ? pwm_level + dx * 4 : 255; } else if (dx < 0) { pwm_level = (pwm_level > (uint32_t)(-dx) * 4) ? pwm_level - (uint32_t)(-dx) * 4 : 0; } pwm_set_gpio_level(PWM_GPIO, pwm_level);这样鼠标移动就变成了实实在在的灯光变化,随便拿个手电筒照一下就能确认系统在正常工作。如果你不想外接LED,也可以把printf那行作为主要验证手段,二者都试试最好。
5. 踩坑实录:五个让我差点放弃的细节
5.1 鼠标完全没反应,VBUS供电路径的坑
第一次接好线,串口一个mount消息都没有,鼠标底部的灯也不亮。排查了半个多小时,最后发现USB3300模块的5V引脚没有接电源。鼠标是外部供电设备,主机最重要的工作之一就是给总线提供5V VBUS。Pico板载3.3V输出额定电流有限,而且电平本来就不对,千万不要让鼠标从Pico的3V3取电。正确做法是外接5V电源,并且确保USB3300模块的5V引脚和Pico的GND共地。这些问题看不出来,但缺一个系统就完全不动。
5.2 枚举不稳定:USB3300的复位时序
后来换了另一个鼠标,插上后mount成功,但松一下线或者重新插拔就会概率性失败。看打印发现总是卡在设备描述符读取阶段。仔细翻了USB3300数据手册,发现问题出在复位时序上。USB3300的RESET引脚需要在上电且时钟稳定后拉低再拉高,如果和RP2040同时复位,PHY可能来不及准备好。解决方法是把RESET引脚放在GPIO14上,在固件初始化时手动控制:
#define ULPI_RESET_PIN 14 void ulpi_phy_reset(void) { gpio_init(ULPI_RESET_PIN); gpio_set_dir(ULPI_RESET_PIN, GPIO_OUT); gpio_put(ULPI_RESET_PIN, 0); sleep_ms(10); gpio_put(ULPI_RESET_PIN, 1); sleep_ms(10); }这一下立竿见影,插拔稳定性好了很多。核心思路是:PHY芯片复位要比USB控制器晚一点,给它留出启动时间。
5.3 报告长度不是3字节,多功能鼠标直接崩
我把一个带侧键的联想鼠标插上去,按下侧键后串口打印的数据变得很诡异,左右移动没反应,但某一个字节在疯狂变化。打印报告长度才发现,这个鼠标的HID报告是5字节,不是标准3字节,多出来的字节里包含了侧键状态和其他数据。我原来在report_received_cb里只读前3个字节,所以按钮值其实是侧键,而真正的X位移落在后面。
这个问题没有通用捷径,必须回到tuh_hid_mount_cb里打印的报告描述符,确认实际布局。我自己后来写了一个简易解析函数,把Usage Page、Usage、Report Size、Report Count这些字段读出来,再生成一份“第几个字节是X、第几个字节是Y”的映射表。项目初期如果只是验证,可以固定用三键鼠标,避开报告长度不一致的问题。
5.4 PIO方案只能认低速鼠标,电竞设备劝退
用PIO方案验证接线时,我顺手把办公室的高分辨率游戏鼠标插上,结果设备反复枚举失败,插普通办公鼠标就一切正常。问题不在代码,而在于PIO模拟的USB主机只支持低速模式,而游戏鼠标往往是全速设备。这个限制是物理层面的,PIO虽然能跑1.5Mbps的低速协议,但更高速率的时钟精度和信号时序靠PIO模拟非常吃力,性能和兼容性都不如专用PHY。所以如果你要接的设备是全速的,老老实实用USB3300加TinyUSB主机模式,别在PIO方案上死磕。
5.5 RP2350和RP2040的微小差异
我在Pico 2(RP2350)上跑了同一套代码,结果基本无缝迁移,GPIO25的板载LED也通用。要说差异,主要在官方SDK对RP2350的初始化时序上,以及RP2350支持更灵活的BANK电压配置,但默认3.3V下和RP2040没有区别。还有一点,RP2350的PIO速度更高,运行PIO USB方案时的余量更足,但USB主机方案仍然是接外部PHY更可靠。如果你从Pico换到Pico 2,手册上引脚定义基本一致,接线表不必改动。
6. 没有ULPI PHY的另一条路:PIO模拟低速USB主机
6.1 PIO方案的整体思路
既然RP2040/RP2350有PIO这个强大的可编程IO外设,社区就有人用它直接模拟USB的低速主机协议。核心原理是用两个PIO状态机分别处理USB的D+和D-数据线,通过精确控制引脚电平翻转时间,在软件层面实现低速USB的位时钟和差分信号收发。RP2040主频跑在125MHz以上时,PIO有能力在微秒级精度内完成USB低速信号时序。
这套方案的接线极为简单:USB鼠标的D+接到GPIO0,D-接到GPIO1,5V接到VSYS或者外部电源,GND共地,一共四根线。对比USB3300方案的12根信号线,它最大的优势就是省硬件,面包板就能搭起来。
6.2 启用PIO主机的关键配置
代码上,PIO方案同样跑到TinyUSB框架里,只是底层初始化不同。把pico-pio-usb仓库拉进工程后,在main函数里初始化PIO配置,再调用TinyUSB相关初始化:
#include "pio_usb.h" #include "tusb.h" void pio_usb_host_init(void) { pio_usb_configuration_t config = PIO_USB_DEFAULT_CONFIG; // 使用GPIO0/GPIO1作为DP/DM config.pin_dp = 0; config.pin_dm = 1; tuh_rhport_init(0, &config); }之后主循环依然跑tuh_task(),HID回调和ULPI方案完全一致,因为TinyUSB已经帮你把底层差异封装掉了。这意味着你之前为USB3300写的鼠标解析代码,一行都不用改,直接跑在PIO方案上。
6.3 PIO方案的取舍与建议
PIO方案适合什么场景?我觉得有三个:一是手头没有ULPI PHY芯片,想最快验证USB主机的软件逻辑;二是做一次性原型机,插普通办公鼠标够用;三是想深入理解USB低速协议的原理,PIO源码是最好的教科书。它不适合做产品、不适合接全速设备、不适合对实时性要求很高的场景。
我个人的建议是:刚入门时可以先买USB3300模块走标准路线,因为它少了很多“靠天吃饭”的时序约束,遇到问题和网上资料、官方示例都能对得上。等把TinyUSB主机栈和HID回调玩明白了,再回头用PIO方案,你会突然发现各种细节都变得很好理解,因为底层协议你已经在上层摸透了。
后来自从这套系统跑通,我又顺手在上面接了USB键盘,把按键事件直接转发成串口指令;还试着接了一个摇杆手柄,读到的轴数据成功控制了一个步进电机。这个方向的可玩性非常高,底层就是同一个框架:PHY负责物理连接,TinyUSB负责枚举和类驱动解析,你的业务代码只需要在HID报告回调里拿到坐标和按键,剩下的想象空间全部由你决定。我的体会是,USB主机模式最难的从来不是那几十行代码,而是硬件链路的耐心排查。一旦你熬过VBUS、复位时序、报告描述符这几关,后面基本就是一马平川。