STM32多路USB CDC组合设备实现:从时钟树到稳定枚举
2026/9/14 14:15:06 网站建设 项目流程

简介:基于STM32与HAL库实现USB组合设备多路CDC的完整源码包,主要针对并解决嵌入式开发中单路虚拟串口不够用、多路CDC难以复合枚举的痛点,适合具备一定STM32基础、希望借助HAL库快速搭建USB多串口设备的工程师与学习者。压缩包共202个文件,工程文件类型齐全:.h/.c源码占据主体,另有.ioc图形化配置工程、.s启动文件、.ld链接脚本及.bat批处理脚本,说明书同时提供docx与md版本,便于不同习惯阅读,整体大小8.74MB。资源内容聚焦USB设备描述符配置、CDC类驱动适配、多路端点分配及HAL库回调机制,附带的工程模板、清理与格式化脚本可辅助快速完成代码修改和环境整理,目录结构清晰,便于逐模块对照学习。目前已有252人学习下载,既可作为多路CDC量产项目的参考模板,也适合作为嵌入式课程设计的进阶素材。

1. 用STM32做多路USB CDC,为什么值得自己搭组合设备

一个工控采集板要同时对接8路传感器,上位机软件只认COM口,每路传感器都被映射成一个串口节点。单路CDC用STM32CubeMX点几下就能跑,但一旦端口数超过两路,问题就从“串口能不能通”变成“一根USB线上为什么枚举不出多个COM口”。多路CDC本质上是USB组合设备(Composite Device)的一种具体形态:一个设备地址、一个配置描述符里挂多组接口,每组接口对应一个CDC功能类。HAL库的PCD层天然支持多接口描述符,真正的改动集中在usbd_cdc.c的描述符表、端点分配和usbd_cdc_if.c的回调分发上。这篇文章按“时钟与参数、CubeMX工程到多路代码、枚举抓包与驱动、端口固定与带宽”四条线展开,偏重F1和F4上可直接复现的实现细节,适合已经跑通单路CDC、准备把设备做成多路虚拟串口的嵌入式工程师。

2. STM32与HAL库的USB时钟和设备参数,先定好再改代码

2.1 48MHz时钟树是USB设备能被枚举的前提

STM32F1/F4的USB外设都以48MHz作为参考时钟,HAL库的PCD初始化里会检查这个频率是否有效。时钟配置错了,代码写得再对,设备插上电脑也只会得到一个“未知USB设备”。在STM32CubeMX的Clock Configuration页面里,USBCLK或USB 48MHz这一栏必须显示为绿色锁定状态,黄色或红色都说明这条时钟链没有闭合。

以两块常用芯片为例,给出两组可以直接抄的时钟参数:

芯片HSEPLLMPLLNPLLP/PLLRSYSCLKUSB时钟来源
STM32F103C8T68 MHz19PLLP=272 MHzPLLCLK/1.5 = 48 MHz
STM32F407VGT68 MHz8336PLLP=2, PLLQ=7168 MHzPLLQ = 48 MHz

F1的USB时钟直接取PLLCLK分频,只要SYSCLK是72MHz,PLLCLK/1.5就是48MHz。F4的OTG_FS和后续F7系列则通过PLLQ输出,PLLQ的值必须能被整除到48MHz,用上面F407那组参数,PLLQ=7时输出正好是48MHz。若你的板子HSE不是8MHz,比如常见的25MHz无源晶振,PLLM要相应调整,原则始终是让PLL喂给USB分频器的最终结果等于48MHz,差几百kHz都会导致枚举失败或枚举后通信随机中断。

2.2 USB Device Only 与 OTG 模式的选择

F103只有USB Device控制器,不涉及选择问题。F4/F7的OTG_FS既能做Host也能做Device,CubeMX里可以选择OTG_Host、OTG_Device或OTG_FS(双角色)。做多路CDC时,建议直接选Device Only。原因是CDC设备完全不需要Host逻辑,双角色模式要求HAL_PCD_MspInit里把VBUS检测和ID引脚初始化好,一旦VBUS GPIO配错,设备侧枚举会时好时坏,排查起来非常绕。

如果偏偏要用双角色模式,需要确保USB_OTG_FS_GPIO_Remap和VBUS引脚的EXTI中断都正确配置,同时USB电源管理寄存器里的VBUS检测位要打开。这类配置对多路CDC没有任何功能增益,反而多了一个不稳定因素。我一般会把精力省下来用在描述符的端点排布上,而不是浪费在OTG角色切换上。

