1. 为什么在工控板子上折腾USB CDC ACM
拿到一块GD32H759的工控板,跑着RT-Thread,串口资源紧张的时候,USB CDC ACM虚拟串口几乎是绕不开的方案。我手头这个项目,主控要同时对接触摸屏、两路RS485传感器、一个4G模组,硬件串口排下来只剩一个调试口,但上位机又要求能实时下发参数、回传运行日志。加一颗CH340?板子空间不够,成本也不划算。GD32H759自带USB FS/HS控制器,RT-Thread又有现成的USB Device协议栈,把CDC ACM跑起来,一根Type-C线既能供电又能当串口用,这才是工控场景里最省事的做法。
CDC ACM全称是Communication Device Class Abstract Control Model,说白了就是让MCU在PC端枚举成一个"USB串口"。PC端不需要装额外驱动(Windows 10以上自带usbser.inf,Linux内核自带cdc_acm),打开设备管理器看到的是一个COM口,用串口助手就能收发数据。对工控现场来说,这意味着维护人员不用带专用工具,插上就能调试,这一点比自定义HID或者WinUSB方案友好太多。
这篇文章面向的是已经能把RT-Thread跑起来、会用ENV配置、能烧录程序的嵌入式开发者。如果你还在纠结RT-Thread怎么移植到GD32H759,建议先看前面的时钟和串口配置。本文重点讲清楚三件事:USB CDC ACM在RT-Thread下的完整配置流程、收发链路的实现细节、以及实际调试中那些文档里不会写的坑。整套方案实测在GD32H759I-EVAL上跑通,收发稳定,波特率设置对CDC来说其实是个"摆设",后面会解释为什么。
2. 方案选型与整体架构拆解
2.1 为什么选RT-Thread的USB Device协议栈而不是自己撸
RT-Thread的USB Device协议栈(rt-thread/components/drivers/usb/usbdevice)已经封装好了CDC ACM类,包括描述符、端点管理、EP0控制传输的状态机。自己从零写USB协议栈,光是枚举阶段的SET_CONFIGURATION、GET_DESCRIPTOR处理就够喝一壶,更别说CDC特有的SET_LINE_CODING、SET_CONTROL_LINE_STATE这些类请求。用现成协议栈,核心工作就剩下三块:配置描述符、实现收发回调、对接串口设备框架。
GD32H759的USB控制器和STM32的OTG FS在寄存器层面有差异,但RT-Thread的驱动层已经做了抽象。GD32的BSP包里提供了drv_usb.c,我们只需要在board目录下确认USB时钟和引脚配置正确即可。这里有个关键点:GD32H759的USB FS和HS是分开的,CDC ACM用FS就够了(12Mbps),HS虽然快但PHY配置复杂,工控场景下虚拟串口传的是参数和日志,FS带宽完全够用。
2.2 整体数据流架构
整个链路是这样的:PC端串口助手 → USB主机 → GD32H759 USB Device控制器 → RT-Thread USB Device Core → CDC ACM类驱动 → 环形缓冲区 → 应用线程。反向则是应用线程调用rt_device_write → CDC类驱动 → USB端点 → PC。
这里要理解一个核心概念:CDC ACM在USB层面有两个接口(Interface),一个是控制接口(用于类请求),一个是数据接口(用于实际收发)。数据接口有两个端点:Bulk IN和Bulk OUT。RT-Thread的CDC驱动把这两个端点封装成了一个rt_device,对上层应用来说,操作这个设备就跟操作普通串口一样,用rt_device_read和rt_device_write就行。
2.3 硬件层面的准备工作
GD32H759的USB FS引脚通常是PA11(DM)和PA12(DP),但具体要看封装和板子设计。我用的这块板子USB FS走的是PB14和PB15,所以第一步必须确认原理图。VBUS检测引脚如果不用,可以在配置里关掉,否则枚举会卡住。另外,USB时钟必须精确配置为48MHz,GD32H759的USB FS时钟来源是PLL,配置错了会导致枚举失败或者通信不稳定。
注意:GD32H759的USB FS和USB HS共用部分引脚,如果两个都要用,需要仔细核对数据手册的复用表。工控板子上如果USB座子只引出了一组,那就更要在配置阶段确认清楚。
3. RT-Thread下CDC ACM的完整配置流程
3.1 ENV工具配置与Kconfig选项
打开ENV工具,进入bsp/gd32/gd32h759-xxx目录,执行menuconfig。需要打开的选项路径是:
RT-Thread Components → Device Drivers → [*] Using USB [*] Using USB Device [*] Enable USB Device CDC ACM同时要确认RT-Thread的版本,CDC ACM在4.1.0之后的版本里比较稳定,早期版本有端点分配的问题。配置完成后执行scons --target=mdk5或者scons生成工程。这里有个细节:如果用的是Keil,生成工程后要检查usbd_cdc.c和usbdevice_core.c是否被加入编译,有时候Kconfig勾了但工程文件没更新,需要手动scons --target=mdk5重新生成。
3.2 描述符配置的关键参数
CDC ACM的描述符在usbd_cdc.c里,但VID、PID、厂商字符串这些通常在应用层通过rt_usbd_cdc_acm_init传入。我一般会定义一个结构体:
static struct udcd _udcd; static struct ep_id _ep_pool[] = { {0x0, USB_EP_ATTR_CONTROL, USB_DIR_INOUT, 64, ID_ASSIGNED}, {0x1, USB_EP_ATTR_BULK, USB_DIR_IN, 64, ID_UNASSIGNED}, {0x2, USB_EP_ATTR_BULK, USB_DIR_OUT, 64, ID_UNASSIGNED}, {0x3, USB_EP_ATTR_INT, USB_DIR_IN, 64, ID_UNASSIGNED}, {0xFF, USB_EP_ATTR_TYPE_MASK, USB_DIR_INOUT, 0xFF, ID_UNASSIGNED}, };端点池的定义很关键。CDC ACM需要至少三个端点:一个控制端点(EP0,固定)、一个Bulk IN、一个Bulk OUT。中断端点(EP3)是可选的,用于通知主机串口状态变化,如果不用可以去掉。端点大小我设的是64字节,FS下最大就是64,设大了没用。
VID和PID建议不要用默认的,工控产品最好申请自己的VID,调试阶段可以用0x1234这种临时值。字符串描述符里厂商名和产品名要写清楚,现场维护人员看到"GD32H759 CDC"比看到一堆乱码强。
3.3 时钟与引脚配置的坑
GD32H759的USB FS时钟配置在drv_usb.c里,核心是确保USB时钟为48MHz。我遇到过一个问题:系统主频跑到400MHz,但USB时钟分频算错了,结果枚举时好时坏。后来用示波器量DP线上的信号,发现时钟偏差导致眼图很差。解决办法是仔细核对PLL配置,GD32H759的USB FS时钟通常来自PLL1_Q或者PLL2_Q,具体看时钟树。
引脚配置方面,除了DM和DP,如果用了VBUS检测或者ID引脚,也要在GPIO初始化里配好。我建议调试阶段先把VBUS检测关掉,减少变量。等枚举稳定了再打开。
实操心得:GD32H759的USB FS对电源质量比较敏感,如果板子上USB座子的VBUS和MCU供电不是同一路,最好加个磁珠或者LC滤波。我踩过一次坑,USB线插上后枚举成功但传大数据就断,查了半天是VBUS纹波太大导致USB控制器复位。
4. 收发链路的实现与代码细节
4.1 设备注册与线程创建
配置好之后,在应用层初始化CDC ACM:
int usb_cdc_init(void) { rt_usbd_cdc_acm_init(&_udcd, 0x1234, 0x5678); return 0; }这个函数会注册USB设备、初始化CDC类、创建USB处理线程。RT-Thread的USB协议栈内部会创建一个usb线程,优先级默认是8,栈大小2048。如果应用里USB数据量大,建议把这个线程优先级调高一点,栈也加大到4096,避免丢包。
初始化完成后,用rt_device_find("vcom")找到CDC设备。设备名默认是"vcom",可以在Kconfig里改。找到后用rt_device_open打开,注意打开模式要带RT_DEVICE_FLAG_INT_RX或者RT_DEVICE_FLAG_DMA_RX,具体看驱动支持。GD32H759的USB驱动目前只支持中断模式,DMA模式需要自己改驱动。
4.2 接收回调与环形缓冲区
CDC ACM的接收有两种方式:轮询和回调。工控场景下我推荐回调+环形缓冲区。在rt_device_open之后,设置接收回调:
static rt_err_t cdc_rx_callback(rt_device_t dev, rt_size_t size) { rt_size_t len; len = rt_device_read(dev, 0, rx_buf, size); if (len > 0) { rt_ringbuffer_put(&cdc_rb, rx_buf, len); } return RT_EOK; }这里有个关键点:回调是在USB线程上下文里执行的,不能做耗时操作。所以回调里只做数据搬运,实际解析放到应用线程里。环形缓冲区大小根据协议定,我一般给2KB,够存几帧Modbus报文。
4.3 发送链路的阻塞与非阻塞
发送用rt_device_write,但要注意USB Bulk传输的特性。如果主机没及时取数据,写操作会阻塞。工控场景下如果应用线程不能阻塞,可以用非阻塞模式加发送队列。我的做法是创建一个发送线程,应用把数据丢到消息队列,发送线程负责调用rt_device_write。
static void cdc_tx_thread_entry(void *param) { struct cdc_msg msg; while (1) { if (rt_mq_recv(&tx_mq, &msg, sizeof(msg), RT_WAITING_FOREVER) == RT_EOK) { rt_device_write(cdc_dev, 0, msg.data, msg.len); } } }发送线程优先级要比USB线程低,避免抢占导致USB枚举异常。栈大小1024够了。
4.4 波特率设置的真相
CDC ACM的SET_LINE_CODING请求里包含波特率、数据位、停止位、校验位。但USB CDC是"虚拟"串口,实际传输速率由USB总线决定,波特率参数只是透传给应用层。也就是说,PC端设115200还是9600,对USB传输速度没影响。但工控协议里如果依赖波特率做超时判断,就需要在应用层读取这个参数。
RT-Thread的CDC驱动提供了rt_usbd_cdc_acm_set_line_coding回调,可以在这个回调里保存PC端设置的波特率。我一般会把它存到一个全局变量,应用层需要的时候读。
注意:有些串口助手在打开CDC设备时会发送SET_LINE_CODING,如果驱动没处理这个请求,枚举会失败。RT-Thread的驱动已经处理了,但如果你自己改了描述符,要确认这个请求还在。
5. 常见问题排查与实战避坑
5.1 枚举失败问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 设备管理器显示未知设备 | 描述符错误 | 用USB分析仪抓包,看GET_DESCRIPTOR返回 |
| 枚举成功但无COM口 | CDC类请求未响应 | 检查SET_LINE_CODING回调是否实现 |
| 枚举时断时续 | 时钟不稳或VBUS纹波 | 示波器量DP/DM和VBUS |
| 能识别但收发无数据 | 端点配置错误 | 确认Bulk IN/OUT端点地址和大小 |
| 大数据量传输断连 | 缓冲区溢出或线程优先级 | 加大环形缓冲区,调高USB线程优先级 |
5.2 我踩过的三个典型坑
第一个坑是端点地址冲突。GD32H759的USB FS控制器对端点地址有要求,Bulk IN和Bulk OUT不能共用同一个端点号。我一开始把IN设成EP1,OUT也设成EP1,结果枚举成功但数据发不出去。后来改成IN用EP1,OUT用EP2就好了。
第二个坑是字符串描述符长度。USB字符串描述符是UTF-16编码,每个字符占2字节。我一开始按ASCII长度算,导致描述符长度字段不对,PC端识别出来的厂商名是乱码。正确做法是:字符串长度 = 字符数 * 2 + 2。
第三个坑是USB线程栈溢出。CDC ACM在枚举阶段会分配一些临时缓冲区,如果栈太小,会触发hard fault。我把usb线程栈从2048加到4096之后就没再出过问题。
5.3 稳定性优化的几个技巧
工控现场电磁环境复杂,USB线缆质量参差不齐。我在软件上做了几件事提升稳定性:一是给USB中断加优先级,确保不被其他中断打断;二是在CDC接收回调里加超时保护,如果连续收到错误帧就复位USB设备;三是定期发送心跳包,让主机知道设备还在。
另外,GD32H759的USB FS支持SOF中断,可以利用SOF做1ms的定时基准,比用系统tick更准。不过这个要看具体需求,一般工控协议对时间精度要求没那么高。
6. 性能实测与场景延展
6.1 实测数据
在GD32H759跑400MHz、USB FS模式下,我用CDC ACM做批量传输测试。发送1MB数据,PC端接收耗时约1.2秒,平均速率约830KB/s。这个速率受限于FS的12Mbps理论带宽和协议开销,实际能到800KB/s以上已经不错了。接收方向类似,PC端发送1MB,MCU接收耗时约1.3秒。
如果换成USB HS,理论能到480Mbps,但GD32H759的HS需要外接ULPI PHY,工控板子上一般不会这么设计。所以FS的800KB/s对虚拟串口来说完全够用,传参数、日志、甚至小文件都没问题。
6.2 多路CDC ACM的实现
有些工控场景需要多个虚拟串口,比如一路调试、一路业务。CDC ACM支持多实例,RT-Thread的驱动里可以通过修改描述符实现。具体做法是在配置描述符里增加一个CDC接口,端点池里多分配一组Bulk IN/OUT。不过要注意,GD32H759的USB FS端点数量有限,最多支持6个端点,多路CDC要算好端点分配。
我试过做两路CDC,一路给调试用,一路给业务用,跑起来没问题。但两路同时大数据量传输时,带宽会互相挤占,需要应用层做流控。
6.3 与工控协议的对接
CDC ACM跑通之后,上层对接Modbus RTU或者自定义协议都很方便。我的做法是在CDC设备之上再封装一层,把环形缓冲区里的数据按帧解析,解析出来的报文丢给协议栈处理。这样CDC驱动只管收发,协议解析独立,代码耦合度低。
如果协议里有超时重传机制,要注意CDC的延迟。USB Bulk传输的延迟通常在几毫秒到几十毫秒,比物理串口略大。如果协议超时设得很短,比如10ms,可能会误判。建议把超时放宽到50ms以上。
实操心得:CDC ACM在Windows下有个特性,如果串口助手打开后不发送任何数据,USB线拔掉再插上,COM口可能会变。这是因为Windows重新枚举时分配了新的COM号。工控现场如果依赖固定COM号,建议在PC端软件里做设备路径匹配,而不是硬编码COM号。
7. 代码组织与工程化建议
7.1 目录结构
我一般把USB CDC相关的代码放在applications/usb_cdc目录下,包含三个文件:usb_cdc_init.c(初始化和设备注册)、usb_cdc_rx.c(接收回调)、usb_cdc_tx.c(发送线程)。这样职责清晰,后续维护方便。
7.2 配置参数集中管理
VID、PID、设备名、缓冲区大小这些参数,我习惯放在一个usb_cdc_cfg.h里,用宏定义。这样换项目的时候只改一个文件就行,不用满工程找。
#define CDC_VID 0x1234 #define CDC_PID 0x5678 #define CDC_DEV_NAME "vcom" #define CDC_RB_SIZE 2048 #define CDC_TX_MQ_SIZE 16 #define CDC_TX_THREAD_STACK 1024 #define CDC_TX_THREAD_PRIO 207.3 调试接口
调试阶段我建议保留一个物理串口作为日志输出,CDC ACM作为业务通道。因为CDC在枚举阶段如果出问题,日志打不出来,排查会很痛苦。等CDC稳定了,再把日志也切到CDC上。
另外,可以在CDC接收回调里加一个简单的命令解析,比如收到"reset"就复位设备,收到"info"就返回版本号。这样现场调试不用接调试器,用串口助手就能做基本维护。
8. 写在最后的几点个人体会
CDC ACM这个方案我在三个工控项目里用过,从GD32F450到GD32H759,RT-Thread的USB协议栈越来越稳。但每次移植还是会遇到新问题,核心原因还是USB协议本身复杂,枚举阶段的任何一个小错误都会导致设备无法识别。
我的建议是:第一次跑CDC的时候,一定要用USB分析仪或者至少用Wireshark加USB抓包插件,把枚举过程完整看一遍。知道主机发了什么请求、设备回了什么描述符,排查问题就有方向了。光看代码猜,效率太低。
还有一点,GD32H759的USB驱动在RT-Thread的BSP包里更新不算频繁,如果遇到驱动层面的bug,可以对比STM32的OTG驱动,两者寄存器操作有相似之处,改起来有参考。但要注意GD32的USB控制器有自己的特性,不能直接照搬。
最后,CDC ACM的波特率虽然是个"摆设",但在工控协议对接时,还是要正确响应SET_LINE_CODING请求,否则有些上位机软件会认为设备不兼容。这个细节容易被忽略,但现场调试时经常卡在这里。