基于 STM32F103C8T6 实现 USB 双接口复合键盘鼠标的深度实践
在嵌入式开发领域,让一块成本不到十元的 STM32F103C8T6 “蓝 pill” 板子变身成一个既能打字又能移动光标的 USB 设备——听起来像是极客玩具,但背后却是一整套精密的协议设计与资源调度艺术。这不仅关乎如何“让电脑认出我”,更关键的是: 怎样让它清晰地知道,“你是谁”——是键盘?还是鼠标?或者两者都是?
我们今天要拆解的,正是这样一个典型的复合 HID(Human Interface Device)应用场景:使用 STM32CubeMX 配合 HAL 库,将 STM32F103C8T6 配置为一个拥有两个独立接口(Interface 0 和 Interface 1)的 USB 键盘+鼠标设备。这种设计不是炫技,而是为了规避传统“单报告描述符混合键鼠数据”带来的兼容性陷阱。
想象一下,你在 Windows 上插上一个自定义 USB 设备,系统弹窗提示“发现新硬件:HID 兼容设备”。几秒后,它自动识别出了两个功能模块:“标准键盘”和“标准 PS/2 鼠标”。无需驱动,即插即用。这一切是如何实现的?
核心在于
USB 枚举过程中的描述符结构设计
。当主机请求配置描述符时,如果看到
bNumInterfaces = 2
,就会意识到这是一个多接口设备。而每个接口都声明了
Class = 0x03
(HID 类),并通过
bInterfaceNumber
区分角色——0 是键盘,1 是鼠标。于是操作系统会分别为这两个逻辑通道加载对应的 HID 驱动程序,建立两条独立的数据通路。
这比把所有数据塞进一个报告描述符里聪明得多。后者虽然也能工作,但在某些老旧或定制系统中可能解析失败,甚至导致其中一个功能被忽略。双接口方案则最大限度保证了跨平台兼容性,尤其是在 Linux udev 规则或 Windows 策略控制场景下,可以对键盘与鼠标进行分别管控。
那么问题来了:STM32F103C8T6 这颗芯片,真的撑得起这样的任务吗?
别忘了它的资源账本:64KB Flash,20KB RAM,没有外部存储,也没有 DMA 支持 USB。其内置的 USB 2.0 全速控制器依赖 CPU 主动搬运数据包,通过一段名为 PMA(Packet Memory Area)的 512 字节专用 SRAM 完成收发缓冲。这意味着每一次 IN 请求响应、每一个 OUT 数据读取,都需要 CPU 亲自介入。
但这恰恰也是它的魅力所在——极限压榨有限资源,达成看似不可能的任务。
关键在于三点:
-
时钟必须稳 :USB 模块要求精确的 48MHz 时钟源。通常采用外部 8MHz 晶振,经 PLL ×9 倍频得到 72MHz 系统主频的同时,分频出 48MHz 给 USB 使用。若使用内部 RC 振荡器,频率偏差过大极易导致枚举失败。
-
PMA 分配要精打细算 :PMA 不属于常规内存空间,需手动划分 TX/RX 缓冲区地址。例如,可将前 64 字节分配给键盘输入端点,接下来 32 字节给鼠标。一旦重叠或越界,轻则丢包,重则死机。
-
协议栈不能臃肿 :ST 的 HAL 库默认生成的 USB 驱动已较为轻量,但仍建议关闭调试日志输出(如
USBD_DEBUG_LEVEL设为 0),避免printf占用大量堆栈。最终编译后的固件大小应控制在 40KB 以内,留足余地给应用逻辑。
借助 STM32CubeMX,我们可以快速搭建起基础框架。流程并不复杂:
-
启用
USB外设,模式选择 “Device Only” -
在 Middleware 中添加
USB_DEVICE,类别选HID - 设置 VID/PID、厂商字符串、产品名称等基本信息
-
生成代码后,重点修改
usbd_conf.c和usbd_desc.c
但默认生成的是单接口 HID。要想扩展为双接口,就必须深入底层描述符结构。
真正的技术攻坚点,在于重构配置描述符(Configuration Descriptor)。原始的
USBD_HID_CfgDesc
数组只包含一个接口段,我们需要手动插入第二个接口的完整描述序列:
__ALIGN_BEGIN uint8_t USBD_HID_CfgDesc[USB_HID_CONFIG_DESC_SIZ] __ALIGN_END =
{
// 配置描述符头部
0x09, // bLength: 配置描述符长度
USB_DESC_TYPE_CONFIGURATION, // bDescriptorType: CONFIGURATION
USB_HID_CONFIG_DESC_SIZ, 0x00, // wTotalLength: 包括所有子描述符的总长度
0x02, // bNumInterfaces: 两个接口!
0x01, // bConfigurationValue
0x00, // iConfiguration
0xC0, // bmAttributes: 自供电 + 支持远程唤醒
0x32, // bMaxPower: 100mA
// ------------- 接口 0: 键盘 -------------
0x09, // bLength: 接口描述符长度
USB_DESC_TYPE_INTERFACE, // bDescriptorType: INTERFACE
0x00, // bInterfaceNumber
0x00, // bAlternateSetting
0x01, // bNumEndpoints: 1 个中断 IN 端点
0x03, // bInterfaceClass: HID
0x01, // bInterfaceSubClass: Boot Interface
0x01, // bInterfaceProtocol: Keyboard
0x00, // iInterface
// HID 描述符 (键盘)
0x09, // bLength: HID 描述符长度
HID_DESCRIPTOR_TYPE, // bDescriptorType: HID
0x11, 0x01, // bcdHID: 1.11
0x00, // bCountryCode
0x01, // bNumDescriptors
0x22, // bDescriptorType: Report
LOBYTE(KEYBOARD_REPORT_DESC_SIZE),
HIBYTE(KEYBOARD_REPORT_DESC_SIZE),
// 端点描述符 (EP1 IN - 键盘)
0x07, // bLength
USB_DESC_TYPE_ENDPOINT, // bDescriptorType: ENDPOINT
0x81, // bEndpointAddress: IN EP1
0x03, // bmAttributes: Interrupt
LOBYTE(HID_IN_PACKET), // wMaxPacketSize
HIBYTE(HID_IN_PACKET),
0x0A, // bInterval: 10ms
// ------------- 接口 1: 鼠标 -------------
0x09, // bLength
USB_DESC_TYPE_INTERFACE,
0x01, // bInterfaceNumber: 注意这里是 1!
0x00, // bAlternateSetting
0x01, // bNumEndpoints
0x03, // bInterfaceClass: HID
0x00, // bInterfaceSubClass: 无 Boot 支持
0x00, // bInterfaceProtocol: None
0x00, // iInterface
// HID 描述符 (鼠标)
0x09,
HID_DESCRIPTOR_TYPE,
0x11, 0x01,
0x00,
0x01,
0x22,
LOBYTE(MOUSE_REPORT_DESC_SIZE),
HIBYTE(MOUSE_REPORT_DESC_SIZE),
// 端点描述符 (EP1 IN - 鼠标,共享同一端点)
0x07,
USB_DESC_TYPE_ENDPOINT,
0x81, // 仍是 IN EP1
0x03, // Interrupt
LOBYTE(HID_IN_PACKET),
HIBYTE(HID_IN_PACKET),
0x08 // bInterval: 8ms
};
注意:尽管两个接口共用 EP1 IN,但由于它们属于不同
bInterfaceNumber
,主机仍视为两个独立实体。同时,在报告描述符中启用
Report ID
是实现数据分流的关键。
来看这份融合了键盘与鼠标的报告描述符片段:
__ALIGN_BEGIN static uint8_t CUSTOM_HID_ReportDesc_FS[80] __ALIGN_END =
{
// --- 键盘部分 ---
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)
// ... 键修饰位、按键数组定义
0xC0, // End Collection
// --- 鼠标部分 ---
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
0x85, 0x02, // Report ID = 2
0x09, 0x01, // Usage (Pointer)
// ... X/Y 位移、按钮定义
0xC0 // End Collection
};
这里巧妙利用了 HID 协议的
Report ID
机制。发送数据时,只需在调用
USBD_HID_SendReport()
时指定对应 ID,协议栈便会将其封装进正确的集合中。主机收到后,根据 ID 自动路由到相应的输入处理引擎。
举个例子:
void Send_Mouse_Move(int8_t x, int8_t y, uint8_t btn) {
uint8_t buf[4] = {2, btn, (uint8_t)x, (uint8_t)y}; // ID=2 表示鼠标
USBD_HID_SendReport(&hUsbDeviceFS, buf[0], &buf[1], 3);
}
void Send_Keyboard_Press(uint8_t mods, const uint8_t keys[6]) {
uint8_t buf[9] = {1, mods, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]};
USBD_HID_SendReport(&hUsbDeviceFS, buf[0], &buf[1], 8); // ID=1 表示键盘
}
注意参数顺序:第二个参数是 Report ID,第三个是指向实际数据的指针(跳过 ID 字节本身)。这是很多初学者容易踩坑的地方。
实际开发中常见的几个痛点也值得分享:
-
枚举不稳定?先查晶振和上拉电阻
D+ 引脚必须接 1.5kΩ 上拉至 3.3V,以标识“全速设备”身份。此外,PA11/PA12 是否正确启用为复用推挽输出?是否启用了 USB 中断优先级? -
主机只识别一个设备?检查
bNumInterfaces和接口编号
很多人复制粘贴时忘了改bInterfaceNumber = 1,结果两个接口编号冲突,主机只认第一个。 -
数据发不出去?确认前一次传输已完成
USBD_HID_SendReport是非阻塞调用,连续快速调用可能导致状态机混乱。建议加入状态检测:
c if (hUsbDeviceFS.dev_state == USBD_STATE_CONFIGURED) { USBD_HID_SendReport(...); } -
RAM 不够用了怎么办?
把大缓冲区移到静态区,关闭调试串口,使用-Os优化等级编译。必要时可裁剪报告描述符中不必要的 Usage 项。
这套方案的价值远不止做个“伪外设”玩玩。在真实工程场景中,它可以演化为:
- 自动化测试工具 :模拟用户操作完成 UI 回归测试,尤其适合无触摸屏的工控设备。
- 安全密钥设备 :结合 U2F 协议,作为支持物理按键确认的身份认证令牌。
- 无障碍辅助输入 :为行动不便者提供定制化的单手操作界面,集成摇杆与宏按键。
- 教学实验平台 :帮助学生理解 USB 枚举流程、描述符层级、HID 报告格式等底层机制。
未来还可在此基础上拓展:
- 添加 Consumer Control 功能(音量调节、播放/暂停)
- 集成旋转编码器作为滚轮替代
- 支持 DFU 模式实现免拆升级
- 结合同款芯片上的其他外设(如 CAN、SPI 屏)构建多功能 HMI 控制中心
从一块廉价的 Cortex-M3 芯片出发,通过精准的时序控制、紧凑的内存布局和严谨的协议构造,最终实现一个被主流操作系统无缝接纳的复合输入设备——这个过程本身就是对嵌入式工程师综合能力的一次完整考验。
它告诉我们:即使没有 RTOS、没有丰富外设、没有大容量内存,只要吃透底层原理,依然能在资源受限的舞台上跳出优雅的技术之舞。而这,正是嵌入式开发最迷人的地方。
1万+

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



