做 STM32H5 的 USB 虚拟串口,很多人第一反应是上 USBX,毕竟是新系列,配上 ThreadX 才算“正统”。但我这次实际做项目时却选了另一条路:用 H5 自带的 USBD Classic 驱动移植 CDC。原因很直接,项目里跑的是一套自研状态机框架,不想为了一路打印日志和参数配置的虚拟串口,把整个 RTOS 生态引进来。Classic 这套传统 USB Device Library 足够轻,文档虽然老,但胜在稳定、好查错。这篇就把完整的移植过程、代码改造点、还有我踩过的几个坑记录下来,给想在 STM32H5 上用 Classic 栈做 CDC 的朋友一个参考。
1. 为什么选 STM32H5 还要用 Classic 驱动
1.1 Classic 和 USBX 之间的选择
打开 STM32CubeMX,在 H5 上启用 USB 外设之后,中间件面板里会出现两个选项,一个是USB_Device(也就是我们常说的 USBD Classic、传统 USB 设备库),另一个是USBX_Device。很多人对新系列有固有印象,觉得新芯片就该配新中间件,但这个选择其实应该由项目场景决定,而不是由“新旧”决定。
Classic 库的本质是一套不依赖 RTOS 的裸机 USB 设备协议栈,内部由USBD_HandleTypeDef驱动状态机,配合底层 HAL PCD 驱动完成枚举、控制传输、批量传输。它的特点是体积小、逻辑直白、依赖关系简单,把usbd_cdc_if.c改明白基本就够日常用了。缺点是代码风格比较老,全局变量和回调满天飞,读起来没那么“现代”。
USBX 则完全不同,它来自 ThreadX 生态,模块化做得更好,支持多类复合设备、多实例,底层有完善的传输管理。但代价也很明显:需要 ThreadX 内核支撑,哪怕你把它接到 FreeRTOS 上,也得处理一堆抽象层和事件标志,RAM 占用和代码体积都不小。对于一个只需要“插上电脑出现一个 COM 口,能双向传数据”的应用,USBX 属于大炮打蚊子。
我把两者的取舍整理成一张表,做方案评审的时候可以直接拿去用:
| 对比维度 | USBD Classic | USBX |
|---|---|---|
| 运行环境 | 裸机 / 任意 RTOS 均可 | 强制依赖 ThreadX |
| RAM 占用 | 较小,典型几 KB 内 | 较大,任务栈加中间件堆 |
| 移植成本 | CubeMX 生成后少量修改 | 需配置内核、内存池、中断绑定 |
| 调试友好度 | 调用链短,断点容易定位 | 层级多,追根时容易绕晕 |
| 复杂设备支持 | 单类为主,多类复合较吃力 | 多类、多实例、高速更擅长 |
| Cube 生态维护 | 仍在维护,但不再是重点 | 当前主推方向 |
1.2 这套组合适合什么场景
我这次的项目是一台电机控制器,主控是 STM32H563,系统里需要 USB-C 接口用于参数配置和固件包下发,同时后台还有 CAN 和 RS485 在跑数据。整个实时逻辑都在一颗 MCU 上裸机完成,状态机按 1ms tick 调度,引入 RTOS 纯属增加负担。
选 Classic 还有一个实际好处:网上关于老 STM32 系列(F1/F4/F7)的 CDC 资料特别多,几乎所有坑都有人踩过了。虽然 H5 的内核和时钟树换成了 Cortex-M33,但 USBD Classic 的上层逻辑是通用的,HAL PCD 驱动接口也和 F 系列一脉相承,遇到问题参考老平台的解决方案基本都能对上,这比啃 USBX 的一堆新概念要省时间得多。
如果你的项目是下面这几种情况,我会建议你也用 Classic:
- 纯裸机工程,不想为了 USB 引入操作系统;
- 功能上只需要虚拟串口或 HID,不需要复杂类组合;
- 从旧平台迁移老代码,希望 USB 部分尽量少改动;
- 团队新人对 USB 栈不熟,Classic 的代码量小,容易讲清楚。
2. 移植前的准备工作:CubeMX 工程与时钟
2.1 时钟树配置:48MHz 从哪来
STM32H5 的 USB 全速外设(USB_DRD_FS)对时钟要求是严格 48MHz,时钟不对最典型的症状就是设备插上电脑毫无反应,或者枚举到一半变成 Unknown Device。所以移植的第一步不是写代码,而是先把时钟树理清楚。
H5 上得到 48MHz 主要有两条路。第一条是直接用内部 HSI48 振荡器,这个 RC 振荡器内部经过校准,CubeMX 里启用 USB 时钟后,直接把USB_DRD_FS的时钟源选成HSI48就行。好处是省事、不依赖外部晶振;坏处是 RC 精度有限,如果你对 USB 时序稳定性要求比较高,或者还想同时给别的外设提供精准时钟,我更推荐第二条路。
第二条路是从 PLL 分频出来。比如外部 25MHz HSE 进来,经过 PLL 配置出 48MHz 的时钟输出。在 CubeMX 的 Clock Configuration 页面里,找到USB_DRD_FS对应的时钟节点,把分频比调到最终显示为48.0 MHz即可。H5 的时钟树里可以选 PLL1 或 PLL2 的输出,具体用哪个要看主频倍频后的分配情况,只要最终 USB 节点是 48MHz,通常就没问题。
我这次的配置是 HSE 25MHz 进 PLL1,主频跑 250MHz,USB 时钟从 PLL1 的 Q 输出经分频得到 48MHz。CubeMX 会在生成代码时自动把 RCC 相关寄存器写好,你不需要手写 PLL 初始化,关键点是确认最终页面上 USB 节点确实显示 48MHz。
提示:移植时如果遇到 USB 偶尔能枚举、断电重插又不行的情况,优先怀疑 48MHz 时钟不稳定,不要一上来就改代码。用示波器量 USB_DP 上枚举时候的波形,或者直接看时钟树配置,这是最快的排查路径。
2.2 USB 引脚与中断配置
STM32H5 的 USB_DRD_FS 引脚分布在 PA11(USB_DM)和 PA12(USB_DP),这部分和绝大多数 STM32 系列一致。如果板子上还有 VBUS 检测需求,VBUS 通常分配到 PA9。CubeMX 里把USB_DRD_FS外设使能成 Device Only 后,相关引脚会自动被占用,不需要手动改 GPIO 配置。
这里有一个很多人容易忽略的点:STM32 的 USB 全速设备端在 D+ 引脚内部已经有软件可控的上拉电阻,PCD 驱动初始化后会自己拉起来完成枚举。所以你的板子上不要画外部 1.5k 上拉电阻,如果照搬了某些老设计图,等 USB 枚举到一半时序异常时,优先检查这个。
中断配置也要留意。H5 的 USB 全局中断在 NVIC 里默认是USB_DRD_FS_IRQn(部分系列叫OTG_FS_IRQn),CubeMX 生成工程时会默认打开,但如果你是从旧工程裁剪过来的,一定要确认中断开关没有被误关。USB 的收发全靠这个中断驱动,关掉了设备就完全没反应。
2.3 中间件选择:把 Classic CDC 组件加进来
在 CubeMX 里把USB_DRD_FS使能后,进入Middleware and Software Packs,选择USB_Device,然后在 Class 里选Communication Device Class (Virtual Port Com)。新版 CubeMX 有时候会问你是用 Classic 还是 USBX,这里选USB_Device(Classic)就对了。
Classic CDC 相关的配置项不多,核心是两个缓冲区大小:APP_RX_DATA_SIZE和APP_TX_DATA_SIZE,默认都是 2048。这两个值分别指的是应用层接收缓冲区和发送缓冲区长度,会直接体现在生成的usbd_cdc_if.c里。如果只是做参数配置和日志输出,2048 够用;如果后面要传较大的数据包,可以适当改大,但要同步评估 RAM 占用。
生成代码前,还有一个小细节值得设置:USB_DEVICE_CONFIG里如果允许你选VBUS sensing,要根据自己板子的硬件来。如果 VBUS 检测脚没接,建议把它关掉或者把 VBUS 引脚接到 PA9,否则有些板子因为检测不到会话电压而无法进入 Device 模式。
3. 生成工程的代码结构拆解
3.1 目录结构与各文件职责
CubeMX 生成工程后,USB 相关代码分散在几块地方。第一次接触的人容易看懵,因为文件多,而且不在一层目录里。我这里把关键文件和职责整理了一下:
| 文件 | 路径示意 | 职责 |
|---|---|---|
usbd_cdc_if.c/.h | USB_DEVICE/app | 用户接口层,收发函数、回调,需重点修改 |
usbd_desc.c/.h | USB_DEVICE/app | 设备描述符、字符串描述符、序列号 |
usbd_conf.c/.h | USB_DEVICE/target | 底层配置,中断处理、PCD 句柄绑定 |
usbd_cdc.c | Middlewares/.../Class/CDC | CDC 类驱动,处理类请求和端点收发 |
usbd_core.c | Middlewares/.../Core | USB 协议栈核心状态机、控制传输 |
usbd_ctlreq.c | Middlewares/.../Core | 标准控制请求处理(获取描述符、设置地址等) |
usbd_ioreq.c | Middlewares/.../Core | 控制传输数据收发请求封装 |
值得说明的是,usbd_cdc_if.c是“应用层”和“协议栈层”之间的桥。你在 main 里调用CDC_Transmit_FS发送数据,实际是经过这个文件把数据交给usbd_cdc.c,再由它通过底层的 PCD 端点接口发出。反过来,主机发来的数据也是由usbd_cdc.c解析后,通过CDC_Receive_FS这个回调通知到应用层。
3.2 描述符文件 usbd_desc.c
usbd_desc.c是设备在主机眼里“是谁”的定义。默认生成的 VID 是 ST 的0x0483,PID 是0x5740,字符串描述符默认是STMicroelectronics相关字样。做产品化改版的时候,这几个值建议都改掉,尤其是做多台设备联调时,如果序列号一样,Windows 会认为它们是同一台设备。
关键定义集中在文件开头的宏:
#define USBD_VID 0x0483 #define USBD_PID 0x5740 #define USBD_LANGID_STRING 0x409 #define USBD_MANUFACTURER_STRING "MyCompany" #define USBD_PRODUCT_STRING "STM32H5 CDC Demo" #define USBD_SERIALNUMBER_STRING "H5CDC0001"这里说一个 Windows 场景下的经验:如果你每次修改固件都会导致序列号变化(比如把编译时间戳写进去),那么每次下载完固件,Windows 都会把设备识别成新 COM 口,设备管理器里会积累一堆旧端口,调试时容易搞混。想要固定 COM 口,序列号就得保证稳定不变。
3.3 CDC 接口文件 usbd_cdc_if.c 的使用方法
usbd_cdc_if.c是所有上层逻辑的落脚点。生成后的文件里,你需要关注的函数主要有这三个:
CDC_Transmit_FS:发送接口,app 调用它向 USB 发数据;CDC_Receive_FS:接收回调,USB 收到数据时由协议栈在中断里调用;CDC_Control_FS:处理主机发来的 CDC 控制请求,比如设置波特率、DTR/RTS 状态。
生成的CDC_Transmit_FS内部已经帮你做了两件事:检查设备是否处于 CONFIGURED 状态,以及检查上一次发送是否完成。如果链路还没就绪或者正在发送中,会返回USBD_BUSY或USBD_FAIL。很多新手拿到这个函数后直接在主循环里拼命调用,然后发现数据发不出去,这就是没注意返回值导致的。
4. 核心数据收发逻辑与改造
4.1 可变长数据的接收
CDC 的接收逻辑是移植中最容易出问题的地方。USB 串口和物理 UART 不一样,主机发过来的数据是分包到达的,包大小由 USB 协议决定,和你应用层的“一帧命令”没有任何对应关系。也就是说,你在 PC 端发一个AT+RST\r\n,设备端可能一次收到AT+R,下一次收到ST\r\n,也可能一次性全收进来,完全看主机的发送节奏和 USB 调度。
默认生成的CDC_Receive_FS长这样:
static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len) { /* USER CODE BEGIN 6 */ USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return (USBD_OK); /* USER CODE END 6 */ }它做的事只有一个:拿Buf重新挂载接收缓冲区,然后再次调用USBD_CDC_ReceivePacket让协议栈进入下一次接收等待。如果你不主动做数据搬运,那主机发来的数据就这样被“静默丢弃”了(实际上是被下一包数据覆盖)。所以应用层必须自己找地方把数据存下来。
我的做法是维护一个环形缓冲区,在CDC_Receive_FS里直接把数据搬进环形区,然后立即重新挂载接收:
#define RX_RING_SZ 1024 static uint8_t rx_pool[RX_RING_SZ]; static volatile uint16_t rx_head = 0; static volatile uint16_t rx_tail = 0; static uint16_t rx_next(uint16_t idx) { return (idx + 1) % RX_RING_SZ; } uint16_t rx_available(void) { return (uint16_t)((rx_head - rx_tail + RX_RING_SZ) % RX_RING_SZ); } uint16_t rx_read(uint8_t *out, uint16_t maxlen) { uint16_t n = 0; while (n < maxlen && rx_tail != rx_head) { out[n++] = rx_pool[rx_tail]; rx_tail = rx_next(rx_tail); } return n; }对应的CDC_Receive_FS改成:
static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len) { uint32_t len = *Len; if (len <= (RX_RING_SZ - 1 - rx_available())) { for (uint32_t i = 0; i < len; i++) { rx_pool[rx_head] = Buf[i]; rx_head = rx_next(rx_head); } } USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return (USBD_OK); }注意:
CDC_Receive_FS是在 USB 中断上下文里执行的,不要在它里面做耗时操作,比如解析协议、刷 Flash、打印日志。正确姿势是快速搬运数据、快速重新挂载接收,然后回到主循环再处理。
4.2 发送函数的使用与限制
发送方向的默认函数CDC_Transmit_FS看起来很简单,但里面有几个隐性约束。先看生成代码的核心:
uint8_t CDC_Transmit_FS(uint8_t *Buf, uint16_t Len) { uint8_t result = USBD_OK; USBD_CDC_HandleTypeDef *hcdc = (USBD_CDC_HandleTypeDef *)hUsbDeviceFS.pClassData; if (hcdc->TxState != 0) { return USBD_BUSY; } USBD_CDC_SetTxBuffer(&hUsbDeviceFS, Buf, Len); result = USBD_CDC_TransmitPacket(&hUsbDeviceFS); return result; }第一个坑是TxState忙检查。这个函数如果发现上一次发送还没完成,会直接返回USBD_BUSY,但调用者如果没有检查返回值,数据就悄无声息地丢了。我见过很多人在主循环里写CDC_Transmit_FS("hello", 5);然后发现程序跑得飞快、数据时不时丢,就是这个原因。
第二个坑是缓冲区生命周期。USBD_CDC_SetTxBuffer只是把指针存下来,真正把数据从缓冲区搬进端点缓冲器是在后续的传输过程中。如果你的Buf指向一个临时数组,函数返回后临时数组就失效了,那实际发送时可能发出错误数据。保险做法是让发送缓冲区保持有效,或者等TxState清零后再复用这块内存。
第三个坑是单次发送长度。虽然APP_TX_DATA_SIZE设成 2048,但 USB 全速批量端点每个事务最多 64 字节,长数据会被协议栈拆成多包。这个过程中如果主机忙于枚举或者暂停,发送会卡在半路,表现为TxState长时间不释放。应用层要做超时保护,不能无限等待。
4.3 缓冲队列的简单实现
为了不丢数据,我给发送方向也加了一个环形队列。所有业务代码不再直接调CDC_Transmit_FS,而是先往队列里写,主循环里轮询队列,发现队列有数据且 USB 发送空闲,才取出一个 chunk 发送:
#define TX_QUEUE_SZ 4096 static uint8_t tx_pool[TX_QUEUE_SZ]; static volatile uint16_t tx_head = 0; static volatile uint16_t tx_tail = 0; static uint16_t tx_next(uint16_t idx) { return (idx + 1) % TX_QUEUE_SZ; } uint16_t tx_queued(void) { return (uint16_t)((tx_head - tx_tail + TX_QUEUE_SZ) % TX_QUEUE_SZ); } void tx_enqueue(const uint8_t *data, uint16_t len) { while (len--) { if (tx_next(tx_head) == tx_tail) break; /* 队列满,直接丢弃新数据 */ tx_pool[tx_head] = *data++; tx_head = tx_next(tx_head); } } uint16_t tx_dequeue(uint8_t *out, uint16_t maxlen) { uint16_t n = 0; while (n < maxlen && tx_tail != tx_head) { out[n++] = tx_pool[tx_tail]; tx_tail = tx_next(tx_tail); } return n; }主循环里的发送任务这样写:
static uint8_t tx_chunk[64]; void usb_send_task(void) { USBD_CDC_HandleTypeDef *hcdc = (USBD_CDC_HandleTypeDef *)hUsbDeviceFS.pClassData; if (hcdc == NULL || hcdc->TxState != 0) { return; } uint16_t n = tx_dequeue(tx_chunk, sizeof(tx_chunk)); if (n > 0) { CDC_Transmit_FS(tx_chunk, n); } }这样的好处是,不管业务模块是中断里、定时器里还是主循环里产生的日志,只要调tx_enqueue就能安全排队,真正往 USB 写的动作被收敛到usb_send_task一个地方,排查问题非常方便。代价是 4KB 的 RAM,对 H5 来说完全不是问题。
5. 实操调试:枚举、收发、稳定性
5.1 第一次上电怎么验证
代码移植完成后,第一次上电不要急着写业务逻辑,先把链路跑通。我的建议是做一个最简回环:USB 收到什么就原样发回去。这样一旦 PC 端能收发,说明枚举、端点配置、收发通道全部正常,之后再在此基础上加协议解析。
Windows 下,插入 USB-C 后打开设备管理器,正常情况下会出现一个STMicroelectronics Virtual COM Port(如果改了描述符就是你的产品名),也可能显示成通用的USB Serial Device,后者说明 Windows 用的是系统自带 usbser.sys 驱动,功能上没差别。Linux 下用dmesg | grep -i usb能看到 cdc_acm 的枚举日志,然后ls /dev/ttyACM*找到端口设备。
串口终端我用的是 MobaXterm,设置里选好 COM 口,波特率随便填(后面会解释为什么),流控设为 None。打开后往设备发几个字节,如果回环正常,终端里能看到同样的字节。
这里要专门说一下波特率:USB CDC 虚拟串口的波特率只是个“标称值”,设备端CDC_Control_FS里的SET_LINE_CODING请求会收到它,但 USB 传输本身完全不依赖这个波特率。所以你终端里填 9600 还是 115200,实际传输速度都一样,填错也不影响数据正确性,这一点跟真实 UART 有本质区别,别在上面浪费时间。
5.2 收发联调中的常见现象
联调时我几乎总能遇到下面几个现象,列出来大家对照自查。
PC 发数据,设备端完全收不到。这种最常见的原因是接收没有重新挂载。如果你把CDC_Receive_FS里USBD_CDC_ReceivePacket那行注释掉或者写错地方,设备只在第一次能收到数据,之后就彻底“聋”了。排查时在CDC_Receive_FS入口加个 GPIO 翻转,用示波器看中断是否进来,很快就能定位。
设备端发数据,PC 端没有反应。先检查CDC_Transmit_FS的返回值,如果一直是USBD_BUSY,多半是某个长数据包卡在发送状态里,检查是否配置过休眠、USB 时钟是否被暂停。如果返回值是USBD_FAIL,大概率是设备根本没有进入 CONFIGURED 状态,也就是枚举没成功,这时候回到 5.1 看枚举。
PC 端能收能发,但数据偶尔缺一段。这通常是发送队列溢出或者接收环形区溢出导致的。我项目里打印日志比较频繁,曾经把TX_QUEUE_SZ设成 512,一开全量日志就丢数据,后来改成 4096 才稳定。溢出时的最直观指标,是tx_queued()长期接近队列上限。
5.3 性能与稳定性测试
链路跑通后,建议做一轮简单的压力和稳定性测试,别等产品上线了才发现问题。我这边测试大概分三步。
第一步是基础吞吐测试。PC 端用脚本每 10ms 发 64 字节,设备端回环,连续跑 10 万包,统计错误包数。对全速 USB 来说,这种压力很小,正常应该零错误。
第二步是长连接测试。设备模拟实际业务场景,每秒发几十条日志到 PC,持续跑 24 小时以上,观察 USB 是否出现枚举断开、设备假死、缓冲区泄漏。这一步最容易暴露定时器和 USB 中断互相干扰的问题。如果你用了低功耗模式,务必把休眠和 USB 唤醒逻辑一起测进去。
第三步是拔插测试。让设备在运行状态下反复插拔 USB,看系统是否能稳定重新枚举。H5 的 PCD 驱动在拔出时会产生相关中断,如果你的工程里把异常处理写成死循环等待,那拔插一次就锁死。建议在拔插测试前,提前处理好 USB 异常回调和 Disconnect 事件。
6. 踩坑记录与排查速查表
6.1 枚举失败类问题
USB 移植里枚举失败是最头疼的,因为现象