1. 为什么在工控板卡上折腾USB CDC ACM,而不是继续用UART
做工业控制这行时间长了,你会发现一个很现实的问题:调试口永远不够用。一块主控板上,UART0接了HMI屏,UART1挂了RS485总线,UART2留给了一个Modbus从站,等到你想再引一路出来打日志、传参数、做固件升级的时候,引脚已经排满了。更麻烦的是,很多工控现场机箱封死之后,你根本不想再拧开盖子去接那三根杜邦线。
我在几个基于GD32H759的项目里都遇到过这个场景。GD32H759这颗片子本身外设很足,USB FS/HS控制器是片上自带的,RT-Thread又对USB协议栈有比较完整的支持,所以把USB口做成一个CDC ACM虚拟串口,在PC端看起来就是一个标准的COM口,这件事的性价比其实非常高。你不需要装驱动(Windows 10以上自带usbser.inf),不需要额外的USB转串口芯片,一根Type-C线插上去,设备管理器里就多出来一个串口,波特率随便设,实际速率远超物理UART。
这篇内容就是围绕"GD32H759 + RT-Thread + USB CDC ACM"这个组合,把从零跑通一个虚拟串口的过程拆开讲。适合两类人看:一类是刚接触RT-Thread USB协议栈、想找个能落地的例程照着做的嵌入式工程师;另一类是已经在用GD32H7系列、想把调试通道从UART迁移到USB的老手。我会把设备栈的注册逻辑、描述符的坑、收发缓冲的设计、以及实际联调时那些文档里不会写的问题都摊开说。
先说结论:USB CDC ACM在RT-Thread上跑通不难,难的是跑稳。枚举成功只是第一步,真正在生产环境里用起来,你要处理的是热插拔、PC端串口软件打开关闭时的状态机跳变、以及大数据量下的流控问题。下面按我的实际调试顺序来展开。
2. GD32H759的USB外设底子与RT-Thread USB设备栈的对接逻辑
2.1 先搞清楚这颗片子的USB控制器能力边界
GD32H759系列内部集成了USB FS(全速12Mbps)和USB HS(高速480Mbps)控制器,其中HS控制器通常需要外接ULPI PHY才能跑到480Mbps,而FS控制器可以直接用片内PHY,D+和D-直接引到连接器上。对于CDC ACM这种应用,如果你只是做调试串口和参数传输,FS的12Mbps其实已经够用了——换算下来理论峰值大概1.2MB/s,实际能跑到700KB/s到900KB/s,比115200的UART快了将近一个数量级。
但这里有个选型上的细节要注意:GD32H759的USB FS和USB HS在引脚复用上是分开的,你在做原理图阶段就要确定用哪个。如果板子已经打样了,只能看D+ D-接在哪组引脚上。我手上这块板子用的是FS,PA11/PA12,这也是最常见的接法。HS那组一般走ULPI,引脚多,布线要求也高,除非你要做视频或者大容量存储,否则CDC ACM没必要上HS。
另外一个容易被忽略的点是VBUS检测。很多工控板为了省事,USB座子的VBUS引脚直接悬空或者只接了个电容,结果就是设备插拔的时候,固件根本感知不到。RT-Thread的USB设备栈里,USB_DEVICE_ATTACH和USB_DEVICE_DETACH这两个状态是靠VBUS或者SOF中断来驱动的。如果你不做VBUS检测,至少也要保证在USB中断里能正确响应复位和挂起事件,否则会出现"插上去没反应,拔了再插才行"这种玄学问题。
2.2 RT-Thread USB Device栈的分层结构
RT-Thread的USB设备协议栈大致分三层:最底下是USB Device Controller驱动层,也就是usbd_dcd,这一层跟具体芯片相关,GD32的DCD驱动需要你根据RT-Thread的接口去适配;中间是USB Device Core层,负责端点管理、描述符解析、标准请求处理;最上面是Class层,CDC ACM就属于这一层,它实现了USB CDC类规范里定义的通信接口和数据接口。
CDC ACM这个类比较特殊,它实际上由两个接口组成:一个Communication Class Interface,用来做线路控制、波特率设置这些管理功能;另一个Data Class Interface,用来真正传数据。每个接口下面挂不同的端点。通信接口通常用一个中断端点做通知,数据接口用一对批量端点做收发。你在配置描述符的时候,这两个接口要一起描述,而且接口号、端点地址都不能冲突。
RT-Thread里CDC ACM的实现在usbdevice_cdc.c这个文件里,它已经帮你把标准请求、类请求、端点回调都搭好了框架。你要做的主要是两件事:一是把DCD驱动对接好,让底层能正确响应SETUP包和端点中断;二是在应用层调用rt_usb_device_cdc_init之类的接口,把设备注册进去,然后拿到一个rt_device句柄,像操作普通串口一样去读写。
2.3 端点资源的分配与FIFO规划
GD32H759的USB FS控制器内部有专门的PMA(Packet Memory Area),也就是端点FIFO缓冲区。这个区域大小有限,FS模式下通常是1.25KB左右,你要给每个用到的端点分配FIFO。CDC ACM至少要用到三个端点:一个控制端点EP0(双向),一个中断端点(IN,用于通知),一对批量端点(IN和OUT,用于数据)。
这里有个实操经验:批量端点的FIFO不要分配得太小。我一开始按默认的64字节分配,结果在PC端用串口助手连续发大包数据的时候,经常出现丢包。后来把批量OUT端点的FIFO加到256字节,IN端点也加到256字节,丢包问题就消失了。原因是FS模式下批量端点的最大包长就是64字节,但如果你的FIFO只有64字节,那么当一个包还没被应用层读走,下一个包就来了,硬件层面就会NAK甚至覆盖。给FIFO留出几个包的余量,能显著改善突发流量的处理能力。
具体分配的时候,EP0一般固定给64字节,中断端点给16或32字节就够(CDC通知包很小),剩下的全部分给批量端点。GD32的USB库里有类似usbd_ep_fifo_config的接口,RT-Thread的DCD驱动里也会有对应的配置入口,你要根据实际使用的端点号去设置。
3. 从零搭建CDC ACM设备的完整操作链路
3.1 工程配置与依赖项检查
在开始写代码之前,先把RT-Thread的配置工具打开,确认几个关键选项。在RT-Thread Components -> USB -> USB Device下面,要勾选Enable USB Device,然后在Class里面勾选CDC ACM device。同时,Using USB Device Controller要选对,GD32H759对应的DCD驱动如果官方没有现成的,你可能需要自己移植一份。
另外,RT_USB_DEVICE_CDC这个宏要打开,它决定了CDC类的代码是否参与编译。还有一个容易漏掉的是RT_USB_DEVICE_CDC_ACM,有些版本的RT-Thread把它单独拆出来了,不勾的话编译能过,但注册的时候会找不到符号。
堆栈方面,USB设备线程的栈建议给到2048字节以上。CDC ACM在枚举阶段会处理不少描述符和字符串,栈太小容易在枚举过程中HardFault。我实测下来,1536字节在简单场景下能跑,但一旦PC端发送了比较长的类请求,就有风险。
3.2 描述符的定制:VID、PID和字符串
描述符是USB枚举的灵魂,也是新手最容易翻车的地方。RT-Thread的CDC ACM例程里通常给了一套默认描述符,VID和PID是随便填的。你在实际项目里,如果不想跟PC上已有的驱动冲突,最好改成自己申请的VID/PID,或者至少用一个不常见的组合。
设备描述符里,bDeviceClass要填0x02(CDC),bDeviceSubClass填0,bDeviceProtocol填0。配置描述符里,bNumInterfaces要填2,因为CDC ACM有两个接口。接口关联描述符(IAD)在CDC ACM里是可选的,但Windows对IAD比较敏感,加上它枚举会更稳。
字符串描述符里,iManufacturer、iProduct、iSerialNumber这三个索引要跟字符串描述符数组对应上。我踩过一个坑:字符串描述符的长度计算错了,少算了一个字符,结果Windows枚举到一半就报"设备描述符请求失败"。后来用USB抓包工具一看,是字符串长度字段和实际数据对不上。这种问题在Linux下可能能容忍,但Windows的USB栈比较严格,一点错就拒绝枚举。
提示:改完描述符之后,一定要用USB分析仪或者软件抓包工具确认一下枚举流程,不要只看设备管理器有没有认出来。有些错误枚举能过,但后续通信会不稳定。
3.3 设备注册与线程模型的建立
描述符准备好之后,就可以在应用层注册CDC ACM设备了。典型的调用顺序是:先初始化USB设备栈,然后调用CDC ACM的初始化函数,拿到rt_device句柄,最后打开设备并设置接收回调。
rt_device_t cdc_dev; cdc_dev = rt_device_find("vcom"); if (cdc_dev == RT_NULL) { rt_kprintf("find vcom failed\n"); return -RT_ERROR; } rt_device_open(cdc_dev, RT_DEVICE_FLAG_RDWR | RT_DEVICE_FLAG_INT_RX); rt_device_set_rx_indicate(cdc_dev, cdc_rx_callback);这里的vcom是设备名称,RT-Thread的CDC ACM默认注册成这个名字。接收回调里不要做太重的处理,最好只是发个信号量或者消息队列,让专门的线程去读数据。因为USB的接收回调是在中断上下文或者USB线程上下文里执行的,你如果在里面直接调用可能阻塞的API,整个USB栈都会卡住。
发送的时候,直接用rt_device_write就行。但要注意,rt_device_write返回的是实际写入的字节数,如果底层FIFO满了,它可能只写入一部分。所以对于大块数据,你要么循环写直到写完,要么自己做一个发送缓冲队列,在USB IN端点完成中断里继续发下一包。
3.4 收发缓冲区的设计:环形缓冲还是消息队列
在实际工控场景里,USB CDC ACM收到的数据往往是变长的命令帧,发送的可能是周期性的状态上报。我比较推荐的做法是:接收方向用一个环形缓冲区,USB回调里只管往环形缓冲里塞数据,应用线程从环形缓冲里按帧解析;发送方向用一个消息队列或者链式缓冲,把待发送的数据块排队,USB IN完成中断里触发下一块发送。
环形缓冲的大小要根据你的最大帧长和突发量来定。我一般给到2KB到4KB。太小了容易在高速收发时溢出,太大了浪费RAM。GD32H759的RAM很充裕,但如果你还跑了其他中间件,也要算总账。
发送方向有个细节:CDC ACM的批量IN端点在FS模式下最大包长64字节,如果你一次要发1KB的数据,底层会自动拆成16个包。但RT-Thread的CDC实现里,rt_device_write可能不会帮你做这个拆分,它只是把数据拷贝到发送缓冲,然后启动第一次传输,后续的包要在IN完成中断里继续发。所以你要确认你用的RT-Thread版本里,CDC ACM的发送逻辑是否支持自动续传。如果不支持,你就得自己在应用层分片,每次最多写64字节,然后在回调里写下一片。
4. 联调阶段那些让人抓狂的典型问题与排查路径
4.1 枚举成功但PC端串口打不开
这个现象很常见:设备管理器里能看到"USB串行设备",但用串口助手打开的时候提示"拒绝访问"或者"端口被占用"。原因通常有两个:一是Windows自带的usbser驱动没有正确绑定,二是CDC ACM的通信接口里,线路控制相关的类请求没有正确响应。
排查的时候,先在设备管理器里看设备属性,如果驱动是"未知设备"或者带了黄色感叹号,那就是描述符或者类请求的问题。如果驱动正常,但串口打不开,那多半是SET_LINE_CODING这个类请求处理有问题。PC端在打开串口的时候,会先发SET_LINE_CODING设置波特率、数据位、停止位,如果你的固件没有正确ACK这个请求,PC端就会认为设备没准备好。
我在GD32H759上遇到过这个问题,后来发现是DCD驱动里对SETUP包的处理有遗漏,类请求被当成了标准请求。修复方法是在DCD的SETUP阶段中断里,正确区分请求类型,把类请求转发给CDC类层去处理。
4.2 数据收发一段时间后卡死
这个问题的表现是:刚开始通信正常,跑几分钟或者几十分钟之后,PC端发数据没反应,设备端也收不到。用调试器看,USB线程可能卡在某个信号量上,或者端点状态寄存器显示NAK。
根因通常是端点FIFO溢出或者NAK超时没有恢复。FS模式下,如果OUT端点的FIFO满了,硬件会自动回NAK,PC端会重试。但如果你的应用层长时间不读数据,FIFO一直是满的,PC端的重试次数超限之后,有些主机会直接放弃这个传输,甚至重新枚举。这时候如果你没有正确处理重新枚举,设备就"假死"了。
解决办法有两个层面:一是应用层要保证及时读取数据,不要在接收回调里做耗时操作;二是可以在DCD驱动里加一个超时检测,如果某个端点连续NAK超过一定次数,就主动清一下FIFO或者触发一次端点复位。RT-Thread的USB栈里有一些调试宏,打开之后可以打印端点状态变化,对定位这类问题很有帮助。
4.3 热插拔之后设备不重新枚举
工控现场经常需要带电插拔USB线,如果固件对DETACH事件处理不当,第二次插上去的时候PC端就认不到设备了。这个问题的核心在于:USB设备被拔出时,DCD驱动要能检测到VBUS掉电或者SOF中断停止,然后调用rt_usb_device_detach把设备状态复位。如果没做这一步,设备栈还停留在"已配置"状态,再次插入时就不会重新走枚举流程。
GD32H759的USB控制器支持VBUS检测中断,如果你硬件上把VBUS接到了检测引脚,那就在中断里处理。如果硬件没接,那就只能靠SOF中断超时来判断。RT-Thread的DCD驱动里一般会有SOF中断处理,你可以在里面加一个计数器,超过一定时间没有SOF就认为设备被拔出了。
注意:热插拔处理不好,轻则枚举失败,重则导致USB控制器进入异常状态,需要复位整个USB外设才能恢复。所以在产品化阶段,一定要把DETACH处理做扎实。
4.4 波特率设置无效但数据能通
CDC ACM的波特率其实是个"名义参数",USB传输本身不依赖波特率,你设成9600还是115200,实际传输速率都是一样的。但PC端的串口软件会把这个值显示出来,有些上位机还会根据波特率做超时计算。所以即使波特率不影响实际通信,你也要在SET_LINE_CODING请求里正确保存这个值,并且在GET_LINE_CODING的时候返回回去。
我见过一个案例:固件里没有保存波特率,PC端设置115200之后,再读回来发现是9600,上位机就认为设备没配置成功,一直重试。这种问题不影响底层数据,但会让上位机逻辑混乱。所以类请求的处理要完整,该保存的字段都要保存。
5. 把虚拟串口真正用起来:性能调优与工程化建议
5.1 吞吐量实测与瓶颈定位
在GD32H759的FS模式下,我用CDC ACM做过实测:PC端用脚本连续发送1KB的数据包,设备端收到后原样回传,统计下来稳定在600KB/s到800KB/s之间。这个数字受几个因素影响:PC端USB主控制器的调度、端点FIFO大小、应用层读取的及时性。
如果你发现吞吐量明显偏低,比如只有几十KB/s,那就要检查是不是每个包都在等应用层处理。USB批量传输本身是异步的,硬件可以在应用层还没读走上一个包的时候,继续接收下一个包(只要FIFO有空间)。所以提高吞吐的关键是让FIFO尽量满,应用层尽量快地消费。
另一个瓶颈可能是RT-Thread的USB线程优先级。如果USB线程优先级太低,被其他线程抢占,端点中断得不到及时处理,也会拉低吞吐。我一般把USB设备线程的优先级设得比普通应用线程高,但比硬实时线程低。
5.2 与工控协议栈的对接方式
CDC ACM虚拟串口在系统里就是一个rt_device,你可以把它当成普通串口来用。如果你的工控协议栈(比如Modbus RTU、自定义帧协议)是基于串口设备抽象写的,那基本上不用改代码,把设备名从uart2换成vcom就行。
但有一点要注意:USB CDC ACM的帧边界跟UART不一样。UART有字符间隔,你可以用空闲中断来判断一帧结束;USB是包传输,每个包有明确的边界,但包与包之间不一定对应你的协议帧。所以协议解析层最好用环形缓冲加状态机的方式,不要依赖"一次read就是一帧"这种假设。
我在一个项目里把Modbus RTU跑在CDC ACM上,一开始按UART的思路,每次read返回的数据就当一帧处理,结果发现有时候两个Modbus帧被合并到一个USB包里,有时候一个帧被拆到两个包里。后来改成环形缓冲加状态机解析,问题就解决了。
5.3 产品化阶段的几个加固点
如果这个虚拟串口是要出货的,有几个地方必须加固。第一是描述符的序列号,每台设备最好有唯一的序列号,这样PC端能区分不同的设备,不会因为插了多台就混淆。序列号可以从MCU的UID或者外部Flash里读。
第二是通信超时和错误恢复。USB通信虽然比UART可靠,但也不是万无一失。应用层要有超时重传机制,底层要有端点异常恢复机制。我一般会在USB线程里加一个看门狗式的检测,如果发现端点状态异常超过一定时间,就重新初始化USB设备栈。
第三是功耗管理。工控设备有时候要求低功耗,USB挂起状态要能正确进入和退出。RT-Thread的USB栈支持挂起回调,你可以在挂起的时候关掉一些外设,恢复的时候再打开。但要注意,挂起期间如果有数据要发,要等恢复之后再发,否则会丢数据。
5.4 调试工具与抓包方法
最后说一下调试手段。USB问题如果只靠打印,效率很低。我建议至少准备两种工具:一个是软件抓包工具,可以在PC端看到USB的枚举过程和传输内容;另一个是硬件USB分析仪,虽然贵一点,但在排查电气层和协议层问题时无可替代。
软件抓包在Windows上可以用Wireshark配合USBPcap,Linux下直接用usbmon。抓包的时候重点看枚举阶段的描述符请求和响应,以及数据传输阶段的端点号和包长。很多时候,问题就藏在某个描述符字段或者某个端点的NAK响应里。
如果条件有限,至少要在固件里加足够的日志。RT-Thread的USB栈有调试开关,打开之后会打印枚举状态机的变化。但要注意,日志本身会占用USB带宽和CPU时间,调试完之后要记得关掉,否则会影响正常通信的性能。
我在实际项目里,从第一次跑通CDC ACM到最终稳定出货,大概花了三周时间,其中大部分时间都花在处理热插拔和异常恢复上。枚举成功真的只是开始,后面那些边界情况才是真正考验人的地方。希望这些经验能让你少走一些弯路。