尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32H5 USBx下为HID设备添加OUT端点实现双向通信

STM32H5 USBx下为HID设备添加OUT端点实现双向通信 做HID设备双向通信的时候我在LAT1658这块STM32H5板卡上踩了一个很典型的坑USBx中间件默认生成的HID工程只有一条IN端点设备能往主机发数据主机却连一条指令都发不下来。鼠标键盘这类纯上报场景够用但一旦涉及双向交互比如上位机下发PID控制参数、校准指令、配置信息缺少OUT端点就等于通道只修了一半。这篇文章就记录我如何在USBx裸机环境下给HID类设备手工添加OUT端点补上主机到设备的这条下行链路实现完整的双向通信。整个过程涉及HID报告描述符、配置描述符、USBx类驱动回调、端点接收缓冲管理几个关键改动适合手头正好在用STM32H5或同系列芯片做USB HID设备的开发者参考。1. 先把需求看清楚为什么HID默认只有IN端点1.1 HID类设备的基本模型与端点方向USB协议里端点方向是以主机为参照的。IN端点是设备往主机方向传输数据OUT端点是主机往设备方向传输数据。HID类全称Human Interface Device最初为键盘、鼠标、游戏手柄这类人机交互设备设计典型工作模式就是设备采集用户的按键、移动轨迹然后通过IN端点主动上报给主机。ST的USBx中间件在生成HID工程时自然只注册了一个IN端点地址通常是0x81中断传输类型最大包长64字节。这样设计没有错但问题在于一旦你把HID当作通用双向通信手段来用默认模板就不够用了。比如设备需要接收主机下发的PID参数、阈值配置或者主机要向设备发起某个控制指令你会发现上位机根本找不到能写的接口。因为配置描述符里只声明了IN端点主机侧驱动就认为这个设备只支持从设备读取数据写操作无路可走。1.2 为什么在USBx下继续选HID而不是换其他类有人会问既然HID默认只有IN端点干脆换USB CDC虚拟串口或者Vendor自定义类不就行了我在实际选型的时候权衡过这几点最终还是用HID第一免驱动。HID是系统原生支持的类Windows、Linux、macOS插上就能用不需要安装额外的驱动文件。CDC类在Windows上虽然也有系统驱动但不少精简系统或者特定环境下会出现驱动签名、兼容性问题。Vendor自定义类就更麻烦必须自己写驱动或者依赖WinUSB/WDM方案。第二权限友好。HID设备在Linux下访问通常只需要基本的USB权限配置不像某些自定义设备动不动要root权限。在Windows下直接用HID API读写开发门槛低。第三改动范围可控。USBx的HID模板已经帮你把类描述符、初始化流程、IN端点收发回调搭好了只要在原有基础上补一个OUT端点就能实现双向整体代码结构清晰出问题也好排查。所以结论很直接在HID类上补OUT端点是改动最小、成本最低、兼容性最好的双向通信方案。1.3 添加OUT端点的整体思路一句话概括整个改造思路要让主机识别到设备具备下行接收能力必须同时修改三类信息缺一不可。报告描述符Report Descriptor里要声明Output报表。HID设备的读写能力不只由端点决定还依赖报告描述符定义。只加端点不加Output报告系统可能仍然认为设备只支持输入。配置描述符Configuration Descriptor里要增加OUT端点描述符。这个很好理解主机枚举时就是靠它知道设备有哪些端点可用。代码层要注册OUT端点并实现接收回调。描述符只是让主机知道有这个端点真正要让数据进得来还得在USBx的类回调里把OUT端点打开准备好接收缓冲区并处理PCD层的数据到达事件。这三个改动必须同步完成漏掉任何一个双向通信都会以某种诡异的方式失败。后面几章我按实际操作顺序拆开讲。2. 准备工作从CubeMX生成HID工程开始2.1 LAT1658板卡的工程环境LAT1658是我手头这块基于STM32H5系列MCU的评估板。STM32H5是Cortex-M33内核主频能跑到250MHz片上带一个USB_DRD_FS外设支持全速USB2.0可以作为Device、Host或者DRD双角色设备使用。做HID设备时我把它配置为Device Only模式。工程用STM32CubeMX搭建步骤很简单在PinoutConfiguration里使能USB_DRD_FS模式选Device Only。中间件栏会多出USBx Device Class for USB FS选项打开后Class选HID。时钟配置里记得把USB时钟源设为48MHzSTM32H5的USB外设对时钟要求比较严格时钟不对会直接导致枚举失败。代码生成时选择单独的main.c和中断管理方式方便后续手动添加代码。这里要提醒一下STM32H5使用USBx中间件不是老的STM32 USB Device Library。两者的类驱动接口、文件名、回调函数定义都有差异。网上很多教程还是老的USB Device库写法直接套到USBx上编译会报错看代码时要先确认工程用的到底是哪个中间件。2.2 生成的HID代码骨架分析CubeMX生成后和HID相关的核心代码主要在三个位置usbd_hid.h和usbd_hid.c这是USBx的HID类驱动。里面定义了HID_EPIN_ADDR、HID数据最大包长、类初始化回调、配置描述符数组等。我们后面的大部分改动都在这个文件里。usbd_desc.c存放设备描述符、字符串描述符以及PID/VID定义。HID描述符的String Descriptor等也在这里。main.c包含MX_USB_DEVICE_Init等初始化流程。USBx中间件的初始化顺序是USBD_Init、USBD_RegisterClass、USBD_Start这个流程不用改我们只是在HID类驱动内部动刀。打开usbd_hid.h你会看到类似这样的定义#define HID_EPIN_ADDR 0x81U #define HID_DATA_MAX_PACKET_SIZE 64U #define USB_HID_CONFIG_DESC_SIZ 34U注意这里只有HID_EPIN_ADDR没有HID_EPOUT_ADDR这就是默认模板只支持上行的证据。usbd_hid.c里的HID_IO_CfgDesc是配置描述符数组类初始化函数HID_IO_ClassInit里用USBD_LL_OpenEP注册了IN端点但同样没有涉及OUT端点的事。2.3 裸机环境下的中断与回调机制有人可能担心裸机下做USB通信会不会很难处理收发时序。其实USBx中间件已经把底层数据搬运、协议解析都封装好了裸机环境我们要做的事情远没有想象中复杂。STM32H5的USB_DRD_FS产生中断后中断服务函数进入HAL_PCD_IRQHandlerPCD层解析事件后回调HAL_PCD_DataOutStageCallback、HAL_PCD_DataInStageCallback等函数USBx内核在回调中完成Class分发最终调用HID类驱动里的OutEvent、DataIn等回调函数。这条链路在USBx的USB中断处理函数中自动完成。裸机环境下应用层只需要做两件事在OutEvent回调中把数据从USBx的缓冲区搬走并置一个标志位主循环轮询这个标志位做后续处理。回调函数里不要做耗时操作这是裸机USB编程的铁律。数据搬走标志置位剩下的交给主循环这样既保证了USB中断的实时响应又不阻塞协议栈运行。3. 添加OUT端点的完整实操流程3.1 第一步修改HID报告描述符先改报告描述符因为很多HID调试工具在上位机打开设备时会先读取报告描述符决定是否支持写操作。如果报告描述符里没有Output报表哪怕底层端点已经加好了设备管理器里看也是正常的但HID API的写接口依然不可用。HID报告描述符是一串有固定格式的字节数组。我在usbd_hid.c中找到了HID_ReportDesc数组默认是一份标准的鼠标报告描述符长度34字节左右。我把它替换为自定义的双向通信报告描述符__ALIGN_BEGIN static uint8_t HID_ReportDesc[] __ALIGN_END { 0x06, 0x00, 0xFF, // USAGE_PAGE (Vendor Defined) 0x09, 0x01, // USAGE (Vendor Usage 1) 0xA1, 0x01, // COLLECTION (Application) // Input report: 设备上报到主机64字节 0x09, 0x01, // USAGE (Vendor Usage 1) 0x15, 0x00, // LOGICAL_MINIMUM (0) 0x26, 0xFF, 0x00, // LOGICAL_MAXIMUM (255) 0x75, 0x08, // REPORT_SIZE (8) 0x95, 0x40, // REPORT_COUNT (64) 0x81, 0x02, // INPUT (Data,Var,Abs) // Output report: 主机下发到设备64字节 0x09, 0x02, // USAGE (Vendor Usage 2) 0x15, 0x00, // LOGICAL_MINIMUM (0) 0x26, 0xFF, 0x00, // LOGICAL_MAXIMUM (255) 0x75, 0x08, // REPORT_SIZE (8) 0x95, 0x40, // REPORT_COUNT (64) 0x91, 0x02, // OUTPUT (Data,Var,Abs) 0xC0 // END_COLLECTION };这段描述符定义了两个方向各64字节的报告方向由Input和Output项区分主机侧HID API的read对应Inputwrite对应Output。注意Report Count我设为0x40也就是64字节和端点最大包长保持一致这样一包数据正好对应一份报告逻辑简单清晰。如果你需要更复杂的交互可以引入Report ID来区分多份报告比如一个ID用于数据通道另一个ID用于配置通道。但基础双向通信上面这份已经够用Report ID的引入反而会让缓冲区和报告解析复杂不少。3.2 第二步修改配置描述符数组配置描述符是主机枚举时判断设备端口能力的核心依据。USBx的HID模板默认生成一份只有IN端点的配置描述符原始数组大致是__ALIGN_BEGIN static uint8_t USBD_HID_CfgDesc[] __ALIGN_END { 0x09, // bLength: Configuration Descriptor 0x02, // bDescriptorType: Configuration USB_HID_CONFIG_DESC_SIZ, 0x00, // wTotalLength 0x01, // bNumInterfaces 0x01, // bConfigurationValue 0x00, // iConfiguration 0x80, // bmAttributes: Bus Powered 0x32, // bMaxPower (100mA) 0x09, // bLength: Interface Descriptor 0x04, // bDescriptorType: Interface 0x00, // bInterfaceNumber 0x00, // bAlternateSetting 0x01, // bNumEndpoints: 1 0x03, // bInterfaceClass: HID 0x00, // bInterfaceSubClass 0x00, // bInterfaceProtocol 0x00, // iInterface 0x09, // bLength: HID Descriptor 0x21, // bDescriptorType: HID 0x11, 0x01, // bcdHID 1.11 0x00, // bCountryCode 0x01, // bNumDescriptors 0x22, // bDescriptorType: Report sizeof(HID_ReportDesc), 0x00, // wDescriptorLength 0x07, // bLength: Endpoint Descriptor 0x05, // bDescriptorType: Endpoint HID_EPIN_ADDR, // bEndpointAddress: IN 0x03, // bmAttributes: Interrupt HID_DATA_MAX_PACKET_SIZE, 0x00, // wMaxPacketSize 0x01, // bInterval: 1ms };这个数组实际长度是43字节和CubeMX里USB_HID_CONFIG_DESC_SIZ宏默认值不一致是正常的因为宏是中间件模板预设值并不是数组的真实长度。我第一反应是直接改这个数组追加端点描述符但有一个更重要的地方必须先改USB_HID_CONFIG_DESC_SIZ宏。修改后的配置描述符数组在接口描述符部分把bNumEndpoints从1改成20x02, // bNumEndpoints: 2然后在IN端点描述符后面补齐OUT端点描述符0x07, // bLength: Endpoint Descriptor 0x05, // bDescriptorType: Endpoint HID_EPOUT_ADDR, // bEndpointAddress: OUT 0x03, // bmAttributes: Interrupt HID_DATA_MAX_PACKET_SIZE, 0x00, // wMaxPacketSize 0x01, // bInterval: 1ms同时把数组开头的wTotalLength从0x2B43字节改成0x3250字节因为多了一个7字节的端点描述符。这里必须手动重新计算总长度否则主机读取配置描述符时长度不匹配会直接导致枚举失败。usbd_hid.h里的USB_HID_CONFIG_DESC_SIZ宏也要同步改成50这个宏被多处引用比如类初始化时分配描述符缓冲区大小不同步的话USBD_GetCfgDesc等接口返回的长度错误同样会引起枚举异常。3.3 第三步在类驱动中注册OUT端点描述符改完后主机已经知道设备有OUT端点但USBx中间件在初始化时并不会主动打开这个端点。如果不注册OUT端点就像一扇没有安装的门门框上是标了位置但实际没法通过。必须在HID_IO_ClassInit中把OUT端点打开。打开usbd_hid.c在HID_IO_ClassInit里找到IN端点注册的那行代码紧跟着加上OUT端点的注册static uint8_t HID_IO_ClassInit(USBD_HandleTypeDef *pdev, uint8_t cfgidx) { // 原有IN端点注册 USBD_LL_OpenEP(pdev, HID_EPIN_ADDR, USBD_EP_TYPE_INTR, HID_DATA_MAX_PACKET_SIZE); pdev-pClassData USBD_malloc(sizeof(USBD_HID_HandleTypeDef)); // 新增加代码打开OUT端点并预备接收 USBD_LL_OpenEP(pdev, HID_EPOUT_ADDR, USBD_EP_TYPE_INTR, HID_DATA_MAX_PACKET_SIZE); USBD_LL_WriteRx(pdev, HID_EPOUT_ADDR, HID_RxBuffer, HID_DATA_MAX_PACKET_SIZE); return USBD_OK; }同时在usbd_hid.h中补充OUT端点地址定义#define HID_EPOUT_ADDR 0x01UUSBD_LL_OpenEP的第一个参数是端点地址注意虽然OUT端点地址常写为0x01但在USBx的寄存器操作中它会通过地址的低4位加上方向位来区分IN和OUT。USBD_LL_WriteRx是准备接收缓冲区的关键调用它告诉USB硬件控制器收到OUT数据时往哪个缓冲区写写多少字节。这一步不做即使端点打开了主机发数据设备也收不到。3.4 第四步实现OUT数据的接收回调USBx的HID类驱动中定义了一个接口结构体USBD_HID_ItfTypeDef包含初始化、反初始化、Setup、OutEvent、DataIn等回调。默认模板里OutEvent通常是空函数或者是注释掉的状态我们需要把它改成实际可用的接收处理函数。在工程中HID_OutEvent_FS这个函数是开放给应用层的。CubeMX生成的模板里它可能长这样static int8_t HID_OutEvent_FS(uint8_t *buf, uint32_t len) { return USBD_OK; }这个函数被USBx在接收完一包OUT数据后调用buf指向USBx内部的接收缓冲区len是实际接收到的数据长度。我在这个函数里加上数据搬运和标志位操作static int8_t HID_OutEvent_FS(uint8_t *buf, uint32_t len) { HID_RxLen len; memcpy(HID_RxBuffer, buf, len); HID_RxFlag 1; // 准备下一次接收必须在数据被搬离USBx缓冲区后重新调用 USBD_LL_WriteRx(hUsbDeviceFS, HID_EPOUT_ADDR, HID_RxBuffer, HID_DATA_MAX_PACKET_SIZE); return USBD_OK; }关键点在于处理完后必须再次调用USBD_LL_WriteRx。这个函数的作用是重新武装端点让USB外设可以继续接收下一包数据。如果漏掉这一步第一次接收能成功后续所有OUT数据都会石沉大海。HID_RxBuffer和HID_RxFlag、HID_RxLen这些变量我放在usbd_hid.c文件头部用volatile修饰裸机环境下主循环轮询HID_RxFlag判断是否有新数据到达。volatile关键字必须加否则编译器可能会把轮询优化成死循环。3.5 实际修改时最容易踩的三个细节这段实操里我梳理了三个很容易出问题但又不太起眼的地方值得单独提醒。第一接口结构体里的OutEvent不能直接改名字或者随便新增。USBx的HID_IO_Init函数里OutEvent回调是通过pdev-pData接口调用的如果你改写了USBD_HID_ItfTypeDef结构体的字段或者函数签名没对上编译可能过但运行时会导致回调不触发。第二USBD_LL_WriteRx的缓冲区必须保证在USB传输期间不被释放或改写。我最初图省事直接把局部变量传进去作为接收缓冲结果数据全被覆盖了。一定要用全局数组或者长期有效的静态缓冲区长度不小于最大包长。第三端点地址方向位不能搞错。IN端点地址0x81OUT端点地址0x01高方向位0x80是标识方向用的。你不能把OUT端点地址也写成0x80或者0x81否则USBD_LL_OpenEP内部会按IN端点处理总线枚举时就会出问题。4. 双向通信的收发流程与验证手段4.1 主机到设备的完整下行链路以Windows上位机通过HID API向设备发送64字节数据为例完整流程是这样的上位机调用HidD_SetOutputReport或者WriteFile数据先经过Windows HID类驱动再由USB主机控制器以中断传输的形式发送到OUT端点。STM32H5的USB_DRD_FS外设收到数据后PCD层产生DataOut事件USBx内核查找到对应的是HID类调用HID_IO_OutEvent回调。我们在回调里把数据从USBx缓冲区拷贝到应用缓冲区置HID_RxFlag标志。主循环检测到标志后进入数据处理逻辑比如解析PID参数并应用到控制环路。这条链路中最容易出问题的环节是USBD_LL_WriteRx的重新武装。如果回调执行完没有再次调用端点缓冲区就一直处于已满状态后续所有OUT传输都会被主机认为失败或者根本不会发起。这算USBx HID双向通信里最典型的只通一次问题排查时优先检查这里。4.2 设备到主机的上行链路设备到主机的发送用USBx提供的接口直接发送即可uint8_t data[64]; // 填充 data USBD_HID_SendReport(hUsbDeviceFS, data, sizeof(data));这个函数把数据放入IN端点发送缓冲区USBx会在合适时机发送。裸机环境下要注意发送频率上一次发送还没完成就再次调用可能返回USBD_BUSY。我的做法是维护一个发送完成标志在HID_IO_DataIn回调里置位主循环发送前检查这个标志。上行链路在默认HID模板里本来就是完整的所以这部分改动不多主要注意发送节奏和缓冲管理。4.3 用HID调试工具和Python脚本验证验证双向通信我推荐三层手段设备管理器看枚举信息、HID调试助手交互测试、自写脚本做自动化验证。设备管理器或者UsbTreeView可以直观看到设备枚举后的接口和端点列表。如果修改成功UsbTreeView里应该能看到HID接口下有两个中断端点一个是0x81 IN一个是0x01 OUT。如果只有IN端点说明配置描述符有问题主机没解析到OUT端点。交互测试我用过不少HID调试助手这类工具通常会列出系统里的HID设备显示设备的输入报告和输出报告可以直接在界面上填写数据并发送。但在Windows下部分工具对自定义HID设备的写支持不是很好如果一个工具不支持写换个工具再试有时候不是设备问题是工具本身的兼容性问题。自动化验证我推荐用Python配合hidapi库代码量很小import hid # 替换为自己的VID/PID device hid.device() device.open(0x1234, 0x5678) # 读取设备输入报告IN方向 data device.read(64, timeout1000) print(recv from device:, data) # 发送数据到设备OUT方向 device.write([0x00] list(range(1, 65)))注意hidapi的write需要在数据前加一个Report ID字节即使你没有显式使用Report ID这个字节也要留出来写0x00。这个细节很多人第一次会忽略导致上位机报错或者数据错位。Linux下我用同样的hidapi库跑测试效果一致。如果系统识别到HID设备但没有读写权限需要给设备添加udev规则这个和使用USBx的裸机配置无关属于Linux USB权限管理的基本操作。4.4 从协议层面确认数据正确性不少朋友在双向通信成功后直接拿业务逻辑测试一旦数据不对就怀疑是端点代码的问题。我的习惯是先做环回测试设备收到主机发来的64字节数据后原样或者加上固定特征码再通过IN端点发回主机。上位机检查环回数据和发送数据是否一致。这样能快速把USB链路的问题和业务逻辑的问题分开。环回测试通过后再把业务数据格式套进来。这个过程看起来绕了一圈实际上排查效率最高避免把USB层问题和应用层问题混在一起。我这次调试PID参数下发功能就是用环回测试确认链路没问题后才去查业务解析的bug结果真的发现上位机的字节序和设备的解析逻辑不一致跟USB本身一点关系都没有。5. 常见问题与排查实录5.1 典型问题速查表我把实际操作中遇到的典型问题整理成了一张表方便对照排查。现象可能原因排查和解决方法枚举失败设备管理器显示未知设备配置描述符长度错误、wTotalLength未同步、端点地址冲突用UsbTreeView抓取描述符原文逐字节核对代码数组设备枚举成功但HID工具无法打开报告描述符没有Output项检查报告描述符是否包含OUTPUT (0x91)字段OUT发送一次后后续全部失效接收回调没有重新调用USBD_LL_WriteRx在OutEvent处理完数据后立即重新武装端点设备能上报主机写操作报错OUT端点没有注册或地址写错检查HID_IO_ClassInit是否调用USBD_LL_OpenEP接收数据错位或长度不对hidapi发送时少了Report ID引导字节上位机write时在第0字节填0x00枚举正常但系统提示设备无法启动bInterval为0或过大全速中断端点bInterval建议为0x01到0x10IN发送偶发失败上一次发送未完成就再次调用SendReport用DataIn回调标志位控制发送节奏避免连续调用5.2 我被Code 43折腾了一天这次调试过程中最让我记忆深刻的是设备管理器反复报Code 43设备一插入就显示Windows已停止此设备因为其有问题。最开始我一度以为是时钟配置问题把HSI48、PLL各种时钟源试了个遍问题依旧。后来用UsbTreeView抓取总线上的描述符才发现配置描述符长度字段是34但实际数组长度是43主机解析时读取长度失败整个描述符被判定为非法。这里经验就是改描述符类代码时必须用总线抓包工具验证实际枚举内容不要只看代码逻辑。UsbTreeView、Wireshark USBPCAP、逻辑分析仪能抓到原始描述符数据的工具都可以。手动计算描述符长度容易漏尤其当你在数组中间插入了字段后续长度都要重新算一遍。5.3 接收数据偶尔丢包的定位过程还有一个问题让我花了不少时间主机连续下发数据时设备偶发性丢包。起初以为是USBx缓冲不够把HID_RxBuffer加大后问题依旧。后来在OutEvent回调里加了串口调试输出发现丢包发生在上位机快速连续写操作时往往是上一次接收还没处理完下一次就来了。USBx协议栈在OutEvent回调返回后才会处理下一个OUT事务如果OutEvent里耗时太长端点来不及重新武装新数据就会丢失。我在OutEvent里只做数据搬运和标志置位把数据解析全部移到主循环丢包问题明显缓解。如果数据量更大可以考虑双缓冲机制OutEvent里交替使用两块缓冲区能进一步降低丢包概率。5.4 裸机环境下两个容易被忽略的优化点第一主循环轮询间隔不要太长。裸机环境下主循环可能被其他任务占用如果设备处理USB接收不及时缓冲区数据可能被覆盖。我的做法是保证主循环周期在1ms以内同时给HID_RxFlag设置一个超时清零机制防止标志位长期置位导致逻辑误判。第二所有在中断和主循环共享的变量都用volatile修饰。裸机开发没有RTOS的互斥锁机制编译器优化有时候会踩坑。比如HID_RxFlag如果不用volatile主循环里可能出现永久轮询不到的情况这个坑排查起来非常隐蔽。5.5 其他类似设备的经验迁移这次调试经验同样适用于其他使用USBx中间件的芯片比如STM32F4、STM32L4等。这些芯片的USBx HID类驱动结构高度相似只是底层的PCD实现略有差异。如果你在用I2C HID设备设备管理器报感叹号原理其实是相通的都是描述符解析失败。I2C HID设备的描述符在I2C控制器里通过HID Descriptor寄存器暴露给主机如果寄存器值配错主机同样无法正确枚举。排查思路和USB HID是类似的都是沿着描述符的字节流去核对。6. 最后的实操体会这块LAT1658板子的USB功能调试完以后我最大的感受是USB协议栈本身并不难难的是对协议字段的理解和对调试工具的熟练使用。很多USB问题看起来莫名其妙实际上就是描述符里某个字段值不对用抓包工具对照协议规范看一遍问题基本都能浮出水面。修改HID双向通信时我建议按这个顺序操作先改报告描述符再改配置描述符再注册端点和实现回调最后用环回测试验证链路。每一步改完都可以编译烧录在设备管理器里看枚举结果对不对不要一上来把改动全部做完再调试否则出了问题你根本不知道是哪个环节引入的。另外如果后续想在这个基础上做复合HID设备比如一个接口同时支持键盘、鼠标和自定义双向通道核心思路是一样的只是描述符的并集关系会更复杂Report ID要细分到每个功能上。万变不离其宗把OUT端点的添加逻辑吃透后面扩展就顺理成章了。
返回列表