三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

STM32 USB开发实战:从串口到虚拟串口的完整实现指南

STM32 USB开发实战:从串口到虚拟串口的完整实现指南

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差分数据线。对于全速USB,STM32内部已经集成了收发器(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域取电。常见的方案有:

  1. 完全总线供电:STM32的整个系统电源都来自USB的5V VBUS。这需要通过一个LDO(如AMS1117-3.3)将5V转换为3.3V。这是最常见也最经济的方式。
  2. 自供电设备:STM32由其他电源(如电池、外部适配器)供电,同时USB仅用于通信。这时需要特别注意电平匹配和共地问题。你的系统3.3V需要和USB的GND共地。在某些情况下,你可能还需要在DP/DM线上串联小电阻(22Ω)以进行阻抗匹配和防止倒灌。

2.2 软件工程创建与库的选择

我使用的是STM32CubeIDE,它基于Eclipse,集成了CubeMX配置工具和ARM GCC编译器,非常方便。你也可以使用Keil MDK或IAR,配置思路是相通的。

步骤一:使用STM32CubeMX初始化

  1. 新建工程,选择你的具体STM32型号。
  2. Pinout & Configuration标签页中,找到Connectivity->USB
  3. 选择Device (FS)模式。此时,PA11和PA12会被自动配置为USB_DM和USB_DP。
  4. 强烈建议:找到PA9(或其他你用于VBUS检测的引脚),将其配置为GPIO_Input,并为其启用中断(EXTI线)。在NVIC Settings中使能对应的EXTI中断。这样你就能在代码中响应插拔事件。
  5. Middleware部分,选择USB_DEVICE。这里就是选择你要实现的USB设备类的地方。对于初次尝试,我建议从Communication Device Class (Virtual Port Com)开始,也就是虚拟串口(VCP)。它非常实用,且调试信息可以方便地通过这个虚拟串口打印出来,形成“自举”调试。
  6. 配置时钟树(Clock Configuration)。USB模块对时钟要求非常严格,它需要一个精确的48MHz时钟。这个时钟通常由主PLL分频而来。请务必使用CubeMX的“自动计算”功能,或者手动确保给USB提供时钟的路径最终输出是准确的48MHz。如果使用外部晶振(HSE),配置会相对直观;如果使用内部RC振荡器(HSI),则需要通过PLL进行倍频/分频,并注意校准精度是否满足USB要求(通常±0.25%以内)。F1系列通常需要外部晶振才能保证USB时钟精度。
  7. 生成工程代码。选择你的IDE和工具链。

步骤二:理解生成的代码框架CubeMX会生成一个基于STM32 HAL库的完整USB设备框架。主要关注以下几个文件/目录:

  • Core/Inc/usb_device.h&Core/Src/usb_device.c: USB设备层初始化入口。MX_USB_DEVICE_Init()就在这里,它调用了USB设备库的初始化函数。
  • USB_DEVICE/App/: 这是应用层代码存放地,也是你需要主要修改和添加业务逻辑的地方。
    • usbd_cdc_if.h/c: 如果你选择的是CDC(VCP)类,这里包含了虚拟串口的回调函数接口,如数据接收CDC_Receive_FS()、数据发送CDC_Transmit_FS()等。
    • usbd_desc.h/c: USB描述符定义文件。这是USB设备的“身份证”和“能力说明书”,主机通过读取这些描述符来识别你的设备。包括设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符等。通常,你只需要在这里修改厂商ID(VID)、产品ID(PID)、产品字符串等信息。
  • 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配置CDC(VCP)类时,它会自动为你分配端点:通常一个Bulk OUT端点(接收PC数据),一个Bulk IN端点(发送数据到PC),可能还有一个Interrupt IN端点(用于传输串口线路状态,如DTR/RTS信号)。

3.2 描述符(Descriptor):设备的“简历”

描述符是一系列标准格式的数据结构,告诉主机“我是什么”、“我能做什么”。主机在枚举过程中会逐步请求读取这些描述符。主要描述符包括:

  1. 设备描述符 (Device Descriptor): 最高级别的描述,包含VID、PID、设备类(bDeviceClass)、协议、厂商字符串索引等。usbd_desc.c中的USBD_FS_DeviceDescriptor就是它。
    • VID/PID: 这是设备的身份证。如果你只是个人学习,可以使用ST的测试VID(比如0x0483)。但如果要做产品,必须向USB-IF申请自己的VID。PID可以自己定义。Windows等系统会根据VID/PID来加载对应的驱动程序。
  2. 配置描述符 (Configuration Descriptor): 描述设备的一种工作配置(一个设备可以有多个配置,但一次只能激活一个)。它包含了配置下的所有接口描述符端点描述符的总长度。CDC配置描述符比较复杂,因为它包含两个接口:通信接口(管理)和数据接口(传输)。
  3. 接口描述符 (Interface Descriptor): 描述设备的一个功能接口。例如,一个复合设备可能同时包含HID接口(鼠标)和CDC接口(串口)。每个接口有自己的类代码(bInterfaceClass)、子类、协议。
  4. 端点描述符 (Endpoint Descriptor): 描述一个端点的属性,包括端点地址、方向、类型(Bulk/Interrupt等)、最大包大小(wMaxPacketSize)和轮询间隔(bInterval)。
  5. 字符串描述符 (String Descriptor): 提供人类可读的文字信息,如厂商名、产品名、序列号。它们是可选的,但强烈建议提供,方便识别。

usbd_desc.c中,USBD_FS_DeviceDescriptorUSBD_FS_ConfigDescriptorUSBD_FS_StringDescriptors这几个数组就定义了所有这些信息。修改产品信息主要就是改这里和对应的字符串索引。

3.3 枚举(Enumeration):握手与识别

当你把设备插入主机,一场精密的“对话”就开始了:

  1. 上电与连接检测:主机在VBUS上检测到设备,D+线上的1.5k上拉电阻让主机知道这是一个全速设备。
  2. 复位:主机向USB总线发送一个复位信号,设备进入默认状态,地址为0。
  3. 获取设备描述符:主机向地址0、端点0发送标准请求GET_DESCRIPTOR,请求设备描述符。设备通过EP0 IN返回描述符。主机据此知道最大包大小等信息。
  4. 设置地址:主机分配一个唯一的地址(1-127)给设备,发送SET_ADDRESS请求。设备之后就用这个新地址通信。
  5. 获取完整配置信息:主机用新地址,再次获取设备描述符,然后获取配置描述符。配置描述符是一个集合,主机会一次性读取所有接口和端点的描述符。
  6. 选择配置:主机发送SET_CONFIGURATION请求,激活某个配置(通常是配置1)。设备完成相应端点的初始化,进入配置状态。
  7. 加载驱动:主机根据设备/接口的类代码(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 FS,Middleware选择Communication Device Class (Virtual Port Com)。时钟务必确保USB时钟是48MHz。生成代码。

4.2 填充应用层回调函数

打开USB_DEVICE/App/usbd_cdc_if.c,这是我们工作的主战场。

关键函数一:CDC_Control_FS这个函数处理主机发来的CDC类特定请求。最重要的是处理CDC_SET_LINE_CODINGCDC_SET_CONTROL_LINE_STATE请求。

  • CDC_SET_LINE_CODING:主机设置虚拟串口的参数(波特率、停止位、校验位、数据位)。你会收到一个LineCodingTypeDef结构体。注意:这个波特率对你的STM32程序来说只是一个数字,STM32的USB通信速率与这个波特率无关(USB固定是12Mbps)。你通常需要将这个值保存下来,当你需要通过这个USB通道与另一端设备进行“串口语义”通信时(例如转发给另一个真实UART),才需要用到它。对于简单的回环测试,可以忽略。
  • CDC_SET_CONTROL_LINE_STATE:主机控制虚拟串口的“流控”信号,主要是DTR(Data Terminal Ready)和RTS(Request 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.cwhile(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 编译、下载与测试

  1. 编译工程,确保无错误。
  2. 下载程序到STM32,并连接USB线到电脑的USB口(务必连接到开发板的USB Device口,而不是ST-Link的虚拟串口)。
  3. 观察电脑设备管理器
    • 如果一切顺利,你会在“端口(COM和LPT)”下看到一个新的串行设备,例如“USB串行设备(COMx)”。这表明Windows已经成功识别并加载了CDC驱动。
    • 如果出现“未知设备”或带感叹号的设备,可能是驱动问题。可以尝试手动指定驱动路径:C:\Windows\System32\DriverStore\FileRepository\...查找usbser.inf,或者安装ST提供的虚拟串口驱动(STTinyUSB)。
    • 如果根本没有新设备出现,请检查硬件连接(VBUS?上拉电阻?),并检查STM32的USB时钟配置。
  4. 使用串口助手测试
    • 打开串口助手(如Putty、SecureCRT、或者你喜欢的任何工具)。
    • 选择对应的COM口,设置波特率(任意,如115200,因为实际速率是USB决定的)。
    • 勾选“DTR”(如果串口助手支持),这可能会触发你代码中的DTR检测逻辑。
    • 发送一段数据,你应该能收到同样的回环数据。

恭喜你,一个最基本的USB虚拟串口设备已经跑通了!这为你后续实现更复杂的USB功能(如HID、MSC)打下了坚实的基础。这个框架的核心——描述符、端点、回调机制——是相通的。在下一篇文章中,我们可以基于这个框架,尝试将设备改造成一个自定义的HID设备,实现键盘按键模拟或者自定义数据报告传输。

← 返回列表