2.3 CubeMX里CDC类参数与文件结构

CubeMX的USB_DEVICE配置里,Class for FS IP选择Communication Device Class (Virtual Port COM),会自动生成一个完整的单路CDC工程,包括usbd_cdc.c、usbd_cdc_if.c、usbd_desc.c和usbd_conf.c。需要注意,CubeMX本身不支持一次生成多个CDC类,工程生成后还需要手动复制描述符结构体和回调分发代码。换句话说,CubeMX在这里的作用是先把USB最底层的PCD、配置描述符模板和中断处理搭好,多路化的任务在后面对源码做扩展。

一个可复现的工程骨架如下:

Core/Src/main.c // 初始化调用 MX_USB_DEVICE_Init() USB_DEVICE/App/usbd_desc.c // 设备描述符、VID/PID、序列号 USB_DEVICE/App/usbd_cdc_if.c // 单路CDC回调,多路化的主战场 USB_DEVICE/Target/usbd_conf.c // PCD MSP回调,端点FIFO分配 USB_DEVICE/Device/usbd_cdc.c // CDC类处理函数,含Receive/Transmit

usbd_conf.c中需要关注端点FIFO分配,F4的OTG控制器每个端点FIFO独立,两个CDC各占两组收发端点,FIFO大小在HAL_PCD_MspInit里通过HAL_PCDEx_SetTxFiFo和HAL_PCDEx_SetRxFiFo配置。F1没有独立FIFO,直接共用256字节双端口RAM,配置更简单。

2.4 整理一份适合自己的CDC配置参数表

动手改代码前,先把多路CDC的端点规划写清楚。下面是两路CDC的一组推荐排布:

功能接口号端点方向端点地址用途
CDC00,1IN0x81数据发送
CDC00,1OUT0x01数据接收
CDC00,1IN0x83命令通知
CDC12,3IN0x82数据发送
CDC12,3OUT0x02数据接收
CDC12,3IN0x84命令通知

每组CDC占用两个接口,一个CDC Command Interface(带一个通知端点)加一个CDC Data Interface(带收发两个端点)。两路CDC共占4个接口、6个端点(加上端点0共7个)。STM32F1的USB Device控制器提供8个端点,做到两路CDC还有余量;F4的情况要看具体型号,OTG_FS可用IN/OUT端点数量有限,做到4路CDC以上就要回头检查端点号是否越界,这是整个设计和调试中最容易出现“描述符报错但代码编译通过”的环节。

3. 多路CDC的HAL库实现:描述符、回调与端点分发

3.1 配置描述符里IAD决定Windows能否识别“组合”

HAL库默认生成的usbd_desc.c里,设备描述符的bDeviceClass通常是0x02(CDC),这种情况下整个设备被主机视为一个CDC类设备。多路CDC属于完全不同的设备形态:设备包含了多个独立的接口集合,主机必须知道哪几个接口被捆绑成一个功能单元。这种信息在USB描述符里就是接口关联描述符(Interface Association Descriptor)。

在usbd_cdc.c的USBD_CDC_CfgDesc数组中,单路CDC的描述符排列是“接口描述符(命令) + 接口描述符(数据) + 端点描述符×3”。多路CDC时要改成“IAD + CDC0命令接口 + CDC0数据接口 + CDC1命令接口 + CDC1数据接口”。IAD放在关联的接口描述符之前,主机读到IAD后,会把后续几个接口归为一组,Windows和Linux都按这个逻辑分配串口。

修改设备描述符的bDeviceClass,让它符合组合设备的规范:

__ALIGN_BEGIN static uint8_t usbd_dev_desc[USB_LEN_DEV_DESC] __ALIGN_END = { 0x12, /* bLength: 设备描述符固定18字节 */ USB_DESC_TYPE_DEVICE, /* bDescriptorType: 0x01 */ 0x00, 0x02, /* bcdUSB: USB 2.00 */ 0xEF, /* bDeviceClass: Miscellaneous */ 0x02, /* bDeviceSubClass: Common */ 0x01, /* bDeviceProtocol: Interface Association Descriptor */ 0x40, /* bMaxPacketSize0: 64字节 */ 0x83, 0x04, /* idVendor, idProduct */ 0x00, 0x01, /* bcdDevice */ 1, /* iManufacturer */ 2, /* iProduct */ 3, /* iSerialNumber */ 0x01 /* bNumConfigurations */ };

