前言:

本项目基于瑞芯微 RK3568 Linux 主板作为 USB 主机,GD32F303CBT6 作为 USB 从设备,单 USB 物理链路实现双路独立 CDC-ACM 虚拟串口通信。

相较于传统物理 UART,USB 双虚拟串口布线简洁、无电平转换、通道隔离、扩展性强;

本文结合全程调试踩坑、硬件寄存器实测、Linux 驱动行为特性,整理双 CDC 核心原理、MCU 编程要点、Linux 主机适配、关键坑点解决方案,作为项目阶段性总结。

一、整体硬件架构

  • 上位主机:RK3568(Linux 系统)
  • 下位从机:GD32F303CBT6
  • 通信链路:RK3568 USB Host → 直连 GD32 USB Device
  • 设备形态:USB 复合设备(Composite Device)
  • 最终节点:Linux 自动生成 /dev/ttyACM0/dev/ttyACM1 两路独立虚拟串口

二、双 CDC-ACM 核心基础原理

1. CDC-ACM 协议基础

  • CDC:USB 通信设备类,专为串口类透传通信设计;
  • ACM:抽象控制模型,是 Linux/Windows 系统原生识别虚拟串口 (VCOM) 的标准协议;
  • 本质:利用 USB 控制端点 + 批量端点,封装串口帧数据,上层应用完全当作普通串口使用。

2. 双路 CDC 复合设备原理

  1. 单个 USB 物理设备,在设备描述符、接口描述符中,上报两套完整独立的 CDC 接口
  2. 依靠 USB 标准字段 wIndex 区分:wIndex=0 对应 ACM0,wIndex=1 对应 ACM1;
  3. Linux 内核 cdc-acm 驱动自动解析枚举信息,生成两个互不干扰的 ttyACM 设备节点;
  4. 两路串口:波特率、停止位、校验位、收发数据完全独立配置、互不串扰

3. 两类关键 USB 传输

  1. 控制传输(Endpoint 0)负责设备枚举、标准请求下发,核心命令:
  • SET_LINE_CODING:主机下发串口配置参数(波特率 / 数据位 / 停止位 / 奇偶校验)
  • GET_LINE_CODING:主机读取当前设备串口配置
  • SET_CONTROL_LINE_STATE:串口链路状态控制
  1. 批量传输(BULK IN/OUT)承载上下行业务数据,是虚拟串口收发报文的高速通道,双 CDC 各自分配独立端点。

4. 控制回调触发机制

  • req_process:解析主机发来的 USB 标准请求;
  • ctlx_out控制 OUT 数据包接收完成后自动回调
  • 项目关键:SET_LINE_CODING 的 7 字节参数,仅在 ctlx_out 中完成最终生效

三、GD32F303 从机 核心编程要点

1. 工程基础配置

  1. 开启 USB 外设时钟,配置为 USB Device 从机模式;
  2. 基于 GD32 官方 USB 设备库,移植 CDC 类驱动;
  3. 合理划分双 CDC 独立端点、独立接收 / 发送缓冲区、状态标记位。
/** 
 * @brief USB双CDC虚拟串口 总初始化函数(项目入口) 
 * @param 无 
 * @retval 无 
 */
void Bsp_UsbVirtualSerialInit(void) 
{ 
    // 1. USB外设时钟配置:开启USB模块所需的RCU时钟(核心底层时钟)
    usbd_rcu_config(); 
    // 2. USB GPIO配置:配置USB_DM/USB_DP引脚为USB功能模式
    usbd_gpio_config(); 
    // 3. USB设备栈初始化:绑定USB设备句柄、双CDC描述符、CDC类回调接口
    usbd_init(&usbd_dual_cdc, &dual_cdc_desc, &dual_cdc_class); 
    // 4. USB中断配置:配置USB中断优先级,使能USB中断响应
    usbd_nvic_config(); 
    // 5. 使能USB上拉电阻:硬件枚举上线,主机开始识别USB设备
    usbd_connect(&usbd_dual_cdc); 
}

2. 双接口隔离(重中之重)

  • 所有串口配置请求,必须通过 req->wIndex 识别当前是 ACM0 还是 ACM1;
  • 解析请求时保存接口索引,参数配置阶段绑定对应 CDC 实例与硬件 UART。
