STM32实现双接口键鼠复合设备

AI助手已提取文章相关产品:

基于 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 亲自介入。

但这恰恰也是它的魅力所在——极限压榨有限资源,达成看似不可能的任务。

关键在于三点:

  1. 时钟必须稳 :USB 模块要求精确的 48MHz 时钟源。通常采用外部 8MHz 晶振,经 PLL ×9 倍频得到 72MHz 系统主频的同时,分频出 48MHz 给 USB 使用。若使用内部 RC 振荡器,频率偏差过大极易导致枚举失败。

  2. PMA 分配要精打细算 :PMA 不属于常规内存空间,需手动划分 TX/RX 缓冲区地址。例如,可将前 64 字节分配给键盘输入端点,接下来 32 字节给鼠标。一旦重叠或越界,轻则丢包,重则死机。

  3. 协议栈不能臃肿 :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、没有丰富外设、没有大容量内存,只要吃透底层原理,依然能在资源受限的舞台上跳出优雅的技术之舞。而这,正是嵌入式开发最迷人的地方。

您可能感兴趣的与本文相关内容

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值