这段代码把设备类改成0xEF,子类0x02,协议0x01。0xEF的含义是Miscellaneous Device Class,配合IAD使用后,主机在枚举时会正确处理多组接口。这里最容易犯的错误是只复制了接口描述符而没有加IAD,结果Windows把4个接口当成4个独立的USB功能,设备管理器里出现一串“USB 输入设备”或直接报“配置描述符无效”,而Linux下往往只枚举出一个ttyACM节点。

3.2 usbd_cdc_if.c 的多实例化与接收缓冲

HAL库的usbd_cdc_if.c里只维护了一份接收缓冲和一个USBD_CDC_HandleTypeDef实例。多路CDC要求每一路都有独立的收发缓冲、busy标志和错误计数。定义端口结构体:

#define CDC_PORT_NUM 2 typedef struct { uint8_t rx_buf[64]; /* 单路CDC接收缓冲区 */ volatile uint16_t rx_len; /* 最近一次接收的字节数 */ volatile uint8_t busy; /* 发送忙标志 */ uint8_t connected; /* 串口是否被主机打开 */ } cdc_port_t; cdc_port_t cdc_port[CDC_PORT_NUM];

这里rx_buf每路固定64字节,对应全速CDC端点最大包长。如果应用层一次要接收更长数据,可以在主循环里反复调用USBD_CDC_ReceivePacket补充接收,或者把缓冲区加大到4×64并用环形队列管理。后一种做法更实用,因为Windows串口调试助手发送数据时往往一次性把整包数据交给usbser,缓冲区太小会丢。注意这个数组必须在文件作用域或静态区定义,不能在CDC_Receive_FS的栈上,因为USB中断异步访问它。

3.3 端点地址到端口的映射

上一节的cdc_port[]数组通过一个函数映射到具体USB端点,这是多路CDC和单路CDC最关键的区别。USB回调函数里拿不到“这是第几路”的现成参数,只能从当前端点地址反推:

static uint8_t get_port_from_ep(uint8_t ep_addr) { switch (ep_addr) { case 0x01: /* CDC0 OUT 端点 */ case 0x81: /* CDC0 IN 端点 */ return 0; case 0x02: /* CDC1 OUT 端点 */ case 0x82: /* CDC1 IN 端点 */ return 1; } return 0xFF; }

接收路径里,HAL库的USBD_CDC_Receive会把端点接收到的数据放到SetRxBuffer设置的缓冲中,然后触发CDC_Receive_FS回调。在回调中按端点地址找到对应的cdc_port索引,把数据搬进该端口的接收队列。

static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len) { uint8_t port = get_port_from_ep(USBD_GetCurrentEpAddr(&hUsbDeviceFS)); if (port < CDC_PORT_NUM) { cdc_port[port].rx_len = (uint16_t)(*Len); memcpy(cdc_port[port].rx_buf, Buf, (uint16_t)(*Len)); } /* 重新把接收缓冲挂回USB设备,准备接收下一包 */ USBD_CDC_SetRxBuffer(&hUsbDeviceFS, cdc_port[port].rx_buf); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return USBD_OK; }

USBD_CDC_SetRxBuffer后面必须紧跟USBD_CDC_ReceivePacket,中间不能插入耗时代码。否则主机连续发送数据时,端点OUT缓冲区来不及重新武装,设备会回NAK,表现为主机侧发送超时或串口工具报错。

3.4 发送路径与多路CDC总线互斥

发送接口要对上层提供指定端口的发送函数:

uint8_t CDC_SendBuffer(uint8_t port, uint8_t *buf, uint16_t len) { uint8_t epin; if (port >= CDC_PORT_NUM) { return USBD_FAIL; } /* 等待该端口上一次发送完成 */ while (cdc_port[port].busy) { /* 可加入超时机制,防止死等 */ } cdc_port[port].busy = 1; if (port == 0) { epin = 0x81; } else { epin = 0x82; } USBD_CDC_SetTxBuffer(&hUsbDeviceFS, buf, len); USBD_CDC_TransmitPacket(&hUsbDeviceFS, epin); return USBD_OK; }

