
1. 从串口到USB为什么我的STM32项目需要USB如果你和我一样是从51单片机或者早期的STM32F1系列开始玩嵌入式开发的那么你对串口UART一定再熟悉不过了。它简单、直接两根线TX/RX就能搞定通信是调试和传输数据的“万金油”。很长一段时间里我的项目里都离不开一个USB转TTL的小板子它就像开发板和电脑之间的“翻译官”。但随着项目越来越复杂我开始遇到瓶颈。比如我想做一个能直接插上电脑就被识别为鼠标或键盘的HID设备或者一个能像U盘一样直接存取文件的Mass Storage设备又或者一个能高速传输大量传感器数据的虚拟串口VCP。这时候传统的串口就力不从心了。它速度慢通常115200bps就到头了协议简单需要额外的转换芯片而且最重要的是它无法实现那些“即插即用”的、符合标准USB类协议的功能。这就是我决定深入折腾STM32片上USB外设的起点。STM32的USB功能非常强大它集成了一个全速12 Mbps或高速480 Mbps取决于型号的USB设备控制器允许你的微控制器直接通过USB线与主机通常是PC通信而无需任何外部PHY芯片对于全速模式。这意味着你可以用一根最普通的Micro-USB或Type-C线就让你的STM32板子变身成各种有趣的USB设备。然而和所有强大的功能一样STM32的USB开发也有一定的门槛。它不像点亮一个LED或读取一个ADC值那样直观。你需要理解USB的基础协议框架需要处理复杂的描述符需要编写正确的中断服务程序还需要熟悉ST提供的USB设备库无论是标准外设库、HAL库还是LL库。网上资料虽然多但往往比较零散或是基于特定开发板和特定库版本的“保姆级”教程一旦换了个型号或环境可能就水土不服。所以这个系列记录不是另一个“从零开始”的教程而是我作为一个从串口时代过来的开发者在征服STM32 USB路上踩过的坑、理清的思路和验证过的方案。我会尽量抛开对特定开发板的依赖聚焦于核心概念和可移植的代码逻辑希望能给同样在这条路上摸索的你提供一些不一样的视角和切实可用的参考。2. 硬件准备与工程框架不只是插上线那么简单在写第一行代码之前正确的硬件和软件环境准备至关重要很多“莫名其妙”的问题都源于此。2.1 硬件连接与电源考量首先确认你的STM32型号支持USB。绝大多数STM32F0、F1、F3、F4、L0、L4系列都包含全速USB设备接口。对于F2、F4、F7、H7等系列的高端型号可能还支持高速USB需要外部ULPI PHY芯片。我们以最常见的全速USB设备为例。关键引脚USB_DP (PA12) / USB_DM (PA11) 这是USB差分数据线。对于全速USBSTM32内部已经集成了收发器Transceiver所以你只需要将这两个引脚直接连接到USB连接器的D和D-即可。注意USB协议规定全速设备需要在D线上接一个1.5kΩ的上拉电阻到3.3V这个电阻通常集成在STM32内部可以通过软件控制连接/断开用以通知主机设备的插入和速度。VBUS (PA9) 这个引脚用于检测USB主机是否提供了5V电源。它不是必须用于通信但强烈建议连接。你可以用它来触发中断得知设备被插入或拔出从而进行相应的初始化或省电处理。通常VBUS信号通过一个简单的分压电路例如两个电阻将5V分压到3.3V以内连接到这个GPIO。GND 共地必须连接。注意很多精简的“最小系统板”为了节省空间可能只引出了DP/DM而没有连接VBUS。如果你的项目需要可靠的插拔检测请务必检查原理图或自行飞线连接。供电方案STM32的USB模块需要从VDD域取电。常见的方案有完全总线供电STM32的整个系统电源都来自USB的5V VBUS。这需要通过一个LDO如AMS1117-3.3将5V转换为3.3V。这是最常见也最经济的方式。自供电设备STM32由其他电源如电池、外部适配器供电同时USB仅用于通信。这时需要特别注意电平匹配和共地问题。你的系统3.3V需要和USB的GND共地。在某些情况下你可能还需要在DP/DM线上串联小电阻22Ω以进行阻抗匹配和防止倒灌。2.2 软件工程创建与库的选择我使用的是STM32CubeIDE它基于Eclipse集成了CubeMX配置工具和ARM GCC编译器非常方便。你也可以使用Keil MDK或IAR配置思路是相通的。步骤一使用STM32CubeMX初始化新建工程选择你的具体STM32型号。在Pinout Configuration标签页中找到Connectivity-USB。选择Device (FS)模式。此时PA11和PA12会被自动配置为USB_DM和USB_DP。强烈建议找到PA9或其他你用于VBUS检测的引脚将其配置为GPIO_Input并为其启用中断EXTI线。在NVIC Settings中使能对应的EXTI中断。这样你就能在代码中响应插拔事件。在Middleware部分选择USB_DEVICE。这里就是选择你要实现的USB设备类的地方。对于初次尝试我建议从Communication Device Class (Virtual Port Com)开始也就是虚拟串口VCP。它非常实用且调试信息可以方便地通过这个虚拟串口打印出来形成“自举”调试。配置时钟树Clock Configuration。USB模块对时钟要求非常严格它需要一个精确的48MHz时钟。这个时钟通常由主PLL分频而来。请务必使用CubeMX的“自动计算”功能或者手动确保给USB提供时钟的路径最终输出是准确的48MHz。如果使用外部晶振HSE配置会相对直观如果使用内部RC振荡器HSI则需要通过PLL进行倍频/分频并注意校准精度是否满足USB要求通常±0.25%以内。F1系列通常需要外部晶振才能保证USB时钟精度。生成工程代码。选择你的IDE和工具链。步骤二理解生成的代码框架CubeMX会生成一个基于STM32 HAL库的完整USB设备框架。主要关注以下几个文件/目录Core/Inc/usb_device.hCore/Src/usb_device.c: USB设备层初始化入口。MX_USB_DEVICE_Init()就在这里它调用了USB设备库的初始化函数。USB_DEVICE/App/: 这是应用层代码存放地也是你需要主要修改和添加业务逻辑的地方。usbd_cdc_if.h/c: 如果你选择的是CDCVCP类这里包含了虚拟串口的回调函数接口如数据接收CDC_Receive_FS()、数据发送CDC_Transmit_FS()等。usbd_desc.h/c: USB描述符定义文件。这是USB设备的“身份证”和“能力说明书”主机通过读取这些描述符来识别你的设备。包括设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符等。通常你只需要在这里修改厂商IDVID、产品IDPID、产品字符串等信息。USB_DEVICE/Target/: 硬件抽象层通常不需要修改。USB_DEVICE/Class/USB_DEVICE/Core/: ST提供的USB设备库核心源码不建议直接修改但可以阅读以理解其工作机制。这个框架采用了回调Callback机制。USB底层驱动中断处理、数据传输由库负责当特定事件发生时如设置配置、收到数据、发送完成它会调用你在App层实现的回调函数。你的主要工作就是在这些回调函数里填充你的应用逻辑。3. 核心概念拆解端点、描述符与枚举过程要驾驭USB必须理解这三个核心概念。它们决定了你的设备如何与主机对话。3.1 端点Endpoint通信的管道你可以把端点理解为USB设备上的一个“邮箱”或“管道”。每个端点都有唯一的地址和方向。STM32的USB外设硬件上提供了多个端点缓冲区通常8-16个取决于型号。端点0 (EP0) 这是所有USB设备都必须有的控制端点而且是双向的包含OUT和IN。它专门用于传输控制请求例如设备枚举、设置地址、读取描述符等。你几乎不需要直接操作EP0库已经处理好了。其他端点 (EP1, EP2...) 这些是用于数据传输的端点。每个端点必须指定方向IN 端点 设备到主机Device - Host。例如当你的STM32有数据要发送给PC时就通过一个IN端点发送。OUT 端点 主机到设备Host - Device。例如当PC发送数据给你的STM32时数据通过一个OUT端点送达。端点类型 还分为四种类型对应不同的传输需求和特性控制传输 (Control) 可靠的、突发的小数据量传输用于枚举和命令。EP0就是控制端点。中断传输 (Interrupt) 保证延迟的周期性传输。用于HID设备鼠标、键盘报告数据或者VCP的“串行状态”信息。批量传输 (Bulk) 可靠的、大流量的数据传输但没有带宽和延迟保证。用于大文件传输如Mass Storage、打印机等。同步传输 (Isochronous) 保证带宽的实时传输但可能丢数据。用于音频、视频流。在CubeMX配置CDCVCP类时它会自动为你分配端点通常一个Bulk OUT端点接收PC数据一个Bulk IN端点发送数据到PC可能还有一个Interrupt IN端点用于传输串口线路状态如DTR/RTS信号。3.2 描述符Descriptor设备的“简历”描述符是一系列标准格式的数据结构告诉主机“我是什么”、“我能做什么”。主机在枚举过程中会逐步请求读取这些描述符。主要描述符包括设备描述符 (Device Descriptor) 最高级别的描述包含VID、PID、设备类bDeviceClass、协议、厂商字符串索引等。usbd_desc.c中的USBD_FS_DeviceDescriptor就是它。VID/PID 这是设备的身份证。如果你只是个人学习可以使用ST的测试VID比如0x0483。但如果要做产品必须向USB-IF申请自己的VID。PID可以自己定义。Windows等系统会根据VID/PID来加载对应的驱动程序。配置描述符 (Configuration Descriptor) 描述设备的一种工作配置一个设备可以有多个配置但一次只能激活一个。它包含了配置下的所有接口描述符和端点描述符的总长度。CDC配置描述符比较复杂因为它包含两个接口通信接口管理和数据接口传输。接口描述符 (Interface Descriptor) 描述设备的一个功能接口。例如一个复合设备可能同时包含HID接口鼠标和CDC接口串口。每个接口有自己的类代码bInterfaceClass、子类、协议。端点描述符 (Endpoint Descriptor) 描述一个端点的属性包括端点地址、方向、类型Bulk/Interrupt等、最大包大小wMaxPacketSize和轮询间隔bInterval。字符串描述符 (String Descriptor) 提供人类可读的文字信息如厂商名、产品名、序列号。它们是可选的但强烈建议提供方便识别。在usbd_desc.c中USBD_FS_DeviceDescriptor、USBD_FS_ConfigDescriptor、USBD_FS_StringDescriptors这几个数组就定义了所有这些信息。修改产品信息主要就是改这里和对应的字符串索引。3.3 枚举Enumeration握手与识别当你把设备插入主机一场精密的“对话”就开始了上电与连接检测主机在VBUS上检测到设备D线上的1.5k上拉电阻让主机知道这是一个全速设备。复位主机向USB总线发送一个复位信号设备进入默认状态地址为0。获取设备描述符主机向地址0、端点0发送标准请求GET_DESCRIPTOR请求设备描述符。设备通过EP0 IN返回描述符。主机据此知道最大包大小等信息。设置地址主机分配一个唯一的地址1-127给设备发送SET_ADDRESS请求。设备之后就用这个新地址通信。获取完整配置信息主机用新地址再次获取设备描述符然后获取配置描述符。配置描述符是一个集合主机会一次性读取所有接口和端点的描述符。选择配置主机发送SET_CONFIGURATION请求激活某个配置通常是配置1。设备完成相应端点的初始化进入配置状态。加载驱动主机根据设备/接口的类代码Class Code、VID/PID在系统驱动库中寻找匹配的驱动程序并加载。对于CDC类Windows通常有内置的usbser.sys驱动。至此枚举完成设备就绪。对于CDC设备Windows还会额外进行一次“调制解调器”枚举发送一些AT命令来设置虚拟串口的参数波特率、数据位等这部分由CDC类驱动在后台处理。整个过程都是由USB库自动处理的你的代码只需要正确提供描述符并在相应的回调函数如USBD_CDC_SetCtrlLineState中响应主机的一些设置请求即可。理解这个过程能让你在设备无法被识别时有一个清晰的排查思路是描述符错了还是端点没配置对或者是驱动问题4. 实战实现一个最简可用的USB虚拟串口理论说得再多不如动手实现一个。我们的目标是创建一个USB设备插入电脑后在设备管理器中出现一个COM口我们可以用串口助手像操作真实串口一样与之通信。4.1 CubeMX配置与代码生成回顾按照第2.2节的步骤在CubeMX中正确配置USB为Device FSMiddleware选择Communication Device Class (Virtual Port Com)。时钟务必确保USB时钟是48MHz。生成代码。4.2 填充应用层回调函数打开USB_DEVICE/App/usbd_cdc_if.c这是我们工作的主战场。关键函数一CDC_Control_FS这个函数处理主机发来的CDC类特定请求。最重要的是处理CDC_SET_LINE_CODING和CDC_SET_CONTROL_LINE_STATE请求。CDC_SET_LINE_CODING主机设置虚拟串口的参数波特率、停止位、校验位、数据位。你会收到一个LineCodingTypeDef结构体。注意这个波特率对你的STM32程序来说只是一个数字STM32的USB通信速率与这个波特率无关USB固定是12Mbps。你通常需要将这个值保存下来当你需要通过这个USB通道与另一端设备进行“串口语义”通信时例如转发给另一个真实UART才需要用到它。对于简单的回环测试可以忽略。CDC_SET_CONTROL_LINE_STATE主机控制虚拟串口的“流控”信号主要是DTRData Terminal Ready和RTSRequest To Send。很多串口助手在打开串口时会发送DTR信号。你可以在这里检测DTR状态的变化作为“串口打开/关闭”的触发信号。这是一个非常实用的技巧static int8_t CDC_Control_FS(uint8_t cmd, uint8_t* pbuf, uint16_t length) { switch (cmd) { case CDC_SET_LINE_CODING: // 将pbuf中的数据拷贝到你的LineCoding结构体中例如 // memcpy(user_line_coding, pbuf, sizeof(LineCodingTypeDef)); // USBD_CDC_LineCodingTypeDef user_line_coding; break; case CDC_SET_CONTROL_LINE_STATE: // pbuf[0] 包含控制线状态 // 检查DTR位 (bit 0) if (pbuf[0] 0x01) { // DTR置位通常表示串口工具已连接/打开 dtr_enabled 1; } else { // DTR清除通常表示串口工具已断开/关闭 dtr_enabled 0; // 可以在这里清理接收缓冲区等 } break; // ... 其他case } return (USBD_OK); }关键函数二CDC_Receive_FS这是数据接收回调函数。当主机PC通过USB发送数据到你的设备时USB库在完成一次OUT传输后会调用这个函数并把接收到的数据缓冲区指针Buf和长度Len传给你。这是你处理PC下发数据的入口。static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { // 1. 将数据存入你的应用缓冲区例如环形队列 // 例如ring_buffer_write(rx_ring_buf, Buf, *Len); // 2. 可选触发一个信号量或设置标志通知主循环有数据到达 // 例如osSemaphoreRelease(rx_semaphore); // 3. 非常重要重新启动接收准备下一次数据传输。 // 如果不调用此函数USB库将不会准备下一次OUT传输导致无法接收后续数据。 USBD_CDC_SetRxBuffer(hUsbDeviceFS, Buf[0]); USBD_CDC_ReceivePacket(hUsbDeviceFS); return (USBD_OK); }核心经验CDC_Receive_FS是在USB中断上下文中被调用的这意味着它应该尽快执行并返回。绝对避免在这里进行长时间处理如复杂的计算、打印日志、等待信号量。标准的做法是只做最简单的数据拷贝和通知触发将实际的数据处理移到主循环或一个低优先级的任务中。关键函数三CDC_Transmit_FS这是数据发送函数注意它通常不是回调而是由你主动调用的API。当你的STM32需要发送数据给PC时就调用这个函数。uint8_t CDC_Transmit_FS(uint8_t* Buf, uint16_t Len) { uint8_t result USBD_OK; // 1. 检查上次传输是否完成。USB通信是异步的。 if (tx_busy) { // 上次传输还未完成可以返回错误或者等待不推荐在中断中等 return USBD_BUSY; } // 2. 设置忙标志 tx_busy 1; // 3. 调用库函数启动传输 result USBD_CDC_TransmitPacket(hUsbDeviceFS, Buf, Len); // 4. 如果启动失败清除忙标志 if (result ! USBD_OK) { tx_busy 0; } // 如果启动成功忙标志将在传输完成回调中清除 return result; }关键函数四CDC_TransmitCplt_FS这是发送完成回调函数。当你调用CDC_Transmit_FS启动的传输完成后USB库会调用这个函数。这是你清除“发送忙”标志、或者启动下一次发送链式调用的地方。static int8_t CDC_TransmitCplt_FS(uint8_t *Buf, uint32_t *Len, uint8_t epnum) { // 传输完成清除忙标志允许下一次发送 tx_busy 0; // 可以在这里通知你的应用层发送已完成 return (USBD_OK); }4.3 主循环中的数据处理逻辑在main.c的while(1)循环中或者在一个独立的RTOS任务中你可以这样组织逻辑// 伪代码示例 uint8_t usb_rx_buffer[APP_RX_DATA_SIZE]; uint32_t usb_rx_len 0; while (1) { // 1. 检查是否有USB数据到达通过信号量、标志位或查询环形缓冲区 if (usb_data_available()) { usb_rx_len get_usb_rx_data(usb_rx_buffer, sizeof(usb_rx_buffer)); // 2. 处理数据例如回环Echo if (usb_rx_len 0) { // 简单回环 CDC_Transmit_FS(usb_rx_buffer, usb_rx_len); // 或者根据协议解析数据... // process_protocol(usb_rx_buffer, usb_rx_len); } } // 3. 其他应用任务... HAL_Delay(1); // 或使用RTOS延时 }4.4 编译、下载与测试编译工程确保无错误。下载程序到STM32并连接USB线到电脑的USB口务必连接到开发板的USB Device口而不是ST-Link的虚拟串口。观察电脑设备管理器如果一切顺利你会在“端口COM和LPT”下看到一个新的串行设备例如“USB串行设备COMx”。这表明Windows已经成功识别并加载了CDC驱动。如果出现“未知设备”或带感叹号的设备可能是驱动问题。可以尝试手动指定驱动路径C:\Windows\System32\DriverStore\FileRepository\...查找usbser.inf或者安装ST提供的虚拟串口驱动STTinyUSB。如果根本没有新设备出现请检查硬件连接VBUS上拉电阻并检查STM32的USB时钟配置。使用串口助手测试打开串口助手如Putty、SecureCRT、或者你喜欢的任何工具。选择对应的COM口设置波特率任意如115200因为实际速率是USB决定的。勾选“DTR”如果串口助手支持这可能会触发你代码中的DTR检测逻辑。发送一段数据你应该能收到同样的回环数据。恭喜你一个最基本的USB虚拟串口设备已经跑通了这为你后续实现更复杂的USB功能如HID、MSC打下了坚实的基础。这个框架的核心——描述符、端点、回调机制——是相通的。在下一篇文章中我们可以基于这个框架尝试将设备改造成一个自定义的HID设备实现键盘按键模拟或者自定义数据报告传输。