ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32自制USB HID键盘:C++封装驱动与自动打字实战

STM32自制USB HID键盘:C++封装驱动与自动打字实战 哟哟哟系列第六篇来了。先把上篇没说完的“活”给补上这次讲的是让STM32自己变成一个USB设备做的是一把能自动打字的HID键盘。前面五篇咱们把点灯、串口、定时器、中断、状态机都过了一遍底子已经够扎实了这一篇正式把视角从“单片机控制外部世界”切换到“单片机作为计算机外设”这条路线上来。全是使用C来封装驱动的经验工程可以整个搬走照着搞就能跑。这个系列写到这里其实我自己是有点故意的。前几篇一直在用HAL库写C语言风格代码主要为了让新手把外设寄存器、中断、时钟这些硬骨头啃下来。但从这一篇开始我会把“嵌入式C”真正落到项目层面也就是类的封装、静态回调、模板复用这些技巧。如果你已经在用C写STM32想换个思路看看C怎么玩转单片机这篇文章正合适。1. 先盘一下这个系列走到哪儿了还差什么活1.1 前五篇的路线回顾第一篇讲了STM32的工程骨架用CubeMX生成一个点灯程序把GPIO推挽输出、开漏输出、上下拉电阻这些概念串了一遍算是入门。第二篇补了时钟树的基础知识搞明白为什么SystemClock能跑到72MHz以及SysTick延时是怎么来的。第三篇做了串口收发从那之后我所有的调试信息都走串口打印了方便得很。第四篇玩了定时器PWM输出顺带做了超声波测距模块的驱动就是HC-SR04那个经典传感器。第五篇把状态机思想引进来用按键长按短按识别作为案例ADC采样顺便也提了一嘴。说实话前五篇下来基本的外设操作套路已经够了。但有一个问题一直没解决STM32和电脑通信之前用的都是板载的USB转串口芯片比如CH340这种说实话这芯片本身就是个USB设备那时候我就想怎么不让STM32自己直接变USB设备呢这就是我从系列一开始就埋下的“活”。1.2 这次补的账让STM32自己当USB设备“还差活滴”这句标题里的方言味儿其实就是我心里一直记挂的这件事让STM32直接作为USB设备插到电脑上。为什么非要做这个因为USB转串口芯片只是把串口数据包成了USB外壳STM32本身并没有真正参与USB协议。而当你把STM32做成一个USB HID设备之后不需要额外芯片不需要装驱动一根USB线插上电脑就能识别出来。具体到使用场景玩法就很多了。最常见的是做一个“一键输入”小键盘比如财务人员把常用账号做成一个键程序里按下就自动输入一长串也可以做快捷指令按键按下后模拟CtrlC、CtrlV这些组合键甚至可以把几个按键组合起来做成一个简易的宏键盘。这篇文章我选了一个最直观的需求来落地板子上一个按键按下去之后自动在电脑上输入一串预设文本。比起串口通信做成USB HID设备的最大优势就是免驱、即插即用。Windows、Linux、macOS都会把它识别为通用键盘这点非常考验USB协议底层参数的合法性如果描述符写得不对电脑根本不会认。咱们这次就把这套最核心的东西给拆开看。2. 环境切到C老外设也得换个玩法2.1 开发环境怎么搭先回答一个经常被问的问题嵌入式C到底用哪个IDE我的建议是用STM32CubeIDE因为它默认就把工程组织成C风格外设库和中间件全都给你安排好了。当然你用Keil也行但需要在工程设置里把编译器从GNU C切到GNU C然后把main.c改成main.cpp这里有一个经典坑——HAL库头文件是C语言写的编译C文件时会因为函数重载和名字修饰问题报错解决办法是给所有C语言头文件包一层extern CCubeIDE新建工程的时候会自动帮你处理Keil就得手动加。如果你更习惯VSCode那也可以用stm32cubeCLT加上CMake工具链配合Cortex-Debug插件做调试。VSCode下配置STM32开发环境其实也不复杂核心就是让CMake工具链识别到arm-none-eabi-gcc交叉编译器然后在launch.json里指定好openocd路径和芯片配置。实测下来编辑器体验确实比CubeIDE舒服但对新手来说CubeIDE能减少很多环境配置上的折腾不急着一开始就追求编辑器极致体验。2.2 嵌入式C不是“带类的C”很多从C转到C的朋友容易陷入一个误区觉得C就是类和继承于是把边界搞得特别复杂一套驱动抽象三层最后编译出来代码体积大一截还不好调试。嵌入式领域里我更推荐把C看成“更好用的C”核心价值是三块封装、对象生命周期、模板复用。拿这次USB键盘的例子来说一个HIDKeyboard类把初始化、发送按键报告、发送释放报告、发送字符串这些操作全部收进成员函数上层逻辑只需要调用keyboard.sendString(Hello World)就行了完全不用管底层的报告格式和端点细节。这就是封装的意义。以前写C的时候一个Object_Create函数加一个Object_Send函数就能达到类似效果但C的构造函数可以保证对象创建时就初始化完毕不会出现“忘了调用初始化函数”这种低级错误。生命周期也是嵌入式C的一大优势。比如设备类对象构造时申请资源、配置外设析构时关闭外设、进入低功耗状态这种“对象出生和消亡”的思维方式比单纯在main函数里按顺序写一堆初始化代码要清晰得多。2.3 C与C混合编译的实操细节STM32的固件库和HAL库本质都是C语言写的。在C工程里要调用它们首先要解决的问题就是名字修饰冲突。我的标准做法是定义一个统一头文件把所有C语言头文件包进去#ifdef __cplusplus extern C { #endif #include stm32f1xx_hal.h #include usb_device.h #include usbd_hid.h #ifdef __cplusplus } #endif这样HAL库的函数在C编译单元里就不会被C编译器改名链接时能正确对上符号。另外一个关键技巧是中断回调的处理方式。STM32的中断服务函数必须用C链接方式声明否则编译器会把名字改成带参数类型信息的符号中断向量表里的符号就匹配不上了。但是中断服务函数要调用C类的成员函数怎么办这个后面在实操部分会详细讲先记住一个原则中断里不要直接new对象、不要抛异常能用静态函数包一层再转发到实例方法这是最稳的方案。3. USB HID没你想的那么玄描述符和端点3.1 USB通信的最小认知要理解USB设备先放下那些吓人的协议栈名词。USB通信可以简单理解成“主机不停地问设备问题设备按规定的格式回答”。电脑作为主机每隔一段时间就会问我“你是谁你能干什么你现在有什么状态”设备回答“我是谁”靠的是设备描述符“我能干什么”靠的就是接口描述符和HID报表描述符“现在有什么状态”靠的是端点上的数据上报。所有USB通信都发生在端点Endpoint上。端点就像一栋楼的信箱主机往几号信箱塞信设备去取设备往几号信箱塞信主机来取。每个USB设备至少有一个默认控制端点编号为0所有枚举和配置交流都走这里。对于键盘这类设备还有一个“中断端点”键盘一有按键动作就往这个端点写数据主机轮询到这个数据就认为是按键事件。3.2 HID到底是个啥HID是Human Interface Device的缩写人机交互设备键盘、鼠标、游戏手柄都归它管。之所以HID设备能做到免驱不是因为设备端不用写驱动而是操作系统内置了一套完整的HID协议栈应用软件通过系统API就能拿到键值根本不需要设备厂商提供额外驱动。这样做的代价是设备的“说明书”必须写清楚这份说明书就是HID报表描述符。它用一套特定的语法告诉操作系统我有哪些按键哪些是修饰键一次最多按几个键等等。描述符写错了轻则某些键按不出来重则电脑直接报“无法识别的USB设备”。所以我建议你第一次做HID直接拿我下面这份成熟描述符去用不要一开始就想着自定义一段精简描述符。3.3 HID键盘报表描述符解读下面就是标准键盘描述符六键无冲支持修饰键static const uint8_t HID_ReportDescriptor[] { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xA1, 0x01, // Collection (Application) 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) 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) 0x81, 0x01, // Input (Constant) 0x95, 0x05, // Report Count (5) 0x75, 0x01, // Report Size (1) 0x05, 0x08, // Usage Page (LEDs) 0x19, 0x01, // Usage Minimum (1) 0x29, 0x05, // Usage Maximum (5) 0x91, 0x02, // Output (Data, Variable, Absolute) 0x95, 0x01, // Report Count (1) 0x75, 0x03, // Report Size (3) 0x91, 0x01, // Output (Constant) 0x95, 0x06, // Report Count (6) 0x75, 0x08, // Report Size (8) 0x15, 0x00, // Logical Minimum (0) 0x25, 0xFF, // Logical Maximum (255) 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0x00, // Usage Minimum (0) 0x29, 0xFF, // Usage Maximum (255) 0x81, 0x00, // Input (Data, Array) 0xC0 // End Collection };这段描述符的信息量比我一开始想象的要多但核心就两部分。前面8个按键的“修饰键区”用位图来表示比如Bit0代表Left ControlBit1代表Left Shift这些修饰键可以同时按下。后面6个字节是普通按键区用的是数组方式最多一次上报6个非修饰键。系统就是靠这段描述符知道“设备报告是8个字节第一个字节是修饰键位后六个字节是普通键”。3.4 在STM32上使能USB外设在CubeMX里打开USB功能选Device模式然后在中间件里勾选USB HID系统会自动生成usbd_hid.c和usbd_desc.c这些文件。配置时钟时一定要保证USB外设得到48MHz的时钟。以STM32F103为例外部8MHz晶振PLL倍频9倍得到72MHz系统时钟USB预分频器选1.5分频即72MHz除以1.5就是48MHz。如果这个时钟不在48MHz附近枚举过程会直接失败电脑提示“设备描述符请求失败”。CubeMX生成的工程里USB的初始化调用顺序是HAL_PCD_MspInit负责硬件时钟和引脚GPIO初始化然后MX_USB_DEVICE_Init把PCD注册到USB设备栈并完成HID类的初始化。我们把C类加进去时只需要在main函数里保持这些初始化顺序不变然后把HIDKeyboard对象放在初始化之后就行。4. 实操用C封装一只可以自动打字的USB键盘4.1 需求和硬件准备硬件部分很简单STM32最小系统板一块一个按键连接在PA0引脚上一个USB座子还需要一根USB线连到电脑。按键按下触发PA0外部中断中断服务函数里调用键盘对象的发送字符串方法往电脑上输入预设文本。这里顺手说一句连USB的时候要确认芯片第一脚的位置。STM32LQFP封装上一般有个小圆点或缺口标记但最好还是对着数据手册的引脚图核对一遍尤其是D和D-两根信号线接反了枚举必然失败而且PC端还不一定能给出明确提示。4.2 HIDKeyboard类设计先来写类的基本结构class HIDKeyboard { public: HIDKeyboard(USBD_HandleTypeDef* usb_handle) : usb_handle_(usb_handle) {} void init() { // USB设备已经是CubeMX初始化好的这里主要是确保类内部状态干净 keys_pressed_ 0; } void press(uint8_t hid_key, uint8_t modifier 0) { uint8_t report[8] {0}; report[0] modifier; report[2] hid_key; send_report(report); } void release() { uint8_t report[8] {0}; send_report(report); } void tap(uint8_t hid_key, uint8_t modifier 0) { press(hid_key, modifier); HAL_Delay(5); release(); HAL_Delay(5); } void send_string(const char* str) { while (*str) { uint8_t code 0; uint8_t mod 0; if (*str a *str z) { code ascii_to_hid_table[*str - a]; } else if (*str A *str Z) { mod HID_KEY_LEFT_SHIFT; code ascii_to_hid_table[*str - A]; } else if (*str 0 *str 9) { code ascii_to_hid_table[26 (*str - 0)]; } else { switch (*str) { case : code HID_KEY_SPACE; break; case .: code HID_KEY_PERIOD; break; case ,: code HID_KEY_COMMA; break; case \n: code HID_KEY_ENTER; break; default: str; continue; } } tap(code, mod); str; } } private: USBD_HandleTypeDef* usb_handle_; uint8_t keys_pressed_; void send_report(uint8_t* report) { USBD_HID_SendReport(usb_handle_, report, 8u); HAL_Delay(2); } };这里需要解释一下USBD_HID_SendReport这个函数。CubeMX生成的USB HID类库里真正向端点发数据的就是这个函数第三个参数传入的是报告长度对我们来说固定填8。实测下来连续发送报告时如果不加延时PC端会偶尔丢掉按键事件所以我在send_report内部加了2毫秒延时发送字符串的节奏上也保证至少5毫秒间隔键盘输入体验就稳定了。4.3 ASCII到HID键码的映射这个映射表是整个程序里最容易踩坑的地方。HID键盘报告的键值和ASCII码根本不是一回事。比如字母a的ASCII是0x61但在USB HID里普通字符按键区的键码是0x04。操作系统拿到0x04再根据有没有按Shift判断是a还是A。我是这样维护映射表的static const uint8_t ascii_to_hid_table[] { HID_KEY_A, HID_KEY_B, HID_KEY_C, HID_KEY_D, HID_KEY_E, HID_KEY_F, HID_KEY_G, HID_KEY_H, HID_KEY_I, HID_KEY_J, HID_KEY_K, HID_KEY_L, HID_KEY_M, HID_KEY_N, HID_KEY_O, HID_KEY_P, HID_KEY_Q, HID_KEY_R, HID_KEY_S, HID_KEY_T, HID_KEY_U, HID_KEY_V, HID_KEY_W, HID_KEY_X, HID_KEY_Y, HID_KEY_Z, HID_KEY_1, HID_KEY_2, HID_KEY_3, HID_KEY_4, HID_KEY_5, HID_KEY_6, HID_KEY_7, HID_KEY_8, HID_KEY_9, HID_KEY_0 };USB HID协议里字母使用大写方式定义但按下时如果不按Shift系统输入的是对应的小写字母所以send_string里对大小写的处理就特别关键。小写字母直接发送键码不加修饰键大写字母加Left Shift修饰键再发送同样键码。数字和字母不一样数字0到9在HID里没有大小写之分直接发送对应键码就行。4.4 按键中断和C类联动按钮按下时我们希望触发外部中断中断服务函数里调用键盘对象的send_string。但前面提到过中断服务函数是C链接的而且C成员函数无法直接作为中断回调。我的做法是用一个静态成员函数做中转class Button { public: Button(GPIO_TypeDef* port, uint16_t pin, HIDKeyboard* keyboard) : port_(port), pin_(pin), keyboard_(keyboard) {} static void irq_handler(Button* self) { self-on_pressed(); } private: void on_pressed() { if (keyboard_) { keyboard_-send_string(Hello from STM32!\n); } } GPIO_TypeDef* port_; uint16_t pin_; HIDKeyboard* keyboard_; };然后在中断服务函数里这样写extern C void EXTI0_IRQHandler(void) { // 清除中断标志 if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) ! RESET) { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); Button::irq_handler(g_button); } }静态成员函数不依赖具体对象所以能像普通函数一样从C代码里调用但静态成员函数里又可以拿到对象指针通过指针调用成员变量和非静态成员函数。这个模式就是嵌入式C里最常用的“静态中转站”我在所有项目里都用它稳定好用。全局对象g_button要在头文件里extern声明在某个.cpp文件里定义Button g_button(hgpioa, GPIO_PIN_0, g_keyboard); HIDKeyboard g_keyboard(hUsbDeviceFS);这里有个细节即对象初始化顺序。C的全局对象构造函数在main函数之前就会执行但我们的HAL库初始化是在main函数里做的对象的成员函数可能在USB还没准备好时就被调用。解决办法是不要在构造函数里做实际外设操作只保存指针真正的初始化放到main里统一调init()。HIDKeyboard的构造函数只是存了usb_handle指针没有任何外设操作这样就避开了初始化顺序陷阱。4.5 实测效果和参数调优整个程序烧录之后把STM32用USB线插到电脑上系统会弹出“正在安装设备驱动程序”的提示几秒钟之后设备管理器里出现“HID-compliant keyboard”说明枚举成功了。这时候点开一个文本编辑器按下板子上的按钮就可以看到“Hello from STM32!”被一个字符一个字符地打出来回车换行也正常生效。字符之间的延时我做了几组测试2毫秒延时偶尔丢字5毫秒延时完全稳定10毫秒延时输入过程肉眼看还是流畅的。在USB里主机对中断端点的轮询周期默认是10毫秒左右但我们发送的是多个报告只要每次发送间隔小于主机轮询间隔就算数据排队也是能送达的。虽然理论上键盘报文不能超过6个键同时按下但我们是逐个字符发送再释放每次报告最多就一个键所以不会撞上这个上限。4.6 关于“一键宏”的进阶玩法基础功能跑通之后马上就能扩展成实用的宏键盘。比如我想让STM32自动输入一段带有大小写、数字和空格的复杂密码只要在send_string里传入对应字符串就行。如果再配上多个按键就是用布尔数组或状态机来管理不同按键对应的不同文本。USB HID还支持系统控制和消费者控制页面的按键比如媒体播放、音量增减这些功能。HID描述符里的Usage Page需要换成Consumer Page而且报表长度要重新设计。这一块可以作为后续扩展核心逻辑和普通HID键盘是一样的理解了上面的描述符结构再写媒体键就不难了。5. 踩坑实录USB枚举不过与C回调那些事5.1 USB枚举失败的排查顺序做USB设备十个里有八个会在枚举阶段折腾一阵。我把踩过的坑整理成了一张查询表遇到枚举失败就按这个顺序查现象可能原因验证方法电脑完全没反应USB线只接了电源没接数据换一根好的USB数据线或接到主板后置USB口提示“设备描述符请求失败”48MHz时钟配置错误D/D-接反检查CubeMX时钟树USB时钟是否是48MHz万用表量D/D-电压是否在3.0V左右枚举到一半设备掉线供电不足USB无上拉电阻使用外部5V供电或确认STM32 F1芯片内置的1.5k上拉电阻已经使能能识别但一直显示“未知设备”HID描述符长度或内容错误用USB协议分析软件看枚举交互逐步对照描述符这里特别提一下STM32F103的D上拉。这个型号芯片内部集成了1.5kΩ上拉电阻CubeMX默认就通过USB库的配置打开了软件控制的连接所以外部不需要额外再拉一个上拉电阻。但很多板子会在D线上预留一个外部上拉电阻的位置如果外部也焊了电阻就会导致电平异常解决办法是二选一要么用内部上拉要么用外部上拉不要同时开。5.2 C中断回调的那些坑第一个坑就是忘记用extern “C”包中断服务函数。如果你在C文件里直接写void EXTI0_IRQHandler(void)C编译器会对函数名做修饰最终的符号变成EXTI0_IRQHandlerv中断向量表根本找不到它编译不报错但中断永远不会触发。我在初学的时候被这个问题整整折磨了两个小时。第二个坑是中断里干了太多事。USB协议栈自带的一堆回调已经占了很重的处理负担按键中断里再直接跑一遍几十个字符的循环发送会导致其他中断响应变慢。正确的做法是中断里只设置一个标志位然后在main循环里轮询这个标志再调用send_string。如果一定要在中断里发字符串至少确保发送间隔足够避免打爆USB发送缓冲区。5.3 排查工具和调试技巧枚举失败时只靠肉眼观察电脑提示信息量太少。我常用两个工具排查逻辑分析仪和数据包分析软件。逻辑分析仪接在D/D-引脚上抓取USB低速或全速的信号能直观看到枚举请求的序列软件层面则用USB树查看器或Wireshark配合USBPcap这类抓包方式不过Windows上底层抓包比较麻烦免费的BusHound能用但要看更详细的协议细节Linux下用usbmon模块抓包会更方便。还有一个不起眼但非常重要的经验调试USB设备时尽量把串口打印也保留着。当USB枚举失败导致电脑无法识别时串口往往是最后一个能从设备里拿到信息的通道。我在USB初始化流程的每一步都加了串口打印看到“USB Clock Ready”“HID Class Registered”这些日志就能快速定位问题在前半段还是后半段。6. 下一步还能玩什么6.1 升级成组合设备这篇文章做的是纯HID键盘但USB设备并不限制只能有一个类的功能。把HID键盘和HID鼠标组合在一起就是一个模拟鼠标键盘的硬件盒子这在自动化脚本和工控场景里很实用。CubeMX里如果选择USB复合设备生成的工程结构会复杂一点使用两个接口描述符每个接口对应一组端点。注意端点数量要够用STM32F103的USB是全速设备只有端点0和两个端点可用组合设备就得精打细算。6.2 从HID扩展到CDC虚拟串口如果你对USB还不过瘾下一站推荐做USB CDC虚拟串口。CDC设备在电脑上会映射成一个COM口不用外接串口芯片STM32直接物理上变成串口设备。和HID最大区别是CDC的数据是流式的没有HID报表这种结构化限制理论上可以传大包数据。CubeMX里配置CDC之后发送函数是CDC_Transmit_FS接收则靠CDC_Receive_FS回调同样可以用C封装成类。6.3 C封装的思路可以复制到整个工程USB键盘只是C封装优势的冰山一角。现在回到整个系列的角度如果让我给出一份“嵌入式C学习路线”我会建议先理解类和对象然后用GPIO点灯引入构造函数和析构函数再用UART串口引入回调函数和静态中转接着用定时器引入模板和编译期优化最后用USB设备把前面所有知识串起来。这套路线比单纯背八股文效率高多了因为我们每一步都有一个能跑通的实物。延伸方向上可以把TFT触摸屏和USB键盘结合起来做一个图形化的宏键盘面板也可以把之前做的超声波测距模块加到USB设备里电脑上实时显示距离数据甚至可以把鱼缸自动控制系统做成USB设备通过上位机软件直接控制喂食器和水泵这样嵌入式单片机就和上位机软件完美打通了。我自己做完这个USB键盘之后最大的感受是以前做单片机项目总觉得外设驱动是“一次性”的写完就完事。但用C封装一套驱动类之后换板子、换芯片、加功能时只要改很小的局部代码其他模块照常复用。这可能就是我坚持用C写嵌入式最大的动力。再分享一个小技巧新拿到一个芯片型号先别急着埋头写代码把外设驱动按“这个对象生下来要做什么、活着要提供什么能力、死后要清理什么资源”三个问题问一遍类的结构自然就出来了。这个方法帮我把很多复杂项目理顺了你们下个项目也能试试。
返回列表