基于 STM32F4 实现 USB CDC 透明转发 EC20 模块的嵌入式网关设计

在工业物联网和远程终端设备开发中,一个常见的需求是:让上位机通过标准串口工具直接与蜂窝模块通信,而中间主控芯片只做“透明通道”。比如你在调试移远 EC20 LTE 模块时,不想每次都拆开设备插串口线,而是希望像使用普通 USB 转串口一样,即插即用、实时收发 AT 指令。

这时候如果系统里已经有一颗 STM32F4 主控,为什么不干脆让它扮演这个“虚拟串口”角色呢?毕竟它自带 USB OTG 外设,又有多个 UART 接口,完全能实现“PC ←USB→ STM32 ←UART→ EC20”的透明传输链路。这样做不仅省了 CH340 或 CP2102 这类桥接芯片,还能在需要时加入智能逻辑——比如自动心跳、断线重连、本地状态查询等。

这正是我们今天要深入探讨的技术路径:如何利用 STM32F4 内建的 USB 功能模拟一个 CDC(Communication Device Class)虚拟串口,并将数据无缝转发给 EC20 模块,构建一个高集成度、低成本、易调试的 4G 通信网关。


为什么选择 STM32F4 自实现 USB CDC?

很多人第一反应是:“加个 USB 转串芯片不就完了?”确实,外置桥接方案简单可靠,但也有明显短板:增加 BOM 成本、占用 PCB 面积、功能固化。当你已经在用 STM32F4 做主控,且有额外处理能力时,自实现 CDC 就成了更优解。

更重要的是,这种架构赋予了你前所未有的灵活性。想象一下:

  • 上位机输入 AT+CUSTOM? ,你不把它转发出去,而是返回 MCU 当前温度;
  • 收到 +++ 触发软复位或进入固件升级模式;
  • 在后台默默监控网络注册状态,异常时自动重启 EC20;
  • 把所有 AT 指令记录到 Flash,供后续导出分析。

这些高级功能在外置桥接芯片上几乎无法实现,但在 STM32F4 上只需几行代码就能完成。这才是真正意义上的“智能透传”。


USB CDC 是怎么工作的?

CDC 并不是某种神秘协议,它是 USB 官方定义的一类设备类(Class),专门用于模拟传统串口。当你的设备声明自己是一个 CDC ACM(Abstract Control Model)设备后,现代操作系统(Windows 7+/Linux/macOS)会自动加载内置驱动(如 Windows 的 usbser.sys ),创建一个虚拟 COM 口,用户无需安装任何额外驱动即可使用。

STM32F4 的 USB OTG FS 控制器支持全速(12Mbps)设备模式,配合 ST 提供的 USB Device Middleware 中的 CDC 类库,可以快速搭建起一个功能完整的虚拟串口。

整个流程分为两个阶段:

1. 枚举阶段
设备插入 PC 后,主机发起枚举请求。STM32F4 返回一系列描述符,包括设备描述符、配置描述符、接口描述符等。其中关键在于声明了一个 CDC 控制接口(带中断端点)和一个 CDC 数据接口(带批量 IN/OUT 端点)。PC 解析这些信息后,识别出这是一个串口设备,分配 COM 编号并加载驱动。

2. 数据传输阶段
一旦枚举成功,通信就开始了。数据通过两个批量端点进行双向传输:
- EP1_OUT :PC 发送数据 → STM32F4 接收(例如 AT 指令)
- EP1_IN :STM32F4 发送数据 → PC 接收(例如模块响应)

此外还有一个控制端点 EP0,用于处理标准请求(如获取描述符)以及 CDC 特有的控制信号(如 SET_LINE_CODING 设置波特率、SET_CONTROL_LINE_STATE 控制 DTR/RTS)。

别小看 DTR 和 RTS——它们其实很有用。比如你可以把 EC20 的 Power Key 引脚接到某个 GPIO,当 PC 打开串口时触发 DTR 上升沿,从而唤醒休眠中的模块,实现“即插即用”的体验。


如何实现数据转发?关键回调函数解析

