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实现,那才是真正考验系统设计能力的地方。