拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32 USB CDC虚拟串口开发实战:从原理到稳定通信的实现

1. 项目概述与核心价值最近在做一个需要和上位机频繁交互的小设备用串口吧速度慢、线缆多还得额外配个USB转串口模块总觉得不够“利索”。于是我决定把STM32的USB功能用起来直接做成一个USB设备插上电脑就能识别、通信这才是嵌入式开发的“完全体”。这个“stm32-USB使用记录一”系列就是把我从零开始折腾USB CDC通信设备类虚拟串口到最终稳定通信的整个过程包括踩过的坑、调通的参数、验证过的代码都详细记录下来。如果你也在为STM32的USB开发头疼或者想摆脱串口调试的束缚那这篇记录应该能给你提供一条清晰的路径。USB开发尤其是对于刚从裸机或简单外设转过来的朋友常常会觉得门槛有点高。它不像点个LED灯那样直观涉及到底层驱动、协议栈、描述符、端点配置等一系列概念。但一旦打通你会发现它带来的便利性是革命性的无需额外硬件、即插即用、通信速率快、供电还方便。这次我选用了最常见的STM32F103C8T6也就是我们常说的“蓝桥杯”核心板同款芯片和STM32CubeMX工具链目标是实现一个稳定的USB虚拟串口让PC端能像操作普通COM口一样收发数据。2. 开发环境搭建与工程创建2.1 工具链选型与考量工欲善其事必先利其器。STM32的USB开发目前最主流、最省心的方式就是使用ST官方推出的STM32CubeMX配合HAL库。我知道有些资深工程师偏爱标准外设库StdPeriph Lib甚至直接寄存器操作追求极致的控制和效率。但对于USB这种复杂协议栈尤其是初次接触我强烈建议从CubeMXHAL开始。原因很简单USB协议栈本身非常复杂ST的HAL库已经为我们封装好了底层协议处理、中断调度、描述符构建等绝大部分繁琐工作。我们只需要关注应用层的逻辑比如数据从哪里来、到哪里去这能极大降低入门门槛和出错概率。我的环境配置如下IDE: Keil MDK-ARM V5。当然你也可以选择IAR或者免费的STM32CubeIDE。选择Keil主要是出于习惯和生态插件丰富。配置工具: STM32CubeMX V6.11.0。这是核心工具用于图形化配置时钟、引脚和中间件Middleware包括USB。开发板/芯片: STM32F103C8T6主频72MHz拥有USB 2.0全速设备接口。USB库: 通过CubeMX自动集成HAL库中的USB设备库USB Device Library。这里有个关键点确保你的CubeMX版本和HAL库包STM32CubeF1 Firmware Package是比较新的版本。旧版本可能在USB CDC实现上有一些已知的Bug。我一开始用了一个比较老的库就遇到了枚举失败的问题更新后迎刃而解。2.2 使用CubeMX进行关键配置打开CubeMX新建工程选择你的芯片型号STM32F103C8T6。接下来的配置步骤是重中之重每一步都影响着USB能否正常工作。第一步时钟树配置。USB模块对时钟精度有要求。对于STM32F103USB的时钟源必须来自PLL且必须精确到48MHz。在Clock Configuration标签页我的配置路径是8MHz外部高速晶振HSE - PLL倍频到72MHz作为系统时钟SYSCLK - 再经过一个专用的分频器USB Prescaler为USB提供48MHz时钟USBCLK。务必确认“USB Clock”显示为48.00MHz这是USB模块工作的“心跳”不准则无法识别。第二步开启USB外设。在“Pinout Configuration”标签页的左侧找到“Connectivity” - “USB”。在“Mode”下拉菜单中选择“Device (FS)”即全速设备模式。此时CubeMX会自动占用PA11DM和PA12DP这两个USB数据引脚。第三步配置USB中间件。这是核心。在左侧的“Middleware”分类下找到“USB_DEVICE”。在“Class For FS IP”中选择“Communication Device Class (Virtual Port Com)”。这就是我们需要的USB CDC类它会在电脑上模拟出一个串口。第四步配置CDC的具体参数。点击“USB_DEVICE”进入详细配置。这里有很多参数对于初版实现我们重点关注以下几个Product String: 设备产品名称比如“MyUSB-CDC Device”这个会显示在电脑的设备管理器里。CDC Settings:USB CDC Class Parameters: 保持默认即可。CDC Communication Interface: 这里配置的是“通信接口”负责传输控制命令如波特率设置。注意它分配的端点号通常是端点0用于控制传输端点1-IN用于通知。CDC Data Interface: 这里配置的是“数据接口”负责实际的数据收发。重点关注它分配的Bulk IN端点和Bulk OUT端点例如端点2-IN和端点2-OUT。Bulk传输是保证数据可靠性的关键。Device Descriptor: 可以修改VID厂商ID和PID产品ID。如果只是自己测试可以用ST的默认值或者在网上找一些测试用的ID。如果要发布产品则需要申请自己的VID。第五步生成工程代码。在“Project Manager”标签页设置好工程名称、路径、IDEMDK-ARM V5。在“Code Generator”部分我习惯勾选“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”这样代码结构更清晰。最后点击“GENERATE CODE”。注意生成代码后CubeMX可能会提示安装缺失的软件包如HAL库按照提示操作即可。第一次生成后千万不要在Keil中直接编译我们还需要进行关键的手动修改。3. USB CDC核心原理与代码解析生成了代码骨架我们得理解它干了什么才能知道哪里需要修改出了问题怎么排查。3.1 USB描述符设备的“身份证”和“说明书”当你的STM32设备插入电脑电脑首先会问“你是谁能干什么”回答这个问题的就是一系列的描述符。CubeMX已经帮我们生成好了大部分文件主要在USB_DEVICE/App/usbd_cdc_if.c和USB_DEVICE/Target下的usbd_conf.c和usbd_desc.c。设备描述符 (Device Descriptor): 定义了设备的全局信息如VID、PID、设备版本、支持的配置数量等。在usbd_desc.c的USBD_FS_DeviceDescriptor数组中。配置描述符 (Configuration Descriptor): 定义设备的一种工作模式。一个设备可以有多个配置但通常只有一个。它包含了接口和端点的信息。接口描述符 (Interface Descriptor): 描述一个逻辑功能。我们的CDC设备有两个接口通信接口Interface 0和数据接口Interface 1。端点描述符 (Endpoint Descriptor): 定义数据传输的通道。每个端点有地址含方向和属性传输类型、最大包大小等。CDC虚拟串口通常需要控制端点0 (EP0): 双向用于枚举、控制命令。中断IN端点 (EP1_IN): 用于CDC通信接口发送通知如线路状态。批量IN端点 (EP2_IN): 用于CDC数据接口发送数据到主机STM32 - PC。批量OUT端点 (EP2_OUT): 用于CDC数据接口接收来自主机的数据PC - STM32。最大包大小 (Max Packet Size)是关键参数在端点描述符中定义。对于全速USB的批量端点最大可以是64字节。这意味着一次传输的数据不能超过64字节。如果你的应用需要发送更长的数据必须在固件层进行分包处理。3.2 CDC接口类请求处理CDC类定义了一套特定的控制请求用于管理虚拟串口。例如当你在PC端用串口助手设置波特率、数据位、停止位时这些命令会通过控制端点0下发最终由usbd_cdc.c中的USBD_CDC_Setup函数处理。HAL库已经实现了标准的处理流程我们一般不需要修改。3.3 应用层数据收发核心回调函数我们的主要工作集中在应用层即usbd_cdc_if.c文件。这里有三个至关重要的回调函数CDC_Receive_FS(uint8_t* Buf, uint32_t *Len)功能当主机PC通过批量OUT端点发送数据到设备时USB底层驱动在接收完成后会自动调用此函数。参数Buf指向接收到的数据缓冲区Len是接收到的数据长度。你的任务在这个函数里处理从PC发来的数据。比如将数据存入环形缓冲区或者直接解析执行。非常重要的一点处理完数据后必须重新启动OUT接收否则USB将无法接收下一次数据。通常是通过调用USBD_CDC_ReceivePacket或USBD_CDC_SetRxBuffer和USBD_CDC_ReceivePacket来实现。CDC_Transmit_FS(uint8_t* Buf, uint16_t Len)功能这是你主动发送数据到PC时调用的函数。参数Buf是待发送数据的指针Len是数据长度。关键限制Len必须小于等于批量IN端点的最大包大小如64。如果数据更长你必须自己分包多次调用此函数。阻塞与非阻塞默认情况下这个函数可能是阻塞的即它会等待上一次发送完成。在高实时性要求的系统中你需要根据其返回值或状态设计非阻塞的发送机制比如配合状态机和环形缓冲区。CDC_Control_FS(uint8_t cmd, uint8_t* pbuf, uint16_t length)功能处理来自主机的CDC类特定控制命令如设置线路编码波特率等、设置控制线状态DTR/RTS。应用你可以在这里捕获PC端串口助手设置的波特率。例如当cmd为CDC_SET_LINE_CODING时pbuf指向一个结构体里面包含了波特率、数据位等信息。你可以将其保存到全局变量中供你的串口解析逻辑使用虽然虚拟串口不依赖硬件波特率但知道这个值有时对协议解析有用。4. 关键代码修改与实现步骤CubeMX生成的代码是一个通用框架要让它跑起来我们必须进行几处关键的修改和添加。4.1 修复CDC接收回调函数必做这是新手最容易出错的地方。CubeMX自动生成的CDC_Receive_FS函数可能只是一个空架子或者有瑕疵。一个典型、稳定的实现如下// 在 usbd_cdc_if.c 中 static uint8_t cdc_rx_buffer[APP_RX_DATA_SIZE]; // 接收缓冲区大小在usbd_cdc.h中定义默认为2048 static volatile uint32_t cdc_rx_len 0; // 实际接收到的长度 static volatile uint8_t cdc_rx_busy 0; // 缓冲区忙标志 static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { /* USER CODE BEGIN 6 */ // 1. 将数据拷贝到应用层缓冲区这里简单示例实际建议用环形缓冲区 if((*Len) 0 cdc_rx_busy 0) { cdc_rx_busy 1; cdc_rx_len *Len; memcpy(user_rx_buffer, Buf, cdc_rx_len); // user_rx_buffer是你自己定义的全局缓冲区 // 可以在这里设置一个标志通知主循环有新数据到达 usb_rx_flag 1; cdc_rx_busy 0; } // 2. 至关重要重新启动OUT端点接收准备下一次数据传输 // 指向接收缓冲区和设置接收长度 USBD_CDC_SetRxBuffer(hUsbDeviceFS, cdc_rx_buffer); // 启动下一次接收 if (USBD_CDC_ReceivePacket(hUsbDeviceFS) ! USBD_OK) { // 可以添加错误处理比如重试或日志 } return (USBD_OK); /* USER CODE END 6 */ }为什么必须重新启动接收USB的Bulk OUT传输是主机发起的。设备在收到一包数据并通知应用层后必须明确告诉USB内核“我已经处理完了缓冲区空了可以接收下一包了。”USBD_CDC_ReceivePacket就是这个“告知”动作。如果忘了调用USB内核会认为缓冲区一直满着后续的数据包就会被丢弃表现为PC端发送数据设备只收到第一次的。4.2 实现非阻塞数据发送默认的CDC_Transmit_FS在数据未发送完成前可能处于忙状态。一个健壮的发送流程应该检查状态。// 定义一个发送函数 uint8_t USB_CDC_Send_Data(uint8_t *data, uint16_t len) { uint8_t result USBD_BUSY; uint32_t timeout 0; // 等待USB CDC发送就绪 while(CDC_Transmit_FS(data, len) USBD_BUSY) { timeout; if(timeout 500000) // 超时机制防止死等 { result USBD_FAIL; break; } // 可以在这里执行其他任务或延时 HAL_Delay(1); } if(timeout 500000) { result USBD_OK; } return result; } // 在主循环或需要发送的地方调用 if(USB_CDC_Send_Data(tx_buffer, tx_len) ! USBD_OK) { // 发送失败处理比如重试或记录错误 }对于需要发送大量数据的场景你应该实现一个发送环形缓冲区。CDC_Transmit_FS只负责从缓冲区头部取一包数据最多64字节发送发送完成中断中再触发下一包发送从而实现流式非阻塞传输。4.3 处理线路编码与控制信号在CDC_Control_FS函数中我们可以获取PC端设置的虚拟串口参数。static int8_t CDC_Control_FS(uint8_t cmd, uint8_t* pbuf, uint16_t length) { /* USER CODE BEGIN 5 */ switch(cmd) { case CDC_SET_LINE_CODING: // pbuf指向一个USBD_CDC_LineCodingTypeDef结构体 linecoding.bitrate (uint32_t)(pbuf[0] | (pbuf[1] 8) | (pbuf[2] 16) | (pbuf[3] 24)); linecoding.format pbuf[4]; linecoding.paritytype pbuf[5]; linecoding.datatype pbuf[6]; // 将获取到的波特率等信息保存到全局变量可用于调试信息显示 g_usb_cdc_baudrate linecoding.bitrate; break; case CDC_SET_CONTROL_LINE_STATE: // 处理DTR/RTS信号 // pbuf[1] 包含控制线状态 g_usb_cdc_dtr (pbuf[1] 0x01) ? 1 : 0; // DTR g_usb_cdc_rts (pbuf[1] 0x02) ? 1 : 0; // RTS // 许多串口助手会在打开串口时拉高DTR。可以利用这个信号判断“连接”状态。 if(g_usb_cdc_dtr) { usb_connected 1; // 连接建立可以做一些初始化比如清空缓冲区 } else { usb_connected 0; } break; default: break; } return (USBD_OK); /* USER CODE END 5 */ }利用DTR信号来判断虚拟串口是否被打开是一个非常实用的技巧可以避免在串口助手未连接时盲目发送数据。5. 调试、枚举与常见问题排查代码修改完成后编译下载到开发板。将开发板的USB口注意是USB Device口通常是Micro-USB或Type-C且需要连接PA11/PA12插入电脑。5.1 枚举过程与驱动安装第一次插入电脑会提示发现新设备“STM32 Virtual COM Port”。如果电脑网络通畅Windows通常会通过Windows Update自动搜索并安装对应的CDC驱动usbser.sys。等待其自动完成即可。驱动安装失败如果自动安装失败可以去ST官网搜索“STSW-STM32102”这是ST官方的USB虚拟串口驱动包下载后手动指定安装。查看设备安装成功后在设备管理器的“端口COM和LPT”下应该能看到一个“USB串行设备COMx”后面的x是一个数字就是你的虚拟串口号。5.2 常见问题与解决方案实录以下是我在调试过程中遇到的实际问题及解决方法堪称“血泪史”问题一设备管理器里出现“未知USB设备”或带感叹号的设备。排查思路硬件检查首先确认USB线是数据线而非仅充电线。测量VBUS5V是否正常供电到板子。检查PA11DM和PA12DP是否被其他外设占用或配置错误。时钟检查这是最常见的原因反复确认CubeMX中USB时钟是否为精确的48MHz。检查PLL配置确认输入时钟源HSE是否正常起振。可以在代码初始化后通过调试器查看RCC-CFGR寄存器验证时钟配置。描述符检查描述符配置错误会导致枚举失败。使用USB协议分析仪如Bus Hound是终极手段但成本高。可以先用逻辑分析仪抓一下USB D/D-的波形看是否有数据交互。更简单的方法是逐字节核对usbd_desc.c中的描述符数组特别是设备描述符、配置描述符的总长度、端点地址和最大包大小。电源问题STM32的USB模块需要从VDD取电。确保芯片供电稳定。有些开发板需要短接跳线帽将USB的5V连接到板载LDO。问题二设备能识别成串口但串口助手打开时提示“打开失败”或“拒绝访问”。排查思路端口被占用检查是否其他程序如之前的串口助手未关闭、Keil的串口调试窗口等占用了该COM口。DTR/RTS处理不当如前所述串口助手打开时会发CDC_SET_CONTROL_LINE_STATE命令。如果你的CDC_Control_FS函数没有正确处理或返回错误可能导致打开失败。确保该函数返回USBD_OK。端点配置冲突检查usbd_conf.c中的USBD_CDC_HANDLE结构体初始化确保分配的端点地址CDC_IN_EP,CDC_OUT_EP,CDC_CMD_EP没有重复且符合CubeMX的配置。问题三PC发送数据设备只能收到第一包后续收不到。原因与解决99%的原因是CDC_Receive_FS函数中没有正确调用USBD_CDC_ReceivePacket来重新启动OUT接收。请严格按照4.1节的示例修改你的代码。另外检查接收缓冲区大小APP_RX_DATA_SIZE是否足够。问题四设备发送数据PC端接收不完整、有乱码或丢包。排查思路分包发送确认你发送的数据长度没有超过端点最大包大小64字节。如果超过必须在应用层分包。发送阻塞与流控CDC_Transmit_FS是阻塞的。如果你在中断服务函数中快速连续调用它可能导致数据覆盖或丢失。绝对不要在中断里进行大量、可能阻塞的数据发送。应该采用“主循环查询发送标志中断填充缓冲区”或“DMA环形缓冲区”的方式。PC端串口助手设置确认波特率、数据位等设置与设备端CDC_SET_LINE_CODING获取的值一致虽然虚拟串口不依赖硬件波特率但某些助手软件会校验。尝试换一个串口助手软件如Tera Term, Putty, AccessPort进行测试。USB总线繁忙如果MCU还在进行其他大量数据操作如高速ADC采样并存储可能会占用过多CPU时间导致USB中断服务不及时而丢包。可以尝试优化代码或者降低其他任务优先级。问题五设备频繁断开重连。排查思路电源不稳定USB供电电压跌落可能导致设备复位。检查板子电源电路尤其是LDO的带载能力。可以在代码中检测复位标志判断是否是电源复位。软件看门狗复位如果开启了看门狗IWDG/WWDG且USB处理函数如CDC_Receive_FS执行时间过长可能导致看门狗复位。适当延长看门狗喂狗周期或优化USB数据处理逻辑。堆栈溢出USB中断和数据处理可能会消耗较多堆栈。在启动文件或链接脚本中适当增大堆栈Stack大小。调试USB耐心和细致的逻辑分析是关键。建议采用“分步验证法”先确保枚举成功设备识别再测试控制传输打开串口最后调试批量数据传输收发数据。每完成一步再进行下一步能有效缩小问题范围。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门