从零实现CH552 USB HID键盘:深入解析USB描述符与底层驱动

发布时间:2026/7/31 5:39:15

从零实现CH552 USB HID键盘:深入解析USB描述符与底层驱动 1. 项目概述与核心价值上次我们聊了CH552开发环境的搭建和第一个点灯程序算是跟这位“USB小钢炮”打了个招呼。这次咱们得动真格的了直奔主题——玩转CH552的USB功能。为什么说CH552是“小钢炮”因为它内置了全速USB 2.0控制器和收发器这意味着你不需要像在STM32F103上那样还得外挂一个CH340之类的USB转串口芯片才能和电脑通信。它自己就能直接“开口说话”成本、板子面积都省下来了对于需要USB通信的小设备、DIY玩具或者学习USB协议来说简直是神器。很多朋友学单片机卡在USB这一关。协议栈复杂、描述符眼花缭乱、调试全靠猜最后往往选择用现成的CDC虚拟串口库知其然不知其所以然。这次我打算带大家从最底层、最直接的方式入手不依赖任何复杂的库就用沁恒官方提供的简洁明了的USB内核驱动库亲手实现一个最基础的USB HID人机接口设备键盘。这个过程会让你彻底明白一个USB设备是如何被电脑识别又是如何发送一个按键信号给电脑的。搞定了这个你再去看CDC、MSC大容量存储甚至自定义设备都会有一种“哦原来如此”的通透感。2. 硬件设计与核心电路解析2.1 最小系统与USB接口电路CH552的核心最小系统很简单电源、晶振、复位。但对于USB功能有几个引脚需要特别关注。USB接口P3.0, P3.1这是CH552的USB D和D-信号线。电路设计上必须严谨。上拉电阻USB规范要求全速设备12Mbps需要在D线上接一个1.5kΩ的上拉电阻到3.3V。这个电阻至关重要它是电脑检测到有设备插入的物理标志。CH552芯片内部已经集成了这个上拉电阻可以通过软件控制其连接与断开对应UD_PU_EN寄存器位。这为我们实现USB设备的软连接模拟插拔提供了可能。在我们的原理图上通常仍然会在D线外部预留一个1.5kΩ电阻的位置作为设计冗余或调试备用。阻抗匹配USB数据线是差分信号线为了减少信号反射建议在D和D-线上各串联一个22Ω的电阻。很多开发板为了简化会省略这两个电阻在低速或短距离通信时可能工作正常但为了信号完整性最好加上。ESD保护USB接口暴露在外容易受静电冲击。建议在D、D-对地各接一个ESD保护二极管如USBLC6-2P6。这是产品化设计中必不可少的环节。电源管理CH552的USB模块需要稳定的3.3V供电。虽然芯片有5V容忍的IO但USB收发器的工作电压是3.3V。确保你的LDO如AMS1117-3.3能提供至少100mA的电流。USB总线本身可以提供500mA电流但设备初始枚举阶段是从总线取电的因此板载的3.3V稳压电路必须稳定可靠。注意如果你用的是市面上常见的CH552G开发板这些电路通常已经设计好了。但自己画板时务必核对原理图特别是1.5kΩ上拉电阻和22Ω串联电阻的位置接反或接错会导致无法识别。2.2 调试接口与按键电路为了实现“按键触发USB键盘输入”的功能我们需要至少一个外部按键。按键电路最简单的接法就是按键一端接GPIO如P1.4另一端接地。GPIO内部配置为上拉输入模式。当按键未按下时GPIO读到高电平按下时GPIO被拉低到地读到低电平。记得在GPIO引脚和按键之间串联一个1kΩ左右的电阻可以一定程度上防止IO配置错误时的大电流冲击。调试指示灯除了之前的LED建议再增加一个LED连接到另一个GPIO如P3.4专门用于指示USB枚举状态例如枚举成功时点亮。这对于调试至关重要。3. 软件工程构建与USB库解析3.1 工程框架与官方库引入我们继续使用Keil C51作为开发环境。沁恒官方提供了CH55x_Firmware_Library里面包含了USB、GPIO、定时器等底层驱动。我们不需要理解库的每一行代码但要知道关键文件和作用。核心文件CH552.H芯片寄存器定义头文件。所有对USB、GPIO、定时器等模块的控制最终都体现为对这个头文件里定义的寄存器进行读写。DEBUG.H和DEBUG.C串口调试打印函数。非常重要USB调试不能只靠灯必须要有串口打印信息。我们暂时用不到但后续复杂设备必备。USB.H和USB.CUSB协议栈的核心。里面定义了USB描述符的结构、端点缓冲区管理、以及最关键的USB_DeviceInterrupt()中断服务函数。创建用户文件main.c主程序文件。usb_desc.c重中之重。这个文件我们将自己编写用于定义我们这个“USB键盘”的所有描述符。描述符就是设备的“身份证”和“说明书”告诉电脑“我是什么设备”、“我有什么能力”。usb_hid.c存放HID设备相关的报告描述符定义键盘按键数据的格式和发送按键报告的函数。将必要的库文件.C和.H添加到你的Keil工程中并设置好头文件包含路径。3.2 USB核心流程与中断机制理解CH552的USB模块工作基于中断驱动。理解下面这个流程是成功的关键物理连接设备插入电脑D线上的1.5kΩ上拉电阻使总线电平变化电脑主机检测到有设备接入。复位与枚举主机向USB总线发送复位信号然后开始“枚举”过程。枚举就是主机通过一系列标准的USB请求Request向设备索要各种描述符。中断响应所有主机发来的请求、数据发送完成等事件都会触发CH552的USB中断。程序会跳转到USB_DeviceInterrupt()函数在USB.C中。请求处理在中断函数里库函数会解析主机发来的数据包判断是何种请求如GET_DESCRIPTORSET_ADDRESSSET_CONFIGURATION等。描述符提供当主机请求描述符时库函数会调用一个名为SetupGetDescriptor()的回调函数。我们需要自己实现这个函数在这个函数里我们根据主机请求的描述符类型和索引返回对应的描述符数据。这是我们与主机通信的桥梁。配置与就绪主机获取完所有描述符并为设备分配一个唯一的地址SET_ADDRESS后会发送SET_CONFIGURATION请求激活设备。此后设备进入配置状态可以开始传输应用数据比如我们的按键报告。整个过程是自动的、标准的。我们的主要工作就是正确地提供描述符以及在合适的时机通过端点发送我们的数据。4. USB HID键盘描述符详解与实现4.1 设备描述符与配置描述符首先在usb_desc.c中我们要定义几个关键的描述符数组。这些是固定的数据结构需要严格按照USB规范填写。设备描述符 (Device Descriptor)告诉电脑最基础的信息。// USB设备描述符 code uint8_t DevDesc[] { 0x12, // bLength: 描述符长度18字节 0x01, // bDescriptorType: 设备描述符类型1 0x00, 0x02, // bcdUSB: USB规范版本号2.00 0x00, // bDeviceClass: 设备类0表示由接口描述符定义 0x00, // bDeviceSubClass: 设备子类 0x00, // bDeviceProtocol: 设备协议 0x40, // bMaxPacketSize0: 端点0最大包大小64字节 0x34, 0x12, // idVendor: 厂商ID示例0x1234可自定义但产品化需申请 0x78, 0x56, // idProduct: 产品ID示例0x5678 0x00, 0x01, // bcdDevice: 设备版本号1.00 0x01, // iManufacturer: 厂商字符串索引1 0x02, // iProduct: 产品字符串索引2 0x00, // iSerialNumber: 序列号字符串索引0表示无 0x01 // bNumConfigurations: 配置数量1 };配置描述符集合 (Configuration Descriptor Set)包含配置描述符、接口描述符、HID描述符和端点描述符。它们通常连接在一起返回。// USB配置描述符集合 code uint8_t CfgDesc[] { // 配置描述符 (9字节) 0x09, // bLength 0x02, // bDescriptorType: 配置描述符类型2 0x22, 0x00, // wTotalLength: 配置描述符集合总长度34字节 0x01, // bNumInterfaces: 接口数量1 0x01, // bConfigurationValue: 配置值1 0x00, // iConfiguration: 配置字符串索引0表示无 0x80, // bmAttributes: 属性总线供电 0x32, // bMaxPower: 最大功耗100mA // 接口描述符 (9字节) 0x09, // bLength 0x04, // bDescriptorType: 接口描述符类型4 0x00, // bInterfaceNumber: 接口编号0 0x00, // bAlternateSetting: 备用设置0 0x01, // bNumEndpoints: 端点数量除端点0外还有1个 0x03, // bInterfaceClass: 接口类3 HID 0x01, // bInterfaceSubClass: 接口子类1 Boot Interface 0x01, // bInterfaceProtocol: 接口协议1 Keyboard 0x00, // iInterface: 接口字符串索引0 // HID描述符 (9字节) 0x09, // bLength 0x21, // bDescriptorType: HID描述符类型33 0x11, 0x01, // bcdHID: HID规范版本1.11 0x00, // bCountryCode: 国家代码0 不支持 0x01, // bNumDescriptors: 下级描述符数量1 0x22, // bDescriptorType: 下级描述符类型34 报告描述符 sizeof(ReportDesc), 0x00, // wDescriptorLength: 报告描述符长度 // 端点描述符中断输入端点(7字节) 0x07, // bLength 0x05, // bDescriptorType: 端点描述符类型5 0x81, // bEndpointAddress: 端点地址IN端点1 0x03, // bmAttributes: 传输类型3 Interrupt 0x40, 0x00, // wMaxPacketSize: 最大包大小64字节 0x0A // bInterval: 轮询间隔10ms };字符串描述符可选的用于提供人类可读的厂商名、产品名等。需要以Unicode格式UTF-16LE提供。这里先不展开可以留空或返回空描述符。4.2 HID报告描述符——键盘的“数据协议”这是HID设备的核心它定义了设备发送和接收的数据格式。对于键盘它定义了哪个字节代表按键哪个字节代表修饰键Ctrl, Shift等。报告描述符有一套自己的“语言”比较复杂。幸运的是我们可以借鉴标准的键盘报告描述符。// HID报告描述符 - 键盘 code uint8_t ReportDesc[] { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xA1, 0x01, // Collection (Application) 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0xE0, // Usage Minimum (0xE0) - 左Ctrl 0x29, 0xE7, // Usage Maximum (0xE7) - 右GUI 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) - 每个用法用1bit表示 0x95, 0x08, // Report Count (8) - 共8个用法8个修饰键 0x81, 0x02, // Input (Data,Var,Abs) - 修饰键字节 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) - 保留字节8bit 0x81, 0x01, // Input (Cnst,Arr,Abs) - 常量保留 0x95, 0x05, // Report Count (5) - LED状态输出5个bit 0x75, 0x01, // Report Size (1) 0x05, 0x08, // Usage Page (LEDs) 0x19, 0x01, // Usage Minimum (Num Lock) 0x29, 0x05, // Usage Maximum (Kana) 0x91, 0x02, // Output (Data,Var,Abs) - LED状态 0x95, 0x01, // Report Count (1) 0x75, 0x03, // Report Size (3) - LED输出补足到1字节 0x91, 0x01, // Output (Cnst,Arr,Abs) - 常量补位 0x95, 0x06, // Report Count (6) - 最多6个按键 0x75, 0x08, // Report Size (8) - 每个按键1字节 0x15, 0x00, // Logical Minimum (0) 0x25, 0x65, // Logical Maximum (101) - 最大按键键值 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0x00, // Usage Minimum (0) 0x29, 0x65, // Usage Maximum (101) 0x81, 0x00, // Input (Data,Arr,Abs) - 按键数组 0xC0 // End Collection };这个描述符定义了一个8字节的报告字节08个bit分别代表8个修饰键Ctrl, Shift, Alt, GUI。字节1保留。字节2LED状态Num Lock, Caps Lock等由主机发送给设备。字节3-7最多6个普通按键的键值例如0x04代表a或A取决于Shift状态。4.3 回调函数实现与描述符分发现在我们需要在usb_desc.c中实现那个关键的SetupGetDescriptor()函数。// 获取描述符的回调函数 uint8_t SetupGetDescriptor(uint8_t *pBuf) { uint8_t len 0; uint8_t descriptorType (pBuf[1] 0xFF); // 获取请求的描述符类型 uint8_t descriptorIndex (pBuf[3] 0xFF); // 获取描述符索引 switch (descriptorType) { case USB_DESCR_TYP_DEVICE: // 设备描述符 memcpy(pBuf, DevDesc, sizeof(DevDesc)); len sizeof(DevDesc); break; case USB_DESCR_TYP_CONFIG: // 配置描述符 memcpy(pBuf, CfgDesc, sizeof(CfgDesc)); len sizeof(CfgDesc); break; case USB_DESCR_TYP_STRING: // 字符串描述符 // 这里可以处理字符串请求我们先返回一个空描述符 pBuf[0] 0x04; // 长度4字节 pBuf[1] USB_DESCR_TYP_STRING; pBuf[2] 0x09; // 语言ID0x0409 英语-美国的低字节 pBuf[3] 0x04; // 语言ID的高字节 len 4; break; case USB_DESCR_TYP_HID_REPORT: // HID报告描述符 if (descriptorIndex 0) { // 通常索引为0 memcpy(pBuf, ReportDesc, sizeof(ReportDesc)); len sizeof(ReportDesc); } break; default: len 0; // 不支持的描述符类型 break; } return len; // 返回实际拷贝的描述符长度 }这个函数根据主机请求的类型和索引将对应的描述符数据复制到pBuf指向的缓冲区并返回数据长度。USB库会将这些数据通过端点0发回给主机。5. 主程序逻辑与按键报告发送5.1 初始化流程在main.c中我们的初始化工作变得清晰#include CH552.H #include USB.H void main() { // 1. 系统时钟初始化内部24MHz mDelaymS(5); // 等待电源稳定 // 2. USB功能引脚初始化P3.0, P3.1 USBDeviceCfg(); // 配置USB端口功能 // 3. USB设备模式初始化 USBDeviceInit(); // 使能USB模块初始化端点0 // 4. 使能USB中断和全局中断 IE_USB 1; // 使能USB中断 EA 1; // 使能全局中断 // 5. 初始化用户按键GPIOP1.4为上拉输入 P1_MOD_OC ~(14); P1_DIR_PU | (14); // 6. 初始化状态LEDP3.4为推挽输出 P3_MOD_OC ~(14); P3_DIR_PU | (14); P3_OUT | (14); // 初始熄灭 while(1) { // 主循环检测按键并发送报告 if ((P1 (14)) 0) { // 按键按下低电平 mDelaymS(20); // 简单消抖 if ((P1 (14)) 0) { // 确认按下 send_keyboard_report(0x04); // 发送按键‘a’的键值 while((P1 (14)) 0); // 等待按键释放 send_keyboard_report(0x00); // 发送释放报告所有键值为0 } } // 可以在这里添加其他任务USB中断会随时打断这里 } }5.2 发送按键报告函数这是应用层的核心我们需要在usb_hid.c中实现发送报告的函数。// 发送键盘报告 void send_keyboard_report(uint8_t keycode) { uint8_t report_buffer[8] {0}; // 8字节报告缓冲区 // 构建报告 // report_buffer[0] 修饰键例如左Ctrl: 0x01 左Shift: 0x02 // report_buffer[2] LED状态由主机设置我们只读 report_buffer[2] 0; // 通常为0除非主机设置了LED report_buffer[3] keycode; // 将按键码放在第一个按键位置 // 等待端点1我们的中断IN端点就绪 while (UEP1_CTRL bUEP_T_TOG); // 等待上一个传输完成检查发送状态位 // 将报告数据复制到USB端点1的发送缓冲区 USB_EP1_T_LEN sizeof(report_buffer); memcpy(EP1_Buffer, report_buffer, USB_EP1_T_LEN); // 配置端点1为有效数据并触发发送 UEP1_CTRL UEP1_CTRL ~MASK_UEP_T_RES | UEP_T_RES_ACK; }这个函数构建了一个8字节的报告。当按下按键时我们将对应的键值如0x04代表a填入报告的第4个字节report_buffer[3]然后通过端点1发送给主机。当按键释放时我们发送一个全零的报告通知主机按键已释放。关键点while (UEP1_CTRL bUEP_T_TOG);这一行是阻塞等待。它等待上一次的端点发送完成。在实际产品中这可能不是最佳方式因为它会阻塞主循环。更优雅的做法是设置一个“发送请求”标志在USB中断的发送完成回调里处理。但为了首次理解的清晰我们先使用这种简单方式。6. 编译、下载与实战调试6.1 工程配置与编译目标选项在Keil的Options for Target中确认Device选择了CH552GMemory Model选择Small: variables in DATA。输出文件在Output选项卡勾选Create HEX File。编译点击Build。确保0错误0警告。6.2 下载与连接使用WCH-Link或CH552本身的USB接口通过ISP工具下载程序。下载完成后需要给板子完全断电再上电或者按复位键。这是因为CH552的USB模块在下载模式和工作模式下的初始化状态不同冷启动是最可靠的。将板子的USB口插入电脑。此时你应该能听到Windows“叮咚”的硬件识别音效。打开“设备管理器”在“键盘”或“人体学输入设备”类别下应该能看到一个新设备名称可能是“USB输入设备”或者你定义的字符串描述符里的名字。6.3 调试与问题排查实录这是最可能出问题的环节。如果设备没有出现或者出现的是未知设备、带感叹号的设备请按以下步骤排查检查物理连接USB线是否完好D和D-是否接反1.5kΩ上拉电阻是否有效连接到D和3.3V用万用表测量。检查电源测量板子3.3V电压是否稳定。可以在USB枚举时测量看是否有跌落。检查描述符99%的问题出在描述符。这是最需要耐心的地方。使用USBlyzer或Bus Hound这些是USB协议分析软件。抓取枚举过程的通信数据。看主机发送了哪些GET_DESCRIPTOR请求你的设备返回了什么。对比返回的数据和你代码里定义的数组一个字节都不能错。特别是描述符的长度字段bLength和总长度字段wTotalLength非常容易算错。常见错误设备描述符的bMaxPacketSize0不是64。配置描述符集合的wTotalLength长度计算错误导致主机只收到了部分描述符。HID描述符中报告描述符的长度wDescriptorLength填错。端点描述符的bEndpointAddress方向不对输入IN应是0x8X输出OUT是0x0X。检查中断和初始化确认USBDeviceInit()和USB_DeviceInterrupt()被正确调用。可以在中断入口处翻转一个LED灯看是否有反应。简化测试如果键盘功能复杂可以先实现一个最简单的USB设备比如自定义设备只回复设备描述符和配置描述符先确保枚举成功。再逐步添加HID描述符和报告描述符。成功标志当你在电脑的记事本里按下板子上的按键屏幕上出现了一个‘a’字符时恭喜你你已经打通了CH552 USB通信的任督二脉。这个过程虽然繁琐但每一步都清晰可控没有黑盒。你亲手定义了一个USB设备并让它与电脑成功交互。7. 功能扩展与深入探索方向实现了基础键盘后你可以尝试以下扩展每一步都是对USB和HID协议的更深理解多按键与组合键修改报告缓冲区支持同时按下多个键填充report_buffer[3]到report_buffer[8]以及修饰键设置report_buffer[0]的对应位。实现按下CtrlC这样的组合键。鼠标功能定义一个新的HID接口报告描述符改为鼠标Usage: Mouse。报告内容包含X/Y位移和按键状态。实现一个简单的USB鼠标。复合设备在一个配置下定义多个接口Interface。例如一个接口是键盘另一个接口是鼠标。这样一个USB设备就能同时被识别为键盘和鼠标。CDC虚拟串口这是更常用的功能。你需要提供CDC类Communication Device Class的描述符并实现ACMAbstract Control Model子类。数据通过批量传输Bulk Transfer端点进行比中断传输复杂但速率更高。沁恒其实提供了CDC的库例程在你理解了底层描述符机制后再去研究那个库会容易得多。WinUSB或自定义驱动如果你需要传输任意数据可以使用WinUSBWindows或libusb跨平台驱动。这需要将设备类bDeviceClass/bInterfaceClass设置为0xFF厂商自定义并安装对应的.inf文件。这给了你最大的灵活性。这次“USB学习之旅”的第二站我们摒弃了拿来就用的库从描述符这个根源出发实现了一个最基础的USB HID键盘。这个过程就像在拼一张复杂但逻辑严密的地图。当你亲手把每一个描述符字节、每一个端点配置、每一次中断响应都搭建起来并最终看到按键字符出现在屏幕上时那种对USB通信机制豁然开朗的感觉是单纯调用库函数无法比拟的。CH552以其简洁的硬件和清晰的库为我们提供了这样一张绝佳的“地图”。希望这次深入的探索能成为你驾驭更多复杂USB设备的坚实起点。

相关新闻