有两种版本。旧版本HAL库的USBD_CDC_TransmitPacket只接受pdev参数,端点固定为USBD_CDC_EPIN_ADDR;后来的版本增加了epin参数,允许指定不同IN端点,多路CDC必须依赖后者。如果你的HAL库版本接口只有一个参数,需要手动修改usbd_cdc.c内部函数,把传入的EPIN地址传给HAL_PCD_EP_Transmit,这个改动绕不开,也是这个方案里唯一需要动USB类驱动层的部分。

busy标志在CDC_TransmitCplt_FS回调中清零,这样每次发送完成才能发起下一包。使用RTOS时,建议把busy轮询换成信号量,发送线程阻塞等待发送完成信号,避免在USB带宽不足时白白消耗CPU。

3.5 SET_LINE_CODING 与串口打开状态

上位机打开串口时,usbser.sys会向设备发送CDC_SET_LINE_CODING请求,设置波特率和数据格式。这个Setup请求在usbd_cdc.c的CDC_Setup里被解析,多路CDC时要在响应里加上端口号判断:

case CDC_SET_LINE_CODING: /* 根据Setup请求的Interface号找到对应端口 */ cdc_port[port].connected = 1; break;

这个状态字段很有用,应用层可以知道上位机是否已经打开这路串口。但要注意,Windows关闭串口时并不会发送标准的CDC通知,所以connected标志只能置1,不能被可靠地清零。反过来,当USB线拔掉时,PCD的断开回调能把所有端口的connected统一清零。

4. 枚举抓包、驱动安装与多路CDC无法识别的排错

4.1 先看设备管理器还是先看描述符

设备插上后,第一步永远是确认设备是否完成了枚举。Windows下设备管理器刷新后出现“STM32 Virtual COM”但只显示一个COM口,说明枚举成功但描述符里的接口被错误合并;出现“未知USB设备(设备描述符请求失败)”,时钟或硬件层面有硬伤;设备正常显示两个COM口但打不开,问题集中在端点缓冲和HAL库中断处理。

推荐在Linux环境下做枚举排查,信息量比Windows大得多:

dmesg | tail -50 lsusb -v -d 0483:5740

用lsusb打印出完整配置描述符,重点看bNumInterfaces是否等于4(两路CDC)。如果接口数正确,继续看每个接口下的bInterfaceClass,正确排列是“CDC Communication (0x02) + CDC Data (0x0A)”交替出现。接口数正确但只有一个ttyACM节点,则是IAD没生效,该回第三章查设备描述符的bDeviceClass。接口数不足,通常是我们前面说过的接口描述符没有复制全。

4.2 usb抓包 定位枚举中断的位置

纯看lsusb还是不够,USB协议栈抓包能直接看到设备在枚举哪一步被主机放弃。Linux下免费方案是usbmon加Wireshark:

sudo modprobe usbmon sudo wireshark

在Wireshark里选择usbmon接口,过滤条件加上枚举请求的URB,一下就出来了。Wireshark的Usb bus filter里选择usbmon0,很快能看到连续的GET_DESCRIPTOR请求。

抓包时关注控制传输的完成状态。SETUP(GET_DESCRIPTOR Device)对应URB_COMPLETE,说明设备描述符读取成功;GET_DESCRIPTOR Config如果返回STALL,说明配置描述符内容有错或设备来不及处理。多路CDC最常见的抓包现场是:主机读取Config描述符时,收到的wTotalLength只有原长度一半,紧接着设备被复位,重新开始枚举。这种状态十有八九是配置描述符数组的长度定义和实际内容不一致,usbd_cdc.c里的USBD_CDC_CfgDesc数组长度要手动增加,不能沿用单路版本的大小。

4.3 cdc serial 驱动安装与第三驱动冲突

Windows 10/11自带usbser.sys,正确枚举出来的多路CDC会直接显示为“USB 串行设备(COMx)”,不需要安装任何外部驱动。设备管理器里出现黄色感叹号时,先确认VID/PID有没有被其他厂商驱动拦截。很多工控用户习惯性安装FT231X或CH340的通用驱动包,这类驱动包里包含了大量USB转串口芯片的VID/PID映射,一旦镜像里恰好包含当前设备的VID/PID,系统会把你的CDC设备误绑定到serial类驱动上,结果就是“无法启动该设备(代码10)”。

