简介:STM32F407 USB虚拟串口开发包是一套面向嵌入式开发者和学生的完整示例工程,基于ARM Cortex-M4内核的STM32F407微控制器,实现USB CDC类虚拟串口通信,可广泛用于设备调试、数据监控、PC上位机交互以及物联网和工业控制场景。压缩包共211个文件,内容覆盖USB协议、HAL库、固件层等关键模块;以102个h头文件与91个c源文件为主体,包含初始化配置、中断处理、设备描述符、设备枚举及USART设置等实现,另附hex与dfu固件便于直接烧录验证,整体约826KB。目前已有2151人学习下载,说明该工程具有不错的参考价值。通过这套工程,读者可以学习USB设备枚举流程、CDC类接口实现以及虚拟串口驱动匹配等核心知识点,并在红龙407开发板上快速搭建串口应用;资源目录结构清晰,代码组织完整,适合作为入门USB CDC开发的高质量参考。
1. 为什么要用 USB 虚拟串口,而不是 USB 转串口芯片
搞嵌入式的人应该都有过这种经历:手头一台笔记本,没有串口,项目调试日志却要从 UART 往外打,这时候最顺手的方案是掏出一块 CH340 或者 FT232 的 USB 转串口小板。但这东西有个问题——它只是把 USB 信号转成 UART 电平,本质上你还是在跟一个物理串口打交道,波特率、校验位、停止位一个都不能少,线还容易接触不良。
如果主控是 STM32F407,事情就变得有意思了。F407 这颗芯片内部集成了 USB OTG FS/HS 外设,完全可以用它自己来模拟一个串口,也就是标题里说的 USB 虚拟串口。电脑上不需要装任何驱动,插上 USB 线,设备管理器里直接多出一个 COM 口。这个 COM 口看起来和普通串口一模一样,但底层走的是 USB 协议,波特率随便设,因为 USB CDC 通信本身跟波特率没有半毛钱关系。
USB 虚拟串口能做的事情很多:板子日志输出、上位机远程控制、与手机 APP 通信、固件升级等等。对于那些不需要极高实时性、但对连接便利性要求高的场景,它比物理串口好用得多。F407 的 USB 外设最高支持 480Mbps(HS 模式需要外接 PHY),FS(Full Speed)模式下也有 12Mbps,这是 UART 921600 波特率都望尘莫及的带宽。
这个项目的完整目标其实就一句话:让 F407 的 USB 口在电脑上变成一个免驱的虚拟串口,同时能跟板子上的物理 UART 互相转发数据。这样你既可以用它调试,也可以把它当作一个“USB 转串口”模块用——只不过这个模块不需要额外芯片,成本几乎为零。
这个内容适合谁看?用过 CubeMX 生成过 GPIO、定时器工程的入门开发者,想搞明白 USB CDC 协议栈到底怎么转起来的人,以及准备做数据采集、设备控制、日志系统这一类项目、需要和上位机通信的工程师。你不需要提前精通 USB 协议,但至少要知道什么是端点、什么是描述符,不然看完可能会有点发懵。
2. USB CDC 的工作原理:为什么电脑上会出现一个 COM 口
2.1 枚举过程:设备是怎么“自我介绍”的
先把 USB CDC 的原理讲透。USB 设备接入电脑后,主机会做一轮枚举:设备插上后,主机先给设备发一个GET_DESCRIPTOR请求,这个时候设备返回第 1 个 8 字节的设备描述符,告诉主机“我是 USB 设备”;然后主机分配地址,接着依次读取设备描述符、配置描述符、接口描述符、端点描述符,最终决定加载哪个驱动。
CDC 是 USB 的通信设备类,全称 Communications Device Class。F407 的 USB 库中,CDC 设备由两个接口组成:一个是通信接口(Interface 0),里面放一个 Interrupt 类型的端点,用于传输控制信息;另一个是数据接口(Interface 1),里面放两个 Bulk 端点,一个 IN 一个 OUT,用于实际数据收发。
这就是为什么 Windows 识别出 CDC 设备后会弹出“usbser.sys”驱动,然后给你分配一个 COM 口号。其实你这颗 F407 在电脑眼里就是一个“USB 调制解调器”或者“USB 转串口适配器”,它才不关心你芯片里跑的是什么。
2.2 端点与缓冲区:数据到底是怎么流走的
端点(Endpoint)是 USB 通信的最小单位。CDC 设备中,EP0 是默认控制端点,不走数据;EP1 IN 是中断端点,用于发送线路状态、控制信息;EP2 IN 和 EP2 OUT 才是真正传递用户数据的通道。F407 的 USB OTG FS 模块内部有 4KB 专用 FIFO RAM,需要在初始化时把这些 FIFO 分给各个端点。默认 CubeMX 生成工程会分好,但如果你要做 CDC + MSC 复合设备、或者一次收发大块数据,就需要自己重新分配 FIFO 空间。
这里要特别强调一个容易踩坑的点:USB 是“包”传输,它的发送和接收都是基于缓冲区回调的,而不是像 UART 那种流式传输。也就是说,主机发给设备的每个包,到达设备后会触发一个CDC_Receive_FS回调,把数据放在你提供的缓冲里;而你往主机发数据时,要调用CDC_Transmit_FS,把一整块数据交给 USB 外设。
/* usbd_cdc_if.c 中默认生成的接收回调 */ static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { /* USER CODE BEGIN 6 */ USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return (USBD_OK); /* USER CODE END 6 */ }看到问题没有?默认这个回调只做了一件事:把 Buf 重新注册给 USB 外设,等待下一次接收。如果应用层不赶紧把 Buf 里的数据搬走,下一次 USB 包一到,直接把你之前的数据覆盖掉。这是新手做 USB 虚拟串口时最容易出现的丢数据原因,没有之一。
3. 硬件准备与 CubeMX 基础配置:手把手搭工程
3.1 硬件清单
STM32F407 开发板一块,推荐带 USB 接口的那种,比如正点原子探索者、野火指南者,或者自己画的板子也完全没问题;一根 USB 线,注意一定要是带数据线的,那种只能充电的线会出现“连接上了但枚举不成功”的诡异问题;如果要做 USB 与 UART 互转,额外准备一个 USB 转 TTL 模组来观察效果。
F407 的 USB FS 接口有专门的引脚:PA11 是 DM,PA12 是 DP,PA9 是 VBUS 检测。CubeMX 里把 USB_OTG_FS 使能后,这些引脚会自动配置好,不需要手动处理。唯一要确认的是你的开发板上是否把 PA9 连到了 USB 座子的 VBUS 上,如果没有,就把它配置成不用 VBUS 自供电模式。
3.2 时钟树配置:48MHz 是硬指标
USB 外设对时钟要求极其严格,FS 模式下必须精确的 48MHz。F407 的 USB OTG FS 可以使用内部 PLL Q 输出来得到这个 48MHz,配置关系是:外部晶振 8MHz -> PLL M = 8 -> VCO 输入 = 1MHz -> N = 336 -> VCO 输出 = 336MHz -> PLL P = 2 -> SYSCLK = 168MHz -> PLL Q = 7 -> USB 时钟 = 48MHz。
CubeMX 中你需要在 Clock Configuration 页面手动设置这些值。如果项目里用不到 168MHz 主频,也可以选择较低的 PLL Q 配置,但 USB 时钟必须稳在 48MHz,歪了枚举就会失败。这个参数是硬指标,没有任何讨价还价的余地。
3.3 中间件配置:选中 Communication Device Class
在 CubeMX 左侧 Categories 列表里找到 Middleware and Software Packs,点开 USB_DEVICE,Class for FS IP 选择Communication Device Class (Virtual Port Com)。USB_DEVICE 相关的参数保持默认即可,VID(供应商 ID)和 PID(产品 ID)可以改成你自己的,具体怎么改后面专门讲。
生成工程前,建议顺手把 UART1 配置成 115200-8-N-1,因为后面要拿它做串口转发测试。配置完成后生成 MDK-ARM 工程,直接编译,下载到板子里,插上 USB 线,电脑上如果没有意外,应该已经多出一个 COM 口。
到这里,一个最基础的 USB 虚拟串口已经能用了。你打开串口助手,选择这个 COM 口,随便发几个字符,设备本身不会回你任何数据,因为这时的代码只是完成了枚举,还没有做任何业务逻辑。真正的数据通路,需要自己写。
4. 代码实现:让 USB 虚拟串口真正跑起来
4.1 数据通路设计:环形队列是必需品
我的方案是:USB 接收的数据全部丢进一个环形队列,主循环里轮询这个队列,把它转发到 UART1;同时 UART1 接收的数据也丢进另一个环形队列,主循环里再转发到 USB。用环形队列而不是简单数组的原因是,USB 包的到来是突发性的,不攒够一包不给你,靠一个简单的标志位很容易漏。
这里要提醒一点:环形队列的内存不要放在 CCM RAM 里,因为 CCM RAM 不能被 DMA 访问,而 F407 的 UART 如果要用 DMA,就必须使用普通 SRAM。我之前就犯过这个错误,把缓冲区定义到 CCM 里,DMA 怎么都搬不动数据,查了好久才发现是内存区域的问题。
先实现一个通用的环形队列:
#define RING_BUF_SIZE 1024 typedef struct { uint8_t buf[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t; static ring_buf_t usb_to_uart_ring; static ring_buf_t uart_to_usb_ring; static void ring_init(ring_buf_t *q) { q->head = 0; q->tail = 0; } static uint8_t ring_pop(ring_buf_t *q, uint8_t *data) { if (q->head == q->tail) return 0; // 空 *data = q->buf[q->tail]; q->tail = (q->tail + 1) % RING_BUF_SIZE; return 1; } static void ring_push(ring_buf_t *q, uint8_t data) { uint16_t next_head = (q->head + 1) % RING_BUF_SIZE; if (next_head != q->tail) { // 防止覆盖 q->buf[q->head] = data; q->head = next_head; } }这个实现里 head 指向下一个写入位置,tail 指向下一个读取位置,当 head + 1 等于 tail 时认为队列已满,主动丢弃数据。1024 字节的缓冲区对绝大多数应用都够用,如果发大文件,可以调大到 4096 或更大,只要内存放得下。
4.2 修改 USB 接收回调
默认的CDC_Receive_FS必须改掉,在收到 USB 数据后立刻搬进环形队列:
static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { for (uint32_t i = 0; i < (*Len); i++) { ring_push(&usb_to_uart_ring, Buf[i]); } USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return (USBD_OK); }这段代码有一个前提:Buf指向的缓冲区必须一直有效,因为 USB 外设还在用这块内存接收下一包数据。CubeMX 生成代码里这个缓冲是全局数组UserRxBufferFS,生命周期是全局的,所以你不需要额外处理。数据被搬进环形队列后,App 层随时可以取走,不受 USB 接收时序影响。
4.3 主循环业务逻辑
在主循环里轮询两个环形队列,完成双向转发:
while (1) { /* 检查 USB 是否已经枚举成功 */ if (hUsbDeviceFS.dev_state == USBD_STATE_CONFIGURED) { /* UART 收到数据 -> 通过 USB 发给电脑 */ uint8_t byte; while (ring_pop(&uart_to_usb_ring, &byte)) { CDC_Transmit_FS(&byte, 1); } /* USB 收到数据 -> 通过 UART 发出去 */ while (ring_pop(&usb_to_uart_ring, &byte)) { HAL_UART_Transmit(&huart1, &byte, 1, 10); } } }这里有两个细节值得说。第一,CDC_Transmit_FS在 USB 连接未建立时会返回错误,所以必须先判断USBD_STATE_CONFIGURED。第二,UART 发送用的HAL_UART_Transmit是阻塞函数,超时时间设置成 10ms 即可,如果 UART 因为某种原因卡住,不至于让整个主循环卡死。这种逐字节发送的方式在低速调试时完全够用,但如果要跑大流量,就得把环形队列里的数据攒成一整块再发。
4.4 用中断方式接收 UART 数据
主循环里只能发送 UART 数据,那接收怎么办?总不能把 UART 也搞成轮询吧,那会占用大量 CPU。正确打开方式是 UART 空闲中断 + DMA,或者简单的HAL_UART_Receive_IT。用HAL_UART_Receive_IT的话,每次接收一个字节都会触发一次中断,一次中断里做一次环形队列 push,开销也不大。
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { ring_push(&uart_to_usb_ring, uart1_rx_byte); HAL_UART_Receive_IT(&huart1, &uart1_rx_byte, 1); } }uart1_rx_byte是全局的单个字节变量,在 main 里初始化 UART 后主动调用一次HAL_UART_Receive_IT(&huart1, &uart1_rx_byte, 1),之后每次收到一个字节都会进回调,重新开启接收。这样做的好处是代码简单,但每个字节都中断一次,如果 UART 速率跑得很高,主循环会被频繁打断。追求高效率的话,用 UART 空闲中断 + DMA 批量接收是更优雅的方案,这里不展开,思路一样:DMA 把数据搬到一块大缓冲,空闲中断时处理整块数据。
到这里,一个完整的 USB 虚拟串口转 UART 透传功能就已经完成了。你从串口助手随便发点数据,另一端 UART 接的模块就会收到;UART 那边回数据,串口助手也能看到。跑起来的那一刻,你会觉得 F407 的 USB 外设真的是个宝藏。
5. 修改 VID/PID 和字符串描述符:设备个性化
默认的 VID 是 0x0483(ST 官方),PID 是 0x5740。如果你只是自己调试用,不跟别人的设备冲突,保持默认没问题。但如果设备要量产、或者同时插两块相同的 F407 板子,就必须修改 VID/PID,否则 Windows 会认为它们是同一个设备,驱动都复用同一个。
修改位置在usbd_desc.c:
__ALIGN_BEGIN static uint8_t USBD_VID[] = { 0x83, 0x04 }; // 0x0483 小端模式 __ALIGN_BEGIN static uint8_t USBD_PID[] = { 0x40, 0x57 }; // 0x5740 小端模式如果要改成 VID = 0x1234,PID = 0x5678,就写:
__ALIGN_BEGIN static uint8_t USBD_VID[] = { 0x34, 0x12 }; __ALIGN_BEGIN static uint8_t USBD_PID[] = { 0x78, 0x56 };字符串描述符也在同一个文件里,USBD_MANUFACTURER_STRING、USBD_PRODUCT_STRING这些数组改成你自己的产品名。改完 VID/PID 后,第一次插上电脑会提示“正在安装设备驱动程序”,如果之前装过同 VID/PID 的设备,Windows 会直接复用驱动。
还有个细节:如果你想在设备管理器的 COM 口属性里看到设备描述,修改USBD_PRODUCT_STRING就能实现。我之前把产品名改成“F407 Virtual Com Port”,设备管理器里显示的就是这个名字,看起来比默认的 ST 设备友好得多。
6. 常见问题排查:设备为什么枚举失败、丢数据怎么办
USB 虚拟串口这个项目,网上求助最多的问题基本就集中在几个地方:枚举失败、驱动异常、数据丢失。我把遇到的问题和解决方法整理成一张表,方便按图索骥。
| 现象 | 可能原因 | 排查思路与解决方法 |
|---|---|---|
| 插上 USB 后电脑毫无反应,设备管理器不出现新设备 | USB 线是充电线;VBUS 检测引脚有问题;没有给 OTG_FS 使能时钟 | 换一根确定支持数据的线;检查 PA9 是否接入 VBUS;CubeMX 中把 VBUS 检测使能或关闭看电路设计;用示波器量 PA12 是否被拉高到 3.3V(全速设备需要 D+ 上拉) |
| 设备管理器出现“未知 USB 设备(设备描述符请求失败)” | 描述符校验失败;D+ 上拉电阻有问题;USB 时钟不是 48MHz | 仔细核对时钟树中 PLL Q 是否等于 7;用 USB 抓包工具(USBPcap + Wireshark)查看枚举过程,看主机到底卡在哪一步;检查 USB 线材和座子焊接 |
| 设备管理器出现 COM 口,但驱动是黄色感叹号 | Windows 驱动加载失败;描述符中接口数量不对 | 右键更新驱动试一次自动搜索;确认USBD_CFG_MAX_NUM是否足够;尝试换一台电脑确认是否硬件问题 |
| 串口助手打开 COM 口报错或打不开 | COM 口被上一程序占用而未释放;串口助手不支持 CDC 虚拟串口 | 拔掉 USB 线重插,让系统重新枚举;换一个串口助手,或换不同的串口工具测试;Windows 下重启一次也可能解决 |
| 通信不稳定,时而收到数据时而丢失 | CDC_Receive_FS里数据没及时搬走导致覆盖;CDC_Transmit_FS上一次没发完就发下一次导致 BUSY | 接收回调里加入环形队列,保证数据立刻拷贝;发送前判断返回状态,不要忽略USBD_BUSY;把主循环里的逐字节发送改成攒够一包再发 |
| 拔插 USB 线后单片机死机 | USB 中断回调里做了阻塞操作(比如 printf、阻塞 UART 发送);中断优先级配置不合理 | 回调里只做快速数据拷贝,所有耗时的业务逻辑放到主循环;检查NVIC中 USB 中断优先级,不要和 UART 中断互相占用 |
| 数据能发不能收,或者能收不能发 | 端点配置错误;接收缓冲没有重新注册;CDC_Receive_FS没有被调用 | 确认端点方向:EP IN 是设备发往主机,EP OUT 是主机发往设备;在CDC_Receive_FS里打断点确认有没有触发;检查USBD_CDC_ReceivePacket是否被调用 |
用 USB 抓包工具排查枚举问题,是高级工程师常用的方法。Windows 下用 Wireshark 配合 USBPcap,可以抓到完整的枚举过程。如果设备描述符回包没发出来,说明设备侧代码有问题;如果设备正常回包但主机仍报错,多半是描述符内容不合法。抓包分析能快速定位问题,比自己盲猜高效得多。
7. 踩坑感悟:几个 C 代码之外的细节
这个项目做完,有几点经验特别想分享。
第一,设备插上后一定要先看设备管理器,确认枚举成功再谈数据的收发。很多人一上来就开串口助手发数据,结果设备根本没被识别,自然什么都收不到。先把基础环境确认好,再层层往上调试。
第二,CDC_Transmit_FS这个函数在设备未配置时会返回USBD_FAIL,但在发送过程中如果上一次还没发送完,下一次调用会返回USBD_BUSY。主循环里调用时最好检查返回值,不要让数据因为上一次没发完就被丢掉。如果丢包严重,可以考虑把发送也改成队列机制:主循环从队列取数据,只有上一次 USB 发送完成后再发下一块。HAL 库的HAL_UART_TxCpltCallback给了一个思路,USB 的发送完成回调可以照搬这个思路。
第三,F407 的 USB 外设实际工作中对布线和供电是有要求的。USB 座子的 D+/D- 走线要等长、阻抗匹配,这个在自制 PCB 上要特别注意。面包板飞线做 USB 调试大概率会遇到枚举不稳定、速度上不去的问题,不是代码的锅,是物理连接的问题。
第四,如果你以后要做 USB + 大容量存储复合设备,或者 USB + 声卡,F407 的 USB 外设都能支持,但配置会复杂很多。虚拟串口这个项目是最简单的 USB 入门项目,把 CDC 跑顺了,USB 描述符、端点、FIFO 分配这些核心概念你就全掌握了,后面再去看 HID、MSC、UVC 这些类,思路会是通的。
最后分享一个小技巧:在做 USB 数据吞吐压力测试时,写个简单的上位机脚本,从串口助手循环发大的数据包,同时在 F407 的CDC_Receive_FS回调里加一个计数器变量,通过调试器观察计数值是否等于主机发的包数。这个办法能快速验证数据通路是否顺畅,比凭感觉看串口助手里的回显要可靠得多。
本文还有配套的精品资源,点击获取