用STM32CubeMX快速打造一个USB HID设备:从零到“键鼠自由”的实战之路
你有没有试过想让自己的STM32板子插上电脑,立刻被识别成键盘或鼠标?不是为了炫技,而是真的需要——比如做个自定义的快捷键面板、工业控制按钮组,甚至是一个能打字的温湿度传感器?
别急着外挂CH375、FT232这类USB转串口芯片了。 你的STM32本来就能原生支持USB通信 ,只要正确配置,它完全可以变身成一个即插即用、跨平台免驱的HID设备。
而实现这一切的关键工具,就是ST自家的图形化神器: STM32CubeMX 。
今天我们就来手把手带你走完这条“MCU直连PC”的技术路径——不讲虚的,只说你能马上用上的东西。
为什么选HID?因为它“不用装驱动”啊!
先解决一个灵魂拷问:为啥非得搞HID(Human Interface Device)?就不能老老实实用虚拟串口(CDC)吗?
答案很简单: 用户体验 。
- CDC虽然方便,但Windows经常弹出“正在安装驱动”,Linux要加udev规则,macOS也可能报安全警告;
- 而HID呢?操作系统看到这个类,直接拉起内置驱动:“哦,又一个键盘/鼠标?”——然后静默连接,用户毫无感知 ✅
更妙的是,HID不限于传统输入设备。只要你愿意,它可以传输任何结构化数据——按键状态、ADC采样值、编码器位置、甚至是心跳包。主机端通过标准API读取即可,完全不需要额外驱动开发。
📌 小知识:Logitech、Razer这些大厂的游戏外设,很多底层也是基于HID定制报告描述符实现高级功能的。
所以,当你希望产品“插上去就能用”,尤其是面向终端用户的场景时,HID几乎是最佳选择。
STM32上的USB模块:到底能不能行?
不是所有STM32都支持USB。我们得先确认硬件是否在线。
哪些系列支持USB Device?
常见支持全速USB(12Mbps)的型号包括:
| 系列 | 是否支持 | 备注 |
|---|---|---|
| STM32F103 | ✅ 部分支持 | 如C8T6、CBT6等,需注意无内置PHY,D+需外部上拉 |
| STM32F4xx | ✅ 完整支持 | 如F407VG、F411RE,带OTG_FS模块 |
| STM32L4/L1 | ✅ 支持 | 低功耗系列中部分型号集成USB |
| STM32G0/G4 | ✅ 支持 | 新一代性价比之选 |
重点提一下 F103C8T6 ——也就是大家最熟悉的“蓝 pill”板子。它确实支持USB,但有个坑: 没有内置全速PHY ,必须靠软件模拟D+线的上拉电阻来触发枚举。
这意味着:
- D+引脚必须接到一个GPIO;
- 上电后该GPIO要输出高电平,把D+拉到3.3V;
- 这个GPIO还得支持5V容忍(否则热插拔可能烧毁);
幸运的是,PA12通常满足条件,所以大多数“蓝 pill”开发板已经把D+连到了PA12,并允许你用代码控制上拉。
💡 实践建议:如果你在使用F103系列,请务必检查原理图,确认D+是否有上拉能力。没有的话,轻则枚举失败,重则根本无法被识别。
USB时钟怎么配?48MHz是命根子!
STM32的USB外设对时钟极其敏感—— 必须提供精确的48MHz时钟源 ,误差不能超过±0.25%。
这可不是随便分频就行的。如果时钟不准,握手阶段就会出错,主机直接判定为“通信异常”。
那么问题来了:怎么得到稳定的48MHz?
典型配置方案(以STM32F103为例)
假设你有一个外部8MHz晶振:
8 MHz → PLL倍频 ×9 → 72 MHz SYSCLK
↓
APB1 = 72 MHz / 1.5 = 48 MHz → 提供给USB模块
在STM32CubeMX里设置如下:
- RCC → High Speed Clock: Crystal/Ceramic Resonator
- Clock Configuration:
- 输入8MHz
- PLL Multiplication Factor: 9
- SYSCLK: 72MHz
- APB1 Prescaler: /2 (注意!F1系列默认APB1最大36MHz,但USB需要48MHz,所以实际由内部电路自动处理为48MHz专用路径)
✅ 最终结果:USB CLK = 48MHz,达标!
⚠️ 如果你用的是HSE旁路模式或者内部RC振荡器(HSI),很难保证精度,强烈建议外接晶振。
CubeMX四步走:五分钟搞定HID配置
现在进入正题。打开STM32CubeMX,准备起飞。
第一步:选芯片 + 拉外设
- 创建新工程,搜索并选择你的MCU型号(比如
STM32F103C8Tx) - 在Pinout视图中找到
USB_OTG_FS模块 - 右键点击 → Mode → 设置为 Device Only
这时候你会发现两个引脚自动分配了:
- PA11 → USB_DM
- PA12 → USB_DP
✔️ 正常现象。
🔧 特别提醒:F103没有内置PHY,CubeMX不会自动启用D+上拉。你需要手动配置PA12作为GPIO输出,在初始化后拉高。
第二步:调时钟树
切换到 “Clock Configuration” 标签页。
按照上面说的方法,配置PLL使得系统主频72MHz,同时确保USB时钟显示为 48 MHz (绿色勾)。
如果没看到48MHz,回去检查APB1分频系数和PLL设置。
第三步:加中间件
左侧菜单 → Middleware → USB_DEVICE
双击进入配置页面:
| 参数 | 推荐设置 | 说明 |
|---|---|---|
| Class | HID | 我们要做的是人机设备 |
| VID | 0x0483 | ST官方厂商ID,可用也可自定义 |
| PID | 0x5710 | 建议避开已知设备,避免冲突 |
| Manufacturer | "YourCompany" | 显示在设备管理器里的制造商名 |
| Product | "Custom HID Demo" | 设备名称 |
| Serial Number | 自动生成 or 手动填 | 推荐唯一,防止多设备冲突 |
| Report Length | 8 | 默认是2,改大点好传数据 |
📌 关键点: Report Length 决定了每次可以发送多少字节的数据。键盘通常是8字节(1个修饰键 + 6个按键码),你可以根据需求调整。
但记住:改这里还不够!你还得同步修改报告描述符,否则主机解析会出错。
第四步:生成代码
选择你的IDE(推荐STM32CubeIDE),设置项目名和路径,点击“Generate Code”。
几秒钟后,工程就建好了。
代码层面发生了什么?
生成的项目结构里有几个关键文件:
/Core
├── Src
│ ├── main.c
│ ├── usbd_conf.c ← USB底层配置
│ ├── usbd_hid.c ← HID类实现
│ └── usbd_desc.c ← 描述符定义
└── Inc
├── usbd_conf.h
├── usbd_hid.h
└── usbd_desc.h
我们重点关注三个部分。
1. 报告描述符:告诉主机“我发的是啥”
文件: usbd_hid.c 中的 HID_MOUSE_ReportDesc
这就是那个神秘的 HID Report Descriptor ,一段紧凑的二进制数据,用来定义数据格式。
例如,默认生成的是鼠标格式:
__ALIGN_BEGIN static uint8_t HID_MOUSE_ReportDesc[HID_MOUSE_REPORT_DESC_SIZE] __ALIGN_END =
{
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 */
};
这段代码看着像天书?没错,它就是故意设计得紧凑高效的。
如果你想改成键盘或者其他自定义设备,建议用工具生成。
🛠 推荐工具: HID Descriptor Tool 或在线生成器如 hidrgh.com
比如一个标准键盘的报告描述符长这样(节选):
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
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, Variable, Absolute)
...
改完之后记得更新 #define HID_KEYBOARD_REPORT_DESC_SIZE 的大小,不然会截断!
2. 发送数据:一招制敌 USBD_HID_SendReport
核心API只有一个:
USBD_HID_SendReport(&hUsbDeviceFS, report_buffer, length);
参数说明:
- &hUsbDeviceFS :全局HID设备句柄(由CubeMX生成)
- report_buffer :你要发的数据缓冲区
- length :长度,不能超过你在CubeMX里设置的Report Length
举个例子:模拟按下字母’A’
uint8_t keyboard_report[8] = {0};
// 修饰键(左Ctrl/Shift等) | 保留字节 | 6个按键码
keyboard_report[2] = 0x04; // 'a' or 'A' 键值(HID Usage Key Code)
USBD_HID_SendReport(&hUsbDeviceFS, keyboard_report, 8);
然后别忘了释放按键(发全0):
memset(keyboard_report, 0, 8);
USBD_HID_SendReport(&hUsbDeviceFS, keyboard_report, 8);
⚠️ 注意:这不是立即发送!它是把数据放进中断IN端点的缓冲区,等主机轮询时才真正发出。
数据发不出去?十有八九是“太快了”
新手最容易犯的错误是什么?
连续调用 SendReport() ,结果发现有的数据丢了,或者主机反应迟钝。
原因很朴素: USB是轮询机制,主机每隔一段时间(bInterval)来问一次“你有数据吗?”
如果你在一个周期内多次尝试发送,第二次会失败,因为上次还没完成。
HAL库提供了回调函数来告诉你“现在空闲了”:
static int8_t USER_HID_DataIn(USBD_HandleTypeDef *pdev, uint8_t epnum)
{
// 数据已成功发送,可以准备下一次
app_state.usb_can_send = 1;
return USBD_OK;
}
所以我们应该加个状态锁:
uint8_t usb_sending = 0;
int send_hid_report(uint8_t *data, uint8_t len)
{
if (usb_sending)
return -1; // 正在发送,拒绝请求
if (USBD_HID_SendReport(&hUsbDeviceFS, data, len) == USBD_OK) {
usb_sending = 1;
return 0;
}
return -1;
}
// 回调中释放标志
int8_t USBD_HID_DataIn(USBD_HandleTypeDef *pdev, uint8_t epnum)
{
if (pdev->pClassData && epnum == 0x81) {
usb_sending = 0;
}
return USBD_OK;
}
这样就能避免数据堆积和总线错误。
主机侧怎么接收?Python一行搞定
你以为还需要写C++程序监听HID?Too young.
用Python + pywin32 或 hidapi ,几行代码就能抓到原始数据。
安装依赖:
pip install hidapi
读取示例:
import hid
# 打开设备(根据VID/PID)
device = hid.device()
device.open(0x0483, 0x5710) # 替换为你自己的VID/PID
print("Connected to:", device.get_manufacturer_string())
try:
while True:
data = device.read(64) # 读最多64字节
if data:
print("Received:", data)
finally:
device.close()
是不是比Win32 API友好太多了?
而且这套代码在Linux/macOS也能跑(只要权限到位),真正做到跨平台通信。
实战案例:做一个“温度键盘”
设想这样一个场景:环境温度超过阈值时,自动向PC发送一组快捷键(比如Ctrl+Alt+T弹出报警窗口)。
硬件组成:
- STM32F103C8T6
- DS18B20 温度传感器(单总线)
- PA12 控制D+上拉
软件流程:
while (1)
{
float temp = read_temperature(); // 读取当前温度
if (temp > 30.0f && !alarm_triggered) {
uint8_t report[8] = {2, 0, 0x17, 0x04, 0, 0, 0, 0}; // Ctrl+T
if (send_hid_report(report, 8) == 0) {
alarm_triggered = 1;
}
}
if (temp <= 30.0f && alarm_triggered) {
uint8_t release[8] = {0};
send_hid_report(release, 8);
alarm_triggered = 0;
}
HAL_Delay(100); // 避免频繁扫描
}
效果:温度一超标,电脑自动弹窗,无需额外软件后台运行。
这才是嵌入式智能该有的样子: 无声无息,却精准干预 。
常见翻车现场 & 解决方案
❌ 枚举失败:插入后电脑没反应
排查清单:
1. D+上拉有没有生效?
- F103系列必须在初始化后执行 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_12, GPIO_PIN_SET);
- 否则主机压根不知道设备来了
2. 48MHz时钟稳不稳定?
- 用逻辑分析仪测MCO引脚输出频率
- 或者查RCC寄存器状态
3. 描述符长度和实际不符?
- 修改了Report Descriptor但没改 #define XXX_SIZE
- 导致数据截断,主机拒收
4. 电源不足?
- USB总线供电最大500mA,若外设太多容易掉电
- 加个10μF电容滤波试试
❌ 数据乱码 or 主机崩溃?
多半是报告描述符写错了。
强烈建议:
- 使用专业工具生成(如HID Descriptor Tool)
- 用USB协议分析仪(如Beagle USB 12)对比标准设备行为
- 或者先拿现成的键盘描述符测试通路
❌ macOS不认设备?
苹果对HID比较挑剔,特别是自定义Usage Page。
解决办法:
- 使用标准Usage Page(如Generic Desktop、Consumer)
- 不要用私有Page(0xFF00~0xFFFF),除非你知道自己在干啥
- 添加适当的Product String和Manufacturer
功耗优化:别忘了Suspend/Resume
HID设备支持挂起模式。当USB总线上长时间无活动时,设备会进入低功耗状态。
STM32 HAL库已经集成了相关回调:
void HAL_PCD_SuspendCallback(PCD_HandleTypeDef *hpcd)
{
// 进入低功耗模式
__HAL_RCC_PWR_CLK_ENABLE();
HAL_PWREx_EnableLowPowerRunMode(); // LPRun mode
}
void HAL_PCD_ResumeCallback(PCD_HandleTypeDef *hpcd)
{
// 恢复正常运行
HAL_PWREx_DisableLowPowerRunMode();
}
结合远程唤醒(Remote Wakeup),你可以在检测到事件(如按键按下)时主动唤醒主机。
🌙 适合电池供电的应用,比如无线遥控器、便携仪表。
高阶玩法:不止是“输入”,还能反向控制
你以为HID只能上报数据?错。
HID也支持 OUT端点 ,允许主机向设备发送命令。
典型应用:
- RGB灯效调节(主机下发颜色指令)
- 固件升级(通过HID Bootloader)
- 参数配置(设置采样间隔、报警阈值)
启用方法:
- 在CubeMX中开启 HID Out Endpoint
- 实现回调函数 USBD_HID_ReceivePacket() 和 USER_HID_DataOut()
示例:
void USER_HID_DataOut(USBD_HandleTypeDef *pdev, uint8_t *pbuff, uint32_t length)
{
if (length >= 1) {
switch (pbuff[0]) {
case 0x01: led_on(); break;
case 0x02: led_off(); break;
default: break;
}
}
}
从此你的设备不再被动,而是双向交互的智能节点。
结尾彩蛋:用HID做调试接口,爽过串口!
最后分享一个骚操作: 把HID当成高速调试通道 。
相比UART:
- 速率更高(USB 12Mbps vs UART通常115200bps)
- 不占用珍贵的USART资源
- 无需CP2102/CH340等转换芯片
- 可携带结构化日志(带时间戳、等级、模块名)
做法也很简单:
- 定义一种自定义HID报告格式
- MCU端封装一个 usb_printf() 函数
- PC端用Python实时解析并打印彩色日志
再也不用拔插串口线、切COM口、担心波特率不对了。
整个过程下来你会发现: STM32 + CubeMX + HAL库的组合,已经把复杂的USB协议封装得足够简单 。
你不需要成为USB协议专家,也能做出专业级的人机交互设备。
而这,正是现代嵌入式开发的魅力所在:站在巨人的肩膀上,专注解决真正的问题。

404

被折叠的 条评论
为什么被折叠?



