资讯动态

USB复合设备实战:CDC虚拟串口与HID键盘的融合设计与调试指南

发布时间:2026/8/30 14:21:10 来源:尧图企业网站定制
最近把一个串口通信工具和自定义键盘做进了同一个USB设备里项目名字叫USB Composite Design (CDCHID Keyboard)。说白了就是做一个USB复合设备枚举出来一根线同时带CDC虚拟串口和HID键盘两个功能。这个方案的实用价值很直接设备既要有交互输入又要有调试/升级通道但实在不想占两个USB口更不想在主机端插一堆转接器。这篇文章就把我从描述符设计、框架选型、代码实现到调试量产踩过的坑完整梳理一遍适合正在做USB复合设备、或者是想给客制化键盘加串口日志功能的人参考。我最初的需求其实很简单一个产线自检设备需要模拟按键去操作被测机器同时又要通过串口输出日志、接收命令。如果按老路子做得在设备上放两个USB控制器或者用USB Hub扩一个口不但成本高线材还乱。后来确认走USB复合设备这条路线一个MCU、一个USB口同时注册出一个串口和一个键盘主机端什么都不用装就能直接用。这个项目牵扯到的知识点不少包括USB描述符结构、CDC类的接口定义、HID报告描述符、端点规划、中断与批量传输混用等。但真正动手后发现只要你把描述符那关过了剩下的就是业务逻辑。1. 项目构思与方案选型为什么要把CDC和HID塞进同一个USB设备1.1 这个项目解决的现实问题在很多嵌入式设备里HID键盘和CDC串口是两种极其常见的功能。键盘负责与人交互串口负责与上位机通信、日志输出、固件升级。以往大家习惯分开做设备A做键盘设备B做串口转接或者设备上放两颗USB芯片。这样做的问题很突出首先物料成本翻倍其次主机端占用两个USB口对于笔记本、工控机这种USB口紧张的使用场景来说很痛苦。把两个功能合并成一个复合设备主机端只占用一个USB口设备管理器里会分开显示成一个串口和一个键盘互不干扰。对用户来说体验就是“插一根线什么都有了”。这个方案在客制化键盘、宏键盘、工业HMI面板、产线自动化测试设备上都适用。特别是产线场景一个USB口既能跑测试脚本通信又能通过HID键盘模拟按键去操作被测系统极大简化了布线。还有一个隐蔽的好处标准CDC和标准HID在Windows、Linux、macOS下基本都是免驱的。CDC挂到usbser.sysHID挂到hidusb.sys你不需要给用户发一堆驱动安装包。这一点在实际交付中非常重要很多非技术用户看到驱动安装界面就懵了。1.2 复合设备实现路线从零手写描述符还是用协议栈做USB复合设备摆在面前的第一条岔路是用什么框架。手写描述符和寄存器操作是最底层的做法适合学习理解也适合那种MCU资源极小、跑不动协议栈的场景。但CDC加HID两个类尤其CDC要处理Set Line Coding、Set Control Line State这些类请求手写工作量不小而且很容易在端点管理上出问题。我的建议是如果项目周期紧、芯片Flash和RAM都还够直接用现成的USB协议栈。目前主流选择有两个方向一个是芯片厂商提供的USB库比如STM32的USB Device Library、CubeMX生成的Custom HID和CDC类这类库对自家芯片适配好但做成复合设备往往要自己改描述符和类回调代码耦合比较重。另一个是TinyUSB它是开源跨平台的USB协议栈原生支持CDC、HID、MSC等多个类的任意组合简直是做复合设备的神器。TinyUSB最大的优势在于描述符和类的组织方式非常清晰CDC和HID可以当作独立的功能单元注册主循环里各跑各的任务。我最后选了TinyUSB配合一颗带USB外设的MCU上电就能枚举成两个设备省去了大量调试类请求的时间。当然如果你之前已经深入研究过芯片自带的USB库也完全可以继续用只是要在描述符数组和回调分发上多花点心思。1.3 先用大白话理解复合设备枚举过程很多人对USB复合设备有点畏惧觉得一个设备变两个很玄。其实从USB协议的角度看复合设备在总线上仍然只是一个设备地址只是它的配置描述符里包含多个接口每个接口对应一个功能主机再根据接口的类代码为每个功能加载不同的驱动。举例来说我的设备枚举成一个配置这个配置下有三个接口接口0和接口1组成了CDC功能CDC类规范标准做法是“通信接口数据接口”两个接口配对接口2是HID键盘。Windows的通用父驱动usbccgp.sys看到这种多接口配置会自动把它拆成两个子设备节点一个加载串口驱动一个加载HID键盘驱动。从用户的角度看就是设备管理器里出现了一个串口和一个键盘。这个过程中最关键的机制是IADInterface Association Descriptor。因为CDC占用了两个接口操作系统必须知道“这两个接口是属于同一个功能的”IAD就是把这个绑定关系告诉主机。而HID键盘只占一个接口不需要IAD。听起来简单但描述符里一个字节的顺序错了设备就可能枚举失败或者只认出一半功能。后面我会详细讲这段描述符怎么排布。2. 描述符设计USB复合设备的地基一个字节都不能错2.1 从设备描述符到配置描述符的完整结构USB Enumerate的时候主机首先读取设备描述符Device Descriptor然后读取配置描述符Configuration Descriptor。设备描述符本身不包含功能信息但它有一个很关键的字段组合bDeviceClass必须写成0xEFbDeviceSubClass写成0x02bDeviceProtocol写成0x01。这个组合表示“本设备是复合设备接口各自独立定义功能”。如果漏了这一步很多操作系统不会正确拆分多接口功能设备可能被当成一个未知设备或者只加载第一个接口。配置描述符是整个复合设备的真正核心。它的组织方式是一个配置描述符开头后面跟着该配置下所有接口描述符、端点描述符、类特殊描述符。标准规定配置描述符的wTotalLength字段必须等于整棵描述符树的总字节数。这个字段经常出问题尤其是手工拼描述符的时候少算了某个HID描述符或者CDC功能描述符的长度主机就会在解析到一半时失败表现就是设备管理器的“设备描述符请求失败”。实际项目里我习惯把配置描述符当成一棵树来理解根节点是配置描述符下面按顺序排着每个接口的子树。CDC子树通常是IAD、接口0描述符、CDC功能描述符Header、Call Management、ACM、Union、端点描述符、接口1描述符、端点描述符。HID子树相对简单就是接口描述符、HID描述符、端点描述符。写代码的时候按这个顺序往数组里填字节就不容易漏。2.2 CDC功能描述符与IAD的配合CDC类在USB里的定义比较特殊它把一个串口抽象成两个接口。接口0叫通信接口Communication Interface负责管理类请求比如Set Line Coding、Set Control Line State还会提供一个中断IN端点用来上报串口状态比如DCD变化、break事件等。接口1叫数据接口Data Interface真正承载串口数据的收发它包含一个批量IN端点和一个批量OUT端点。因为这两个接口必须作为一个整体被驱动识别所以需要IAD把它们关联起来。IAD里bFirstInterface填接口0的编号bInterfaceCount填2bFunctionClass填0x02CDCbFunctionSubClass填0x02ACMbFunctionProtocol填0x01。这样操作系统就知道接口0和接口1是一个叫CDC的功能单元会把它们绑定到同一个串口驱动上。这里我踩过一次坑IAD的bFirstInterface字段必须填接口编号但如果你的HID接口排在最前面编号就不是0。也就是说IAD的位置和bFirstInterface值要跟接口排列顺序严格对上。另一个容易错的是CDC功能描述符里的Union Functional DescriptorbControlInterface和bSubordinateInterface0这两个字段要分别填通信接口和数据接口的编号。这些字段看似不起眼但错了的话Windows可能只识别出COM口但无法打开或者直接把设备识别成“USB 串行设备”而不是具体串口号。2.3 HID键盘报告描述符与端点设计HID部分的核心有两块一是HID描述符HID Descriptor二是报告描述符Report Descriptor。HID描述符里有一个重要的bNumDescriptors字段表示后面跟了几份描述符通常填1然后跟一个报告描述符的类型和长度。很多人在做复合设备时忘了把HID描述符的长度计算进wTotalLength导致主机截断描述符树。报告描述符定义了键盘数据的格式。标准键盘通常用固定长度的输入报告8个字节第1字节是修饰键Ctrl、Shift、Alt、GUI的状态位第2字节保留为0后面6个字节是同时按下的按键键码。这种格式被称为6键无冲足够日常使用。报告描述符里要写清楚Usage Page是Generic DesktopUsage是Keyboard然后定义输入字段的Report Size、Report Count和Logical Min/Max。键盘的传输类型是中断IN端点描述符里bInterval字段可以设为1到10毫秒根据手感需求调。我用的1ms轮询实际测试Windows下手感接近有线游戏键盘。这里要注意的是HID键盘只需要一个中断IN端点不需要OUT端点别为了对称性白占一对端点MCU的USB端点资源本来就很宝贵。2.4 端点资源分配的核心原则在开始写任何代码之前先把端点资源画一张表。USB设备控制器里端点数量是硬限制。以我使用的MCU为例USB FS控制器支持的双向端点数量非常有限规划不好就得在功能上做减法。一个可行的分配方案是EP0是控制端点谁都不能占EP1 IN分配给CDC通知端点中断EP2 IN和EP2 OUT分配给CDC数据端点批量EP3 IN分配给HID键盘中断。这样一个复合设备只用了三个数据端点算是比较经济的排法。HID只用IN方向是因为键盘只需要上报数据不需要主机下发特殊报表如果你要做键盘的指示灯控制或自定义命令才需要增加OUT端点。端点规划的原则有三个第一同一时间同一个端点只能被一个接口使用不同接口不要共用端点否则数据会串第二中断端点的bInterval要合理CDC通知端点我用的10msHID键盘用1ms这两个间隔会直接影响枚举成功率和键盘手感第三保证批量端点有足够的缓冲CDC的收发缓冲太小在高速通信时容易丢数据。3. 代码落地基于TinyUSB的CDCHID实现3.1 TinyUSB工程配置与描述符代码TinyUSB的代码组织很清爽核心要改的就是tusb_config.h和usb_descriptors.c。tusb_config.h里把CDC和HID都打开并配置缓冲区大小。我的配置大致是这样#define CFG_TUD_CDC 1 #define CFG_TUD_HID 1 #define CFG_TUD_CDC_RX_BUFSIZE 256 #define CFG_TUD_CDC_TX_BUFSIZE 256 #define CFG_TUD_HID_EP_BUFSIZE 16这里有一点很关键CFG_TUD_CDC这个宏表示注册的CDC接口数量TinyUSB会为每个注册的类生成对应的接口描述符数据。如果你以后想在这个复合设备里再加一个CDC口做第二串口就把这个宏改成2但你的USB控制器要有足够的端点资源。描述符代码里设备描述符的bDeviceClass要按复合设备规范配置// Device Descriptor uint8_t const desc_device[] { 0x12, // bLength 0x01, // bDescriptorType 0x10, 0x01, // bcdUSB 1.10 0xEF, // bDeviceClass: Misc 0x02, // bDeviceSubClass: Common 0x01, // bDeviceProtocol: IAD 0x40, // bMaxPacketSize0 ... };配置描述符的数据结构跟着标准顺序一行一行填注意CDC的IAD要放在通信接口之前HID的接口描述符排在CDC两个接口之后。TinyUSB的tud_descriptor_configuration_cb会返回这个数组主机会在枚举时读取。写这段数组的时候我强烈建议对照USB Device Tree Viewer实时查看每改一个字段就插拔一次设备能立刻发现是哪里解析失败。3.2 主循环里怎么同时伺候串口和键盘TinyUSB是事件驱动的但业务逻辑还是在主循环里轮询。我的主循环大致分成三块按键扫描、CDC接收处理、CDC发送日志。按键扫描的代码会在检测到状态变化时直接调用tud_hid_n_report上报键盘数据。注意这里有个坑tud_hid_n_report的第一个参数是HID接口的编号不是物理接口编号。因为我只有一个HID接口所以填0但如果你以后加了第二个HID设备这里就要仔细核对索引。关键代码如下void cdc_hid_task(void) { // 键盘扫描 hid_keyboard_report_t report {0}; if (keyboard_scan(report)) { tud_hid_n_report(0, 0, report, sizeof(report)); } // CDC 接收把串口收到的数据回显或处理 if (tud_cdc_available()) { uint8_t buf[64]; uint32_t count tud_cdc_read(buf, sizeof(buf)); // 处理 buf ... } // CDC 发送把日志队列里的数据推出去 while (!log_queue_empty()) { uint8_t ch; log_queue_pop(ch); tud_cdc_write(ch, 1); } tud_cdc_write_flush(); }这里需要注意tud_cdc_write_flush不能每次只写一个字节就flush否则效率极低。更好的做法是攒够一包再flush或者用一个定时器每5ms flush一次。因为HID键盘的1ms中断轮询占用了不少USB带宽CDC如果频繁发起小包批量传输两者会在总线上互相干扰。我之前就遇到过HID键盘偶尔卡顿排查后发现是CDC写日志太频繁后来改成队列加定时flush彻底解决。3.3 如果坚持用官方USB库怎么改如果你不想引入TinyUSB坚持用ST官方USB Device Library或者CubeMX生成的代码也是可以做的。思路是把官方例程里的HID和CDC两个工程合并把两者的描述符数组拼接到同一个配置描述符里然后在回调函数里同时处理两个类的请求。官方库做到复合设备有一个最麻烦的点它通常每个类对应一个独立的描述符缓冲区和端点回调。你需要自己写一个分发层根据SETUP请求里wIndex的高位或者接口号把请求路由到正确的类处理函数。比如GET_DESCRIPTOR请求里bDescriptorType和wIndex区分HID Report Descriptor和CDC的Line Coding。这块逻辑不复杂但容易漏因为两个类的请求处理方法是完全不同的。我个人的意见是除非你对芯片USB库的内部机制已经非常熟否则还是用TinyUSB省心。TinyUSB把描述符生成、请求分发、端点管理都封装好了而且代码是经过大量项目验证的。如果你非要用官方库至少把USB Device Tree Viewer抓到的描述符和TinyUSB版本做个对比这样心里有底。4. 调试与问题排查从枚举失败到驱动感叹号4.1 一套标准调试流程我在做这个项目时调试工具主要是USB Device Tree Viewer和Wireshark加USBPcap。USB Device Tree Viewer能直接看到设备枚举后的描述符原文Wireshark则能抓总线上的枚举过程和数据传输两者配合基本能解决所有USB层面问题。调试流程我总结成四步。第一步插上设备看设备描述符是否正常。如果出现“未知USB设备设备描述符请求失败”先查硬件D和D-走线、上拉电阻、供电电压、USB线质量。第二步看配置描述符能否完整读取。这里重点看wTotalLength是否跟实际描述符数组长度一致以及IAD、CDC功能描述符、HID描述符是否都在。第三步看驱动加载情况。Windows下正常会出现“USB 串行设备”和“HID Keyboard Device”两个节点。如果只有其中一个大概率是IAD或者接口编号配置有问题。第四步跑数据流。串口用串口助手收发键盘用HID调试助手看输入报告。HID调试助手是个好东西它能列出所有HID设备还能发送GET_REPORT请求查看设备汇报的数据。我经常用它来确认键盘上报的8字节报告是否正常而不是打开记事本一个键一个键地试。如果调助手显示的数据是对的那就是主机端按键映射问题如果数据不对才需要回头改报告描述符。4.2 常见问题速查表现象典型原因排查与解决设备管理器显示“设备描述符请求失败”D/D-上拉错误、描述符长度错误、供电不足用USB Device Tree Viewer看枚举是否在第一步就失败检查硬件上拉只有串口没有键盘IAD或接口号配置错误HID接口没有被拆分检查配置描述符里HID接口是否在正确偏移bDeviceClass是否0xEF只有键盘没有串口CDC的Union功能描述符或ACM缺失检查CDC通信接口的类请求是否完整IAD是否绑定了两个接口串口能识别但打不开提示代码10CDC通信接口描述符缺失或端点配置错误抓配置描述符确认EP方向、bInterval、端点地址是否冲突设备经常掉线线材过差、供电不足、HID轮询间隔太短换线材增大USB发包超时检查VBUS电容HID调试助手看不到键盘报告描述符格式错误或者接口类代码不是HID检查bInterfaceClass是否为0x03报告描述符长度是否配置正确这里特别强调一下有些人会把“HID over I2C设备感叹号”和USB HID混在一起。I2C HID是另一个体系很多触摸板、触摸屏走的不是USB总线而是I2C接口再包一层HID协议在设备管理器里显示“HID over I2C”。如果你在调USB复合设备发现设备管理器里有个I2C HID设备报错那大概率跟你的USB设备无关是系统触摸板驱动的另外一个问题别浪费时间在USB描述符上排查。4.3 和第三方串口芯片驱动的区别做CDC的时候大家经常搜到一堆“FT232R USB UART驱动下载”“CP2102N USB to UART bridge驱动下载”之类的热词。这是因为很多老产品用的是FTDI、SiLabs这类厂商的方案它们并不是标准CDC类而是在USB层实现了厂商自定义类数据收发逻辑虽然看起来像串口但必须安装各自的厂商驱动才能识别成COM口。而我们自研的CDC复合设备是标准CDC类Windows用的是系统内置的usbser.sysLinux和macOS也内置了对应驱动。所以交付给用户的时候不需要让他们装任何驱动。这一点是复合设备方案很大的优势但也要注意如果我们把描述符里的某些字段做成非标比如漏了ACMWindows可能无法正确识别串口到时候主机端表现就跟芯片方案不自带驱动一样得自己写inf或改注册表非常痛苦。如果你用的是Linux主机标准CDC设备通常注册为/dev/ttyACM0或者/dev/ttyACM1。用minicom或者screen打开即可。如果Linux下识别为/dev/ttyUSB0那通常是厂商类驱动绑定和标准CDC就不是一回事了。这个区别可以作为快速判断设备是否符合标准类的一个低层指标。5. 可靠性优化与量产避坑从能用到好用5.1 键盘chatter问题与消抖算法HID键盘刚做出来能跑不代表按键手感没问题。机械按键按下时金属触点会有回弹一般持续几毫秒到十几毫秒如果不处理一次按下会被当成多次触发这就是大家常说的keyboard chatter。早期我直接用GPIO中断读取按键结果打字时经常出现一个字母重复好几次后来专门写了一段消抖逻辑。消抖的基本思路是不信任单次电平跳变而是连续采样N次确认稳定后再上报。我的做法是每1ms扫描一次按键矩阵维护每个键的逻辑电平历史连续3次采样为同一电平才判定状态翻转。这本质上是低通滤波能滤掉绝大多数抖动。实际测试中10ms的稳定窗口足够应对普通机械轴但老化的轴体抖动会到20ms以上单纯延长窗口又会增加按键延迟手感变肉。更高级的做法是加一个自适应阈值首次检测到按键跳变先不立即上报延迟5ms再次读取如果状态确认则正常上报如果在这个窗口内又发生了反复跳变则认为这是chatter轴自动把确认时间拉长到20ms。这个逻辑在产线老化测试里能明显降低售后返修率。代码实现不复杂但要注意别在主循环里用阻塞延时要基于时间戳做状态机。5.2 电源、线与PCB层面的注意事项USB复合设备有两个功能在同时工作瞬时电流会比单功能设备高。我遇到过设备插在笔记本USB口上偶尔枚举失败后来测量发现是VBUS供电波动导致芯片复位。解决办法是USB插座的VBUS附近放一个22uF到47uF的电容并在芯片电源输入端加LC滤波把瞬态压降控制在100mV以内。另外D和D-这对差分线的布线需要保持等长、阻抗尽量接近90欧走线不要过长中间不要打孔换层。对新手来说最直观的现象就是线稍微长一点就识别不稳这时候不要盲目怀疑固件先检查走线和线材质量。与USB座子连接处加TVS管做ESD防护是量产的标配一个TVS管没多少钱能省掉大量售后问题。还有一点经常被忽略设备描述符里的bMaxPower字段它告诉主机设备最大需要多少电流。这个值是以2mA为单位的如果写的过高某些HUB或电脑会直接拒绝供电写的过低又可能导致设备在上电瞬间跌落复位。我一般按实际测量的最大工作电流加上20%余量来填不要照抄例程。5.3 固件健壮性缓冲区、重连与兼容性在固件层面CDC和HID两路数据同时跑的时候缓冲区设计要格外小心。HID键盘的数据量不大出问题的主要是CDC的串口数据。如果上位机以高速率往串口灌数据而你的MCU主循环没来得及及时读走TinyUSB内部的RX缓冲就会溢出。CFG_TUD_CDC_RX_BUFSIZE不是越大越好它占着RAM而且如果应用层长时间不取数据再大也会满。最好的做法是加一个应用层环形队列USB回调里收到数据就推入队列主循环再慢慢处理不阻塞USB中断上下文。设备热插拔和主机睡眠恢复也是量产必测场景。TinyUSB有tud_cdc_connected()和tud_hid_ready()这类状态查询接口我建议在主循环里检测USB状态断开后自动清空队列重新连接后重新初始化键盘状态避免出现“插拔后键盘第一个按键失灵”这种怪问题。另外要注意驱动兼容性。虽然标准CDC免驱但Windows 10和Windows 11对复合设备的枚举策略略有差异老系统比如Windows 7对IAD支持也有历史问题。如果客户群里还有旧系统建议在项目文档里标注最低系统版本或者在描述符里做一些保守兼容设计。这里没有统一解法只能按目标用户群测试。还有一个容易被忽视的点是VID/PID管理。调试阶段随便填可以但量产时每个产品都要有唯一的VIDPID可以按产品线分配。HID键盘的Report Descriptor一旦发布给市场后续尽量保持向后兼容新增按键可以在报告末尾追加功能不要改动已有字段含义否则会导致老主机端软件解析错乱。最后说一个我调试时印象很深的经验HID键盘上报的bInterval如果设置成1ms在USB总线上会形成比较密集的中断传输如果这时候CDC同时在做大包批量传输Windows偶尔会把HID传输挤到次要优先级表现为键盘卡顿。后来我用USB分析仪抓包发现不是数据错误而是调度延迟。解决办法是把HID的bInterval改成2ms键盘手感几乎无感知但主机的调度压力明显下降。这个细节如果你也用“CDCHID”做量产很可能会遇到属于典型的“合并设备才知道的坑”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价