背景:STM32 的 USB 开发为什么劝退
USB 协议栈是嵌入式领域学习曲线最陡的技术之一,而 STM32 的 USB 开发又把这层难度放大了:ST HAL 库把协议栈藏在层层回调后面,USB 复位、枚举、描述符交换全是黑盒,改一个字段可能牵动六个地方,出错时又不知道从哪里查------USB 分析仪一台几千块,多数人只能靠 printf 盲调。
TinyUSB 走的是反方向:把描述符从黑盒里拿出来,变成你亲手写的字节数组。本文给出在 STM32 上实现一个 USB HID 键盘设备的完整流程。
环境:STM32F4 / F7 / H7 / L4 / G4(TinyUSB 已内置移植层);CubeMX 配时钟;编译链 arm-none-eabi-gcc。
一、一个宏定下设备类型
c
// tusb_config.h ------ 三行宏,USB 设备类型就定下来了
#define CFG_TUD_HID 1
#define CFG_TUD_CDC 0
#define CFG_TUD_MSC 0
插上 USB 线,STM32 就是一台标准 HID 设备,Windows / Linux / macOS 自带驱动。先建立直觉:USB 本质是一套问答游戏,主机问「你是什么设备」设备答「我是键盘」,主机问「数据格式」设备答「8 字节,1 字节修饰键 + 6 字节按键码」。这套问答的标准答案模板,就是描述符。
对比两种写法的差别:
c
// HAL 的方式:描述符藏在结构体赋值里
pdev->DevDesc.bDeviceClass = 0x00;
// TinyUSB 的方式:描述符就是手写的字节数组,你写什么总线上就是什么
uint8_t const desc_hid_report[] = {
TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(RID_KEYBOARD))
};
从数据到总线的映射是透明的------出了 bug 把数组 dump 出来跟 USB 规范对照即可。
二、HID 描述符:三份身份材料
USB 枚举的本质是设备向主机递交三份材料:
1. 设备描述符(我是谁):
c
tusb_desc_device_t const desc_device = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
.idVendor = 0xCafe, // 你的 VID
.idProduct = 0x4001, // 你的 PID
.bNumConfigurations = 1,
};
VID/PID 建议:自己玩用 0xCAFE / 0xBEEF 这类测试 ID;正式产品向 USB-IF 申请或从芯片厂商获取子授权。别用 STM32 官方 VID(0x0483),否则 Linux 上可能把设备认成 ST-Link。
2. 配置描述符(我能干什么,HID 通常只用一种配置)。
3. HID 报告描述符 (我的数据长什么样,HID 设备的灵魂)。TUD_HID_REPORT_DESC_KEYBOARD 宏展开约 60 字节,定义修饰键 1 字节、保留 1 字节、按键码 6 字节。
三、五步从编译到枚举
Step 1:CubeMX 配时钟。 USB 外设要 48MHz 精准时钟,用 HSI48 + CRS(时钟恢复)最省事,无需外部晶振。勾上 USB 外设,不要勾 ST 的 USB Device 中间件。
Step 2:拉源码。 git clone https://github.com/hathach/tinyusb.git,把 src/ 拷到项目 Middlewares/ 下,移植 tusb_config.h 和芯片移植层(STM32F4 已内置)。
Step 3:写描述符。 建 usb_descriptors.c 定义四种描述符,实现回调:
c
uint8_t const *tud_descriptor_device_cb(void) {
return (uint8_t const *)&desc_device;
}
Step 4:实现 HID 回调。
c
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type,
uint8_t *buffer, uint16_t reqlen) {
return 0; // 主机读报告(枚举完读一次)
}
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type,
uint8_t const *buffer, uint16_t bufsize) {
// 主机发报告(键盘 LED 状态)
}
Step 5:主循环发数据。
c
void hid_task(void) {
if (!tud_hid_ready()) return; // 还没枚举完,跳过
uint8_t report[8] = {0};
report[2] = HID_KEY_A;
tud_hid_report(0, report, sizeof(report)); // 发送 'A'
memset(report, 0, sizeof(report));
tud_hid_report(0, report, sizeof(report)); // 发空报告释放按键
}
连上 USB 线,lsusb 或设备管理器里就能看到 CAFE:4001 的新设备。
四、局限
- 不是所有 STM32 系列都有移植层:官方支持 F4 / F7 / H7 / L4 / G4,较新的 H5 / H7RS / U5(带 USB 3.0 PHY)移植还在开发,选型前到 TinyUSB 的 supported MCU 列表确认。
- 多实例 HID 有限制:可同时做键盘+鼠标,但端点配置在 DCD 层复用资源池,不同实例要不同轮询间隔需改移植层。
- Isochronous 传输不成熟:HID 用的中断传输没问题,但 USB 麦克风/摄像头这类 Isochronous 端点只在部分 MCU(如 ESP32-S2/S3)稳定。
- 调试难度依然在 :代码比 HAL 透明,但协议本身没变简单。无分析仪时把
CFG_TUD_LOG_LEVEL开到 3 看描述符请求和枚举过程,是可行的替代。
五、总结
TinyUSB 把描述符从 HAL 黑盒里拿出来,变成你直接可控的字节数组------出了问题能追、改了能懂。想要更省事可用 Adafruit 的 TinyUSB Arduino 库(枚举全自动,灵活度打折);想理解协议、要灵活控制,就用 TinyUSB。剩下的事:找根 USB 线、烧程序、看 dmesg 里弹出新设备的那一刻。