处理办法不是卸载驱动,而是在设备管理器里右键更新驱动,手动选择“USB 串行设备”这个内置类驱动,强制重新绑定到usbser.sys。如果希望彻底避开这种冲突,测试阶段可以使用ST的VID 0x0483配合自定义PID,量产时再换成自己向USB-IF申请的VID。用ST的VID做商业产品存在法律风险,只能用于开发验证。

4.4 枚举顺序乱与序列号的关系

多路CDC节点在Windows里出现的COM口号顺序偶尔会跟代码里定义的接口顺序不一致,尤其是复位之后顺序变化。这个问题的根因往往是设备序列号没有正确提供。Windows对串口节点的命名依赖设备实例路径,设备实例路径里包含序列号字段。若序列号全零或重复,两次插入会得到相同的实例路径,COM口就沿用旧编号,和当前实际枚举顺序脱节。

STM32F1系列的96位唯一ID寄存器地址是0x1FFFF7E8,把它转换成ASCII字符串写到设备描述符的iSerialNumber对应位置:

uint8_t *USBD_GetSerialStr(USBD_HandleTypeDef *pdev, uint8_t *pbuf) { uint32_t uid[3]; uid[0] = *(volatile uint32_t *)0x1FFFF7E8; uid[1] = *(volatile uint32_t *)0x1FFFF7EC; uid[2] = *(volatile uint32_t *)0x1FFFF7F0; sprintf((char *)pbuf, "%08X%08X%08X", (unsigned int)uid[0], (unsigned int)uid[1], (unsigned int)uid[2]); return pbuf; }

设备描述符里iSerialNumber必须指向这个字符串,且字符串描述符长度要大于24。F4系列同理,寄存器地址换成0x1FFF7A10即可。每块芯片序列号唯一后,Windows会按首次插入顺序锁定COM端口,不再随复位而重新编排。

5. 多路CDC的进阶技巧:Linux固定端口名与带宽限制

5.1 用udev规则把ttyACM节点固定成业务名

多路CDC在Linux下枚举为ttyACM0、ttyACM1,节点编号由内核按探测顺序分配,拔插一次就可能交换。对需要长期稳定的上位机程序,写入udev规则让每个物理端口拥有固定别名:

SUBSYSTEM=="tty", ATTRS{interface}=="STM32 Virtual COM", ATTRS{bInterfaceNumber}=="00", SYMLINK+="ttycdc0" SUBSYSTEM=="tty", ATTRS{interface}=="STM32 Virtual COM", ATTRS{bInterfaceNumber}=="02", SYMLINK+="ttycdc1"

规则里的bInterfaceNumber对应CDC0数据接口的接口号0和CDC1数据接口的接口号2,这个值在描述符里是固定的,不会随枚举顺序变化。保存到/etc/udev/rules.d/99-stm32-cdc.rules后执行udevadm control --reload-rules,应用层直接打开/dev/ttycdc0和/dev/ttycdc1,业务逻辑与内核分配解耦。

5.2 多路CDC带宽与总体吞吐量

全速USB每个1ms帧内,批量传输最多只能完成约19个64字节事务,理论极限约1.2MB/s,这是所有CDC端口共享的。四路CDC同时双向跑115200bps,总吞吐约92KB/s,并不会把USB总线打满,但要注意每一路在收发并发时,软件拷贝和中断开销会显著拉高CPU占用。实际项目中,把每路CDC的波特率限制在<460800,并且避免所有端口同时高频发送日志,整体稳定性会好很多。应用层发送频率过高时,只能靠busy标志背压,没有其他手段。

5.3 多路CDC的批量端点轮询与主机负载

组合设备里每个CDC功能都会被主机单独轮询,多路CDC会成倍增加主机侧的中断处理负担。Windows对批量端点采用轮询策略,每路CDC数据OUT端点都会定期接收IN/OUT Token。如果你只关心某一路的数据收发优先级,可以在描述符里给高优先级端口的bInterval设置更小的值,低优先级端口拉大间隔。不过全速批量端点的bInterval表示连续NAK之间的帧数,把不太关键的端口bInterval设为0x10,能节省部分USB带宽给主用端口,这对带宽敏感的场景是有效手段。

最后一章不做总结,唯一建议是:把每一路CDC的端点地址、接口号、busy标志、接收缓冲大小和波特率参数留在同一个头文件里,后续加一路CDC就是复制一组描述符并递增端点地址的事,改代码的时间比抓包排错的时间少得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询