
1. 项目背景与核心需求拆解1.1 为什么要在工控场景下折腾USB CDC ACM先说说这个项目的来龙去脉。手头这块GD32H759是兆易创新的一款高性能MCUCortex-M7内核主频能跑到600MHz外设资源相当丰富。整个系列文章围绕RT-Thread实时操作系统在这颗芯片上的工控实战展开前面几篇分别搞定了环境搭建、GPIO、串口、定时器、ADC、PWM、SPI Flash这些基础外设。到了第8篇轮到USB CDC ACM了。CDC ACM全称Communication Device Class - Abstract Control Model说白了就是让MCU通过USB接口模拟成一个串口设备。你插上USB线电脑上就多出来一个COM口用串口助手就能收发数据。这个功能在工控领域太常见了——设备调试口、参数配置口、日志输出口用USB虚拟串口比传统UART转USB芯片方案省一颗CH340或者CP2102BOM成本直接降下来PCB面积也省了。但为什么要在RT-Thread上做这件事因为工控设备不是跑裸机就完事了。多任务调度、设备管理、协议栈分层这些都需要RTOS来撑。RT-Thread本身对USB协议栈有支持但GD32H759这颗芯片的USB外设跟STM32系列有差异官方RT-Thread的BSP包不一定能直接跑通需要自己适配。这就是这篇要解决的核心问题。1.2 目标读者与前置知识这篇内容适合谁看如果你正在用GD32系列MCU做项目或者打算从STM32迁移到GD32平台又或者你已经在RT-Thread上跑通了基础外设但还没碰USB那这篇就是给你准备的。前置知识方面你需要了解RT-Thread的基本操作线程创建、信号量、设备模型知道USB的基本概念端点、描述符、枚举会用Keil或者IAR这类IDE。如果这些还不熟建议先翻翻前面几篇的基础内容。我假设你已经有一个能跑的RT-Thread工程GD32H759的BSP已经配好串口控制台能正常输出。接下来我们要做的就是把USB Device功能加进去让它枚举成一个CDC ACM设备。1.3 方案选型RT-Thread USB Device协议栈 vs 裸机USB库在RT-Thread上实现USB CDC ACM有两条路可以走。第一条是用RT-Thread自带的USB Device协议栈这是RT-Thread官方维护的组件跟RT-Thread的设备框架深度集成用起来比较统一。第二条是直接用GD32官方的USB库绕过RT-Thread的USB框架自己封装成RT-Thread设备。我选的是第一条路原因有几个。RT-Thread的USB Device协议栈已经实现了CDC ACM的类驱动你只需要提供底层硬件操作接口就行上层几乎不用动。而且它跟RT-Thread的设备模型打通了注册之后可以直接用rt_device_find、rt_device_open这些标准接口操作跟用普通串口一样。如果用GD32官方库虽然底层控制更直接但需要自己处理枚举过程、描述符管理、端点配置这些细节工作量翻倍不说还容易出bug。当然RT-Thread的USB协议栈也不是没有代价。它对底层驱动的抽象层要求比较严格GD32H759的USB外设跟STM32的USB外设在寄存器层面有差异需要仔细对照手册来适配。但这是一次性的工作适配好了之后上层应用开发就很舒服了。2. USB CDC ACM核心原理与RT-Thread协议栈架构2.1 USB CDC ACM的工作机制USB CDC ACM本质上是一个USB设备类规范。USB设备插到主机上之后主机会先读取设备描述符知道这是一个CDC类的设备然后加载对应的类驱动。在Windows上系统自带的usbser.sys就是CDC ACM的驱动所以不需要额外装驱动就能识别成串口。Linux上则是cdc_acm模块。CDC ACM设备内部有两个接口一个通信接口Communication Interface一个数据接口Data Interface。通信接口用来传输控制命令比如设置波特率、握手信号这些数据接口用来传输实际的数据。每个接口下面有若干个端点通信接口通常用一个中断端点来上报状态数据接口用两个批量端点分别做收发。枚举过程中主机会发送一系列标准请求比如GET_DESCRIPTOR、SET_CONFIGURATION、SET_LINE_CODING等。设备端需要正确响应这些请求主机才会认为设备正常工作。RT-Thread的USB Device协议栈已经把这些标准请求的处理都封装好了你只需要提供描述符和底层端点操作。2.2 RT-Thread USB Device协议栈的分层结构RT-Thread的USB Device协议栈大致分三层。最底层是硬件驱动层也就是我们要适配的部分负责操作GD32H759的USB外设寄存器处理中断完成端点的数据收发。中间是协议栈核心层处理USB标准请求、设备状态管理、端点调度。最上层是类驱动层CDC ACM的类驱动就在这一层它实现了CDC ACM规范定义的所有请求处理和数据通道管理。这种分层的好处是换一款MCU只需要改硬件驱动层上面的协议栈和类驱动完全不用动。RT-Thread官方已经为STM32、GD32等系列提供了部分BSP的USB驱动但GD32H759这颗比较新可能需要自己动手。2.3 GD32H759 USB外设的特点GD32H759内部集成了一个USB 2.0 OTG控制器支持高速和全速模式。在CDC ACM场景下全速模式12Mbps就够用了虚拟串口的波特率可以设到很高实际传输速度受限于USB全速的带宽大概能跑到1MB/s左右比传统UART快得多。这颗芯片的USB外设寄存器布局跟STM32F4系列的USB OTG有些相似但不完全一样。比如端点FIFO的分配方式、中断标志位的定义、DMA控制的细节都有差异。适配的时候不能直接照搬STM32的代码必须对照GD32H759的用户手册来改。另外GD32H759的USB外设时钟来源需要配置正确。USB OTG需要48MHz的时钟这个时钟通常由PLL提供。在RT-Thread的时钟初始化代码里需要确保USB时钟被正确使能否则USB外设根本不工作。3. 硬件驱动层适配实操3.1 工程目录结构与文件准备在开始写代码之前先把工程目录理清楚。RT-Thread的USB Device协议栈在rt-thread/components/drivers/usb/usb_device目录下。我们需要在这个目录下新建一个GD32H759的硬件驱动文件比如叫usb_dc_gd32h7.c。同时在BSP的board目录下需要配置USB相关的引脚和时钟。具体来说GD32H759的USB OTG引脚通常是PA11DM和PA12DP这两个引脚需要配置成复用功能并且使能内部上拉。有些封装可能引脚不同需要根据实际原理图确认。时钟方面USB OTG的48MHz时钟来自PLL需要在system_gd32h7xx.c或者BSP的时钟配置函数里确保PLL配置正确。3.2 端点FIFO分配策略USB OTG的端点FIFO是一块共享的RAM区域需要手动分配给各个端点。GD32H759的USB外设总FIFO大小是4KB左右具体数值查手册。CDC ACM需要用到三个端点一个中断端点EP1 IN用于通信接口的状态上报一个批量输出端点EP2 OUT用于接收主机发来的数据一个批量输入端点EP2 IN用于向主机发送数据。FIFO分配的原则是批量端点需要较大的FIFO来保证吞吐量中断端点可以小一些。我一般给EP1 IN分配64字节EP2 OUT分配512字节EP2 IN分配512字节。剩下的FIFO留给其他可能用到的端点。分配的时候要注意FIFO地址不能重叠每个端点的FIFO起始地址需要按4字节对齐。注意FIFO分配错误是USB枚举失败最常见的原因之一。如果设备插上电脑后没有任何反应或者提示“设备描述符请求失败”优先检查FIFO分配。3.3 中断处理与端点数据收发USB中断的处理是整个驱动层最核心的部分。GD32H759的USB外设有多个中断向量包括OTG_FS_IRQHandler等。在中断服务函数里需要读取中断标志位判断是哪种事件然后分别处理。常见的中断事件包括枚举完成ENUMDONE、端点输入完成EPx_IN、端点输出完成EPx_OUT、USB复位USBRST、唤醒WKUP等。对于CDC ACM来说最重要的是端点输入和输出中断。当主机发来数据时EP2 OUT端点会产生中断我们需要从FIFO里把数据读出来放到RT-Thread的接收缓冲区里。当我们要发送数据时把数据写入EP2 IN的FIFO然后触发发送。这里有个细节GD32H759的USB外设支持DMA但RT-Thread的USB Device协议栈默认用的是中断模式。如果数据量大中断模式可能会丢数据这时候可以考虑启用DMA。但DMA的配置更复杂建议先把中断模式跑通再考虑优化。3.4 描述符配置与枚举过程描述符是USB设备的“身份证”主机通过读取描述符来了解设备的能力。CDC ACM需要提供设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符等。RT-Thread的USB Device协议栈已经提供了CDC ACM的默认描述符模板我们只需要修改VID、PID、厂商字符串、产品字符串这些信息。VID和PID可以自己定义但要注意不要跟已有设备冲突。如果是内部项目随便设一个不常见的值就行。字符串描述符需要用UTF-16LE编码RT-Thread的协议栈会自动处理编码转换我们只需要提供ASCII字符串。枚举过程中主机会先读取设备描述符的前8个字节获取端点0的最大包长然后再读取完整的设备描述符。接着读取配置描述符设置配置然后读取字符串描述符。每一步都需要设备正确响应任何一步出错都会导致枚举失败。4. 应用层开发与调试4.1 注册CDC ACM设备到RT-Thread设备框架硬件驱动层适配好之后接下来就是把CDC ACM设备注册到RT-Thread的设备框架里。RT-Thread的USB Device协议栈提供了rt_usb_device_cdc_acm_register之类的接口调用之后会创建一个名为“vcom”的设备。之后就可以用标准设备接口来操作了。注册的时机很重要。一般是在USB中断里检测到枚举完成事件后再调用注册函数。如果注册太早设备还没枚举成功注册会失败。注册太晚主机可能已经超时了。4.2 数据收发线程设计注册成功之后就可以像操作普通串口一样操作虚拟串口了。我一般会创建一个独立的线程来处理数据收发。接收方向用rt_device_read从虚拟串口读数据然后根据协议解析。发送方向用rt_device_write往虚拟串口写数据。这里有个坑RT-Thread的USB Device协议栈在接收数据时默认是阻塞模式。如果接收缓冲区满了rt_device_read会阻塞直到有数据可读。如果不想阻塞可以设置成非阻塞模式用信号量或者消息队列来通知。4.3 与工控协议的对接在工控场景下虚拟串口通常用来传输Modbus、自定义二进制协议等。以Modbus RTU为例虚拟串口收到的数据需要先做帧同步然后解析功能码、寄存器地址、数据长度最后执行相应的操作。发送响应时按照Modbus RTU的格式组帧通过虚拟串口发回去。由于USB虚拟串口的传输速度远高于传统UARTModbus RTU的3.5字符间隔在USB上其实不太适用。实际项目中我一般会在应用层做超时判断而不是依赖物理层的间隔。4.4 调试工具与常见问题排查调试USB CDC ACM最常用的工具就是串口助手。Windows上可以用SSCOM、XCOM这些Linux上直接用minicom或者screen。插上USB线之后如果设备管理器里能看到“USB串行设备”并且分配了COM口说明枚举成功了。如果枚举失败排查思路是这样的先看设备管理器里有没有未知设备如果有说明描述符有问题如果连未知设备都没有说明硬件连接或者时钟配置有问题。可以用USB分析仪抓包看看枚举卡在哪一步。没有分析仪的话可以在代码里加打印看看中断有没有触发端点有没有收到数据。5. 常见问题与避坑经验5.1 枚举失败问题速查表现象可能原因排查方法设备管理器无反应USB时钟未使能检查PLL配置和USB时钟使能位提示“设备描述符请求失败”端点0 FIFO配置错误检查EP0 FIFO大小和地址提示“未知USB设备”描述符内容错误用USB分析仪抓包对比标准描述符枚举成功但无法收发数据端点FIFO分配冲突检查各端点FIFO地址是否重叠数据收发不稳定中断优先级配置不当调整USB中断优先级避免被其他中断打断5.2 实操心得与注意事项第一个坑是时钟配置。GD32H759的USB OTG需要精确的48MHz时钟如果PLL配置有偏差USB枚举会不稳定甚至失败。我建议用示波器或者频率计测量一下USB时钟输出确保在48MHz±0.25%以内。第二个坑是中断优先级。USB中断的优先级不能太低否则在大量数据传输时可能丢包。但也不能太高否则会影响其他关键中断的响应。我一般把USB中断设成中等优先级比SysTick低比普通外设中断高。第三个坑是FIFO分配。前面提过FIFO分配错误是枚举失败的主要原因。我的经验是先把所有端点的FIFO需求列出来算一下总大小确保不超过USB外设的总FIFO容量。然后按照端点的重要性分配批量端点优先。第四个坑是字符串描述符的编码。RT-Thread的协议栈要求字符串描述符用UTF-16LE编码如果直接填ASCII字符串主机会显示乱码。虽然不影响功能但看起来不专业。建议用协议栈提供的转换函数来处理。5.3 性能优化建议如果虚拟串口的数据吞吐量比较大可以考虑几个优化方向。一是启用USB DMA减少CPU中断开销。二是增大端点FIFO减少数据分包。三是优化应用层的数据处理流程避免在USB中断里做耗时操作。实测下来在中断模式下GD32H759的USB CDC ACM可以稳定跑到500KB/s左右。启用DMA之后能到800KB/s以上。对于大多数工控场景这个速度已经绰绰有余了。6. 完整代码框架与关键片段6.1 硬件驱动层初始化代码#include rtthread.h #include rtdevice.h #include usb_dc.h /* USB OTG FS 初始化 */ rt_err_t usb_dc_init(void) { /* 使能USB时钟 */ rcu_periph_clock_enable(RCU_USBFS); /* 配置USB引脚 */ gpio_af_set(GPIOA, GPIO_AF_10, GPIO_PIN_11 | GPIO_PIN_12); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_11 | GPIO_PIN_12); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_11 | GPIO_PIN_12); /* 复位USB外设 */ usb_global_reset(); /* 配置FIFO */ usb_fifo_config(USB_EP0, 64); usb_fifo_config(USB_EP1_IN, 64); usb_fifo_config(USB_EP2_OUT, 512); usb_fifo_config(USB_EP2_IN, 512); /* 使能中断 */ nvic_irq_enable(USBFS_IRQn, 6, 0); return RT_EOK; }6.2 中断服务函数关键逻辑void USBFS_IRQHandler(void) { rt_uint32_t intsts usb_interrupt_flag_get(); /* 枚举完成 */ if (intsts USB_INT_ENUMDONE) { usb_interrupt_flag_clear(USB_INT_ENUMDONE); rt_usb_device_cdc_acm_register(); } /* 端点输出中断 - 收到主机数据 */ if (intsts USB_INT_EP2_OUT) { usb_interrupt_flag_clear(USB_INT_EP2_OUT); rt_uint32_t len usb_ep_out_data_len_get(USB_EP2); usb_ep_read(USB_EP2, usb_rx_buffer, len); rt_mq_send(usb_rx_mq, usb_rx_buffer, len); } /* 端点输入中断 - 数据发送完成 */ if (intsts USB_INT_EP2_IN) { usb_interrupt_flag_clear(USB_INT_EP2_IN); rt_sem_release(usb_tx_sem); } }6.3 应用层数据收发示例static void vcom_thread_entry(void *parameter) { rt_device_t vcom rt_device_find(vcom); rt_device_open(vcom, RT_DEVICE_FLAG_RDWR | RT_DEVICE_FLAG_INT_RX); rt_uint8_t buf[256]; while (1) { rt_size_t len rt_device_read(vcom, 0, buf, sizeof(buf)); if (len 0) { /* 处理收到的数据比如解析Modbus帧 */ process_modbus_frame(buf, len); } } }7. 后续扩展方向这套USB CDC ACM的框架跑通之后可以往几个方向扩展。一是增加第二个CDC ACM接口实现复合设备同时提供两个虚拟串口。二是把CDC ACM跟RNDIS结合起来做成USB网卡加串口的复合设备。三是把虚拟串口跟无线模块对接实现远程调试通道。我在实际项目中还遇到过一种需求设备正常工作时虚拟串口用来输出日志进入配置模式后虚拟串口用来接收参数。这种场景下需要在应用层做模式切换根据当前模式决定数据的流向。实现起来不难但要注意模式切换时的数据缓冲避免丢数据。最后分享一个小技巧如果虚拟串口的数据量很大建议在应用层加一个环形缓冲区把USB中断收到的数据先存起来再由低优先级的线程慢慢处理。这样可以避免在中断里做复杂操作提高系统的实时性。