手把手教你用STM32实现一个USB虚拟鼠标:从协议到代码的完整实践
你有没有想过,一块小小的STM32开发板,也能变成一只即插即用的USB鼠标?不需要驱动、不依赖操作系统,插上电脑就能控制光标移动和点击——这并不是什么黑科技,而是每一个嵌入式工程师都应该掌握的基础能力。
在工业自动化测试、辅助设备开发甚至安全研究领域,这种“伪装成输入设备”的嵌入式方案正变得越来越重要。而它的核心技术,就是我们今天要深入剖析的:如何在STM32平台上通过USB通信实现HID类鼠标功能。
别被“协议”“枚举”这些术语吓退。接下来我会像带徒弟一样,带你一步步揭开USB HID背后的神秘面纱,从硬件配置到报告描述符,再到实际代码实现,全部讲透。
为什么选USB?为什么是HID?
先问个问题:如果你要做一个能控制电脑光标的设备,你会选哪种方式?
蓝牙?串口转虚拟输入?还是直接走USB?
答案很明确:USB + HID 类是最优解。
免驱才是王道
想想看,用户买了一个新鼠标,插上去要装驱动吗?基本不用。因为Windows、Linux、macOS都内置了对标准HID设备的支持。只要你遵循规范,系统就会自动识别为“通用USB输入设备”,立刻可用。
这就是HID的最大优势——跨平台免驱兼容性。相比之下,UART或自定义USB类设备往往需要安装专用驱动,部署成本陡增。
延迟够低,响应才快
鼠标这类输入设备最怕什么?延迟高、操作卡顿。
USB全速模式(Full Speed)提供12 Mbps的带宽,虽然比不上高速USB,但对于每次只传几个字节的鼠标数据来说绰绰有余。配合合理的轮询间隔(通常1~10ms),完全可以做到毫秒级响应。
📌小知识:Windows默认每8ms轮询一次HID设备,也就是说你的鼠标状态最多延迟8ms就能被主机读取到。
不止于“鼠标的形状”
HID的本质是一种数据描述机制,它不限定物理形态。你可以用陀螺仪做空中鼠标,用压力传感器做脚踏开关,甚至用脑电波信号生成点击事件——只要数据格式符合HID报告描述符定义,系统就认你是“鼠标”。
这也正是嵌入式开发者最看重的一点:高度可定制化。
USB通信是怎么跑起来的?别再只会喊“插上去就能用”了
很多人以为USB就是“插上线,配个库,调个函数”,但实际上整个过程远比想象中精密。我们得搞清楚:当你把STM32开发板插入电脑时,背后到底发生了什么。
主机说了算:USB是典型的主从架构
所有USB通信都由主机(PC)发起,设备只能被动响应。这意味着:
- 设备不能主动发数据给PC;
- 每次传输前,必须等主机先发一个“令牌包(Token Packet)”;
- 数据是否送达,也由主机确认。
所以你看,所谓的“发送鼠标数据”,其实是:
主机定时问:“有新动作吗?” → 我们答:“有,X轴动了+5”。
这个交互过程叫做中断传输(Interrupt Transfer),专为低延迟周期性通信设计,正是HID设备的核心传输类型。
枚举:设备的“自我介绍大会”
刚接上电,STM32还什么都不是。只有完成“枚举”流程,PC才会知道它是谁、能干什么。
整个过程就像一场面试:
- 主机问:“你是啥设备?”
- 我们回:“我是USB设备,支持1种配置。”(返回设备描述符)
- 主机再问:“具体有哪些功能?”
- 我们交简历:“我有一个接口,属于HID类,版本1.11。”(返回配置描述符 + HID描述符)
- 主机追问:“你的数据长什么样?”
- 我们亮出结构图:“第一个字节是按键,第二字节X位移,第三字节Y位移……”(上传报告描述符)
一旦这套“对话”顺利完成,操作系统就会加载HID驱动,建立中断通道,设备正式上岗。
✅ 关键提示:任何一个描述符出错,枚举就会失败,设备显示为“未知USB设备”。这是新手最常见的坑!
HID报告描述符:你写的不是代码,是“设备说明书”
如果说USB协议是高速公路,那报告描述符(Report Descriptor)就是告诉交警“这辆车拉的是什么货”的清单。
它用一种紧凑的二进制语言(叫“Item Format”)来定义数据字段的意义。比如下面这段看似天书的东西:
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 (Buttons) 0x19, 0x01, // Usage Minimum (1) 0x29, 0x03, // Usage Maximum (3) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x95, 0x03, // Report Count (3) —— 三个按键 0x75, 0x01, // Report Size (1) —— 每个按键占1位 0x81, 0x02, // Input (Data,Var,Abs) —— 输入数据 0x95, 0x01, // Report Count (1) —— 填充5位 0x75, 0x05, 0x81, 0x01, // Input (Constant) —— 固定值,占位 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 bits) 0x95, 0x02, // Report Count (2) —— X和Y各1字节 0x81, 0x06, // Input (Data,Var,Rel) —— 相对坐标输入 0xC0, // End Collection 0xC0 // End Collection这段代码定义了一个标准三键鼠标,包含:
- 左、中、右三个按钮(bit0~bit2)
- X/Y轴相对位移(signed 8-bit,范围±127)
- 总共占用3字节数据
当你调用USBD_HID_SendReport()发送[0x01, 5, -3]这样的数据包时,PC就知道:“哦,左键按下,向右移动5格,向下移动3格”。
🔧调试建议:可以用 HID Descriptor Tool 在线解析你的描述符,确保格式正确。
STM32实战:让F103C8T6真正“动起来”
现在进入重头戏。我们以最常见的STM32F103C8T6(Blue Pill板)为例,手把手实现一个基础HID鼠标。
硬件准备
- STM32F103C8T6 最小系统板
- Micro USB线(用于供电和通信)
- 可选:一个按键(模拟触发事件)
注意:F1系列没有专用USB引脚,但PA11(D-) 和 PA12(D+) 支持复用为USB通信脚。
第一步:搞定48MHz时钟
USB通信对时钟精度要求极高(±0.25%),F1系列没有外部晶振时可用内部HSI48MHz作为USB时钟源。
使用CubeMX配置如下:
- SYS → Debug: Serial Wire
- RCC → High Speed Clock: Crystal/Ceramic Resonator
- RCC → Clock Security System Enable ✅
- RCC → HSI48 Clock Enabled ✅
- Clock Configuration:
- 设置SYSCLK = 72MHz
- USB时钟分频为1(即直接使用HSI48)
生成代码后,HAL会自动启用__HAL_RCC_HSI48_ENABLE()并配置PLL。
第二步:初始化USB外设
CubeMX中打开USB模块,选择Device FS模式,并添加中间件USB Device → HID。
生成的工程会自动包含:
usbd_core.h/cusbd_hid.h/cusbd_desc.h/c(设备描述符)usbd_conf.h/c
无需手动编写底层寄存器操作。
第三步:定义鼠标数据结构
// mouse_report.h typedef struct { uint8_t buttons; // bit0: left, bit1: right, bit2: middle int8_t x; // X轴相对位移 (-127 ~ +127) int8_t y; // Y轴相对位移 int8_t wheel; // 滚轮(本例暂不用) } Mouse_Report_TypeDef;这个结构体必须与报告描述符严格对应!否则主机无法解析。
第四步:封装发送函数
// usb_mouse.c #include "usbd_hid.h" extern USBD_HandleTypeDef hUsbDeviceFS; void USB_SendMouseReport(uint8_t btn, int8_t x, int8_t y) { Mouse_Report_TypeDef report; report.buttons = btn; report.x = x; report.y = y; report.wheel = 0; USBD_HID_SendReport(&hUsbDeviceFS, (uint8_t*)&report, sizeof(report)); }⚠️ 注意:不要频繁调用此函数!两次发送之间要有足够时间让主机完成轮询,否则可能丢包。
第五步:主循环触发动作
假设我们接了一个按键在PA0,按下时模拟鼠标向右移动并左击:
// main.c int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USB_PCD_Init(); USBD_Init(&hUsbDeviceFS, &FS_PCD_Desc, DEVICE_FS); USBD_RegisterClass(&hUsbDeviceFS, &USBD_HID); USBD_Start(&hUsbDeviceFS); while (1) { if (HAL_GPIO_ReadPin(USER_BUTTON_GPIO_Port, USER_BUTTON_Pin) == GPIO_PIN_SET) { USB_SendMouseReport(0x01, 10, 0); // 按下左键,右移10 HAL_Delay(50); USB_SendMouseReport(0x00, 0, 0); // 释放按键 HAL_Delay(100); } HAL_Delay(10); // 防抖 } }烧录程序,插上电脑——恭喜你,你的STM32已经是一只真正的USB鼠标了!
踩过的坑我都帮你记下来了
别以为写完代码就能成功。我在第一次调试时也遇到了一堆问题,这里总结几个高频“翻车点”:
❌ 枚举失败:Unknown USB Device
常见原因:
- 时钟不准:没启用HSI48或外部晶振频率偏差大。
- DP/DM接反:D+要接1.5kΩ上拉电阻才能识别为全速设备。
- 描述符错误:报告长度与实际发送不符。
✅ 解决方法:
- 用示波器测D+/D-是否有差分信号;
- 使用 USBlyzer 或 Wireshark 抓包分析枚举过程;
- 核对hid_descriptor.bNumDescriptors是否指向正确的报告大小。
❌ 数据发不出去:SendReport总是失败
USBD_HID_SendReport()返回值非USBD_OK?
检查:
- 是否已成功枚举?
- 上一次传输是否已完成?HID不支持连续快速发送。
- 缓冲区是否被占用?避免在中断中调用。
推荐做法:加一个状态标志位,等待上次传输完成后再发下一次。
进阶玩法:不只是“移动+点击”
你以为这就完了?远远不止。有了这个基础框架,你可以轻松扩展更多功能:
添加滚轮支持
修改报告描述符,在最后加上:
0x09, 0x38, // Usage (Wheel) 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x01, 0x81, 0x06, // Input (Relative)然后发送时填入wheel = +1或-1即可滚动一页。
实现空中鼠标
接一个MPU6050陀螺仪,读取角速度积分成位移:
float gyro_x, gyro_y; int8_t dx = (int8_t)(gyro_x * sensitivity); int8_t dy = (int8_t)(gyro_y * sensitivity); USB_SendMouseReport(0, dx, dy);摇一摇就能控制光标,妥妥的DIY体感鼠标。
自动化脚本执行器
类似Digispark的Rubber Ducky,预存一系列鼠标动作序列:
const Mouse_Action_t script[] = { {0x01, 10, 0}, {0x00, 0, 0}, // 左键单击 {0x00, 50, 0}, // 右移50 {0x02, 0, 0}, {0x00, 0, 0}, // 右键单击 };可用于无人值守测试、演示自动化等场景。
写在最后:学会的不仅是技术,更是思维方式
当我们完成这个项目时,收获的绝不仅仅是一个能动的鼠标。
你学会了:
- 如何理解一个复杂协议的分层结构;
- 如何将抽象规范转化为具体代码;
- 如何阅读芯片手册和标准文档;
- 如何排查软硬件协同中的疑难杂症。
这才是嵌入式开发的魅力所在。
下次当你看到某个设备时,不妨多问一句:“它是怎么工作的?”
也许答案,就在你手边这块STM32上。
如果你也在做类似的项目,或者遇到了其他USB HID的问题,欢迎留言交流。我们可以一起探讨更复杂的用法,比如多报告ID切换、复合设备(HID+MSC)、固件升级机制等等。
毕竟,真正的工程师,永远在路上。