ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

USB HID Standalone跨MCU移植实战与排错指南

USB HID Standalone跨MCU移植实战与排错指南 做USB设备的同学都知道HID类设备在主机端几乎零依赖插上就能用不用装驱动、不用折腾inf文件这也是为什么很多产品在需要和PC交互时首选HID。但真正动手把一套HID Standalone方案从一个平台搬到另一个平台时你会发现“移植”这两个字远不只是复制粘贴代码那么简单。我最近刚把一套基于LAT1466平台的USB Device HID Standalone方案完整地移到另一颗MCU上中间踩了不少坑也把整套逻辑彻底捋了一遍。这篇文章就把这次移植的完整过程、关键决策和排错思路写出来给正准备做类似工作的同行一个参考。1. 项目起点LAT1466上那套Standalone HID到底是个什么东西1.1 先搞明白目标方案的真面目接到这个任务时我拿到的是一套跑在LAT1466上的USB Device HID工程。LAT1466这颗芯片本身带一个USB全速设备控制器支持一个配置描述符、多个接口和端点官方的HID例程做得比较完整有标准HID键盘、鼠标、自定义报表几种模式还带了一套Standalone的框架——所谓Standalone就是不依赖任何RTOS直接用一个超级循环配合中断把USB协议栈跑起来。这套方案的核心价值在于它把USB协议栈的底层端点0的控制传输处理、标准请求响应和上层应用HID报表的发送接收完全解耦。底层只需要提供几个回调接口上层应用完全不用关心USB协议细节直接调HID发送函数就能把数据发出去。这种设计在移植时其实是友好的但前提是你得先把它拆开看透。移植的第一步永远是读代码而且是逐行读。我把整个工程按文件梳理了一遍发现核心其实就三块USB设备控制器驱动寄存器层、USB协议栈核心枚举与控制传输、HID类应用层报表和端点读写。搞清楚这三块分别依赖什么、哪些是平台相关的、哪些是纯逻辑后面的移植才有方向。1.2 为什么目标平台选HID而不是自定义类你可能会有疑问既然是新项目为什么不直接在设计阶段就用别的传输方式非要做HID呢这就要看应用场景了。这次的目标设备是一个需要和PC端上位机通信的测量仪器要求免驱、即插即用、主机端软件尽量简单而且传输的数据量不大——单次交互只有几十个字节的控制命令和状态回传。这种场景下HID的自定义报表Vendor-defined usage page几乎是量身定做的。对比一下其他USB类CDC虚拟串口实现简单但Windows下存在COM口号漂移问题而且速度算不上快某些精简系统还得额外装驱动。自定义类Vendor Class传输效率理论上最高但每一台主机都需要写驱动跨平台成本很高。WinUSB微软推荐的方式但需要inf文件或WinUSB驱动安装步骤对用户不友好。而HID类是USB-IF规定的唯一一个在主流操作系统上原生免驱的设备类Windows、Linux、macOS全部原生支持这就是它最大的优势。对于量产工具、测试设备这类场景用户拿过去插上就能用体验完全不同。1.3 Standalone方案与RTOS方案的取舍另一个值得说清楚的点是Standalone和RTOS方案的选择。很多人一提到USB协议栈就默认要跑在FreeRTOS上其实对于HID这种低速、小数据量的设备独立的超级循环方案反而有几个明显好处内存占用低不需要为每个任务分配栈空间整个USB协议栈加上应用也就几KB RAM。调试简单没有任务调度问题不会出现优先级反转、信号量超时这类让新人头疼的毛病。时序可控主循环里可以精确控制轮询节奏配合中断处理端点的收发实际延迟可以做到微秒级。当然如果你后续要在这个设备上扩展一堆功能比如同时跑屏幕显示、传感器采集、网络通信那FreeRTOS是更好的选择。但就这次移植的目标场景而言Standalone的原方案完全够用而且能最大程度保留原有代码的逻辑降低移植风险。我的建议是除非业务需求明确要求上系统否则不要为了“先进”而“先进”能把事情做简单本身就是一种能力。2. 移植前必须吃透的HID协议骨架2.1 描述符的四级结构HID设备的描述符是移植中最容易出错的部分因为它的结构比CDC、Mass Storage都要“多层”。一个完整的HID描述符体系包含四个层次设备描述符Device Descriptor描述VID/PID、设备类别、端点0最大包长。注意bDeviceClass字段必须为0表示类别在接口描述符中定义。配置描述符Configuration Descriptor一个配置下可以挂多个接口。HID设备往往只有一个接口但你要在接口描述符里声明bInterfaceClass为0x03HID类。HID描述符HID Descriptor挂在接口描述符后面声明报表描述符的长度和数量以及bCountryCode等属性。这一层是HID设备独有的别的类没有。报表描述符Report Descriptor真正定义“这个设备能输入/输出什么数据”的地方。它不遵循标准的USB描述符格式用的是HID专用的Item语法。移植时最容易犯的错误就是只改VID/PID不动描述符结构结果设备插上后枚举失败或者设备管理器里直接显示“未知USB设备”。我在这次移植中把每一条描述符的长度都单独用宏定义抽出来方便对照修改。2.2 HID报表描述符的Item语法报表描述符的Item语法简单说就是“prefix data”的结构。每个Item的第一个字节分成三部分bit7~4是Item类型Main、Global、Local三类bit3~2是Item标签bit1~0是数据长度0、1、2、4字节。以最经典的鼠标报表为例0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x02, // Usage (Mouse) 0xA1, 0x01, // Collection (Application) 0x09, 0x01, // Usage (Pointer) 0xA1, 0x00, // Collection (Physical) 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (Button 1) 0x29, 0x03, // Usage Maximum (Button 3) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x95, 0x03, // Report Count (3) 0x75, 0x01, // Report Size (1) 0x81, 0x02, // Input (Data, Var, Abs) 0x95, 0x01, // Report Count (1) 0x75, 0x05, // Report Size (5) 0x81, 0x03, // Input (Const, Var, Abs) 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x30, // Usage (X) 0x09, 0x31, // Usage (Y) 0x15, 0x81, // Logical Minimum (-127) 0x25, 0x7F, // Logical Maximum (127) 0x75, 0x08, // Report Size (8) 0x95, 0x02, // Report Count (2) 0x81, 0x06, // Input (Data, Var, Rel) 0xC0, // End Collection 0xC0 // End Collection这段描述符定义了一个三键鼠标3个bit的按键状态、5个bit的填充位、2个8bit的X/Y相对位移一共3字节输入报表。你在写自定义报表时最重要的就是Report Size和Report Count的乘积必须是8的倍数否则数据会对不齐主机端解析出来的数据就全是乱的。2.3 端点与包长的关系HID设备通常只用中断端点Interrupt Endpoint传输数据全速设备的中断端点最大包长是64字节。端点配置里有两个关键参数wMaxPacketSize和bInterval。全速HID的bInterval单位是1ms帧主机的轮询间隔就是从这个字段读出来的。对于键盘这类设备8字节的报表就够了对于自定义数据交互设备我通常直接上64字节这样每次中断传输可以带完整的一包数据。但要注意64字节的中断端点作为输入端点时如果实际数据不足64字节主机端会收到一个短包short packet这是正常的也是USB协议里判断传输结束的标志。3. 从LAT1466到新平台的逐层移植实操3.1 底层控制器驱动接口抽象这次移植的目标平台是一颗M0内核的国产MCUUSB控制器寄存器和LAT1466完全不同。好在我发现原方案的USB设备控制器驱动层设计得比较规整所有对外的接口都可以归纳为这么几个usb_device_init()初始化控制器、配置端点、使能中断usb_device_connect()物理上拉D通知主机有设备插入usb_device_disconnect()断开连接usb_device_ep_read(ep, buf, len)从某个端点FIFO读取数据usb_device_ep_write(ep, buf, len)向某个端点FIFO写入数据usb_device_irq_handler()处理USB中断所以我这次移植的核心工作之一就是把这几个函数用新平台的寄存器操作重写一遍。这里有个很关键的细节不同芯片的端点FIFO组织方式差别很大有的芯片是双端口RAM直接映射到内存地址有的芯片必须通过寄存器间接读写。我这次用的新平台是后者每次读写端点FIFO都要先配置端点号、方向和FIFO地址代码写起来啰嗦但好在逻辑清晰不容易出错。3.2 描述符表的本地化修改移植时需要根据新平台的能力调整描述符。原方案的HID报表描述符是64字节输入、64字节输出这个保持不动。但设备描述符里的bcdUSB字段我从2.00改成了1.10——因为新平台的USB控制器是Full Speed但不支持High Speed声明为2.00虽然也能枚举但有的主机尤其是某些USB Hub会额外发送一些High Speed相关的请求虽然标准不强制但应对起来容易增加调试难度。配置描述符里的bMaxPower也要根据实际电路调整。原设计是100mA但新平台的板上电路多了一个指示灯我把它改成了250mA。这个值并不影响功能但如果你的设备工作电流超过声明值部分带过流保护的Hub会直接切断供电排查起来非常诡异。按键点在于描述符表的修改必须和实际硬件能力一一对应不能拍脑袋。比如新平台端点0的FIFO大小只有64字节那你配置描述符里端点0最大包长就不能写64以上否则控制传输的SETUP阶段就会出问题。3.3 主循环与中断的接入方式Standalone方案的主循环结构大概是这样的int main(void) { system_clock_init(); gpio_init(); usb_device_init(); usb_device_connect(); while (1) { hid_app_task(); // 应用层任务处理报表发送接收 watchdog_feed(); // 看门狗喂狗 power_manage(); // 低功耗管理可选 } }有几点移植时的注意事项新平台的中断优先级分组方式可能和LAT1466不一样USB中断要设置为最高优先级至少不能低于看门狗和定时器中断。否则在极端情况下USB中断被其他中断阻塞时间过长会导致主机端枚举超时。超级循环里的app_task不能有阻塞等待比如不能用delay等待某个标志位。我习惯的做法是在app_task里轮询一个“事件标志”由USB中断里的回调函数负责置位。这样既保证了应用的实时性也不会因为长时间占用CPU导致USB端点溢出。看门狗喂狗的位置要放在app_task里而不是中断里。如果放在USB中断里万一主循环跑飞了看门狗照样被喂失去了看门狗的意义。这部分的本质是理解USB协议栈的枚举、控制传输、中断传输处理都是在中断上下文完成的而应用层的HID报表逻辑在主循环上下文完成。两个上下文通过环形缓冲区或标志位交互这个模型在任何平台上都适用。4. 调试阶段枚举失败与常见报错的全链路排查4.1 枚举失败的第一步硬件层排查移植完代码兴奋地插上USB线结果设备管理器里一声不响连“未知设备”都没有。这种“完全没反应”的情况九成是硬件层问题和代码没关系。我当时按照这个顺序排查用万用表量USB D和D-的电平。空闲状态下D应该被上拉到3.3VD-应该为0V。如果D没有上拉说明USB连接检测根本不会触发设备不会被主机发现。检查DP上拉电阻的供电。很多芯片的DP上拉是芯片内部集成的需要通过寄存器使能。我这次新平台就是默认关闭内部上拉需要在初始化时序里先打开。用示波器抓主机发送的复位信号SEO信号至少10ms。如果看不到复位信号说明主机的下行端口都没意识到有设备插入。一个很容易忽略的坑USB的数据线是差分对PCB走线如果不等长或者距离过长会导致信号质量差主机可能识别为USB 2.0高速设备后又降速失败。这时候最好的办法是降低USB线缆长度或者直接焊一个短飞线验证。4.2 枚举超时与“Device Descriptor Request Failed”如果硬件正常但设备管理器显示“设备描述符请求失败”或“USB Device Not Recognized”问题大概率出在端点0的控制传输处理上。用Bus Hound抓一下总线数据你会发现主机反复发送GET_DESCRIPTOR(Device)请求但设备没有返回或者返回的数据校验失败。常见原因有两个第一个是端点0的FIFO读写时序不对。M0内核的芯片如果开启了Cache或者总线等待周期设置不合理从FIFO读数据时可能读到旧数据。解决办法是在USB中断处理里加编译屏障或者调整总线等待周期。第二个是设备描述符的bMaxPacketSize0字段和实际端点0缓冲区不匹配。比如你写64但实际端点0只有8字节或16字节的FIFO主机端按64字节去读第一个包读不全就校验失败。还有一个小概率但很坑的情况新平台芯片的USB控制器在收到SETUP包后需要软件手动确认ACK如果忘了在中断里做这个确认操作主机会一直等待设备响应直到超时报错。这种问题在LAT1466上不会出现因为它的控制器是硬件自动ACK的移植到需要软件参与的平台上就特别容易漏。4.3 枚举成功但设备管理器显示“未知设备”或黄色感叹号这个现象和枚举超时不同你已经能在Bus Hound里看到完整的枚举过程了说明控制传输已经通了但主机就是认为设备不合法。这一步要重点检查配置描述符和HID描述符的完整性。我遇到过一个问题配置描述符里声明了接口描述符后面跟着HID描述符但实际返回的数据里HID描述符被漏掉了。Windows在枚举时会严格按描述符长度去解析如果发现长度对不上会直接判定设备无效。这种问题用Bus Hound把描述符数据完整导出来对照规范看一遍就能找到问题。另外如果你把报表描述符写错了比如说用了未定义的Usage PageWindows通常也会枚举成功但设备管理器里会显示一个带感叹号的人体学输入设备HID-compliant device而且你在“设备属性-详细信息-硬件ID”里看不到正常的HID_DEVICE_UP:...之类的标识。这时候反复对照报表描述符的Item语法检查即可尤其是每个Collection的配对和每个Input/Output/Feature项的Report Count。4.4 一个关于调试器的坑No Cortex-M SW Device Found这虽然不是USB本身的问题但移植调试时特别容易遇到。新平台用的是M0内核J-Link默认连接时如果检测不到SWD设备会报“No Cortex-M SW Device Found”。我当时排查了半天最后发现是目标板上电时序问题——USB D上拉和MCU供电是同一个电路MCU在软件上电初始化时USB的D被瞬间拉高导致MCU还没跑起来时USB枚举就开始了调试器被USB干扰。解决办法有两个一是给SWD接口的复位脚加一个RC延时让MCU稳定后再进入程序二是软件里在main函数最开始就执行usb_device_disconnect()等系统初始化完再调用usb_device_connect()确保USB枚举在MCU完全就绪后才开始。还有更隐蔽的M0内核默认的SWD引脚可能是复用功能如果你的GPIO初始化代码把SWD引脚复用成普通GPIO了调试器同样连不上。我在新平台的GPIO初始化里专门留了一行注释提醒自己“不要动SWD引脚配置”。4.5 Linux下的验证与调试Windows这套流程走通之后建议在Linux下再做一轮验证。Linux下测试HID设备比Windows还方便直接看内核日志dmesg | tail -20可以看到类似这样的输出usb 1-1: new full-speed USB device number 5 using xhci_hcd input: HID 1234:5678 as /devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1:1.0/0003:1234:5678.0001/input/input8 hid-generic 0003:1234:5678.0001: input,hidraw0: USB HID v1.10 Device [HID 1234:5678] on usb-0000:00:14.0/input0如果看到hidraw0就说明设备已经被正确识别了。接下来可以用cat /dev/hidraw0直接查看设备上报的数据十六进制流。如果HID设备的报表数据是自定义格式Linux不会像标准键盘鼠标那样解析成input事件但你依然能在hidraw节点上获取原始数据这对验证报表描述符是否正确非常有用。5. 实测中暴露的问题端点缓冲与传输性能调优5.1 中断端点缓冲区大小的选择移植完成后我做了连续大数据量传输的稳定性测试。结果发现一次发64字节没问题但连续发多包时偶尔会丢包。分析后定位到问题中断端点的FIFO缓冲区太小。新平台的端点FIFO是可配置的默认配置下中断端点只有16字节的缓冲区。而我的报表是64字节这意味着USB中断处理里必须赶在主机下一个IN令牌到来之前把64字节全部写入端点FIFO否则会发生缓冲区溢出Overrun主机读到的是半包数据。解决办法很简单把端点FIFO配置成64字节。但要注意如果芯片的USB FIFO总大小有限你给端点1分配了64字节端点2可能就只有32字节了。所以配置端点时一定要根据实际功能分配好FIFO大小不能所有端点都贪大。5.2 轮询间隔bInterval对吞吐量的实际影响全速中断端点的bInterval范围是1~255单位是1ms。bInterval1主机每1ms轮询一次bInterval10主机每10ms轮询一次。理论最大吞吐量就是64字节/ms也就是64KB/sFull Speed下。如果你需要更高的吞吐量有两个思路把bInterval设为1系统忙轮询实测可以稳定跑到60KB/s以上。改用批量端点Bulk Endpoint但HID类不支持Bulk端点。如果吞吐量要求更高那就得换USB类如CDC或自定义类。我这次的产品需求是双向交互输入和输出各64字节每20ms一次所以bInterval设为4就完全够用了。但实测下来发现如果主机的USB控制器太老或者处于USB Hub之后实际轮询间隔可能比bInterval值更大。所以关键数据最好每包都带上序列号接收方发现序号不连续就主动重发比靠协议保证实时性要可靠得多。5.3 数据一致性关于缓存和内存对齐的教训M0内核没有Cache理论上不存在Cache一致性问题但搞嵌入式的人都知道越是觉得没问题的地方越容易出问题。在某次压力测试中我发现输出报表偶尔会收到错数据某个固定的字节位置会出现0xFF或0x00的“毛刺”。排查了很久最后定位到是缓冲区数组没有做4字节对齐。虽然M0支持非对齐访问但在中断频繁写入的情况下非对齐的16位/32位写操作会被拆成多次总线操作中间状态可能被另一个中断读到造成数据错乱。解决办法是在定义缓冲区时加上对齐属性// 使用GCC/Keil/armcc均可用的方式 __attribute__((aligned(4))) uint8_t hid_rx_buf[64];或者直接定义一个联合体来强制对齐。这个教训也提醒我凡是中断和应用层共享的数据缓冲区都要默认4字节对齐不要想当然。6. 移植完成后的一些收尾工作和经验6.1 用USBCV做标准合规性测试移植完成后别急着打包发布先用USB-IF官方的USBCVUSB Command Verifier工具跑一遍协议测试。这个工具会模拟主机发出各种标准请求验证设备的响应是否符合USB 2.0规范。我记得第一次跑USBCV时报了一个“Descriptor Validation”错误原因是配置描述符中接口描述符的bInterfaceNumber是0但接口的备用设置bAlternateSetting不为0时没有正确返回。这在普通使用中完全看不出来Windows、Linux都不检查这个但USBCV会。改了之后设备管理器里所有设备都从“未知设备”变成了正常设备名。所以强烈建议移植完成、基本功能调通之后无论如何都跑一遍USBCV它能把协议栈里那些隐藏的“软肋”全部暴露出来。6.2 HID设备的下电与唤醒处理HID设备如果支持远程唤醒Remote Wakeup在枚举阶段会通过配置描述符的bmAttributes字段声明并在设备处于挂起Suspend状态时响应主机发出的唤醒信号。这个功能在台式机键盘鼠标上很常见但对我们这种测量仪器来说反而容易出问题如果设备进入挂起后不小心被误唤醒会导致主机端USB总线重新枚举正在进行的通信中断。我的做法是在配置描述符里把bmAttributes的Remote Wakeup位清零同时在应用层增加一个“长时间无通信自动关闭HID功能”的逻辑。这样既避免了误唤醒的隐患还能省电。如果你确实需要远程唤醒功能记得在挂起中断里用外部中断唤醒MCU不要依赖USB总线唤醒。6.3 从这次移植中总结的几点通用经验这次移植前前后后大概用了三周时间最后总结几条通用经验移植USB设备类代码第一优先级是吃透描述符。描述符对了设备就能枚举成功描述符错了后面所有调试都是在浪费生命。底层驱动只做“寄存器翻译”不要试图“优化”原方案的协议逻辑。协议栈一旦出问题排查难度会呈指数上升。调试USB问题Bus Hound这类总线抓包工具是命根子。不要靠猜抓包数据会告诉你主机到底在想什么。遇到奇怪问题先怀疑硬件和初始化的时序再怀疑协议栈逻辑。USB是一个严格时序的协议90%的“玄学问题”最终都是时序问题。如果你手头也有类似这种从老平台往新平台移植USB HID的方案建议照着我这个思路走描述符先行、驱动接口抽象、总线抓包验证、USBCV收尾。这个过程走下来你对USB协议的理解会比看十遍规范都深刻。行就说这么多祝你移植顺利。
返回列表