手把手教你用HAL库让STM32变身I2C HID设备
你有没有想过,一块普通的STM32开发板,不接USB线,也能像键盘、鼠标一样被电脑“认出来”?更神奇的是,它还能上报触摸坐标、按钮状态,甚至模拟手写笔行为——这一切都无需额外驱动,插上就能用。
这背后靠的就是I2C HID(HID over I2C)技术。而今天我们要做的,就是用ST官方的HAL库,让你的STM32从一个“默默无闻”的MCU,变成一台即插即用的人机输入设备。
别担心看不懂寄存器,也别怕协议复杂。我们全程基于HAL库开发,目标是:零基础也能跑通第一个I2C HID例程。
为什么选择 I2C + HID 的组合?
在嵌入式系统中,我们常遇到这样的需求:
- 想给主控板(比如树莓派、工控机)接一个自定义的触摸屏;
- 需要扩展一组物理按键或旋钮作为控制面板;
- 做一个人机交互小模块,但不想折腾USB协议栈。
这时候传统的做法可能是:
- 用UART传数据 → 主机得写专用程序解析
- 自定义I2C协议 → 断线重连麻烦,兼容性差
- 上USB HID → 协议复杂,对资源要求高
而I2C HID正好解决了这些痛点:
✅即插即用:操作系统自带通用HID驱动,识别后自动映射为输入设备
✅接口简单:仅需SCL/SDA两根线,适合引脚紧张的项目
✅免驱支持:Windows/Linux/Android均可原生支持(需内核启用i2c-hid)
✅开发友好:结合STM32 HAL库,几乎不用碰底层寄存器
换句话说:你只管把数据准备好,剩下的交给系统去处理。
先搞清楚:I2C到底是怎么工作的?
虽然我们用HAL库“封装”了细节,但如果不理解基本机制,调试起来会非常痛苦。
I2C 是什么?一句话讲清
I2C 是一种主从结构的双线串行总线,一条叫 SCL(时钟),一条叫 SDA(数据)。所有通信都由主机发起,从设备被动响应。
STM32可以做主机,也可以做从机。而在本场景下,我们的角色很明确:STM32 是 I2C 从设备(Slave),等待主机来读取数据。
关键流程:一次典型的读操作长什么样?
假设主机想读取某个寄存器的内容,过程如下:
- 主机发送
START信号 - 发送从机地址 + 写标志(
Addr << 1 | 0) - 从机应答(ACK)
- 主机发送要访问的寄存器地址(如
0x04) - 主机再次发送
RESTART - 发送从机地址 + 读标志(
Addr << 1 | 1) - 从机开始逐字节返回数据
- 最后主机发
NACK并STOP
整个过程看起来繁琐,但好消息是:HAL库已经把这些步骤封装成了函数调用。
更重要的是,当用于 HID 协议时,这套流程被标准化了——主机知道该去读哪些寄存器、如何解析描述符。
STM32怎么做I2C从机?HAL库实战配置
现在进入正题:如何使用HAL库让STM32工作在I2C从模式,并准备好响应主机请求。
💡 提示:以下代码可在STM32CubeMX生成基础上修改,适用于F4/F1/G系列常见型号。
第一步:开启时钟 & 配置GPIO
// 启用I2C1和GPIOB时钟 __HAL_RCC_I2C1_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitTypeDef GPIO_InitStruct = {0}; // PB8 = SCL, PB9 = SDA GPIO_InitStruct.Pin = GPIO_PIN_8 | GPIO_PIN_9; GPIO_InitStruct.Mode = GPIO_MODE_AF_OD; // 开漏输出 GPIO_InitStruct.Pull = GPIO_PULLUP; // 必须上拉! GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF4_I2C1; // 复用功能AF4对应I2C1 HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);📌重点说明:
- I2C要求开漏输出 + 外部上拉电阻(推荐4.7kΩ)
- 若使用内部上拉,仅适合短距离、低速通信
- 引脚复用必须正确设置(查参考手册确认AF编号)
第二步:初始化I2C从机模式
I2C_HandleTypeDef hi2c1; void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.Timing = 0x2010091A; // 对应100kHz标准模式 hi2c1.Init.OwnAddress1 = (0x4A << 1); // 设备地址设为0x4A hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 = 0; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } // 启动从机接收中断监听 HAL_I2C_Slave_Receive_IT(&hi2c1, rx_buffer, 2); }🔧参数详解:
-Timing:决定通信速率。可使用STM32CubeMX工具生成合适值
-OwnAddress1:这是你的“身份证号”。注意左移一位,因为最低位留给R/W标志
-NoStretchMode = DISABLE:允许时钟延展,提升稳定性(尤其在中断处理期间)
💡 小知识:为什么地址要左移?
因为在实际传输中,地址字节的格式是[7:1] 地址位 + [0] R/W 位。所以如果你的物理地址是0x4A,那么发送时要用(0x4A << 1)来保留最后一位给读写方向。
接下来才是重头戏:让STM32“假装”是一个HID设备
HID over I2C 不是随便报个数就行,它有一套严格的规范,叫做HID Usage Tables for I2C Devices,由HID工作组发布。
它的核心思想是:
“我这个I2C设备,其实是个USB HID设备,只是换条路(I2C)说话而已。”
因此,主机需要先读取一段叫HID Descriptor的数据,了解你能上报什么样的信息(比如几个按键、X/Y坐标范围等),然后再周期性地读取Input Report获取实时数据。
标准寄存器映射(必须遵守!)
| 寄存器地址 | 名称 | 功能说明 |
|---|---|---|
| 0x00 | Device Mode | 控制运行/测试模式 |
| 0x01 | HID Descriptor Index | 描述符起始地址索引 |
| 0x02 | Report ID | 报告ID(可选) |
| 0x03 | Report Type | 报告类型(Input=1) |
| 0x04 | Report Data | 实际输入报告内容 |
主机第一次会读0x01得知描述符在哪,然后通过特定命令读取完整描述符;之后就会定期轮询0x04拿最新数据。
写一个最简单的触摸屏HID描述符
下面是一个精简版的HID描述符,表示一个单点触控设备:
__ALIGN_BEGIN static uint8_t HID_ReportDesc[] __ALIGN_END = { 0x05, 0x0D, // Usage Page (Digitizer) 0x09, 0x01, // Usage (Pointer) 0xA1, 0x01, // Collection (Application) // 触摸开关 0x09, 0x42, // Usage (Tip Switch) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1 bit) 0x95, 0x01, // Report Count (1 field) 0x81, 0x02, // Input (Data,Var,Abs) // 填充7位(凑成1字节) 0x75, 0x07, 0x95, 0x01, 0x81, 0x01, // Input (Constant) // X坐标 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x30, // Usage (X) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x0F, // Logical Maximum (4095) 0x75, 0x10, // Report Size (16 bits) 0x95, 0x01, // Report Count (1) 0x81, 0x02, // Input (Data,Var,Abs) // Y坐标 0x09, 0x31, // Usage (Y) 0x15, 0x00, 0x26, 0xFF, 0x0F, 0x75, 0x10, 0x95, 0x01, 0x81, 0x02, 0xC0 // End Collection };📌 这段描述符告诉主机:“我能上报一个触点状态 + 两个16位的X/Y坐标”。
一旦主机解析成功,就会把它识别为一个“触摸指针设备”,并在系统中显示为HID输入设备。
数据怎么传?实现输入报告更新
我们需要维护一个缓冲区,存放当前的输入状态:
uint8_t input_report[5] = {0}; // 存储:[Touch][X_low][X_high][Y_low][Y_high]每当传感器状态变化时,更新这个数组:
void update_touch_report(uint8_t touched, uint16_t x, uint16_t y) { input_report[0] = touched ? 0x01 : 0x00; input_report[1] = x & 0xFF; input_report[2] = (x >> 8) & 0xFF; input_report[3] = y & 0xFF; input_report[4] = (y >> 8) & 0xFF; }然后,在收到主机读请求时,将数据发送出去。
但由于HAL库的I2C从机模式没有直接提供“被动读”回调,我们必须借助中断方式捕获事件。
中断处理:响应主机的读写请求
我们启用从机中断模式,监听两种事件:
- 主机写入(通常是写寄存器地址)
- 主机准备读取(此时我们要把报告数据准备好)
uint8_t rx_buffer[2]; // 接收主机写入的地址 uint8_t tx_buffer[5]; // 要发送的输入报告 // 启动从机接收中断 HAL_I2C_Slave_Receive_IT(&hi2c1, rx_buffer, 1); // 中断回调函数 void HAL_I2C_SlaveRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c == &hi2c1) { // 主机写了一个地址,比如0x04 uint8_t reg_addr = rx_buffer[0]; if (reg_addr == 0x04) { // 准备好要返回的数据 memcpy(tx_buffer, input_report, 5); // 启动从机发送(等待主机发起读操作) HAL_I2C_Slave_Transmit_IT(&hi2c1, tx_buffer, 5); } } }⚠️ 注意:这种方式依赖主机先写地址再读数据的行为模式。某些系统可能采用其他方式(如SMBus命令),需根据实际情况调整。
实战调试:常见坑点与解决方法
❌ 问题1:PC完全看不到设备
🔍 检查清单:
- I2C地址是否正确?逻辑分析仪抓包看是否有ACK?
- 是否接了上拉电阻?电压是否稳定?
- 主机是否启用了i2c-hid驱动?(Linux下检查/sys/module/i2c_hid)
🔧 解法:
- 使用逻辑分析仪查看SCL/SDA波形,确认有起始信号和ACK
- 在Windows设备管理器中查看是否有“HID-compliant device”出现
❌ 问题2:设备能识别,但无法获取描述符
🔍 可能原因:
- 寄存器0x01未正确返回描述符地址
- 描述符格式错误,主机拒绝加载
🔧 建议:
- 使用开源工具 HID Descriptor Tool 验证描述符合法性
- 参考FT5x06、Goodix等成熟芯片的寄存器布局
❌ 问题3:数据更新延迟大、卡顿
🔧 优化建议:
- 改为主动中断通知:STM32通过INT引脚通知主机“有新数据”,避免轮询延迟
- 提高I2C速度至400kHz(注意布线质量)
- 添加DMA或双缓冲机制减少中断处理时间
总结一下:你现在可以做什么?
通过本文的学习,你应该已经掌握了:
✅ 如何配置STM32作为I2C从机
✅ 如何组织HID描述符让主机识别设备
✅ 如何构建输入报告并响应主机读取
✅ 如何使用HAL库+中断实现基本通信流程
下一步你可以尝试:
🔧 接入真实触摸IC或按键阵列,实时上报事件
🔧 实现中断唤醒机制,降低功耗
🔧 扩展多点触控或多Report ID支持
🔧 移植到低功耗L系列MCU做电池设备
结语:这不是终点,而是起点
I2C HID 看似小众,实则潜力巨大。随着Type-C接口普及,越来越多设备通过I2C通道传递辅助信息(如显示器EDID、触控面板通信)。掌握这项技能,意味着你能:
- 构建真正的“即插即用”智能外设
- 为边缘设备添加原生人机接口能力
- 在原型阶段快速验证交互设计
下次当你看到一块触摸屏背后只有四根线(VCC/GND/SCL/SDA)时,你会知道——那里面跑的,很可能就是我们今天写的这套协议。
如果你正在做一个带交互功能的小项目,不妨试试让STM32“冒充”一次HID设备。也许你会发现,原来接入主机世界,可以这么简单。
有问题欢迎留言讨论,我们一起踩坑、一起点亮下一个LED。