☰
STM32F407 USB VCP虚拟串口实战:从CubeMX配置到高速数据收发
2026/9/29 2:26:27 网站建设 项目流程

1. 为什么要在 STM32F407 上折腾 USB VCP

STM32F407 这颗芯片在工控和嵌入式圈子里算是老熟人了,168MHz 主频、1MB Flash、192KB SRAM,外设资源丰富到让人挑不出大毛病。但真正让它在调试阶段大放异彩的,是那颗内置的 USB OTG FS 控制器。很多项目里,我们用 UART 打印调试信息,波特率一高就丢包,接个 USB 转 TTL 模块还得多占一个串口。而 USB VCP(Virtual COM Port,虚拟串口)的思路是:让 MCU 直接通过 USB 接口模拟出一个串口设备,PC 端识别为标准 COM 口,不需要额外转换芯片,插上就能用。

这个方案解决的核心问题是调试通道的带宽和便利性。USB FS 全速模式下理论带宽 12Mbps,实际 CDC 类通信跑个几百 KB/s 很轻松,比传统 UART 的 115200 或 921600 快出一个量级。而且 USB 接口供电和通信一体,一根线搞定,对于 STM32F407 这种带 OTG 的芯片来说,不用白不用。

适合谁来参考这篇内容?如果你手上有 STM32F407 的开发板(正点原子探索者、野火霸天虎、或者自己画的板子都行),用过 STM32CubeMX 生成过工程,对 HAL 库有基本了解,那这篇实战记录可以直接抄作业。如果你之前只用过标准库,也没关系,我会把 CubeMX 的配置逻辑讲清楚,底层寄存器操作 HAL 已经封装好了,重点在于理解 USB 设备枚举的流程和 CDC 类的数据通路。

我这次用的硬件是 STM32F407ZGT6 核心板,外部晶振 8MHz,USB 接口用的是 OTG FS 的 PA11(DM)和 PA12(DP),VBUS 检测没接,直接靠 USB 供电。软件环境是 STM32CubeMX 6.8.1 + Keil MDK 5.38 + STM32CubeF4 固件包 1.27.1。下面从工程配置开始,一步步把 VCP 跑通。

2. 工程配置与 USB VCP 协议栈选型

2.1 CubeMX 里的关键配置项

打开 CubeMX 新建工程,选好 STM32F407ZGTx 之后,第一件事是配时钟。外部晶振 HSE 选 8MHz,PLL 配置成 168MHz 系统时钟,USB 时钟必须是 48MHz,这个在时钟树里 CubeMX 会自动帮你算好分频系数。注意 USB 时钟不能有偏差,否则枚举会失败,这是很多新手踩的第一个坑。

接着配 USB OTG FS。在 Connectivity 里选 USB_OTG_FS,Mode 选 Device_Only,因为我们要做从设备。Speed 选 Full Speed,PHY 选 Internal PHY。中断优先级建议设成 5 或者更低(数值大优先级低),因为 USB 中断比较频繁,别让它抢占关键任务。

中间件里选 USB_DEVICE,Class for FS IP 选 Communication Device Class (Virtual Port Com)。这个选项会自动把 CDC 类的描述符、端点配置、回调函数框架都生成好。CDC 类用了三个端点:EP0 控制端点、EP1 IN 中断端点(用于发送串口控制信息)、EP2 OUT 批量端点和 EP3 IN 批量端点(用于数据收发)。批量端点最大包长 64 字节,这是 USB FS 的硬限制。

2.2 为什么选 CDC 类而不是自己写描述符

USB 协议栈里,CDC 类是专门为通信设备定义的,虚拟串口就是它的典型应用。自己从零写描述符当然可以,但没必要。CDC 类规范里定义好了接口关联描述符(IAD)、通信接口和数据结构,PC 端的驱动(Windows 的 usbser.sys、Linux 的 cdc_acm)直接就能识别。用 CubeMX 生成的标准 CDC 描述符,VID 和 PID 可以用默认的 STMicroelectronics 的 0x0483 和 0x5740,也可以自己改,但改 VID 需要花钱注册,自己玩就用默认的。

有个细节要注意:CubeMX 生成的 CDC 描述符里,字符串描述符默认是 "STM32 Virtual ComPort"。如果你想让 PC 端显示自定义名字,可以在 usbd_desc.c 里改。但改完要重新枚举,而且 Windows 可能会缓存旧设备信息,需要到设备管理器里卸载设备再重新插拔。

2.3 堆栈和中断向量配置

