基于STM32的虚拟串口设计:从原理到实战
当嵌入式设备“没有串口”时,我们该怎么办?
在调试一个嵌入式系统时,你是否遇到过这样的窘境:板子已经封胶封装、外壳焊死,却突然需要查看运行日志?或者你的MCU引脚资源紧张,根本无法引出UART TX/RX线?
传统的RS-232或TTL串口虽然简单可靠,但在现代紧凑型设计中正逐渐“消失”。而与此同时,USB接口几乎成了所有设备的标配。于是,聪明的工程师们想到:既然硬件串口没了,能不能让USB假装成一个串口?
答案是肯定的——这就是我们今天要深入探讨的技术:基于STM32的虚拟串口(Virtual COM Port, VCP)。
它不依赖FT232、CH340这类外部转换芯片,而是直接利用STM32内置的USB外设,通过软件模拟出一个标准串口行为。PC端插入后自动识别为COM口,PuTTY一开就能通信,就像接了根真正的串口线一样。
更重要的是,这项技术不仅用于调试,还能作为产品级的数据通道、配置接口甚至固件升级通路。本文将带你一步步揭开它的底层机制,手把手实现一个稳定可用的VCP工程。
为什么选STM32做虚拟串口?
市面上能跑USB设备协议的MCU不少,但STM32系列无疑是其中最成熟、生态最完善的选择之一。尤其是F1/F4/L4等主流型号,都集成了全速USB 2.0设备控制器(Full-Speed USB Device),无需外部PHY即可连接USB总线。
更关键的是,ST提供了完整的工具链支持:
- STM32CubeMX:图形化配置USB堆栈
- HAL库 + USB中间件:提供CDC类模板代码
- 官方文档齐全:从参考手册到应用笔记应有尽有
这意味着你不需要从零实现整个USB协议栈,只需要聚焦于业务逻辑和数据交互。
虚拟串口背后的三大支柱
要真正理解虚拟串口是如何工作的,我们必须先搞清楚三个核心组件之间的关系:
- USB OTG FS 模块—— 硬件基础
- CDC类协议—— 协议规范
- HAL库与USB中间件—— 软件桥梁
它们层层协作,最终让你的STM32“骗过”电脑,被识别为一个标准串口设备。
USB通信的本质:不只是插拔那么简单
很多人以为USB就是“插上去就能传数据”,其实背后有一套严谨的状态机流程。当STM32作为USB设备接入主机时,首先要经历枚举过程(Enumeration)。
这个过程就像是设备向电脑自我介绍:“我是谁?我能干什么?”
电脑根据这份“简历”决定加载哪个驱动程序。
枚举的关键:描述符(Descriptors)
设备必须提供一组标准化的描述符,包括:
| 描述符类型 | 作用说明 |
|---|---|
| 设备描述符 | 包含VID/PID、设备类、版本号等全局信息 |
| 配置描述符 | 定义电源需求、接口数量等 |
| 字符串描述符 | 可读的厂商名、产品名(如 “STMicroelectronics”, “STM32 Virtual COM”) |
| 接口描述符 | 表明该接口属于哪一类功能(如CDC控制/数据) |
| 端点描述符 | 定义每个端点的传输方向、类型(控制/批量)、最大包长 |
只有这些信息正确无误,操作系统才会成功加载CDC驱动,并创建对应的COM端口。
💡 小知识:Windows自带
usbser.sys驱动支持标准CDC设备,因此无需额外安装驱动。这也是虚拟串口“即插即用”的根本原因。
STM32如何接入USB总线?看懂OTG_FS模块
在STM32上启用虚拟串口的第一步,是正确配置其USB外设。以最常见的STM32F103为例,使用的正是USB OTG Full Speed(OTG_FS)模块。
关键引脚与电路要求
| 引脚 | 名称 | 功能 |
|---|---|---|
| PA11 | DM | USB差分数据负 |
| PA12 | DP | USB差分数据正 |
这两个引脚必须连接到USB插座的D−和D+线上。此外还需注意:
- 内部D+上拉电阻:PA12需通过内部弱上拉(约1.5kΩ)拉高,用来通知主机“有设备插入”。这是触发枚举的关键一步。
- VDD_USB供电:部分型号要求单独给USB模块供电(通常由外部LDO或内部稳压器提供3.3V)
- ESD保护:建议在DM/DP线上加TVS二极管(如SR05),防止静电击穿
初始化流程要点
// HAL库中的典型初始化顺序 __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USB_CLK_ENABLE(); // 配置PA11(PA11)/PA12(PA12)为复用推挽输出 GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_11 | GPIO_PIN_12; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 启动USB设备 USBD_Init(&hUsbDeviceFS, &FS_Desc, DEVICE_FS); USBD_RegisterClass(&hUsbDeviceFS, &USBD_CDC); USBD_CDC_RegisterInterface(&hUsbDeviceFS, &USBD_Interface_fops_FS); USBD_Start(&hUsbDeviceFS);这段代码看似简单,实则暗藏玄机:
FS_Desc是设备描述符结构体,决定了你的设备叫什么名字、使用哪个PID/VIDUSBD_Interface_fops_FS是一组函数指针,定义了底层如何收发数据- 最后的
USBD_Start()会启动中断服务例程,开始监听USB事件
一旦执行完成,STM32就会主动“宣告”自己上线了。
CDC协议详解:让USB学会“说串口话”
光有USB硬件还不够,还得让它“冒充”成一个串口设备。这就轮到CDC类协议登场了。
CDC(Communication Device Class)是USB-IF制定的标准设备类之一,专为通信设备设计。而在虚拟串口中,我们使用的是它的子类:Abstract Control Model (ACM)。
ACM是怎么模拟串口的?
ACM设备对外暴露两个接口:
| 接口编号 | 类型 | 功能 |
|---|---|---|
| Interface 0 | 控制接口 | 处理AT命令、波特率设置、DTR/RTS状态 |
| Interface 1 | 数据接口 | 承载实际数据流(Bulk IN / OUT) |
虽然没有真实的UART外设参与,但它模仿了传统串口的所有控制信号:
SET_LINE_CODING:主机设置波特率、数据位、校验方式SET_CONTROL_LINE_STATE:控制DTR(终端就绪)、RTS(请求发送)GET_LINE_CODING:查询当前线路参数
尽管这些参数在纯软件实现中可能并不真正影响物理传输速率(毕竟USB本身是高速的),但保留这些交互能让串口助手等工具“感觉正常”。
实际控制请求处理示例
uint8_t is_host_ready = 0; USBD_CDC_LineCodingTypeDef LineCoding; static int8_t CDC_Control_FS(uint8_t cmd, uint8_t* pbuf, uint16_t length) { switch(cmd) { case CDC_SET_LINE_CODING: // 解析主机下发的串口参数 LineCoding.bitrate = pbuf[0] | (pbuf[1]<<8) | (pbuf[2]<<16) | (pbuf[3]<<24); LineCoding.format = pbuf[4]; // 停止位 LineCoding.paritytype= pbuf[5]; // 校验类型 LineCoding.datatype = pbuf[6]; // 数据位 break; case CDC_SET_CONTROL_LINE_STATE: // 判断PC是否打开了串口助手 if (pbuf[0] & 0x01) { is_host_ready = 1; // DTR置位,表示主机已准备好 } else { is_host_ready = 0; } break; default: break; } return USBD_OK; }✅ 实战技巧:你可以利用
is_host_ready标志来判断用户是否打开了串口工具。一旦检测到连接,再开始周期性发送传感器数据,避免无效广播。
HAL库怎么帮你省下90%的工作量?
如果说USB协议像一本厚达千页的操作手册,那STM32的HAL库+USB中间件就是一位经验丰富的向导,带着你绕开所有坑。
分层架构一览
+---------------------+ | 应用层 | ← 用户编写:数据处理、命令解析 | - CDC_Transmit_FS() | | - CDC_Receive_FS() | +----------+----------+ ↓ +----------v----------+ | USB中间件层 | ← ST提供:usbd_cdc.c/usbd_core.c | - CDC类逻辑管理 | | - 状态机调度 | +----------+----------+ ↓ +----------v----------+ | HAL驱动层 | ← 直接操作寄存器 | - HAL_PCD_xxx() | +---------------------+这种分层设计使得开发者只需关注顶层应用逻辑。
发送数据有多简单?
uint8_t msg[] = "Hello from STM32!\r\n"; CDC_Transmit_FS(msg, sizeof(msg)-1);一行代码搞定非阻塞发送!数据会被放入IN端点缓冲区,在后台由USB中断自动发出。
如何接收主机发来的数据?
接收稍微复杂一点,因为需要手动重启接收队列:
uint8_t rx_buffer[64]; static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { // 回显收到的数据 CDC_Transmit_FS(Buf, *Len); // 必须重新激活接收,否则下次无法触发 USBD_CDC_SetRxBuffer(&hUsbDeviceFS, rx_buffer); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return USBD_OK; }⚠️ 注意:如果不调用USBD_CDC_ReceivePacket(),USB模块将不再监听新的OUT包,导致后续数据丢失!
加个环形缓冲区更安全
为了避免数据溢出,推荐使用环形缓冲区暂存接收到的内容:
#define RX_BUFFER_SIZE 256 uint8_t usb_rx_ring[RX_BUFFER_SIZE]; volatile uint16_t usb_rx_head = 0, usb_rx_tail = 0; void enqueue_usb_data(uint8_t *data, uint32_t len) { for (uint32_t i = 0; i < len; i++) { usb_rx_ring[usb_rx_head] = data[i]; usb_rx_head = (usb_rx_head + 1) % RX_BUFFER_SIZE; if (usb_rx_head == usb_rx_tail) { // 缓冲区满,丢弃旧数据 usb_rx_tail = (usb_rx_tail + 1) % RX_BUFFER_SIZE; } } } // 在回调中调用 static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { enqueue_usb_data(Buf, *Len); USBD_CDC_SetRxBuffer(&hUsbDeviceFS, rx_buffer); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return USBD_OK; }这样即使主循环来不及处理,也不会丢失任何字节。
典型应用场景与工程实践
一个完整的虚拟串口系统长什么样?
USB PC <-----------------------------------> STM32 (串口助手) 差分信号 (PA11/PA12) ↗ 温湿度传感器 GPS模块 EEPROM在这个系统中:
- STM32通过USB虚拟串口上传采集数据
- PC可通过串口发送指令(如“GET_TEMP”、“RESET”)
- 整个通信过程对用户而言完全透明,就像连了一根真实串口线
工作流程拆解
上电 → 初始化USB
- 开启时钟、配置GPIO、启动D+上拉
- 进入待枚举状态主机枚举设备
- 读取描述符 → 加载CDC驱动 → 创建COMx端口用户打开串口助手
- 波特率任意(如115200)
- DTR置位 → 触发SET_CONTROL_LINE_STATE双向通信建立
- STM32检测到连接 → 开始发送心跳包或传感器数据
- PC发送命令 → MCU解析并响应断开重连
- 拔线或复位 → 自动释放COM口
- 重新枚举 → 新连接建立
实际开发中的“坑”与应对策略
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 插上没反应,电脑提示“未知设备” | 描述符错误或未开启D+上拉 | 检查VID/PID是否合法;确认PA12上拉已启用 |
| COM口出现又消失 | 枚举过程中断(如供电不稳) | 添加上电延时;检查VDD_USB稳定性 |
| 数据发送失败或卡住 | 未正确重启接收/发送忙 | 在回调中及时恢复接收;避免阻塞式发送大块数据 |
| 接收乱码或丢包 | 缓冲区太小或未及时处理 | 使用环形缓冲区;提高主循环频率 |
| 多次插拔后识别异常 | 内存泄漏或状态未清理 | 在USBD_Disconnect_Callback()中重置关键变量 |
🔧 调试建议:使用Wireshark + USBPcap抓包分析枚举过程,可快速定位握手失败问题。
提升产品体验的设计细节
别忘了,虚拟串口不仅是给开发者用的,也可能面向终端用户。以下几点能显著提升专业感:
定制化字符串描述符
c /* 修改 usbd_desc.c 中的字符串 */ const uint8_t* USBD_Device_string = (uint8_t*)"My Smart Sensor"; const uint8_t* USBD_Manufacturer_string = (uint8_t*)"Acme Inc.";
这样设备管理器里显示的就是清晰的品牌名称,而不是一堆十六进制ID。支持常见波特率列表
即使你不真的切换波特率,也要在LineCoding中返回标准值(如9600、115200),否则某些串口工具会报错。加入连接状态指示灯
c if (is_host_ready && !was_connected) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); // 点亮蓝灯 }
给用户直观反馈:“我现在在线。”结合Bootloader实现免按键升级
利用虚拟串口传输固件镜像(XMODEM/YMODEM协议),实现“插上线就能升级”,彻底告别跳线帽和烧录器。
写在最后:虚拟串口不只是调试工具
很多人把虚拟串口当作临时调试手段,用完就删。但事实上,它完全可以成为一个产品的正式通信接口。
想想看:你的智能设备出厂时没有调试口,售后人员如何获取日志?现场升级怎么办?参数配置靠什么完成?
一个稳定的虚拟串口,等于为你的系统装上了“黑匣子”和“遥控器”。
未来随着RISC-V等平台USB栈的成熟,这一模式也将普及开来。而在STM32平台上,结合FreeRTOS、USB复合设备(Composite Device)等技术,你甚至可以做出:
- “串口 + HID键盘”双模调试器:平时是COM口,特定指令下变身为键盘输入设备
- 多通道虚拟串口桥接器:一台设备映射多个COM口,分别对应不同子系统
- 带加密认证的私有VCP协议:防止非法访问设备内部信息
掌握这项技术,你就掌握了通往高效、智能嵌入式系统的钥匙。
如果你正在做一个无串口的小型化项目,不妨试试加上虚拟串口——也许它会成为你调试路上最得力的帮手。
欢迎在评论区分享你的VCP实战经验:你是怎么解决枚举失败的?有没有遇到奇葩兼容性问题?我们一起交流避坑!