USB复合设备实战:CDC虚拟串口与HID键盘合一设计

发布时间:2026/8/30 16:41:30
USB复合设备实战:CDC虚拟串口与HID键盘合一设计 上周有个同事找我说他手头一个工控项目需要做个USB小工具插到电脑上之后既能作为虚拟串口被串口助手打开用来下发指令和回传数据同时又得模拟键盘在特定条件下自动向主机输入一串命令。一开始他打算做两个独立设备一个USB转串口、一个USB键盘但用户只给一个USB口这就尴尬了。我说解决办法就一个——做USB复合设备USB Composite Device把CDC和HID Keyboard塞进同一个USB设备里。插上电脑后系统会同时识别出一个虚拟串口和一个HID键盘互相独立、互不干扰一套固件搞定所有事情。这个项目看起来只是“CDC类加HID类揉在一起”但实际做起来坑基本都集中在描述符设计和类请求处理上。踩过一轮之后我觉得值得把这套设计完整记录下来从方案选型、描述符结构、端点分配到调试排障给想做USB复合设备或者正在被枚举问题折磨的朋友一个能直接“抄作业”的参考。适合的读者包括正在学USB协议的嵌入式开发者、想把自定义HID键盘和串口通信合二为一的工程师、以及凡是遇到“Windows无法识别USB设备”就想砸电脑的同仁。1. 项目整体方案与架构设计1.1 为什么要做CDC和HID键盘的复合设备先说说为什么要把CDC和HID键盘放在同一个设备里而不是做成两个USB设备插两个口。最直接的原因是物理接口受限很多笔记本就两个USB口接个鼠标一个口就没了再插一个串口工具就非常别扭。更重要的是有些设备在逻辑上就是“一体”的比如一个硬件配置工具需要PC通过串口下发动态数据然后设备内部根据数据通过键盘通道模拟输出如果拆成两个USB设备用户还得管理和区分设备身份体验很差。USB协议本身是支持“一个设备多个接口Interface”的接口才是主机识别功能的真正单位。一个USB设备可以根据需要在同一个配置Configuration下暴露多个接口每个接口可以属于不同的类比如接口0和接口1组成CDC类接口2组成HID类。主机侧看到的仍然只有一个物理设备但操作系统会为每个接口枚举出对应的功能驱动于是设备管理器里就会出现一个串口和一个键盘。这就是USB复合设备的基本原理也是“一个口干两件事”的底层支持。另外一个容易被忽略的好处是驱动兼容性。CDC虚拟串口在Windows下使用系统自带的usbser.sys驱动HID键盘用的是系统自带的hidusb.sys驱动都不需要额外安装第三方驱动。这意味着设备换到任何一台Windows、Linux或macOS机器上插上就能用对量产和现场维护特别友好。相比那些需要特定VCP驱动的USB转串口芯片方案CDC类在系统兼容性上省了一大堆麻烦。1.2 复合设备的描述符树设计USB设备向主机描述自己的能力靠的就是一套层级化的描述符结构。CDCHID键盘复合设备的核心在于设备描述符下面只有一个配置描述符这个配置描述符里要同时包含CDC功能占用两个接口和HID功能占用一个接口。为了把CDC的两个接口合并成一个逻辑功能必须引入接口关联描述符IAD, Interface Association Descriptor。如果没有IAD操作系统可能会把CDC的两个接口当成两个独立的“USB功能”在某些系统上会导致串口枚举异常。描述符的层级关系可以理解为设备描述符告诉主机“我是一个设备”配置描述符告诉主机“我这个设备由哪些接口组成”接口描述符告诉主机“这个接口属于什么类、用哪种协议”端点描述符告诉主机“数据具体走哪个通道”。在CDCHID键盘这个组合里各接口的规划如下表接口类型端点用途接口0CDC通信接口Abstract Control ModelEP1 IN中断8字节传输串口状态通知如DCD、DSR等接口1CDC数据接口EP2 OUT / EP2 IN批量64字节实际的虚拟串口数据收发接口2HID键盘EP3 IN中断8字节上报键盘按键状态接口0和接口1通过IAD绑定成一个功能设备接口2单独成为一个HID功能。这里有个细节IAD必须紧跟在配置描述符后面也就是接口0描述符之前并且bFirstInterface填0、bInterfaceCount填2告诉主机前两个接口属于同一个复合功能。HID接口的bInterfaceClass是0x03bInterfaceSubClass是0x01bInterfaceProtocol是0x01这套参数要严格按HID Usage Table来填填错了轻则枚举失败重则直接被识别成“未知设备”。1.3 方案选型协议栈还是手写描述符做USB复合设备摆在面前的第一道选择题是用现成协议栈还是自己手写描述符。我的建议是如果目标是快速出产品直接用TinyUSB或者STM32CubeMX生成的USB设备库如果目标是彻底搞懂USB协议那至少要手写一遍描述符哪怕写完再扔掉这个过程对理解USB的枚举机制非常有帮助。TinyUSB是目前开源方案里对复合设备支持最好的它的描述符结构是C语言结构体不用自己去拼字节流而且自带CDC和HID的类驱动改起来非常快。STM32CubeMX在新版本HAL库里也支持同时勾选CDC和HID类生成工程后只需在回调函数里填业务逻辑。如果选用STM32标准外设库老工程官方USB库虽然也有CDC和HID例程但要把两者合并就得手动改描述符数组工作量大一些而且代码风格偏老调试起来没那么直观。我自己的习惯是先用TinyUSB把协议跑通确认枚举正常、收发正常之后再回过头来对照描述符手册看每一段的字节含义。这样做的好处是协议栈帮你处理了大部分类请求你能把注意力集中在真正需要理解的描述符结构和数据流上而不是一开始就陷进USB控制器寄存器的汪洋大海。2. 核心细节解析与实操要点2.1 描述符配置最容易翻车的部分描述符配置是整个复合设备项目里最容易翻车、也最难排查的部分。一个字节的长度错误、一个端点地址重复、一个接口顺序颠倒都会导致Windows直接报“无法识别的USB设备”或者Linux下枚举到一半设备断开。配置描述符的总长度必须和实际发送的字节数完全一致主机是按wTotalLength字段去循环读取描述符的一旦读到的数据和长度对不上整个枚举就失败了。下面给出一份CDCHID键盘复合设备的配置描述符核心字节流我按常见的手写方式组织并标注关键字段含义// 配置描述符集合字节流方式总长度0x64 100字节 static const uint8_t config_desc[] { // 配置描述符 0x09, 0x02, 0x64, 0x00, 0x03, 0x01, 0x00, 0x80, 0x32, // 0x09: bLength 0x02: bDescriptorTypeConfiguration // 0x64,0x00: wTotalLength100, 后面所有描述符的总长 // 0x03: bNumInterfaces3 (CDC通信口, CDC数据口, HID口) // 0x01: bConfigurationValue1 0x00: iConfiguration // 0x80: bmAttributesBus Powered 0x32: 100mA // 接口关联描述符 IAD把接口0和接口1绑定为CDC功能 0x08, 0x0B, 0x00, 0x02, 0x02, 0x02, 0x01, 0x00, // 0x08: bLength 0x0B: bDescriptorTypeInterfaceAssociation // 0x00: bFirstInterface0 0x02: bInterfaceCount2 // 0x02: bFunctionClassCDC 0x02: bFunctionSubClassACM // 0x01: bFunctionProtocolAT 0x00: iFunction // 接口0CDC通信接口 0x09, 0x04, 0x00, 0x00, 0x01, 0x02, 0x02, 0x01, 0x00, // 0x09: bLength 0x04: bDescriptorTypeInterface // 0x00: bInterfaceNumber0 0x00: bAlternateSetting // 0x01: bNumEndpoints1 (只有通知端点) // 0x02: bInterfaceClassCDC 0x02: bInterfaceSubClassACM // 0x01: bInterfaceProtocol 0x00: iInterface // CDC功能描述符头描述符 / 调用管理 / ACM / 接口联合 0x05, 0x24, 0x00, 0x10, 0x01, 0x05, 0x24, 0x01, 0x00, 0x01, 0x04, 0x24, 0x02, 0x02, 0x05, 0x24, 0x06, 0x00, 0x01, // 通知端点EP1 IN中断传输8字节bInterval10 0x07, 0x05, 0x81, 0x03, 0x08, 0x00, 0x0A, // 接口1CDC数据接口 0x09, 0x04, 0x01, 0x00, 0x02, 0x0A, 0x00, 0x00, 0x00, // 0x01: bInterfaceNumber1 0x02: bNumEndpoints2 // 0x0A: bInterfaceClassCDC Data // 数据端点EP2 OUT / EP2 IN批量传输64字节 0x07, 0x05, 0x02, 0x02, 0x40, 0x00, 0x00, 0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00, // 接口2HID键盘 0x09, 0x04, 0x02, 0x00, 0x01, 0x03, 0x01, 0x01, 0x00, // 0x02: bInterfaceNumber2 0x01: bNumEndpoints1 // 0x03: bInterfaceClassHID 0x01: bInterfaceSubClassBoot // 0x01: bInterfaceProtocolKeyboard // HID描述符类描述符类型0x21报告描述符长度为64字节 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x40, 0x00, // 中断端点EP3 IN中断传输8字节bInterval10 0x07, 0x05, 0x83, 0x03, 0x08, 0x00, 0x0A, };这里面的几个关键点值得细说。IAD描述符是整个复合设备的“胶水”没有它Windows通常也能识别出两个接口但有概率把CDC接口当成两个独立的设备功能导致串口功能异常。不同操作系统的处理策略有差异稳妥的做法就是合法地加上IAD两个接口一组交给系统去绑定。其次是HID接口的子类很多例程里bInterfaceSubClass填0x01、bInterfaceProtocol填0x01这是Boot Subclass主机在BIOS阶段也能识别对做键盘这类设备是标准配置。如果你做的是自定义用途的HID设备不要求BIOS下能用可以保持bInterfaceSubClass0x00、bInterfaceProtocol0x00。这点在抄开源工程时很容易忽略。2.2 HID报告描述符与按键上报格式HID设备与普通串口最大的不同在于设备通过报告描述符Report Descriptor向主机描述自己的数据格式。这个描述符是一套“自描述”的字节流主机解析后才明白你发来的每个字节代表什么含义。对于标准键盘报告描述符描述的是一个8字节的输入报告这8字节的布局必须遵循HID Usage Table中Keyboard/Keypad这一页的定义。标准键盘报告格式是第1字节为修饰键Modifier包括Ctrl、Shift、Alt、GUI每个位对应一个功能键第2字节为保留字节固定填充0x00第3到第8字节是6个普通按键的键码按下的按键依次填入最多同时表达6个键。这个“6键无冲”方案对绝大多数场景足够用如果需要更多按键就得扩展报告长度。下面是我常用的标准键盘报告描述符直接可用static const uint8_t hid_report_desc[] { 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) 0x26, 0xFF, 0x00, // 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 };这段描述符的总长度是64字节所以在HID描述符里的wDescriptorLength字段要填0x40, 0x00。如果长度填错主机会去读不存在的报告描述符或者只能读到半个设备在设备管理器里就会出现黄色感叹号。固件端上报键盘按键时严格遵循“按下发键值、松开发空报告”的规则。以按下A键为例typedef struct __attribute__((packed)) { uint8_t modifier; uint8_t reserved; uint8_t keycode[6]; } HID_KeyboardReport_t; HID_KeyboardReport_t kbd_report; memset(kbd_report, 0, sizeof(kbd_report)); kbd_report.modifier 0x00; // 无修饰键 kbd_report.keycode[0] 0x04; // A键的Usage ID // 发送到EP3 IN端点按键松开后必须再发一份全0的报告。如果漏了这一步主机端会一直认为A键被按住输入框里就会出现一连串的“aaaaaa……”这是很多初做HID键盘的朋友踩过的典型坑。2.3 CDC数据端点与HID中断端点的分工CDC虚拟串口的数据传输走的是批量端点Bulk批量传输的特点是数据可靠但实时性没有保证适合文件传输和串口数据这种非实时场景。全速USB下单个批量端点一次最多传64字节数据量大了就在总线上排队所以虚拟串口通信里如果数据帧很大要注意分包处理和缓冲区管理。HID键盘走的是中断端点Interrupt端点描述符里的bInterval字段表示主机多长时间轮询一次设备。全速USB下bInterval的单位是毫秒填10就代表10毫秒轮询一次也就是每秒最多上报100次对键盘这种人类输入设备来说完全够用。如果填1主机会每个毫秒都来问一次数据CPU开销和总线占用都会上升没有必要。这里要特别注意中断IN端点是需要设备“主动”上报数据的但底层机制其实是主机按bInterval周期来查询设备是否有新数据。所以设备端在按键事件发生时只需要把报告写入端点缓存USB控制器就会在下一个轮询周期把数据发出去。对于复合设备EP1 IN的CDC通知端点、EP3 IN的HID键盘端点都是中断传输但用途完全不同描述符里bInterval的设置也可以各自独立。如果你只是把CDC当成普通串口用通知端点其实很少发数据但描述符里必须定义它否则Windows驱动可能不认这个CDC接口。这是一个看起来没用但不能少的“面子工程”。另外我见到不少朋友把CDC数据端点大小设置成8字节这在低速设备里常见但全速下建议改成64字节效率会高很多尤其是通过串口刷固件或者传日志的时候性能差距非常明显。2.4 一个容易混淆的缩写USB CDC与跨时钟域CDC搜索资料的时候一定会看到“CDC跨时钟域”这个词很多人第一反应是“USB CDC怎么还牵扯跨时钟域了”其实这里有两个完全不同的CDC。USB语境里的CDC是Communications Device Class通信设备类也就是本文讨论的虚拟串口而在数字电路设计里CDC是Clock Domain Crossing跨时钟域指信号从某个时钟域跨越到另一个时钟域时的同步处理问题。这两个概念只共享一个缩写没有任何关联。之所以专门提这个是因为我被坑过一次。有一次在论坛搜USB虚拟串口乱码问题结果跳出来的全是FPGA跨时钟域同步的帖子看完才发现方向完全跑偏。搜索时建议加限定词“USB CDC”或者“CDC ACM”能过滤掉大量无关结果。同理如果你在调试硬件工程师写的FPGA代码时看到CDC那才是跨时钟域的意思别拿USB协议去套。3. 实操过程固件搭建与调试3.1 硬件准备与工程框架这套设计的硬件部分不复杂关键是MCU要有USB外设。以最常见的STM32F407为例它内置USB OTG FS自带全速PHYD和D-两根线直接连USB座子不需要外接PHY芯片。硬件上需要准备一块STM32F407开发板、一个USB转MicroUSB线、两个按键用于模拟键盘输入测试、一个USB转TTL串口模块用于调试日志输出。工程框架建议分层底层是USB控制器驱动中间是USB协议栈CDC类和HID类上层是应用逻辑按键扫描、串口数据处理。如果用STM32CubeMX可以在USB_Device配置里同时勾选Communication Device Class和Human Interface Device Class生成的代码会自动构建复合设备描述符省去手写字节流的痛苦。但生成完一定要检查描述符数组里的端点分配确认和需求一致。引脚分配上USB_OTG_FS的D/D-是固定引脚PA11/PA12无需额外配置。DP引脚上需要接1.5k上拉电阻到3.3V用来告诉主机“这是一个全速设备”。大多数开发板已经集成这个电阻如果是自己画的板子务必检查这个上拉电阻有没有漏焊漏了的话设备永远不会被识别。3.2 最小实现流程从CDC回环到HID上报建议的落地步骤是先做CDC回环再做HID键盘最后合并。一口吃不成胖子分步验证能让你在出问题时快速定位是哪个模块的问题。第一步实现CDC回环。把USB设备栈初始化好在CDC收到的数据回调里把数据原样塞回发送缓冲区void cdc_receive_callback(uint8_t *buf, uint32_t len) { // 把收到的数据原封不动发回去测试链路通断 cdc_send_data(buf, len); }把固件下载进板子插上电脑设备管理器里应该能看到新的COM口。打开串口助手随便发一串字符能收到相同的回显就说明CDC通路是通的。这个回环越早验证越好因为它能排除一大堆底层问题。第二步实现HID按键上报。接一个按键到某个GPIO扫描到按下时发送“A”键码松开发送空报告void keyboard_task(void) { static uint8_t last_state 0; uint8_t state gpio_read_key(); if (state ! last_state) { HID_KeyboardReport_t report; memset(report, 0, sizeof(report)); if (state) { report.keycode[0] 0x04; // A } hid_send_report(report); last_state state; } }第三步合并两个模块。此时你的主循环大概是轮询USB状态、处理CDC收发、扫描键盘并上报。三者之间互不阻塞特别注意不要在USB回调里做耗时操作比如按键去抖延时一旦阻塞在回调里USB的实时性就崩了。3.3 在Windows和Linux下验证复合设备Windows下验证最简单。插入设备后打开设备管理器展开“端口(COM和LPT)”能看到一个新的COM口展开“键盘”能看到一个HID Keyboard Device在“通用串行总线设备”里还能看到USB Composite Device。这三个条目同时出现才能说明复合设备枚举成功。Linux下可以用dmesg验证枚举过程插入设备后执行dmesg | tail -20正常会看到类似下面的输出usb 1-1: new full-speed USB device number 5 using xhci_hcd cdc_acm 1-1:1.0: ttyACM0: USB ACM device input: HID 1234:5678 as /devices/.../input/input23 hid-generic 0003:1234:5678.0002: input,hidraw0: USB HID v1.11 Keyboard看到ttyACM0和hidraw设备节点创建成功说明两个子功能都枚举正常。如果只是做键盘测试可以用evtest工具查看输入事件evtest /dev/input/eventX按下板子上的按键能看到KEY_A事件上报。这个环节能确认HID报告解析后的结果是否符合预期。4. 常见问题与排查技巧实录4.1 枚举类问题设备无法识别、代码43先说说最让人头疼的“无法识别的USB设备”和“设备管理器代码43”。这类问题绝大多数出在描述符上而不是硬件上。排查的第一步是确认D/D-的上拉电阻有没有焊好、3.3V供电是否正常把模拟开关排除掉之后再开始查描述符。我自己的排查顺序是先用USB Device Tree Viewer看设备有没有被枚举出来如果能看到设备树但节点是红的就能看到系统读到哪一步就崩了。然后对照配置描述符数组重点检查wTotalLength是不是和数组长度一致。这个长度错一个字节主机就会多读或少读描述符直接判定设备异常。再往深了查可以把描述符用Wireshark抓包验证。Windows下装好USBPcap驱动后Wireshark能抓到USB总线上的URB请求看主机在枚举时发出的GET_DESCRIPTOR请求和设备返回的数据。如果设备返回的描述符第一个字节不对主机就会撤销枚举。这个方法能精确定位到是哪个描述符被主机拒绝比盯着数据手册瞎猜高效得多。还有一种情况是设备管理器里能看到“USB输入设备”但看不到串口或者反过来。这通常是IAD写得不规范导致系统把CDC的两个接口拆开识别了。解决办法是把IAD的bFirstInterface置0、bInterfaceCount置2并且确保IAD紧接着配置描述符之后出现中间不能插入任何其他描述符。4.2 数据类问题虚拟串口打不开、按键重复触发虚拟串口能枚举出来但打不开最常见的原因是CDC的SetControlLineState类请求没处理好。Windows在打开串口时会通过控制管道下发SET_CONTROL_LINE_STATE请求告知设备DTR和RTS状态。如果协议栈没有正确响应这个请求Windows会认为设备没有就绪表现为“串口被占用”或“打开失败”。按键重复触发的问题则要从事件上报逻辑里找原因。首先要

相关新闻