STM32 HAL库USB开发实战:从HID到CDC虚拟串口
1. 项目概述为什么STM32的USB开发值得深究如果你玩过STM32大概率用过串口打印调试信息或者通过串口和上位机通信。但当你需要高速、稳定地传输大量数据时比如传输摄像头图像、音频流或者想做一个看起来更专业的设备插上USB线就能识别为一个特定设备而不是一个串口USB就成了绕不开的话题。而HAL库作为ST官方主推的硬件抽象层它把USB这个复杂的协议栈封装起来让我们这些应用开发者能更专注于业务逻辑而不是去死磕USB协议手册里那些令人头秃的细节。我最初接触STM32的USB是想做一个自定义的HID人机接口设备控制器用来传输游戏摇杆的数据。当时在标准库和HAL库之间纠结了很久最终还是选择了HAL库。原因很简单ST未来的新芯片几乎只提供HAL库支持生态在往这边迁移而且HAL库的结构更统一虽然初期觉得有些“臃肿”但熟悉之后移植和跨项目复用代码的效率高得多。这个项目标题“STM32 HAL库之USB”听起来像是一个技术模块但背后实际是一整套从硬件连接、协议栈配置、到驱动编写和上位机联调的完整工程实践。它解决的不仅仅是“通信”问题更是如何让一个嵌入式设备以更标准、更高效的方式融入现代计算机体系的问题。2. 核心思路与框架设计HAL库如何“抽象”USB很多新手拿到STM32CubeMX生成的一大堆USB代码会感到迷茫感觉像在看天书。要理清头绪关键在于理解HAL库为USB设计的分层架构。它不是一坨不可分割的代码而是一个精心设计的“洋葱”每一层都有明确的职责。2.1 HAL库USB驱动框架的三层模型我们可以把HAL库的USB支持分为三层从下到上依次是硬件抽象层HAL/PLL Driver这是最底层直接操作STM32内部的USB外设寄存器。它负责处理最基础的USB电气信号、端点缓冲区管理、中断触发等。这部分代码通常是stm32f4xx_hal_pcd.c/.h对于设备模式或stm32f4xx_hal_hcd.c/.h对于主机模式。作为应用开发者我们几乎不直接调用这一层的函数HAL库已经帮我们封装好了。USB设备协议栈层USB Device/Core Library这是核心层实现了USB 2.0规范中设备端的基本协议。它管理设备描述符告诉主机“我是什么”、处理标准的设备请求如获取描述符、设置地址、设置配置、管理各个端点的数据流状态。这一层的代码位于Middlewares/ST/STM32_USB_Device_Library/目录下。它提供了一套回调函数接口我们需要填充这些回调函数来定义设备的行为。USB设备类层USB Device Class这是应用层基于协议栈层实现具体的USB设备类别功能。比如你想做一个USB虚拟串口CDC类就使用CDC类库想做一个人机接口设备HID如键盘、鼠标就使用HID类库想实现大容量存储MSC模拟一个U盘就使用MSC类库。ST提供了这些常用类的实现模板位于Middlewares/ST/STM32_USB_Device_Library/Class/目录下。我们的主要工作就是基于某个类模板进行修改和适配。为什么这样设计这种分层最大的好处是解耦和复用。协议栈层是通用的不管你做键盘还是U盘处理标准请求的逻辑都一样。类层是可选的你可以用ST提供的也可以自己实现一个自定义类。而我们的应用代码只需要关心“数据来了怎么处理”和“数据怎么发出去”无需关心底层数据包是如何被拆解、CRC校验、发送的。这极大地降低了开发门槛。2.2 关键概念端点、管道与描述符在深入代码前必须吃透这三个概念否则配置起来就是盲人摸象。端点Endpoint可以理解为USB设备上的“数据收发信箱”。每个端点都有一个唯一的地址和方向。地址0是默认的控制端点用于传输配置、命令等关键信息。其他端点如0x81, 0x01用于传输应用数据。IN端点设备到主机和OUT端点主机到设备通常是成对出现的。管道Pipe逻辑上的数据传输通道建立在主机和设备上的某个端点之间。你可以把它想象成连接主机和端点“信箱”的“邮路”。描述符Descriptor一组定义设备属性、能力和配置的数据结构。它是USB设备的“身份证”和“说明书”。主机在枚举设备时会一步步读取这些描述符从而知道该如何与设备通信。主要描述符包括设备描述符描述设备的总体信息如厂商IDVID、产品IDPID、设备版本、支持的配置数量等。配置描述符描述设备的一种工作模式如高功耗模式、低功耗模式包括接口数量、最大功耗等。接口描述符描述设备提供的一种功能。一个配置可以包含多个接口。例如一个带音频功能的USB摄像头可能有视频捕获接口和音频流接口。端点描述符描述某个端点的属性如端点地址、方向、传输类型控制、中断、批量、同步、最大包大小等。字符串描述符可选的提供人类可读的设备名称、厂商字符串等。在HAL库项目中这些描述符通常以常量数组的形式定义在一个独立的文件如usbd_desc.c中。通过STM32CubeMX配置USB设备时它会自动生成这些描述符的框架我们只需要修改里面的VID、PID、字符串等关键信息即可。注意VID/PID非常重要。如果只是个人学习可以使用ST的测试ID如VID0x0483 PID0x5740。但如果产品要上市必须向USB-IF申请属于自己的VID并为每个产品分配唯一的PID否则可能会与系统已有的设备冲突导致无法识别。3. 从零构建一个USB HID设备以自定义报告设备为例理论讲得再多不如动手做一遍。我们以创建一个自定义的USB HID设备为例它不模拟键盘鼠标而是传输我们自定义格式的数据比如传感器数据包。这种设备在需要PC软件与STM32进行实时、双向、小数据量通信的场景下非常有用因为HID类设备的驱动在Windows、macOS、Linux上都是系统自带的无需额外安装驱动。3.1 环境准备与CubeMX工程配置首先确保你有一个支持USB Device功能的STM32开发板如STM32F103、F407、F429等并且板载了USB Micro-B或Type-C接口或者你通过跳线正确连接了USB的DM/DP线到芯片对应引脚。启动STM32CubeMX选择你的芯片型号。配置时钟树USB模块需要精确的48MHz时钟。对于STM32F4系列通常由主PLL分频得到。在Clock Configuration标签页确保USB OTG FS或USB OTG HS取决于你使用的USB接口的时钟源被正确配置为48MHz。这是最容易出错的一步时钟不对USB根本无法工作。使能USB外设在Pinout Configuration标签页找到Connectivity-USB_OTG_FS以全速USB为例。将Mode设置为Device_Only。此时对应的USB DMPA11和 DPPA12引脚会被自动分配。配置USB中间件切换到Middleware区域激活USB_DEVICE。在Class For FS IP下拉菜单中选择Human Interface Device Class (HID)。配置HID参数在Configuration标签页下找到USB_DEVICE-HID。这里需要设置HID Report Descriptor。报告描述符是HID设备的核心它定义了设备上报的数据格式。对于初学者可以先使用一个简单的示例。你可以点击Use custom report descriptor然后粘贴一个简单的描述符。例如下面这个描述符定义了一个包含4字节输入报告和4字节输出报告的设备__ALIGN_BEGIN static uint8_t HID_CUSTOM_ReportDesc[] __ALIGN_END { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 0x09, 0x01, // Usage (0x01) 0xA1, 0x01, // Collection (Application) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x04, // Report Count (4) 0x09, 0x01, // Usage (0x01) 0x81, 0x02, // Input (Data, Var, Abs) - 4字节输入报告 0x09, 0x01, // Usage (0x01) 0x91, 0x02, // Output (Data, Var, Abs) - 4字节输出报告 0xC0 // End Collection };设置HID Polling Interval为10即10ms这是主机轮询设备获取输入报告的间隔。生成工程设置好你的IDEKeil、IAR、STM32CubeIDE和工程名生成代码。3.2 剖析生成的代码结构与关键回调函数CubeMX生成的代码结构非常清晰。我们重点关注以下几个文件Core/Inc/usbd_conf.h和Core/Src/usbd_conf.cUSB设备底层配置如端点缓冲区大小、内存管理函数等。一般无需修改。Core/Inc/usbd_desc.h和Core/Src/usbd_desc.c设备描述符定义。你需要在这里修改USBD_VID、USBD_PID、USBD_PRODUCT_STRING等。USB_DEVICE/App/usbd_hid.c和USB_DEVICE/App/usbd_hid.hHID类应用层代码的核心。我们需要修改的就是这里。打开usbd_hid.c找到几个关键的回调函数static int8_t HID_Init_FS(void)HID设备初始化函数。在这里你可以初始化你的应用相关变量。static int8_t HID_DeInit_FS(void)反初始化函数。static int8_t HID_OutEvent_FS(uint8_t event_idx, uint8_t state)这个函数在HID类模板中可能不存在。实际上对于自定义HID接收主机发来的数据输出报告通常是通过另一个机制。ST的HID类库提供了一个函数USBD_HID_ReceivePacket来启动接收并在接收到数据后会调用一个名为HID_OutEvent_FS的回调如果实现的话或者更常见的是我们需要在应用层主动轮询或在一个全局回调里处理。这是一个常见的困惑点。 更标准的做法是在main.c或你的应用文件中调用USBD_HID_SendReport发送数据并处理接收。接收数据通常通过端点OUT中断回调来处理。但HAL库的HID类已经封装了这部分。查看usbd_hid.c你会发现一个函数static int8_t USBD_HID_Setup (USBD_HandleTypeDef *pdev, USBD_SetupReqTypedef *req)它处理HID特定的SETUP请求。对于OUT数据数据会被接收到类库内部的一个缓冲区。因此我们的主要任务变为发送数据设备-主机在需要的时候例如定时器中断、传感器数据准备好时调用USBD_HID_SendReport(hUsbDeviceFS, report_buf, report_size)。其中report_buf是你的数据缓冲区report_size必须与报告描述符中定义的输入报告大小一致本例中为4。接收数据主机-设备主机发送的输出报告数据会存放在HID类库内部。我们需要周期性地检查或在一个回调中处理。一个简单可靠的方法是在main函数的while(1)循环中调用一个处理函数来检查是否有新数据。但更优雅的方式是利用HAL库提供的机制。实际上在usbd_hid.c中有一个函数USBD_HID_DataOut它在OUT端点接收到数据后被底层调用。我们可以修改这个函数将接收到的数据拷贝到我们的应用缓冲区并设置一个标志位。3.3 实现自定义数据收发修改HID类模板让我们动手修改实现一个明确的收发机制。步骤一定义应用缓冲区在usbd_hid.h中添加以下外部变量声明/* USER CODE BEGIN EXPORTED_VARIABLES */ extern uint8_t UserRxBufferFS[4]; // 接收缓冲区大小与输出报告一致 extern volatile uint8_t UserRxReady; // 接收完成标志 /* USER CODE END EXPORTED_VARIABLES */在usbd_hid.c文件顶部定义这些变量/* USER CODE BEGIN PV */ uint8_t UserRxBufferFS[4] {0}; volatile uint8_t UserRxReady 0; /* USER CODE END PV */步骤二修改数据接收回调在usbd_hid.c中找到函数static int8_t USBD_HID_DataOut (USBD_HandleTypeDef *pdev, uint8_t epnum)。这个函数在OUT端点有数据到达时被调用。修改它static int8_t USBD_HID_DataOut (USBD_HandleTypeDef *pdev, uint8_t epnum) { /* Get the received data length */ uint32_t recv_len USBD_LL_GetRxDataSize(pdev, epnum); if(recv_len 4) // 确保长度符合预期 { /* Copy data to user buffer */ // 这里假设pdev-pClassData指向HID句柄我们需要获取接收缓冲区地址。 // 更直接的方式是HAL库的HID类将接收到的数据放在一个内部结构体中。 // 查看USBD_HID_HandleTypeDef结构体定义在usbd_hid.h它有一个成员uint8_t Report_buf[USBD_HID_REPORT_MAX_SIZE]。 // 实际上数据已经在这个缓冲区里了。我们需要从类句柄中获取它。 USBD_HID_HandleTypeDef *hhid (USBD_HID_HandleTypeDef *)pdev-pClassData; if(hhid ! NULL) { memcpy(UserRxBufferFS, hhid-Report_buf, recv_len); UserRxReady 1; // 设置标志位 } } /* Prepare next OUT transfer */ // 这句很关键它重新启动OUT端点的接收等待主机下一次发送数据。 USBD_LL_PrepareReceive(pdev, HID_EPOUT_ADDR, hhid-Report_buf, USBD_HID_REPORT_MAX_SIZE); return USBD_OK; }实操心得USBD_LL_PrepareReceive这个调用至关重要。USB通信是主机主导的轮询机制。设备在接收到一次数据后必须明确告知底层驱动“我准备好接收下一次数据了”否则主机后续发送的数据将无法被接收导致通信中断。这是很多USB通信“只能收一次”问题的根源。步骤三在应用中发送和接收数据现在你可以在主循环或中断服务程序中轻松地处理USB数据了。发送数据到PCuint8_t sensor_report[4] {0xAA, 0xBB, 0xCC, 0xDD}; // 模拟传感器数据 if(USBD_HID_SendReport(hUsbDeviceFS, sensor_report, 4) ! USBD_OK) { // 处理发送错误 } // 注意此函数是非阻塞的。它把数据放入发送FIFO后立即返回。 // 实际发送由USB中断在后台完成。接收并处理数据来自PCwhile (1) { /* USER CODE END WHILE */ if(UserRxReady) { UserRxReady 0; // 清除标志 // 处理UserRxBufferFS中的数据 // 例如控制一个LED if(UserRxBufferFS[0] 0x01) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); } } /* USER CODE BEGIN 3 */ }3.4 上位机测试使用Python或Bus Hound设备端代码写好了如何测试你需要一个上位机程序。简单测试Bus Hound这是一个强大的USB协议分析工具。插上你的设备在Bus Hound中选择对应的USB设备就能看到所有USB通信的原始数据包包括描述符请求、SETUP事务、IN/OUT数据。你可以用它来验证设备枚举是否成功以及报告描述符是否正确。发送数据测试则需要编写上位机。Python上位机推荐使用pywinusbWindows或hidapi跨平台库可以非常方便地与HID设备通信。import hid # 需要安装 pip install hidapi # 根据你的VID/PID打开设备 VID 0x0483 PID 0x5740 device hid.device() device.open(VID, PID) # 打开设备 # 发送数据输出报告 data_to_send [0x00, 0x01, 0x02, 0x03] # 第一个字节通常是报告ID我们没定义报告ID所以从0开始或忽略 device.write(data_to_send) # 接收数据输入报告 data_received device.read(4) # 读取4个字节 print(fReceived: {data_received}) device.close()运行这个Python脚本你就能实现PC与STM32的双向通信了。4. 进阶实现USB虚拟串口CDCHID适合小数据量、实时性要求高的场景。如果你需要兼容传统的串口调试工具如Putty、串口助手或者传输的数据流更像文件那么USB虚拟串口CDC类是更好的选择。CDC设备在主机上会显示为一个COM口所有现有的串口软件都能直接使用。4.1 CubeMX配置CDC配置过程与HID类似但在Middleware中选择Communication Device Class (Virtual Port Com)。CubeMX会自动帮你生成一个功能完整的虚拟串口设备代码包括printf重定向到USB的代码usbd_cdc_if.c中的CDC_Transmit_FS。关键的回调函数在usbd_cdc_if.c中static int8_t CDC_Control_FS(uint8_t cmd, uint8_t* pbuf, uint16_t length)处理CDC特定控制请求如设置串口波特率虽然USB通信本身与波特率无关但这是为了兼容上位机设置。static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len)这是最重要的回调。当主机通过虚拟串口发送数据下来时这个函数被调用。Buf是数据指针Len是数据长度。你需要在这里处理接收到的数据。uint8_t CDC_Transmit_FS(uint8_t* Buf, uint16_t Len)发送数据到主机。你可以直接调用它就像调用HAL_UART_Transmit一样。4.2 CDC使用注意事项与性能调优缓冲区与阻塞CDC_Transmit_FS函数内部有一个发送缓冲区。如果上一次的数据还没发送完再次调用可能会返回USBD_BUSY。好的做法是实现一个简单的发送状态机或队列而不是盲目重试或死等。// 示例非阻塞发送检查 if(CDC_Transmit_FS(tx_buffer, length) USBD_OK) { // 发送成功启动 } else { // 发送忙将数据存入应用层队列稍后重试 }接收速率匹配在CDC_Receive_FS回调中处理数据要快。如果你在这里进行复杂的运算或阻塞操作可能会导致USB端点缓冲区满造成数据丢失。最佳实践是在这个回调里只做最少的操作比如将数据拷贝到一个环形缓冲区然后设置一个标志在主循环中处理这个环形缓冲区里的数据。大流量传输对于高速连续数据流如音频要确保端点的最大包大小wMaxPacketSize设置得足够大全速USB最大64字节高速USB最大512字节并合理使用多个IN端点进行同步传输以提高吞吐量。5. 常见问题排查与调试心得实录玩USB没有不踩坑的。下面是我和很多同行在实践中总结出来的“血泪史”。5.1 设备无法识别或枚举失败这是最常见的问题现象是插上USB线电脑没反应或者提示“未知设备”。检查清单硬件连接DM/DP线是否接反是否接了上拉电阻1.5kΩ接DP到3.3V对于全速设备这是必须的很多开发板已经集成自制板子容易忽略。电源USB的5V电源是否稳定STM32的3.3V供电是否正常可以用万用表测量。时钟配置重中之重用CubeMX的时钟图反复核对USB时钟必须是精确的48MHz。使用外部晶振时检查PLL倍频分频设置。可以使用SystemCoreClock变量打印系统主频并用逻辑分析仪或示波器测量PA8MCO输出的时钟来间接验证。描述符错误描述符数据结构有误比如长度字段写错、描述符顺序不对、总数不匹配。使用USBlyzer或Wireshark配合USBPcap驱动抓取USB枚举过程的通信包对比标准请求和设备的回应能精准定位问题所在。例如主机发送Get_Descriptor(Device)请求你回应的设备描述符第8个字节bMaxPacketSize0应该是64如果你填了0就会导致枚举失败。堆栈大小USB中断服务程序以及HAL库的中间件需要一定的栈空间。如果Stack_Size在启动文件或IDE的链接器配置中设置得太小比如默认的0x400可能在枚举过程中发生栈溢出导致程序跑飞。建议将栈大小至少设置为0x800或更大。5.2 通信不稳定时断时续或数据错误问题能识别但传输数据时偶尔丢失或者上位机收到乱码。排查端点缓冲区溢出检查你的发送代码。是否在上一次USBD_HID_SendReport或CDC_Transmit_FS还没完成返回USBD_BUSY时就强行发送了新数据必须等待发送完成或实现发送队列。接收未重启对于OUT传输你是否在每次接收回调USBD_HID_DataOut或CDC_Receive_FS的最后调用了USBD_LL_PrepareReceive来重新使能接收如果没有设备只会在第一次接收数据。数据对齐与长度确保你发送的数据长度严格等于报告描述符中定义的报告长度。对于CDC也要注意单次发送的数据不要超过端点最大包大小。中断优先级USB中断如OTG_FS_IRQn应该有较高的优先级避免被其他长时间的中断如某些定时器中断阻塞导致数据无法及时处理而丢失。在CubeMX的NVIC配置中调整。电源噪声如果电路板上有电机、继电器等大功率器件可能会干扰USB通信的差分信号。确保电源去耦良好USB数据线走线尽量短且等长必要时在DM/DP线上串联小电阻22Ω并并联对地电容如15pF进行阻抗匹配和滤波。5.3 从标准库移植到HAL库的注意事项很多老项目用的是标准库迁移到HAL库时USB部分几乎是重写。架构差异标准库的USB驱动更“裸”你需要手动处理更多底层细节。HAL库封装得更好但抽象层次更高理解其回调机制是关键。描述符定义两者描述符的结构体定义可能略有不同但内容本质一样。需要将旧描述符数组按照HAL库示例的格式移植过来。回调函数名标准库的回调函数名如EP1_IN_Callback与HAL库的如HAL_PCD_DataInCallback完全不同。需要仔细阅读HAL库的USB文档将业务逻辑移植到正确的新回调函数中。工具链确保你的HAL库版本与STM32CubeMX版本、芯片支持包DFP版本兼容。不匹配的版本是很多诡异问题的源头。调试USB一个好的习惯是充分利用LED和串口如果还有其他串口的话进行状态指示。比如在HAL_PCD_ConnectCallback设备连接和HAL_PCD_DisconnectCallback设备断开中翻转一个LED在描述符请求回调中打印日志能让你快速了解设备枚举到了哪一步。当硬件工具不足时这种“土法调试”往往最有效。
