3、USB协议栈移植:TinyUSB/STM32 USB库选择、端点配置、描述符定制、CDC与HID类实现
好,咱们进入正题。USB协议栈移植这块,说难不难,说简单也不简单。我见过不少工程师,一上来就对着USB描述符猛改,结果设备死活枚举不过。其实啊,核心就三件事:选对库、配好端点、写对描述符。
3.1 协议栈选型:TinyUSB vs STM32 USB库
先聊聊选型。我个人习惯,如果是新项目,优先考虑TinyUSB。为什么?
- TinyUSB:开源、跨平台、支持设备类和主机类都很全。我在一个基于GD32的项目里用过,移植到RISC-V平台也就花了两天。
- STM32 USB库:ST官方维护,跟HAL库深度绑定。如果你用的是STM32,而且不想折腾第三方库,直接用官方库最省心。
我的建议:如果你的MCU不是STM32,或者你需要同时支持CDC、HID、MSC等多个类,选TinyUSB。如果只是做个简单的CDC串口,STM32 USB库完全够用。
我记得有一次,客户要求在一个STM32F103上同时实现CDC虚拟串口和HID键盘。用官方库的话,得自己处理复合设备描述符,挺麻烦的。换成TinyUSB后,直接调用tusb_init(),配置好描述符数组就搞定了。
3.2 端点配置:别小看这个环节
端点配置是USB协议栈移植里最容易出坑的地方。你想想看,USB主机和设备通信全靠端点,配错了设备就认不出来。
| 端点类型 | 典型用途 | 最大包长(全速) | 注意事项 |
|---|---|---|---|
| 控制端点(EP0) | 枚举、控制传输 | 64字节 | 必须存在,双向共用 |
| 批量端点(Bulk) | CDC数据、MSC | 64字节 | 需要IN和OUT各一个 |
| 中断端点(Interrupt) | HID报告 | 64字节(全速) | 轮询间隔要合理设置 |
| 同步端点(Isochronous) | 音频、视频 | 1023字节 | 不保证数据正确性 |
注意:STM32的USB外设,端点数量有限。比如STM32F103只有8个端点(EP0-EP7),每个方向算一个。如果你要同时做CDC+HID,至少需要:EP0(控制)、EP1 IN(CDC数据)、EP2 OUT(CDC数据)、EP3 IN(HID报告)。算下来刚好够用。
我曾经在一个项目里,因为端点号分配冲突,导致HID设备枚举成功但数据发不出去。查了两天才发现,CDC的批量端点占用了EP3,而HID的中断端点也配成了EP3。嗯,这种低级错误,犯过一次就记住了。
3.3 描述符定制:设备能不能被认出来,全看它
描述符是USB设备的身份证。主机通过读取描述符才知道你是什么设备、用什么协议通信。我刚开始做USB时,觉得描述符就是一堆字节数组,随便填填就行。后来被现实狠狠教育了一顿。
以CDC设备为例,标准描述符结构是这样的:
// 设备描述符
const uint8_t desc_device[] = {
0x12, // bLength
0x01, // bDescriptorType (Device)
0x00, 0x02, // bcdUSB (2.00)
0x02, // bDeviceClass (CDC)
0x00, // bDeviceSubClass
0x00, // bDeviceProtocol
0x40, // bMaxPacketSize0 (64)
0x83, 0x04, // idVendor (0x0483)
0x40, 0x57, // idProduct (0x5740)
0x00, 0x01, // bcdDevice
0x01, // iManufacturer
0x02, // iProduct
0x03, // iSerialNumber
0x01 // bNumConfigurations
};
小技巧:描述符里的字符串索引(iManufacturer、iProduct等)要对应到字符串描述符。如果不想显示,可以填0。我一般会至少填一个厂商字符串,方便调试时识别设备。
配置描述符更复杂,因为它要包含接口描述符、端点描述符,甚至功能描述符。CDC设备需要两个接口:一个用于通信管理(包含控制端点),一个用于数据(包含批量端点)。
说实话,手写描述符很容易出错。我建议用工具生成,比如USB Descriptor Tool或者直接参考TinyUSB的示例。TinyUSB里有个usb_descriptor.c,改改VID/PID和端点号就能用。
3.4 CDC类实现:虚拟串口,最常用的功能
CDC(Communication Device Class)说白了就是把USB伪装成串口。主机那边会虚拟出一个COM口,你往USB发数据,上位机就能通过串口读到。
实现CDC类,核心是处理两个端点:
- 批量IN端点:设备发数据给主机。比如传感器数据、日志输出。
- 批量OUT端点:主机发数据给设备。比如配置命令、控制指令。
在TinyUSB里,CDC回调函数很简单:
// 接收回调:主机发数据过来时触发
void tud_cdc_rx_cb(uint8_t itf) {
char buf[64];
uint32_t len = tud_cdc_read(buf, sizeof(buf));
// 处理接收到的数据
}
// 发送数据
void send_data(const uint8_t* data, uint32_t len) {
tud_cdc_write(data, len);
tud_cdc_write_flush(); // 别忘了flush
}
我记得第一次调CDC时,数据发出去上位机收不到。折腾半天发现是忘了调用tud_cdc_write_flush()。TinyUSB的发送是缓冲的,不flush数据就卡在缓冲区里。嗯,这个坑我替你们踩过了。
3.5 HID类实现:键盘、鼠标、自定义设备
HID(Human Interface Device)类比CDC灵活得多。你可以用它模拟键盘、鼠标、游戏手柄,甚至自定义报告描述符实现任意数据交换。
HID的核心是报告描述符。它定义了数据的格式和含义。比如一个简单的键盘,报告描述符长这样:
const uint8_t desc_hid_report[] = {
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224)
0x29, 0xE7, // Usage Maximum (231)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data,Var,Abs)
// ... 省略按键数组定义
0xC0 // End Collection
};
重点:报告描述符写错了,主机可能识别成未知设备,或者按键映射完全不对。我建议先用HID Descriptor Tool生成,再手动微调。
发送HID报告也很直接:
// 发送键盘按键
void send_key(uint8_t keycode) {
uint8_t report[8] = {0};
report[2] = keycode; // 第三个字节是按键码
tud_hid_report(0, report, sizeof(report));
}
// 发送鼠标移动
void send_mouse(int8_t x, int8_t y) {
uint8_t report[4] = {0};
report[0] = 0; // 按键状态
report[1] = x; // X轴偏移
report[2] = y; // Y轴偏移
report[3] = 0; // 滚轮
tud_hid_report(0, report, sizeof(report));
}
你可能会问:为什么键盘报告是8字节?因为标准键盘报告定义了8个修饰键(Ctrl、Shift等)和6个普通按键。嗯,这是HID规范定的,咱们照着做就行。
3.6 复合设备:CDC+HID同时工作
很多产品需要同时支持CDC和HID。比如一个数据采集器,用CDC传大量数据,用HID做状态指示或简单控制。
实现复合设备,关键在配置描述符里声明多个接口。TinyUSB支持这种场景,你只需要在tusb_config.h里同时开启:
#define CFG_TUD_CDC 1 // 启用CDC
#define CFG_TUD_HID 1 // 启用HID
然后描述符里要包含两个接口的配置。注意端点号不能冲突,每个接口的端点要独立分配。
踩坑提醒:我曾经在复合设备里,CDC和HID共用了同一个中断端点。结果主机枚举时只识别出CDC,HID完全没反应。后来把HID的端点号改成EP4,问题解决。记住:每个接口的端点号必须唯一。
另外,Windows对复合设备的支持比较好,但Linux下可能需要加载不同的驱动模块。如果你在嵌入式Linux上做主机端开发,记得检查内核配置是否开启了CDC_ACM和HID驱动。
3.7 调试技巧:没有逻辑分析仪怎么调USB?
说实话,调USB协议栈最痛苦的就是看不到数据。不像串口,发出去收不到还能用示波器看波形。USB是差分信号,普通示波器根本抓不了。
我的经验是:
- 用USB分析仪:便宜点的几十块钱,能抓枚举过程。我自备了一个,每次调描述符都插上看看。
- 软件抓包:Windows下用USBPcap + Wireshark,能分析主机侧的数据。Linux下用usbmon。
- 打印调试信息:在USB中断服务函数里加GPIO翻转,用逻辑分析仪看时序。虽然土,但有效。
我记得有一次,设备枚举总是卡在获取配置描述符阶段。用USB分析仪一看,发现描述符长度字段填错了,主机只读了前16个字节就停了。改过来后一次通过。所以说,工具到位,问题就解决了一半。
好了,USB协议栈移植这块就聊到这儿。下一章咱们讲OTA升级的固件分区设计和Bootloader实现,那才是真正考验系统设计能力的地方。