前阵子想做一个用鼠标控制机械臂的小项目我抄起一块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的芯片不少我目前实测过且身边同行用得最多的是这两款。型号接口支持速度常见形态注意点USB3300ULPILow/Full/High Speed独立模块带USB-A座模块多资料多适合起步TUSB1210ULPILow/Full/High SpeedQFN裸片需自行设计体积小适合做集成产品USB3320ULPILow/Full/High Speed裸片/定制模块功耗更低但小批量不好买如果你只是验证方案直接买USB3300模块最省事模块上已经把晶体、LDO、USB-A座都集成好了用杜邦线就能连Pico。我见过不少人在这一步非要自己画板焊TUSB1210被QFN封装折磨一星期最后又回来买USB3300模块纯属给自己加难度。正常学习路径应该先把主机模式跑通再考虑根据最终产品体积换更小的PHY芯片。2. 硬件接线与供电Pico到USB3300的每一根线2.1 硬件清单与总体拓扑我最终用的硬件组合非常简单1块 Raspberry Pi PicoRP2040后来换Pico 2RP2350验证过同样代码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双向DATA0GPIO3双向DATA1GPIO4双向DATA2GPIO5双向DATA3GPIO6双向DATA4GPIO7双向DATA5GPIO8双向DATA6GPIO9双向DATA7GPIO10输入CLKOUTGPIO11输入DIRGPIO12输出STPGPIO13输入NXTGPIO14输出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 为什么要用TinyUSBPico 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按键状态uint8bit0左键bit1右键bit2中键1X轴相对位移int82Y轴相对位移int83滚轮位移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 2RP2350上跑了同一套代码结果基本无缝迁移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接到GPIO0D-接到GPIO15V接到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、复位时序、报告描述符这几关后面基本就是一马平川。