一、STM32内置USB虚拟串口简述
USB虚拟串口,简称VPC,Virtual Port Com 的简写。但更习惯于把虚拟串口叫作: CDC,因为它是利用 USB 的 CDC类 实现的一种通信接口。
1.1 为什么使用USB虚拟串口
在嵌入式开发中,串口(UART)的局限性比较明显:
1、引脚资源紧张 :高端芯片可能有多个UART,但低端型号如STM32F103C8T6通常只有一两个;
2、速率瓶颈 :即使波特率设到921600,实际有效吞吐也很难超过几百kbps;
3、连接繁琐 :需要额外的USB转TTL模块、电平匹配电路;
4、跨平台兼容差 :某些定制设备还得自己打包驱动。
而USB CDC正好解决了这些痛点:
1、插上就能用 :Windows/Linux/macOS 原生支持,无需任何驱动;
2、高速传输 :USB 2.0 Full Speed理论带宽达12 Mbps;
3、引脚复用: 只需D+和D−两根线,不占用额外GPIO;
4、调试&通信一体化: 一条USB线搞定供电、烧录、日志输出、参数配置。
STM32 芯片,绝大部分型号都带内置USB,如常用的 F1、F4、H7、G4 等系列,能够通过USB接口与计算机或其他USB设备进行通信。STM32内置的USB,均可支持USB 2.0标准,可以支持三种传输速率:
高速模式:最高可达480 Mbps (部分型号支持,且需搭配外部芯片,不常用 )
全速模式:最高可达12 Mbps (最常用)
低速模式:最高可达1.5 Mbps
1.2 硬件电路
高速模式,需要搭配外围USB PHY芯片,如USB3300,硬件成本偏高 。
全速模式,电路很简单。从机在PCB布线时,仅需把STM32的引脚PA11、PA12, 连接至USB座的DP、DM,然后,PA12(DP线)用1.5K电阻上拉至3.3V。
插拔检测:设备未插入时,主机端DP、DM为低电平,当发现被置高,即为有设备插入;
区分速率:DM线上拉是低速模式,DP线上拉是全速\高速模式;
上拉电压:3.3V。USB通信电平是3.3V,而不是总线供电的5V。
当STM32作为USB设备接入PC后,会经历以下几个阶段:
物理连接 :D+上的1.5kΩ上拉电阻告诉主机:"我是低速/全速设备,请开始枚举。"
枚举过程 :主机读取一系列描述符(Device Descriptor, Config Descriptor等),确认这是一个CDC类设备。
驱动加载 :操作系统发现这是个通信类设备,自动加载 usbser.sys (Windows)或创建 /dev/ttyACMx 节点(Linux)。
通信建立 :应用程序(比如PuTTY、串口助手)打开对应的COM端口,开始收发数据。
硬件电路具体如下图:

1.3 相关驱动
Win10、Win11 已带虚拟串口驱动;无需安装任何驱动; Win7 要提前手动安装驱动。STM32 USB虚拟串口驱动 V1.5.0
二、使用 CubeMX 新建工程
2.1 CubeMX 无法连接网络
下载地址:CubeMX下载地址
现象示例:

解决方式:
在防火墙允许STM32CUBEMX安装目录下的该应用访问网络即可。

完成芯片数据包在线导入。

2.2 主要流程
参考文章:【STM32 + CubeMX】 USB 虚拟串口通信
步骤1:启用USB_OTG_FS
在 Pinout 视图中找到PA11(D-)、PA12(D+),它们会被自动选中;
在 Middleware 栏选择 USB_DEVICE ;
设置为"Device Only"模式;
添加Class → 选择"CDC"。
步骤2:时钟配置要点
USB模块必须工作在 精确48MHz 下。
对于F1系列:
如果使用外部晶振(HSE=8MHz),可通过PLL倍频得到72MHz系统时钟,并用分频器输出48MHz给USB;
若无HSE,可启用内部HSI并通过软件校准(部分型号支持);
更推荐的方法是:使用STM32F4/F7/G0等自带HSI48的型号,省去外部晶振烦恼。
CubeMX会自动生成正确的RCC配置,确保USB_CLK有效。
步骤3:生成代码 & 编写逻辑
点击 "Generate Code" 后,工程中会出现几个关键文件:
usbd_cdc_if.c :用户接口层,包含发送、接收、控制回调函数;
usb_device.c :设备初始化与调度;
usbd_cdc.c :标准CDC类处理逻辑;