USB 协议栈需要一定的内存。CubeMX 默认给 USB 分配的堆是 0x200 字节,对于 CDC 类来说够用,但如果你后面要加自定义命令或者多接口,建议把 Heap Size 调到 0x400 或 0x800。栈大小保持默认 0x400 就行。

中断向量表里,USB_OTG_FS_IRQHandler 是必须的,CubeMX 会自动生成。另外注意,如果你用了 FreeRTOS,USB 中断的优先级要设置成比 configMAX_SYSCALL_INTERRUPT_PRIORITY 数值更低(优先级更高),否则在中断里调用 FreeRTOS API 会触发断言。这个坑我在后面 FreeRTOS 集成那节会细说。

3. 代码实现:从枚举到数据收发

3.1 生成的代码框架解读

CubeMX 生成工程后,USB 相关的代码分布在几个文件里。usbd_cdc_if.c是用户接口层,里面有CDC_Receive_FS回调函数,USB 收到数据后会进这里。usbd_cdc.c是 CDC 类的协议实现,一般不用动。usbd_desc.c是描述符,改 VID/PID 和字符串就在这里。usbd_conf.c是底层配置,包括端点初始化和中断处理。

主循环里,CubeMX 生成的代码会调用MX_USB_DEVICE_Init(),这个函数初始化 USB 设备栈并启动。之后 USB 枚举是自动完成的,PC 端会弹出新设备,安装驱动后出现一个 COM 口。你可以在设备管理器里看到 "STM32 Virtual ComPort" 或者你自定义的名字。

3.2 发送数据的正确姿势

发送数据用CDC_Transmit_FS(uint8_t* Buf, uint16_t Len)这个函数。它内部会检查 USB 是否已连接、上一个发送是否完成,然后把数据拷贝到端点缓冲区,触发 IN 传输。注意这个函数是非阻塞的,调用后立即返回,实际发送在中断里完成。所以你不能连续调用它发送大块数据,必须等上一次发送完成。

我见过很多人在这里翻车:在CDC_Transmit_FS后面紧接着又调一次,结果第二次返回 USBD_BUSY,数据丢了。正确的做法是检查返回值,如果是 USBD_OK 就继续,如果是 USBD_BUSY 就等一会儿再试。或者用一个发送完成回调CDC_TransmitCplt_FS,在里面置标志位,主循环检测到标志位再发下一包。

uint8_t tx_buf[64]; uint8_t tx_busy = 0; void CDC_TransmitCplt_FS(uint8_t *Buf, uint32_t *Len, uint8_t epnum) { tx_busy = 0; } void send_data(uint8_t *data, uint16_t len) { while (tx_busy); tx_busy = 1; CDC_Transmit_FS(data, len); }

上面这段代码是简化版,实际用的时候要注意tx_busy的原子性。如果主循环和中断都会访问,最好用__disable_irq()保护一下,或者用 FreeRTOS 的信号量。

3.3 接收数据的处理流程

接收数据在CDC_Receive_FS回调里处理。USB OUT 端点收到数据后,协议栈会调用这个函数,参数 Buf 是数据指针,Len 是长度。注意这个回调是在中断上下文里执行的,所以里面不能做耗时操作,比如浮点运算、大块内存拷贝、调用 printf 等。正确的做法是把数据拷贝到一个环形缓冲区,然后置个标志位,让主循环去处理。

#define RX_BUF_SIZE 512 uint8_t rx_ring[RX_BUF_SIZE]; volatile uint16_t rx_head = 0, rx_tail = 0; static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { for (uint32_t i = 0; i < *Len; i++) { uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { rx_ring[rx_head] = Buf[i]; rx_head = next; } } USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return USBD_OK; }

这里有个关键点:处理完数据后必须调用USBD_CDC_SetRxBuffer重新设置接收缓冲区,然后调用USBD_CDC_ReceivePacket开启下一次接收。漏掉任何一步,后续数据就收不到了。这是 CDC 类接收的标准流程,CubeMX 生成的代码里已经包含了,但如果你自己改动了回调,千万别把这俩忘了。

3.4 端点缓冲区对齐问题

STM32F407 的 USB OTG 外设对缓冲区地址有对齐要求。CubeMX 生成的代码里,USBD_CDC_Init函数会调用USBD_malloc分配缓冲区,这个函数在usbd_conf.c里实现,用的是malloc。但malloc返回的地址不保证 4 字节对齐,而 USB OTG 要求缓冲区地址必须是 4 字节对齐的。如果你发现枚举成功但数据收发异常,先检查这里。

解决办法很简单:把USBD_malloc改成用静态数组,或者用__attribute__((aligned(4)))修饰。我一般直接用静态数组,避免动态内存的碎片问题:

__ALIGN_BEGIN uint8_t usb_buf[512] __ALIGN_END; void *USBD_malloc(uint32_t size) { return usb_buf; } void USBD_free(void *p) { }

4. 实测中遇到的坑与排查记录

4.1 枚举失败:PC 端提示"未知 USB 设备"

这是最常见的问题,原因通常有三个:时钟不对、VBUS 检测没处理、DP 上拉电阻没使能。STM32F407 的 USB OTG FS 内部有 DP 上拉电阻,但需要在初始化时使能。CubeMX 生成的代码里,HAL_PCD_MspInit会调用HAL_PCDEx_SetConnectionState或者直接操作USB_OTG_GCCFG寄存器来使能内部上拉。如果你用的是自己写的初始化代码,检查一下USB_OTG_FS->GCCFG的PWRDWN和VBUSBSEN位。

时钟问题更隐蔽。USB 需要精确的 48MHz 时钟,如果 PLL 配置有偏差,枚举会时好时坏。用示波器测 PA8(MCO)输出的时钟,或者直接看 CubeMX 时钟树里 USB 时钟是不是 48MHz。我遇到过有人把 HSE 设成 25MHz 但实际焊的是 8MHz 晶振,结果 USB 死活枚举不了,查了半天才发现是硬件问题。

4.2 数据丢包:发送太快导致 USBD_BUSY

前面提过,CDC_Transmit_FS是非阻塞的。如果你在主循环里连续调用,第二次大概率返回 USBD_BUSY。我实测过,在 168MHz 主频下,两次调用之间至少间隔 1ms 才能保证不丢。但 1ms 对于 64 字节的包来说,带宽只有 64KB/s,远低于 USB FS 的理论值。

要跑满带宽,必须用发送完成回调。每次发送完成后,回调里置标志位,主循环检测到标志位立即发下一包。这样可以把间隔缩短到几十微秒,实测能跑到 500KB/s 以上。注意回调里不要做耗时操作,只置标志位就行。

4.3 Windows 驱动安装失败

Windows 10 及以上版本自带 CDC 驱动,插上就能识别。但 Windows 7 需要手动安装 ST 的 VCP 驱动,或者用 inf 文件指定。如果你在 Win7 上遇到黄色感叹号,去设备管理器里手动更新驱动,指向 STM32CubeF4 固件包里的Middlewares/ST/STM32_USB_Device_Library/Class/CDC目录下的驱动文件。

还有一个坑:如果你改了 VID/PID,Windows 会认为是新设备,需要重新安装驱动。而且 Windows 会缓存设备信息,有时候改了描述符但设备管理器里还是显示旧名字。这时候需要到"设备和打印机"里删除设备,或者用usbdeview工具清除缓存。

4.4 FreeRTOS 集成时的优先级问题

如果你在项目里用了 FreeRTOS,USB 中断的优先级必须设置成比configMAX_SYSCALL_INTERRUPT_PRIORITY数值更低(即优先级更高)。STM32F407 的 NVIC 优先级是数值越小优先级越高,而 FreeRTOS 的configMAX_SYSCALL_INTERRUPT_PRIORITY通常设成 5。所以 USB 中断优先级要设成 4 或更小。

如果设反了,在 USB 中断里调用xSemaphoreGiveFromISR会触发configASSERT失败,程序卡死在断言里。这个坑我踩过两次,第一次查了半天才发现是优先级问题。后来养成习惯,所有中断优先级都在 CubeMX 里统一规划,USB 中断固定设成 4。

4.5 常见问题速查表

现象可能原因排查方法
PC 端无反应时钟不对、上拉未使能测 PA11/PA12 波形,检查 GCCFG 寄存器
枚举成功但无 COM 口描述符错误、驱动未装用 USB 分析仪抓包,检查设备管理器
发送丢包未等发送完成检查 CDC_Transmit_FS 返回值,用回调
接收丢包未重新设置接收缓冲区检查 CDC_Receive_FS 里是否调用 SetRxBuffer
数据错位缓冲区未对齐检查 USBD_malloc 返回地址是否 4 字节对齐
FreeRTOS 断言中断优先级错误检查 USB 中断优先级是否高于 syscall 阈值

5. 性能优化与进阶玩法

5.1 提高吞吐量的几个手段

默认配置下,CDC 的批量端点最大包长 64 字节,USB FS 每帧 1ms,理论最大带宽 64KB/s。但实际测试中,由于协议栈开销和主机调度,能跑到 500KB/s 以上就算不错了。想再往上提,可以试试这几个方法:

第一,把发送缓冲区改成双缓冲。一个缓冲区在发送时,另一个缓冲区准备数据,发送完成回调里切换缓冲区。这样能减少等待时间,实测能提升 20% 左右。

第二,减少CDC_Transmit_FS里的内存拷贝。这个函数内部会把你的数据拷贝到端点缓冲区,如果数据量大,拷贝开销不小。可以直接操作端点缓冲区,但这样会破坏协议栈的封装,不推荐新手尝试。

第三,调整 USB 中断优先级,让它能及时响应。但注意别影响到其他关键中断,比如电机控制里的 PWM 中断。

5.2 用 VCP 做固件升级通道

USB VCP 除了调试,还可以做固件升级(OTA)。思路是:PC 端发送固件数据,MCU 收到后写入 Flash,最后跳转到新固件。STM32F407 的 Flash 操作 HAL 库已经封装好了,注意擦除前要解锁,写入时要按扇区对齐。

但这里有个坑:USB 中断和 Flash 操作不能同时进行。Flash 擦写时 CPU 会暂停,如果此时 USB 主机发来数据,会丢包。解决办法是在 Flash 操作前先断开 USB 连接,或者用双 Bank 交替升级。STM32F407 的 Flash 是单 Bank 的,所以只能先断开再操作。

5.3 和 FreeRTOS 配合的推荐架构

如果项目里用了 FreeRTOS,建议把 USB 数据处理放在独立任务里。USB 中断只负责把数据丢进队列,任务从队列取数据并处理。发送也是类似,任务把数据放进发送队列,USB 发送完成回调触发信号量,任务继续发下一包。

QueueHandle_t usb_rx_queue; SemaphoreHandle_t usb_tx_sem; void USB_Task(void *arg) { uint8_t buf[64]; while (1) { if (xQueueReceive(usb_rx_queue, buf, portMAX_DELAY) == pdTRUE) { // 处理数据 } if (xSemaphoreTake(usb_tx_sem, 0) == pdTRUE) { // 发送下一包 } } }

这种架构下,USB 中断里只做xQueueSendFromISR和xSemaphoreGiveFromISR,耗时极短,不会影响其他中断。任务里处理数据,可以放心用浮点运算和 printf。

5.4 实测数据与经验值

我在 168MHz 主频、USB FS 全速模式下实测:单次发送 64 字节,间隔 100us,连续发送 10000 包,丢包率为 0。平均吞吐量约 580KB/s。接收方向,PC 端用串口助手以 500KB/s 速率发送,MCU 端用环形缓冲区接收,丢包率约 0.1%,主要发生在缓冲区满的时候。把缓冲区从 512 字节加大到 2048 字节后,丢包率降到 0.01% 以下。

功耗方面,USB 通信时整机电流约 80mA(不含外设),比用 UART 加 USB 转串口芯片的方案省了大概 20mA。对于电池供电的设备,这个差距不算大,但少一个芯片就少一份成本和故障率。

6. 移植到其他 STM32 型号的注意事项

这套代码从 STM32F407 移植到 STM32F103 或者 STM32F411 上,大部分逻辑是通用的,但有几个地方要改。首先是时钟配置,不同型号的 PLL 参数不一样,USB 时钟必须是 48MHz。F103 的 USB 和 CAN 共用时钟,配置时要注意。其次是端点缓冲区大小,F103 的 USB 缓冲区只有 512 字节,比 F407 小,如果用了多个端点,要仔细分配。

另外,F103 的 USB 只支持全速,没有 OTG 的额外功能,描述符里的一些 OTG 相关字段要去掉。CubeMX 生成代码时会自动处理这些差异,但如果你手动移植,要对照参考手册检查。

最后,如果你用的是 GD32 或者 APM32 这类兼容芯片,USB 外设的寄存器地址可能不同,但 HAL 库的 API 基本兼容。移植时重点检查时钟树和中断向量表,其他部分一般不用大改。

我个人在实际操作中的体会是,USB VCP 这东西入门不难,CubeMX 点几下就能跑起来,但要想跑得稳、跑得快,得把协议栈的收发流程吃透。尤其是发送完成回调和接收缓冲区的管理,这两个地方处理好了,基本就不会出大问题。另外,调试 USB 问题时,一个 USB 分析仪能省很多时间,没有的话至少装个 Bus Hound 或者 Wireshark 的 USB 抓包插件,能看到枚举过程和数据传输细节,比盲猜强得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询