基于 STM32CubeMX + HAL 库生成的基础框架,核心逻辑集中在几个回调函数中。

首先是初始化部分,通常由 CubeMX 自动生成:

USBD_Init(&hUsbDeviceFS, &FS_Desc, DEVICE_FS);
USBD_RegisterClass(&hUsbDeviceFS, &USBD_CDC);
USBD_CDC_RegisterInterface(&hUsbDeviceFS, &USBD_Interface_fops_FS);
USBD_Start(&hUsbDeviceFS);

这段代码启动了 USB 设备并注册 CDC 类。真正处理数据的是 CDC_Receive_FS 回调:

int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len)
{
    // 将从 PC 收到的数据转发给 EC20
    HAL_UART_Transmit(&huart1, Buf, *Len, HAL_MAX_DELAY);

    // 重新启用接收
    USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
    USBD_CDC_ReceivePacket(&hUsbDeviceFS);

    return USBD_OK;
}

看起来很简单对吧?但实际上这里藏着坑。 HAL_UART_Transmit 是阻塞调用,如果数据量大或者 UART 波特率较低(比如 115200bps),会导致 USB 接收被长时间挂起,进而引发主机超时甚至断开连接。

更稳健的做法是使用 DMA 或双缓冲机制,把发送任务交给后台异步完成。同时也要注意:不能连续调用 USBD_CDC_TransmitPacket ,必须等待前一次传输完成(TxState == 0),否则会造成硬件冲突。

所以推荐引入一个发送队列:

void send_to_pc(uint8_t *data, uint16_t len)
{
    USBD_CDC_HandleTypeDef *hcdc = (USBD_CDC_HandleTypeDef*)hUsbDeviceFS.pClassData;

    if (hcdc->TxState == 0) {
        USBD_CDC_SetTxBuffer(&hUsbDeviceFS, data, len);
        USBD_CDC_TransmitPacket(&hUsbDeviceFS);
    } else {
        // 加入环形缓冲区或 FreeRTOS 队列
        ring_buffer_write(&tx_ring, data, len);
    }
}

然后在 USBD_CDC_DataIn 回调中检查是否还有待发数据,实现平滑调度。


EC20 模块该怎么对接?

EC20 通过 UART 提供标准 AT 指令接口,默认波特率为 115200 N81。虽然看似简单,但在实际工程中仍有不少细节需要注意。

首先是电源设计。EC20 在发射瞬间电流可达 2A,若供电不足会导致模块重启甚至损坏。强烈建议使用 DC-DC 而非 LDO 供电,并在 VBAT 引脚附近放置至少 1000μF 的低 ESR 电容作为储能。

其次是通信稳定性。不要用轮询方式读 UART,那样极易丢包。正确做法是开启中断 + 单字节 DMA 或双缓冲接收:

uint8_t rx_byte;
uint8_t ec20_rx_buffer[64];
uint32_t ec20_rx_index = 0;

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        ec20_rx_buffer[ec20_rx_index++] = rx_byte;

        // 简单行结束判断
        if (rx_byte == '\n' || ec20_rx_index >= 63) {
            enqueue_usb_send(ec20_rx_buffer, ec20_rx_index);
            ec20_rx_index = 0;
        }

        // 继续接收下一字节
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
    }
}

当然,真正的生产环境中应采用更健壮的解析策略,比如查找 “OK”、“ERROR”、“+IPD” 等关键字来判定报文完整性,避免因换行符缺失导致粘包。

还有一点容易被忽视:AT 指令的响应时间差异极大。有些指令(如 AT )毫秒级返回,而 AT+COPS? 查询运营商可能长达 10 秒以上。应用层必须设置合理的超时机制,并支持重试逻辑。


系统架构与典型应用场景

整个系统的物理连接非常清晰:

  • STM32F4 的 PA11/PA12 接 USB D+/D−(注意 PA12 需内部使能 1.5kΩ 上拉至 3.3V)
  • USART1_TX/RX 分别连接 EC20 的 RXD/TXD(交叉连接)
  • EC20 的 PWRKEY 可由 STM32 控制,实现软件开关机
  • 独立电源管理单元保障峰值电流供应