三、常见问题排查修改
3.1 无法识别USB口常见排查
- 中断配置错误
-
NVIC未使能中断
检查是否在代码中正确启用USB中断,例如:HAL_NVIC_SetPriority(USB_LP_CAN1_RX0_IRQn, 0, 0); // 设置优先级 HAL_NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn); // 使能中断 -
确保总中断开启、中断优先级冲突
确保USB中断优先级未被其他高优先级中断阻塞。
- 时钟配置错误
-
USB时钟未正确分频
STM32 USB需要精确的48 MHz时钟。检查RCC配置:// 例:使用HSE+PLL生成48MHz USB时钟 RCC_PLLConfig(RCC_PLLSource_HSE, PLL_M, PLL_N, PLL_P, PLL_Q); RCC_USBCLKConfig(RCC_USBCLKSource_PLLCLK_1Div5); // 根据PLL输出调整分频 -
USB外设时钟未使能
确保调用
__HAL_RCC_USB_CLK_ENABLE()启用USB时钟。
- 硬件/引脚配置问题
-
USB引脚复用错误
检查USB的DP(PA12)和DM(PA11)是否配置为复用功能:GPIO_InitStruct.Pin = GPIO_PIN_11 | GPIO_PIN_12; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // 复用推挽 GPIO_InitStruct.Alternate = GPIO_AF10_USB; // 复用功能号 -
上拉电阻未启用
USB设备需在DP引脚启用1.5kΩ上拉电阻(部分型号通过
USB_BCDR寄存器的DPPU位控制)。
- USB库配置问题
-
中断服务函数未实现或命名错误
确保中断服务函数名称与启动文件(如startup_stm32fxxx.s)中的向量表一致:void USB_LP_CAN1_RX0_IRQHandler(void) { // 中断函数名根据型号可能不同 HAL_PCD_IRQHandler(&hpcd); // 调用HAL库中断处理 } -
未处理中断标志
在自定义中断函数中,需手动清除中断标志(HAL库通常会自动处理)。
- 初始化顺序错误
-
USB初始化应在中断配置之后
确保先配置NVIC,再初始化USB外设:MX_NVIC_Init(); // 先配置中断,部分型号不需要 MX_USB_DEVICE_Init(); // 再初始化USB
- 供电与VBUS检测
-
VBUS检测未启用或配置错误
部分型号需要检测VBUS信号。检查USB_OTG_FS或USB外设的初始化代码,确保VBUS检测模式正确:hpcd.Init.vbus_sensing_enable = 0; // 如果无需VBUS检测,设为0
3.2 已识别USB但异常
- 能识别,但无法打开串口(提示"端口占用"或"访问被拒绝")
这种情况多出现在Windows系统:
-
关闭杀毒软件或防火墙尝试;
-
卸载重复驱动(设备管理器 → 查看隐藏设备 → 删除旧的COM口);
-
使用管理员权限运行串口工具。
2.数据乱码、丢包严重
可能原因:
-
主循环中长时间执行 HAL_Delay() 导致中断服务延迟;
-
多次快速调用 CDC_Transmit_FS() 未等待完成;
-
FIFO溢出或缓冲区太小。
解决方案:
-
避免在中断中做耗时操作;
-
使用状态机管理发送流程;
-
增大缓冲区或引入流量控制(如XON/XOFF模拟)。
四、相关函数修改
4.1 相关文件
| 类别 | 文件名 | 核心作用 |
|---|---|---|
| HAL库核心文件 | usbd_core.c |
USB设备协议栈核心。 处理底层枚举、控制传输、状态机。 |
usbd_ctlreq.c |
标准设备请求处理器。 响应主机获取描述符、设置地址等标准请求。 | |
usbd_cdc.c |
CDC类协议实现核心。 管理CDC类的特定请求、抽象数据模型。 | |
| 用户实现文件 | usbd_cdc_if.c |
CDC类与应用层的接口。 包含所有应用回调函数。 |
usbd_desc.c / .h |
设备描述符定义。 定义了设备的身份信息(VID, PID, 字符串等)和报告给主机的所有描述符。 | |
usbd_conf.c / .h |
USB外设底层配置与定制。 包含内存分配、PCD句柄、弱定义的回调、以及可选的电源管理配置。 | |
| 其他重要文件 | stm32g4xx_it.c |
中断服务程序。 包含USB_LP_IRQHandler等中断入口函数。 |
usb_device.c |
USB设备层初始化入口。 由CubeMX生成,调用上述各模块的初始化函数。 | |
| 基础定义与请求 | usbd_def``.h |
定义核心数据结构(如USBD_HandleTypeDef)、状态码、宏定义。 |
usbd_ioreq.c / .h |
端点输入/输出请求处理器。 负责处理端点级的数据发送(USBD_LL_Transmit)和接收(USBD_LL_PrepareReceive)的底层细节。 |
4.2 相关函数
| 函数名 | 所在文件 | 作用与调用时机 |
|---|---|---|
CDC_Receive_FS |
usbd_cdc_if.c |
数据接收回调 。当主机通过虚拟串口发送数据,且协议栈完成接收后,自动调用此函数。 |
CDC_Transmit_FS |
usbd_cdc_if.c |
数据发送函数。 |
MX_USB_Device_Init |
usbd_device.c |
协议栈初始化。 |
4.3 USB模拟拔插
电脑端USB口没有插入设备时,DP和DM线,是低电平状态,而设备端的DP线,有1.5K电阻上拉到3.3V,当设备插入到电脑USB口,USB口的DP线就会被置高电平,主机是依靠这个机制判断设备是否插入、拔出,继而触发不同的动作,如枚举、释放端口等。
当虚拟串口所用的USB线一直插在USB口上,在STM32烧录程序重新运行后,程序里的USB代码等待着主机方发起枚举过程;而这个期间虚拟串口的USB线没有断开,主机方认为设备方一直在线,早已枚举成功,一直对其轮询数据收发。

__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能GPIOA端口
GPIO_InitTypeDef GPIO_InitStruct = {0}; // 声明结构体; 如果与文中位置相同,这行可不写
GPIO_InitStruct.Pin = GPIO_PIN_12; // 引脚PA12, 即D+
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 引脚工作模式
GPIO_InitStruct.Pull = GPIO_PULLDOWN; // 下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 引脚反转速度
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 初始化
HAL_Delay(5); // 持续片刻
4.4 连续发送优化
USB虚拟串口通信的几个重点 (特指:USB2.0、全速模式、中断传输):
USB是轮询机制,主机对设备不断轮询,间隔最小1ms;不是固定的1ms, 是最小间隔时间;
USB的数据,是按包传输的;
每个设备,每1ms,最多传输1包数据;
每包最多64字节(有效负载);
CDC_Transmit_FS ( ) 函数:
它的第2个参数,"字节数",范围:0~2048; 这个2048可以在CubeMX里进行设置大小;
字节数 <= 64,算1包。如:发3个字节,也算1包。
字节数 == 0,算1包。俗称空包; 如果上一帧刚好发送64字节,再发一个空包作为结束包;
字节数 > 64, CDC_Transmit_FS ( ) 背后有缓存自动分包1ms左右发1包,直至发完;
++如果上一包还没发完,再次调用CDC_Transmit_FS ( ) ,将放弃本次调用。++

uint8_t CDC_Transmit_FS(uint8_t *Buf, uint16_t Len)
{
uint8_t result = USBD_OK;
/* USER CODE BEGIN 7 */
// 获得设备的状态信息结构体
USBD_CDC_HandleTypeDef *hcdc = (USBD_CDC_HandleTypeDef *)hUsbDeviceFS.pClassData;
// if (hcdc->TxState != 0){
// return USBD_BUSY;
// }
const static uint8_t USB_TX_MAX_LEN = 250;
uint16_t txLen = Len;
uint16_t temp = 0;
while(txLen > 0)
{
USBD_CDC_SetTxBuffer(&hUsbDeviceFS, Buf, temp = (txLen > USB_TX_MAX_LEN ? USB_TX_MAX_LEN : txLen));
result = USBD_CDC_TransmitPacket(&hUsbDeviceFS);
txLen -= temp;
uint32_t timeStart = HAL_GetTick();
while (hcdc->TxState)
{
if (HAL_GetTick() - timeStart > 20)
return USBD_BUSY;
}
}
/* USER CODE END 7 */
return result;
}
4.5 接收机制注意
CDC_Receive_FS
1、每当接收到一包数据,硬件自动触发中断函数, 继而调用此接收回调函数,无需人工调用。
2、与发送机制相似,每间隔1ms,最多接收1包数据,每包最大64字节。
如果需要接收超过64字节的数据帧,注意,指上位机发送的1个完整数据帧,而非USB的单包数据,如上位机发来一张图片数据,8350个字节,则需要在此回调函数中添加额外的代码来判断帧数据传输完整结束 、手动将多个数据包拼接成完整的数据帧。
3、接收到数据时,缓存不会提前自动清零,新数据从Buf的起始位置开始,覆盖存放。
由于该回调函数是被中断函数调用的,因此建议函数内部的处理尽可能地简短,以避免影响系统的实时性(中断函数运行期间,会令程序持续挂起)。
参考文章: