ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32H5 USB虚拟串口:用Classic驱动移植CDC实践

STM32H5 USB虚拟串口:用Classic驱动移植CDC实践 做 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 ClassicUSBX运行环境裸机 / 任意 RTOS 均可强制依赖 ThreadXRAM 占用较小典型几 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主频跑 250MHzUSB 时钟从 PLL1 的 Q 输出经分频得到 48MHz。CubeMX 会在生成代码时自动把 RCC 相关寄存器写好你不需要手写 PLL 初始化关键点是确认最终页面上 USB 节点确实显示 48MHz。提示移植时如果遇到 USB 偶尔能枚举、断电重插又不行的情况优先怀疑 48MHz 时钟不稳定不要一上来就改代码。用示波器量 USB_DP 上枚举时候的波形或者直接看时钟树配置这是最快的排查路径。2.2 USB 引脚与中断配置STM32H5 的 USB_DRD_FS 引脚分布在 PA11USB_DM和 PA12USB_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_IRQnCubeMX 生成工程时会默认打开但如果你是从旧工程裁剪过来的一定要确认中断开关没有被误关。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_DeviceClassic就对了。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/.hUSB_DEVICE/app用户接口层收发函数、回调需重点修改usbd_desc.c/.hUSB_DEVICE/app设备描述符、字符串描述符、序列号usbd_conf.c/.hUSB_DEVICE/target底层配置中断处理、PCD 句柄绑定usbd_cdc.cMiddlewares/.../Class/CDCCDC 类驱动处理类请求和端点收发usbd_core.cMiddlewares/.../CoreUSB 协议栈核心状态机、控制传输usbd_ctlreq.cMiddlewares/.../Core标准控制请求处理获取描述符、设置地址等usbd_ioreq.cMiddlewares/.../Core控制传输数据收发请求封装值得说明的是usbd_cdc_if.c是“应用层”和“协议栈层”之间的桥。你在 main 里调用CDC_Transmit_FS发送数据实际是经过这个文件把数据交给usbd_cdc.c再由它通过底层的 PCD 端点接口发出。反过来主机发来的数据也是由usbd_cdc.c解析后通过CDC_Receive_FS这个回调通知到应用层。3.2 描述符文件 usbd_desc.cusbd_desc.c是设备在主机眼里“是谁”的定义。默认生成的 VID 是 ST 的0x0483PID 是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 端发一个ATRST\r\n设备端可能一次收到ATR下一次收到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 移植里枚举失败是最头疼的因为现象
返回列表