ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ESP32-P4 USB Host实战:读取USB鼠标HID报告全解析

ESP32-P4 USB Host实战:读取USB鼠标HID报告全解析 我们可以先拆一下这个实验的本质ESP32-P4 这颗芯片上跑 USB Host把普通的 USB 鼠标插上去由芯片主动枚举外设、接收 HID 报告最后把鼠标的位移和按键在显示屏或串口上呈现出来。这个实验最直接的收获不是“能控制鼠标”本身而是把 USB 协议栈里最核心的枚举、端点通信、中断传输、HID 报告解析这一整条链路完整地跑通一遍。后面你不管是做 USB 键盘、USB 游戏手柄还是 USB 转串口、U 盘读写走的都是同一套框架只是接口描述符和 Class 协议不同。这篇就按我自己调这个实验的实际顺序来讲把原理、代码、配置和踩坑放在一起尽量让你照着做就能跑起来。1. 项目背景与方案选型1.1 ESP32-P4 的 USB 控制器资源ESP32-P4 和之前的 ESP32-S3 不一样它内部提供了两个独立的 USB OTG 控制器一个支持 Full Speed(FS, 12Mbps)一个同时支持 High Speed(HS, 480Mbps)。这颗芯片定位就是做多媒体、边缘计算这类对 IO 性能有要求的场景USB 资源给得比较足。DNESP32P4 开发板在设计上把这两个 USB 控制器都引出来了一个做成 Device 口通常接 PC 用于烧录和虚拟串口调试另一个就是专门给 Host 实验用的。我们的 USB 鼠标实验用的就是 Host 控制器对应的那个 USB-A 口。这个口可以直接插标准 USB 鼠标、键盘不需要额外转接板。这里有个好处之前的 ESP32 系列做 Host 实验经常要外接 USB PHY 芯片比如 USB3300或者必须用芯片内置的 Full-Speed PHY 再加上外部电平转换。P4 的 Host 口直接把 PHY 集成好了硬件上省很多事软件上也不用关心 PHY 配置聚焦到协议本身即可。1.2 为什么这个实验选 Host 模式而不是 Device有些刚接触 USB 的朋友会有个疑问鼠标本身是 Device为什么不能让两个设备直接对接USB 协议是主从架构。一条 USB 总线上必须有且只有一个 Host由 Host 负责发起所有传输事务、管理总线供电、分配地址、配置设备。鼠标这种外设属于 Device它永远不能主动往总线上发数据只能等 Host 来“问”它。所以让 ESP32-P4 读鼠标P4 就必须扮演 Host。这个角色切换在硬件上由 OTG 控制器决定软件上就是usb_host_install()和usb_otg_switch_role()的事。实验里我们把 OTG 角色强制设为 Host然后插上鼠标P4 就会像电脑一样去识别这个鼠标。1.3 鼠标数据传输用什么传输类型USB 定义了四种传输类型控制传输、中断传输、批量传输、等时传输。鼠标、键盘这类 HID 设备用的是中断传输这里“中断”不是硬件中断而是指“周期性轮询”。Host 必须按照端点描述符里写的bInterval值每隔一段时间(比如 1ms 或 10ms)主动读取一次鼠标发来的报告。鼠标的报告量很小通常一次就 4 到 8 个字节没必要用批量传输中断传输刚好兼顾了低延迟和总线占用小。此外设备初始化阶段靠的是控制传输也就是枚举流程。控制传输走端点 0所有 USB 设备必须实现端点 0Host 通过一系列标准请求(Get_Descriptor、Set_Address、Set_Configuration 等)把设备初始化好。把握这两个传输类型代码逻辑就清晰了枚举阶段全是控制传输枚举完成后打开中断 IN 端点持续轮询。2. 硬件连接与工程搭建2.1 开发板 USB 接口确认DNESP32P4 开发板的 USB 接口有好几个做这个实验前先确认你插的是哪个。通常板子上丝印会标USB_HOST或HOST那个是 USB-A 母座对应的是 HS 控制器支持 480Mbps。如果板卡丝印看不清可以看原理图上芯片引脚的连接关系找连接到芯片 USB_OTG2 引脚的那组 D/D-。我手上这块板子Host 口是标准 USB-A鼠标线直接插进去就能供电不需要外接电源——USB Host 口会输出 5V 给设备供电P4 的 USB 控制器支持这个供电控制。注意不要插到标着UART或DEVICE的那个 Type-C 口那是 Device 模式用的往那儿插鼠标没用。2.2 硬件接线与电平注意事项如果是拿模块自己搭电路需要注意 D/D- 的连接。USB 2.0 的 D/D- 是差分对要求 90 欧姆差分阻抗所以 PCB 走线时要保持差分等长、远离干扰源尽量短。P4 内置 PHY 后D/D- 直接连到芯片引脚上不需要外部上拉电阻——这个和用 ESP32-S2 时的做法类似内置 PHY 会处理好终端电阻和上拉。如果用的是杜邦线飞线连接(比如从扩展排针引出)线长不要超过 10cm否则高速信号完整性会出问题表现为枚举不稳定、鼠标频繁掉线。我之前试过用 20cm 的杜邦线接 USB 鼠标结果 5 次里有 2 次枚举失败后来换成短粗线就稳定了。电源方面USB Host 口负责给鼠标供电如果用的是非标电源适配器或者劣质 USB 线鼠标的供电纹波会大导致芯片内部复位。有条件的用 5V/2A 以上的适配器给开发板供电尽量避免电脑 USB 口同时给开发板供电和烧录容易引入地回路干扰。2.3 工程配置与 menuconfig 关键项正点原子的例程基于 ESP-IDF我用的版本是 v5.2 以上(实测 5.3 也没问题)。打开工程后先跑一次idf.py set-target esp32p4然后进入 menuconfig 做以下配置Component config → USB → Host Stack打开USB Host同时确认Number of USB Host Clients至少为 1 到 2。例程默认是 1够用。Component config → USB → Host Stack → Disable BulkBulk transfers默认不用动鼠标用不到。Component config → USB → Host Stack → Enable Host tests不要开这是测试用的开了会干扰正常枚举。Component config → ESP32P4-Specific → USB如果是 HS 口确认Enable USB High Speed打开如果用的是 FS 口则保持关闭。这些配置本质上是决定 USB Host 库在编译时启用哪些功能。ESP-IDF 的 USB Host 库是可裁剪的比如不需要支持多客户端时就可以减少内存占用。配置保存后先idf.py build编译一次确认没有宏定义冲突。常见的一个坑是如果你同时使能了 TinyUSB 的 Device 模式会和 Host 栈争用 OTG 控制器编译能过但运行时无法切换角色或者开机就被对方抢占。例程里默认只使能 Host不要额外打开 TinyUSB 的USB_DEVICE。2.4 烧录与串口监视烧录命令idf.py build flash monitor如果开发板 Device 口没有连 PCHost 口插鼠标且没有其他串口输出可能会误以为程序没跑。实际上程序已经通过 Host 口枚举鼠标了但你看不到日志。建议先用 USB 线连接 PC 和 Device 口用串口终端看日志。波特率我习惯设 115200日志级别idf.py -p /dev/ttyUSB0 monitor默认就能看到。3. 协议基础与代码实现3.1 USB 鼠标枚举过程拆解这一节是整个实验最值得深挖的地方。USB 枚举过程看起来复杂但理解成“Host 和设备之间的一次自我介绍”就清晰了。鼠标插上后Host 首先通过端点 0 发送GET_DESCRIPTOR(Device)请求拿到设备描述符这个描述符里包含了idVendor、idProduct、bcdUSB、bMaxPacketSize0等关键信息。接着 Host 会给设备分配一个唯一的地址发送SET_ADDRESS请求。分配完地址后Host 重新用新地址和设备通信依次获取配置描述符、接口描述符、端点描述符。这里要特别说明配置描述符是一个复合结构GET_DESCRIPTOR(Configuration)返回的是一整块数据里面包含配置描述符、一个或多个接口描述符、每个接口下的端点描述符。鼠标的配置描述符结构通常是Configuration Descriptor: bNumInterfaces 1 Interface 0: bInterfaceClass 0x03 (HID) bInterfaceSubClass 0x01 (Boot Interface) bInterfaceProtocol 0x02 (Mouse) Endpoint IN: bEndpointAddress 0x81 bmAttributes 0x03 (Interrupt) wMaxPacketSize 4 或 8 bInterval 10 (即 10ms)Host 拿到这些信息后发送SET_CONFIGURATION(1)让鼠标正式开始工作。之后 Host 就知道要周期性读取端点 0x81 的数据了。典型鼠标的枚举日志(抓包/日志输出)USB Host: New device connected USB Host: Address assigned: 1 USB Host: Device descriptor: VID046d, PIDc077 USB Host: Config descriptor: interfaces1, endpoints2 USB Host: HID device found, subclass1, protocol2 USB Host: Using boot protocol mouse3.2 代码框架总览正点原子例程的代码结构大致分三层底层ESP-IDF 的 USB Host Stack负责总线事务、URB 管理、设备连接断开检测。中间层HID Class 驱动逻辑负责解析 HID 描述符、选择 Boot Protocol、处理键盘/鼠标报告。应用层把解析出来的鼠标位移和按键映射到屏幕光标或串口打印。我们不用重复造轮子直接基于 ESP-IDF 的usb_host接口来写应用层代码但理解中间层的 HID 报告解析是核心。3.3 初始化与事件循环先看主流程代码#include usb/usb_host.h #include usb/hid_host.h static void usb_lib_init(void) { const usb_host_config_t host_config { .skip_phy_setup false, .intr_flags ESP_INTR_FLAG_LEVEL1, }; usb_host_install(host_config); } static void usb_client_event_cb(const usb_host_client_event_msg_t *event, void *arg) { switch (event-event) { case USB_HOST_CLIENT_EVENT_NEW_DEV: // 新设备插入触发枚举和打开 break; case USB_HOST_CLIENT_EVENT_DEV_GONE: // 设备拔出释放资源 break; default: break; } } static void usb_client_init(void) { const usb_host_client_config_t client_config { .is_synchronous false, .max_num_event_msg 5, .async { .event_cb usb_client_event_cb, .arg NULL, }, }; usb_host_client_register(client_config, client_hdl); }几点说明usb_host_install()只需要调用一次它初始化底层控制器建立默认的根 Hub。客户端(client)是 USB Host 栈的概念它代表一个“对设备进行操作的主体”。注册客户端时传入事件回调当总线上有新设备插入或设备拔出时这个回调会被触发。事件回调是在中断上下文还是任务上下文取决于配置。例程默认是任务上下文可以在回调里调用阻塞 API但最好还是把重的操作丢给专门的任务处理。初始化完成后要有一个任务不断调用usb_host_client_handle_events(client_hdl, timeout)这个函数负责处理所有与客户端相关的 USB 事件。如果程序卡在某个环节不推进最常见的原因就是没有调用这个函数。void usb_host_task(void *arg) { while (1) { usb_host_client_handle_events(client_hdl, portMAX_DELAY); } }这里有个细节handle_events返回之前内部会调用客户端注册的事件回调。如果事件回调里做了阻塞操作会直接影响后续事件的处理所以回调里只做标记不要长时间停留。3.4 设备打开与描述符解析当检测到新设备插入后需要从设备列表里拿到设备地址然后打开设备。static void process_new_device(void) { // 获取设备信息 usb_host_device_info_t dev_info; usb_host_device_open(client_hdl, dev_addr, dev_hdl); // 获取配置描述符 const usb_config_desc_t *config_desc; usb_host_get_active_config_descriptor(dev_hdl, config_desc); // 解析接口与端点 parse_hid_interface(config_desc); }解析接口这一步是重点。配置描述符的整体结构是先是usb_config_desc_t后面跟一系列接口描述符(usb_intf_desc_t)、端点描述符(usb_ep_desc_t)以及 HID 描述符(usb_hid_desc_t)。我们不能简单地强转指针访问因为描述符之间有对齐填充必须用USB_ITERATE_INTF或者手动按长度字段跳过。我这里给一个通用的遍历方法static void parse_hid_interface(const usb_config_desc_t *config_desc) { int offset 0; const uint8_t *p (const uint8_t *)config_desc; while (offset config_desc-wTotalLength) { const usb_standard_desc_t *desc (const usb_standard_desc_t *)(p offset); if (desc-bDescriptorType USB_W_VALUE_DT_INTF) { const usb_intf_desc_t *intf (const usb_intf_desc_t *)desc; if (intf-bInterfaceClass USB_CLASS_HID) { // 记录接口编号用于后续 SET_INTERFACE / SET_PROTOCOL hid_intf_num intf-bInterfaceNumber; // 遍历该接口下的端点 int ep_offset offset intf-bLength; while (ep_offset config_desc-wTotalLength) { const usb_standard_desc_t *ep_desc (const usb_standard_desc_t *)(p ep_offset); if (ep_desc-bDescriptorType USB_W_VALUE_DT_ENDPOINT) { const usb_ep_desc_t *ep (const usb_ep_desc_t *)ep_desc; if ((ep-bmAttributes USB_BM_ATTRIBUTE_XFERTYPE_MASK) USB_BM_ATTRIBUTE_XFER_INT (ep-bEndpointAddress USB_B_ENDPOINT_ADDRESS_EP_NUM_MASK) 0) { // 0x81 IN 端点 in_ep_addr ep-bEndpointAddress; } } else if (ep_desc-bDescriptorType USB_HID_DESC_TYPE_HID) { // 记录 HID 描述符 } ep_offset ep_desc-bLength; } } } offset desc-bLength; } }遍历时按bLength跳转千万不要硬编码偏移。不同鼠标的配置描述符长度不一样有些带 HID 报告描述符有些带多个接口跳错了就什么也解析不出来。3.5 设置配置与选择 HID 协议设备暴露了多个配置时(通常鼠标只有一个)我们需要选择第一个可用的配置发送SET_CONFIGURATION。usb_host_set_config(client_hdl, dev_hdl, config_desc-bConfigurationValue);之后对 HID 设备还有一个“协议选择”步骤。HID 规范里定义了 Boot Protocol 和 Report Protocol。鼠标的 Boot Protocol 是固定格式的 3 字节报告字节 0按键状态(bits0左键1右键2中键)字节 1X 方向位移(有符号)字节 2Y 方向位移(有符号)相对位移报告里X/Y 是带符号的 int8_t向上为正还是向下为正取决于具体的坐标约定通常在屏幕上 Y 轴向下为正鼠标向下移动时 Y 会增大。我们需要发送SET_PROTOCOL(Boot Protocol)请求这样即使不同厂商的鼠标内部报告格式有差异Host 也能用统一格式解析const usb_ctrl_request_t req { .bRequestType USB_B_REQUEST_TYPE_TYPE_CLASS | USB_B_REQUEST_TYPE_RECIP_INTERFACE | USB_B_REQUEST_TYPE_DIR_OUT, .bRequest USB_HID_REQUEST_SET_PROTOCOL, .wValue 0, // 0 Boot Protocol .wIndex hid_intf_num, .wLength 0, }; usb_host_transfer_submit_ctrl(client_hdl, req, NULL, 0, NULL, timeout);这个请求走控制传输管道不需要额外分配传输缓冲区。3.6 中断传输与报告解析配置完成后申请一个 URB提交中断 IN 传输请求usb_host_transfer_t *xfer NULL; usb_host_transfer_alloc(8, 0, xfer); xfer-device_handle dev_hdl; xfer-bEndpointAddress in_ep_addr; xfer-callback hid_report_cb; xfer-context NULL; xfer-num_bytes 8; xfer-data_buffer_size 8; usb_host_transfer_submit(xfer);回调函数里数据已经填充到了xfer-data_buffer我们只需要按 Boot Protocol 解析static void hid_report_cb(usb_host_transfer_t *xfer) { if (xfer-status USB_TRANSFER_STATUS_COMPLETED) { uint8_t *data xfer-data_buffer; int8_t dx (int8_t)data[1]; int8_t dy (int8_t)data[2]; uint8_t buttons data[0]; printf(btns%02x dx%d dy%d\n, buttons, dx, dy); // 重新提交下一次中断传输 usb_host_transfer_submit(xfer); } else { // 传输失败处理异常 } }注意usb_host_transfer_submit只能在前一次传输结束后再次调用不能在回调还没触发时就重复提交。否则会出现多个 URB 挂在同一个端点上轻则日志报错重则导致中断传输彻底卡死。按我的经验把usb_host_transfer_submit放在回调最后一行是标准做法既保证连续性又避免丢事件。3.7 鼠标数据如何变成屏幕光标例程里通常还带一个 LCD 显示把鼠标位移叠加到光标坐标上static int16_t cursor_x 240; static int16_t cursor_y 135; void mouse_apply_delta(int8_t dx, int8_t dy) { cursor_x dx; cursor_y dy; if (cursor_x 0) cursor_x 0; if (cursor_x 479) cursor_x 479; if (cursor_y 0) cursor_y 0; if (cursor_y 270) cursor_y 270; lcd_draw_cursor(cursor_x, cursor_y); }这里会遇到一个实际体验问题鼠标的位移值默认是“鼠标 tick”屏幕上光标移动的像素数是 1:1 的但如果鼠标设置过高的 DPItick 值会比较大光标移动飞快。建议加一个可调缩放系数cursor_x (int16_t)dx * cursor_scale;我习惯把缩放系数做成一个变量初始设为 1需要时在调试终端里调方便适配不同 DPI 的鼠标。分辨率这块也要注意开发板的屏幕分辨率是多少就按多少来别写死我的例程试过 480x272 和 800x480 都有适配问题。4. 实操过程与关键现象记录4.1 接线与首次上电按前面说的把 USB 鼠标插到开发板的 Host 口上电。首次上电时如果鼠标的 LED 没有亮先检查是不是 Host 口选错了或者供电不足。我强烈建议插那种有线、基础款鼠标。有些电竞鼠标带有多个配置文件上电时会先进入“固件升级模式”或“自定义 RGB 模式”枚举过程会异常表现为枚举成功但拿不到 HID 报告。基础办公鼠标最稳。如果日志里没有出现New device connected多半是硬件问题先用示波器或逻辑分析仪看 D 的电平。USB 设备插入后 D 会被设备内部上拉电阻拉高(Full Speed 设备拉高 D)如果 D 一直是低电平说明设备没进入工作状态或者数据线接反了。4.2 运行日志样例正常的运行日志大致是I (278) usb_host: USB Host installed I (285) usb_host: Client registered I (290) main: Waiting for device... I (310) usb_host: New device connected I (315) usb_host: Device address 1 assigned I (320) usb_host: Config descriptor length 34 I (326) hid_host: HID interface found I (330) hid_host: EP 0x81, interval 10ms I (335) hid_host: Set boot protocol I (340) main: Open device OK I (352) main: btns00 dx0 dy0 I (362) main: btns01 dx1 dy-2 I (372) main: btns02 dx0 dy0如果日志卡在枚举阶段比如Set address failed大概率是 USB 线有问题或者电源不够换根线试试。4.3 用屏幕光标验证鼠标通信如果接入了 LCD跑起来后移动鼠标光标会跟着动按下左键、右键、中键界面上对应的按键状态会变。这个验证很直观。没有 LCD 也没关系串口打印足够验证数据。我习惯把按键、X、Y 三个值用 CSV 格式输出方便后续用脚本统计丢包率event,button,x,y move,0,3,-1 click,1,0,0 move,0,0,15. 常见问题与排查技巧实录5.1 枚举成功后鼠标无数据现象日志里New device connected、Interface found都打印了但btns/dx/dy没有任何输出。排查思路检查是否调用了usb_host_transfer_submit提交中断传输。很多人初始化完配置就忘记提交第一次传输导致端点一直空闲。检查端点地址是否解析正确。如果你硬编码0x81但鼠标的 IN 端点实际是0x82传输自然会失败。必须从配置描述符里解析。检查传输回调里是否重新提交了下一次传输。如果回调里只是打印一次没有再次 submit那就是一次性读到了数据但不再继续了。这类问题占到整个实验调试量的六成以上。5.2 鼠标指针乱跳或反向现象鼠标不动光标自己跑或者左移右移方向相反。原因分析反向Y 轴的符号约定问题。屏幕坐标系的 Y 轴向下为正而鼠标报告里的 Y 正负是“相对鼠标移动方向”的两者正好相反取反即可。乱跳没有按字节长度判断就盲目处理增量某些鼠标在滚轮滚动时会多发数据导致后续字节被当成 X/Y。建议严格按 Boot Protocol 长度如果有滚轮滚轮在第四个字节(索引 3)不要把滚轮数据当成位移。5.3 热插拔不稳定现象鼠标用着用着突然没反应拔出重插后有时能恢复有时不能。原因USB 拔出时Host 端会有USB_HOST_CLIENT_EVENT_DEV_GONE事件。如果应用层没有释放旧设备的资源和 URB重插时枚举可能失败因为usb_host_device_open会返回资源不足。代码里建议在DEV_GONE事件回调中usb_host_transfer_free(xfer); usb_host_device_close(client_hdl, dev_hdl);重新插入后NEW_DEV事件会再次触发重新走枚举流程。别偷懒不释放资源泄漏几次后系统会变得非常不稳定。5.4 wTotalLength 遍历越界解析配置描述符时如果直接用config_desc-wTotalLength作为循环条件但拿到的指针实际是缓存复用的可能出现越界读取。我遇到过一次是因为usb_host_get_active_config_descriptor返回的描述符在客户端事件处理完成后被释放了回调里再访问就变成悬垂指针。解决方案在设备打开后立即把配置描述符拷贝到自己管理的缓冲区中再解析不要长期持有库内部的指针。ESP-IDF 文档里也明确说过配置描述符缓冲区的所有权属于调用者用完之后要释放。5.5 鼠标多为 3 字节报告但某些鼠标是 8 字节有些鼠标比如带侧键的办公鼠标Boot Protocol 下报告可能是 3 字节(满足最基本要求)但一些多按键鼠标在 Report Protocol 下会输出 6 到 8 字节的自定义格式。如果你一定要支持侧键可以读取 HID 报告描述符根据 Report ID 和 Usage 解析每个字段。这个工作量大一些得写一个小的报告描述符解析器。新手阶段先用 Boot Protocol 就够了。提示USB 鼠标的 HID 报告描述符可以用/sys/kernel/debug/hid/路径(在 Linux 上)导出或者用 Wireshark 的 USB 抓包功能对比分析各个字段的含义非常直观。5.6 中断传输的 bInterval 与延迟鼠标的bInterval常见值是 10(单位 ms)意思是 Host 每 10ms 轮询一次。部分高性能鼠标报告间隔是 1ms 或 2ms。这个值由设备决定Host 不能随意修改。如果觉得光标响应有延迟先检查bInterval是不是太大再看打印日志的时间戳。实际体验中 10ms 的轮询对普通桌面应用完全够用肉眼感受不到延迟。6. 这个实验还可以怎么扩展做完鼠标 Host 实验扩展方向非常明确这里分享几个我觉得性价比很高的方向。第一个方向是把同一套 Host 框架改造成 USB 键盘。键盘的枚举过程和鼠标几乎一样只是bInterfaceProtocol 0x01报告格式变成 8 字节Modifier(Shift/Ctrl/Alt)、保留字节、6 个按键码。改一个解析函数就能用。第二个方向是接收无线鼠标接收器。很多无线鼠标的接收器在主机看来就是一个标准 HID 键盘或鼠标不需要特殊处理。实测罗技的优联接收器在 ESP32-P4 上可以直接枚举成功省去单独调 RF 协议。第三个方向是结合 LVGL 做 HID 输入控制。在开发板的触摸屏 UI 上把鼠标当作一个额外的输入设备实现桌面式的操作体验。LVGL 的lv_indev_drv_t注册一个 pointer 设备喂 dx/dy 和按键状态就行。第四个方向是 USB Host 加蓝牙 Host 共存做一个统一的外设接入网关鼠标键盘既可以有线接入也可以蓝牙接入然后统一注入到上位机系统。这在工控、教育硬件里应用挺多。我个人更看好的是把 USB Host 和 TinyUSB Device 做成“双角色动态切换”的小工具平时接电脑当 HID 设备切换到 Host 模式读取 U 盘或鼠标。P4 的两个 USB 控制器正好可以一个做 Host 一个做 Device这种玩法在之前的 ESP32 上很难实现。当然这是后话先把鼠标 Host 实验吃透后面一切好说。我自己在调完这个实验之后最大的感受是USB 协议没有想象中那么神秘它就是把“设备是什么、怎么通信、传什么格式的数据”这三件事用描述符写得清清楚楚。能读完一篇设备描述符再照着它的约定去发请求、读端点USB 开发你就已经入门了。剩下的多是一些设备的“不标准行为”那就靠多抓包、多对比各种鼠标来积累经验了。
返回列表