/*!
    \brief      handle the CDC ACM class-specific requests
    \param[in]  udev: pointer to USB device instance
    \param[in]  req: device class-specific request
    \param[out] none
    \retval     USB device operation status
*/
static uint8_t dual_cdc_req_handler(usb_dev *udev, usb_req *req)
{
    usb_cdc_handler *cdc = NULL;
    uint8_t status = REQ_NOTSUPP, noti_buf[10] = {0U};

    if(0x00U == (req->wIndex & 0xFFU)) {
        cdc = (usb_cdc_handler *)udev->class_data[0];
    } else {
        cdc = (usb_cdc_handler *)udev->class_data[1];
    }

    acm_notification *notif = (void *)noti_buf;

    switch(req->bRequest) {
    case SET_LINE_CODING:
        /* set the value of the current command to be processed */
        udev->class_core->req_cmd = req->bRequest;

        usb_transc_config(&udev->transc_out[0], (uint8_t *)&cdc->line_coding, req->wLength, 0U);

        udev->class_core->req_reserved = req->wIndex & 0xFFU; 

        status = REQ_SUPP;
        break;

    case GET_LINE_CODING:
        usb_transc_config(&udev->transc_in[0], (uint8_t *)&cdc->line_coding, 7U, 0U);

        status = REQ_SUPP;
        break;

    case SET_CONTROL_LINE_STATE:
        notif->bmRequestType = 0xA1U;
        notif->bNotification = USB_CDC_NOTIFY_SERIAL_STATE;
        notif->wIndex = req->wIndex; // 修复:绑定对应CDC接口
        notif->wValue = 0U;
        notif->wLength = 2U;
        noti_buf[8] = (uint8_t)req->wValue & 3U;
        noti_buf[9] = 0U;
        status = REQ_SUPP;
        break;

    default:
        break;
    }

    return status;
}
/*!
    \brief      handle CDC ACM data in DATA OUT transaction
    \param[in]  udev: pointer to USB device instance
    \param[in]  ep_num: endpoint number
    \param[out] none
    \retval     none
*/
static void dual_cdc_data_out(usb_dev *udev, uint8_t ep_num)
{
    usb_cdc_handler *cdc = NULL;

    if((CDC_ACM0_DATA_OUT_EP & 0x7FU) == ep_num) {
        cdc = (usb_cdc_handler *)udev->class_data[0];
    } else {
        cdc = (usb_cdc_handler *)udev->class_data[1];
    }

    cdc->packet_receive = 1U;
    cdc->receive_length = udev->transc_out[ep_num].xfer_count;
}

3. 核心回调函数职责

usb_class dual_cdc_class = {
    .req_cmd       = 0xFFU,
    .init          = dual_cdc_init,
    .deinit        = dual_cdc_deinit,
    .req_process   = dual_cdc_req_handler,  // 解析SET_LINE_CODING等标准请求
    .ctlx_out      = dual_cdc_ctlx_out,     // 串口参数接收完成,执行硬件配置
    .data_in       = dual_cdc_data_in,      // USB上行发送完成回调
    .data_out      = dual_cdc_data_out      // USB下行接收完成回调
};
  1. dual_cdc_req_handler:捕获校验 / 波特率 / 停止位配置请求,缓存当前接口编号;
  2. dual_cdc_ctlx_out:参数接收完毕,执行底层 usb_comport_config 硬件串口配置;
  3. 数据收发回调:双路数据分开处理,避免缓冲区覆盖、数据错乱。

4. GD32F303 硬件特性(实测结论)

适用于 GD32F303 全系列,与 STM32 设计逻辑不同:

  1. 无校验模式:
    • USART 数据位配置:WL=0(8 位数据)
  2. 奇校验 / 偶校验模式:
    • 必须强制配置:WL=1(9 位数据位)
    • 硬件逻辑:8 位有效数据 + 硬件自动附加 1 位校验位,组成 9 位完整帧
  3. 错误现象:开启校验但使用 8 位数据位,会直接触发硬件 PERR (校验错误) / FERR (帧错误),进入中断错误分支,直接丢包 + 乱码;
  4. 最终固定规范:GD32F303 只要开启奇偶校验,必须配置为 9 位数据位。

5. 串口参数映射逻辑

将 Linux 下发的 line_coding 结构体参数:

  • 波特率 dwDTERate
  • 停止位 bStopBits
  • 校验位 bParityType精准映射到对应 USART 寄存器:USART_BAUDUSART_CTL0USART_CTL1

四、RK3568 Linux 主机端关键特性

  1. 设备节点双 CDC 枚举成功:

  • /dev/ttyACM0 → 对应 USB 接口 0
  • /dev/ttyACM1 → 对应 USB 接口 1
  1. Linux cdc-acm 驱动固定行为打开任意一路 ttyACM 串口时,驱动会连续发送两次 SET_LINE_CODING 请求
  • 第一次:中间临时配置,参数还未传输完成,为驱动初始化流程;
  • 第二次:用户串口工具真实配置参数,为最终生效参数;
  • 处理方案:不做过滤、不屏蔽,直接二次覆盖配置,最终以第二次参数为准即可。

五、项目实战核心踩坑总结

坑 1:GD32F303 奇偶校验必乱码 / 帧错误

  • 根因:GD32 硬件规则:开启校验必须设 9 位数据位(8 位数据 + 1 位校验位)
  • 解决方案:配置函数中自动将数据位 + 1,强制切换 9 位模式

坑 2:USB 设置串口参数后,串口接收异常(核心 BUG)

  • 现象:USB 下发串口配置后,发送正常、接收不到数据
  • 根因:配置函数中执行了 usart_disable 关闭串口,硬件自动关闭接收中断,代码未重新开启
  • 解决方案:串口参数配置完成后,重新使能接收中断 USART_INT_RBNE

坑 3:双 CDC 通道串扰、参数错乱

  • 根因:未通过 USB wIndex 区分双路接口,硬编码单一串口
  • 解决方案:根据接口索引分别配置 USART0/USART1,双路完全隔离

坑 4:Linux 驱动两次 SET_LINE_CODING 请求

  • 根因:Linux cdc-acm 驱动标准初始化行为
  • 解决方案:不屏蔽、直接覆盖配置,第二次参数为最终生效值

六、最终总结

  1. 架构:RK3568 + GD32F303 单 USB 实现双路虚拟串口,方案稳定通用
  2. GD32F303 硬件:开启奇偶校验 → 数据位必须设为 9 位
  3. 核心 BUG 修复:USB 重配置串口参数后,必须重启接收中断
  4. Linux 适配:双 CDC 自动生成双 ttyACM 节点,两次配置请求直接覆盖即可

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