软件层面则形成分层结构:

+----------------------------+
|        Application         | <-- 命令拦截、日志记录、心跳维护
+----------------------------+
|     USB CDC Middleware     | <-- ST 提供的 CDC 类封装
+----------------------------+
|        USB Device HAL      | <-- PCD 层 API,处理底层事务
+----------------------------+
|        UART Driver         | <-- 使用中断或 DMA 驱动
+----------------------------+
|       RTOS / Scheduler     | <-- 可选 FreeRTOS 实现多任务协调
+----------------------------+

这样的设计特别适合以下场景:

  • 远程 DTU 设备 :现场传感器数据经 STM32 汇聚后,通过 EC20 上报云平台;运维人员可通过 USB 直接接入查看运行状态,无需联网。
  • 车载 OBD 终端 :车辆熄火后模块休眠,插入 USB 线自动唤醒,导出历史行驶数据。
  • 工业 HMI 网关 :HMI 屏通过 USB 连接主控,主控再通过 4G 上报报警事件,实现本地操作与远程监控一体化。

常见问题与实战经验

▶ USB 枚举失败?

这是最常见的问题之一。排除接线错误后,重点排查三点:

  1. 时钟源是否正确?
    USB 全速模式要求精确的 48MHz 时钟。STM32F4 一般通过 PLLSAI 分频产生 CLK48。务必确认 RCC 配置中启用了 RCC_PERIPHCLK_CLK48 并选择了正确的时钟源(通常是 PLLSAIQ 输出)。

  2. D+ 上拉电阻是否存在?
    USB 协议规定高速设备靠上拉电阻区分全速/低速。对于 STM32F4,PA12 的上拉可通过软件使能( SYSCFG_USBPuCmd(ENABLE) ),但某些型号仍需外部 1.5kΩ 电阻。建议查阅参考手册确认。

  3. 电源噪声干扰?
    EC20 发射时的电流突变会影响 MCU 工作电压,可能导致 USB PHY 失效。务必做好电源隔离,必要时使用磁珠或独立 LDO 为 MCU 和模块分别供电。

▶ 数据乱序或丢失?

多半是缓冲机制没做好。记住两条原则:

  • UART 接收一定要用中断或 DMA,禁用轮询;
  • USB 发送要避免频繁调用 TransmitPacket ,推荐结合消息队列实现流量控制。

如果你用了 FreeRTOS,可以单独创建一个 USB 发送任务:

QueueHandle_t usb_tx_queue;

void usb_send_task(void *pvParameters)
{
    uint8_t buf[64];
    while (1) {
        if (xQueueReceive(usb_tx_queue, buf, portMAX_DELAY)) {
            USBD_CDC_SetTxBuffer(&hUsbDeviceFS, buf, strlen((char*)buf));
            while (USBD_CDC_TransmitPacket(&hUsbDeviceFS) != USBD_OK);
            vTaskDelay(pdMS_TO_TICKS(5)); // 避免过于频繁
        }
    }
}

这样既能保证实时性,又能防止资源竞争。


最后的思考:不止于“透传”

把 STM32F4 当成透明通道只是起点。真正有价值的是在此基础上叠加智能化能力。例如:

  • 添加命令白名单,阻止危险指令(如 AT+QPOWD 关机);
  • 实现简易 shell,支持 help version reboot 等本地命令;
  • 结合 RTC 记录每条 AT 指令的时间戳,便于故障追溯;
  • 支持 DFU 模式,允许通过 USB 更新固件。

未来还可以探索复合设备模式,让 STM32F4 同时呈现为 CDC + MSC(U盘),方便参数配置和日志导出;或者进一步实现 PPPoE 直通,使其成为一个微型 4G 路由器。

这种高度集成的设计思路,正引领着智能终端向更紧凑、更可靠、更易维护的方向演进。而这一切,始于一颗原本就存在的 STM32F4。

更多推荐