1. 项目背景与整体方案选型
做嵌入式开发这些年,USB协议栈的移植一直是块硬骨头。这次接到一个实际需求:在STM32H5系列芯片上,把RT-Thread的USBD Classic驱动框架跑起来,实现一个CDC虚拟串口设备,用于上位机和下位机之间的数据交互。项目本身不算复杂,但STM32H5这颗芯片的特殊性,加上USBD Classic框架的历史包袱,让这次移植踩了不少坑,值得记录一下。
先说说为什么选STM32H5。这颗芯片是ST主推的新一代高性能MCU,Cortex-M33内核,主频能跑到250MHz,带TrustZone安全特性。相比传统的F4/H7系列,H5在功耗控制和安全性上做了很多优化。但问题也随之而来——它的USB外设模块没有沿用F4/H7那套OTG架构,而是换成了全新的USB Device Controller,寄存器布局和初始化流程都变了。这意味着网上那些基于F4/H7的USB移植教程,直接照搬到H5上大概率是跑不起来的。
再聊聊USBD Classic。RT-Thread的USB设备协议栈其实有两条路线:一条是传统的USBD Classic,代码路径在rt-thread/components/drivers/usb/usbdevice/目录下,用C语言写了一套完整的USB协议实现;另一条是后来推出的USBD Device(也叫USB Device Framework),使用RT-Thread的device框架,抽象层次更高。Classic框架虽然看起来"老",但它的优势是代码简洁、依赖少、逻辑直接,尤其适合做一些定制化较强的USB设备。比如做CDC、HID这类标准设备,Classic框架反而更容易上手,因为你直接面对的是协议本身,而不是被各种抽象层包裹。
这次移植的目标很明确:把USBD Classic的CDC类驱动跑在STM32H5的USB Device Controller上,实现虚拟串口功能。整体方案分三层:最底层是ST的HAL库PCD驱动,负责处理硬件寄存器和中断;中间层是RT-Thread USBD Classic的Device层和Core层,负责USB协议解析和端点管理;最上层是CDC类驱动,实现通信设备类的具体逻辑。
这个方案的优势在于:底层用HAL库保证硬件正确性,中间层用Classic框架保证协议完整性,上层CDC驱动经过大量项目验证,稳定性有保障。移植工作主要集中在中间层和底层之间的适配接口,以及CDC设备描述符的配置上。
2. USBD Classic驱动框架解析
2.1 Classic框架的核心机制
USBD Classic框架的核心是struct udcd这个硬件设备描述结构体,它定义了底层硬件需要向上层提供的所有操作接口。在usbdevice/core/usbdevice_core.c文件中,框架通过rt_usbd_device_list_init()初始化设备列表,然后调用rt_usbd_otg_init()完成OTG控制器的初始化。
Classic框架的工作机制可以这样理解:它把USB设备抽象成三个层级。最底层是udcd(USB Device Controller Driver),负责和硬件打交道,处理端点数据的收发;中间层是usbdevice core,负责USB协议的状态机处理,包括枚举、配置、设置地址等标准请求的响应;最上层是各种class driver,比如CDC类、HID类、MSC类,它们实现了设备的具体功能。
在usbdevice/core/usbdevice_core.c中,框架定义了一套完整的事件处理机制。端点0的控制传输由框架自己处理,它通过_setup_request()函数解析主机发来的标准请求,调用对应的处理函数。比如_request_set_address()处理设置地址请求,_request_get_descriptor()处理获取描述符请求。这些处理函数最终都会回调到udcd结构体中对应的函数指针,由底层驱动完成实际的硬件操作。
2.2 Class Driver的注册与匹配逻辑
Class driver在Classic框架中是一个struct uclass结构体,里面包含了驱动名称、设备连接和断开时的回调函数、类请求处理函数等。以CDC为例,它的类驱动实现在usbclass/cdc_class.c文件中,定义了_cdc_init()、_cdc_callback()等函数。
关键点在于,Class driver并不是通过设备描述符中的接口信息自动匹配的,而是需要手动注册。在rt_usbd_class_register()函数中,框架会把类驱动添加到usbdevice_core的类驱动链表中。当设备枚举完成、主机发送SET_CONFIGURATION请求后,框架会遍历这个链表,调用每个类驱动的run函数,让类驱动有机会完成初始化。
这个设计有一个需要注意的地方:如果你注册了多个类驱动,它们都会被调用,但同一个设备描述符中的接口配置可能需要某些类驱动占用特定的接口号。比如CDC设备通常有两个接口:一个通信类接口(接口0)和一个数据类接口(接口1)。如果同时注册了CDC和MSC类驱动,而你的设备描述符只定义了CDC接口,那么MSC类驱动的初始化就会失败,这个问题在调试多类设备时尤其明显。
2.3 H5平台适配层的设计思路
Classic框架的优势在于它把硬件操作抽象成了struct udcd_ops结构体,里面定义了ep_pipe(端点管道操作)、ep_set_stall(端点STALL)、sof_enable(SOF使能)、disconnect(断开连接)等函数指针。我们移植的主要工作就是实现这套udcd_ops,把它和ST的HAL库PCD驱动对接起来。
H5的USB Device Controller和F4/H7的OTG模块在寄存器层面有较大差异。H5的USB Device Controller更像一个独立的USB外设,不再依赖OTG的SRAM和FIFO配置方式。在HAL库中,HAL_PCD_Init()函数会配置USB设备控制器的核心寄存器,包括设备地址、端点使能、传输类型等。而HAL_PCD_Start()函数负责启动USB设备,使其能够响应主机请求。
一种常见的设计方案是:在udcd_ops的init函数中调用HAL_PCD_Init()完成硬件初始化,在ep_pipe函数中调用HAL_PCD_EP_Transmit()或HAL_PCD_EP_Receive()启动数据传输,在中断回调函数HAL_PCD_IRQHandler()中处理传输完成事件,再回调Classic框架的完成函数。
3. STM32H5 USB硬件特性与CubeMX配置
3.1 H5的USB Device Controller关键特性
STM32H5的USB Device Controller是一个全速USB 2.0设备控制器,支持FS(Full Speed)模式,最高速率12Mbps。它不像F4/H7的OTG模块那样支持HS(High Speed),但对于CDC虚拟串口这种应用来说,12Mbps的速率已经完全够用,毕竟虚拟串口的实际吞吐量受限于串口波特率和协议开销。
H5的USB外设时钟源需要特别注意。它不像F4系列可以直接使用PLL48CK,而是需要从PLL1Q或PLL3Q中产生48MHz时钟。在CubeMX中配置时,要确认USB的时钟源选择正确,否则USB控制器无法正常工作。我这次使用的是PLL3Q输出48MHz,配置方法是:PLL3的VCO输出频率除以Q分频系数等于48MHz。PLL3VCO典型配置是96MHz,Q分频为2,这样就能得到48MHz。
H5系列的电源管理也对USB有影响。在低功耗模式下,USB外设的时钟可能会被关闭,导致USB连接断开。如果项目有低功耗需求,需要在进入低功耗之前先通过GPIO控制USB D+线的电平状态通知主机设备断开,否则主机可能会报"无法识别的USB设备"错误。
3.2 CubeMX工程配置要点
在CubeMX中配置STM32H5的USB工程,有几个容易出错的地方。首先要在Connectivity选项中找到USB选项,选择合适的模式。H5的USB只有一个Device模式选项,不像F4系列的OTG那样支持Host和Device双模式。选择Device模式后,CubeMX会自动生成USB设备初始化的代码。
然后是时钟树配置。H5的USB需要48MHz时钟,这个时钟可以从PLL1Q、PLL2Q或PLL3Q获取。推荐使用PLL3Q,因为PLL3通常没有被其他外设占用。在Clock Configuration页面中,找到USB的时钟源选项,选择PLL3Q,然后调整PLL3的分频系数,确保输出为48MHz。这里可以用CubeMX的时钟树验证功能,它会自动检查配置是否合法。
中断配置也是关键一步。在NVIC设置中,需要使能USB全局中断。H5的USB中断向量名称和F4系列不同,需要确认使用的是USB_IRQn还是USB_LPM_IRQn。还有一个细节:如果使用了USB的LPM(Link Power Management)功能,需要额外使能USB_LPM_IRQn中断,否则主机发送LPM命令时设备没有响应。
USB的引脚配置相对简单,H5的USB D+和D-引脚是固定的,不需要像F4那样在OTG_FS_Power_Control和OTG_FS_Overcurrent选项中选择。不过需要注意,如果板子上有USB的VBUS检测电路,需要根据实际硬件设计配置对应的GPIO为输入模式或直接悬空。
3.3 生成代码后的手动调整
CubeMX生成的代码只是个起点,我们需要在生成代码基础上做两类调整:一类是HAL配置参数的微调,另一类是添加RT-Thread USBD Classic框架的适配代码。
HAL配置微调方面,HAL_PCD_Init()函数的PCD_InitTypeDef参数中有一个bMaxPower字段,它定义了设备请求的最大电流,单位是2mA。默认值通常为50,即100mA。如果你的设备需要更多电流,比如通过USB供电的传感器,需要把这个值改大。但要注意,主机端如果供电能力不足,或者USB HUB无法提供足够电流,设备可能会被拒绝识别。
另外要关注Sof_enable配置。在Classic框架中,udcd_ops的sof_enable函数用于使能或禁止SOF包中断。SOF中断频率是1kHz,如果处理不当会占用大量CPU资源。在实际应用中,除非你需要精确的USB帧同步,否则建议关闭SOF中断,只在USB挂起唤醒时使用。我的做法是让sof_enable函数直接返回,不使能SOF中断,这样能减少中断频率,降低CPU负担。
手动添加代码方面,需要把USBD Classic的源码文件加入工程。如果使用RT-Thread Studio,这个步骤会简单很多,因为在RT-Thread Settings中可以勾选USB Device相关组件,它会自动添加Classic框架和CDC类驱动。如果使用Keil或IAR,则需要手动把文件加入工程,并添加头文件路径。
还有一个手动步骤:设置RT_USB_DEVICE_CDC宏定义。在Classic框架中,CDC类驱动是通过条件编译控制的,需要在编译配置中定义RT_USB_DEVICE_CDC才能启用CDC类。在RT-Thread Studio中,可以在rtconfig.h中查看这个宏是否被定义。如果没有定义,需要在board.h或者rtconfig.h中手动添加#define RT_USB_DEVICE_CDC。
4. CDC类驱动原理与USBD Classic实现
4.1 CDC设备的协议结构
CDC(Communications Device Class)设备定义了一种通信设备的抽象模型。在USB协议中,CDC设备通常采用ACM(Abstract Control Model)子类。一个典型的CDC ACM设备包含两个接口:接口0是通信类接口(Class Code 0x02),用于控制信号的传输,通过中断端点(端点0x82)发送状态通知;接口1是数据类接口(Class Code 0x0A),用于实际数据的传输,通过两个批量端点(端点0x01和0x81)实现双向数据收发。
在USB描述符层面,CDC设备需要定义以下描述符:
- 设备描述符(Device Descriptor):定义设备的基本信息,包括VID、PID、设备类等。
- 配置描述符(Configuration Descriptor):定义设备的配置信息,包括接口数量、端点数量、供电方式等。
- 接口描述符(Interface Descriptor):定义接口的功能类型,CDC设备需要两个接口描述符。
- 端点描述符(Endpoint Descriptor):定义端点的地址、传输类型、最大包大小等。
- CDC功能描述符(CDC Functional Descriptor):这是CDC设备特有的描述符,包括Header Functional Descriptor、Call Management Functional Descriptor、ACM Functional Descriptor、Union Functional Descriptor等。
USBD Classic框架中,这些描述符是作为宏定义和静态数组放在usbclass/cdc_class.c文件中的。它的描述符组织方式和ST的USB库不同:ST的USB库通常把描述符放在一个巨大的结构体中,而USBD Classic把不同的描述符分开定义,然后通过指针数组组织起来。对于需要修改VID/PID的情况,只需要修改usbdevice/core/usbdevice_core.c中的_device_descriptor结构体即可。
4.2 Classic框架中CDC的端点配置
在USBD Classic框架中,端点配置是通过uconfig结构体中的ep_desc数组实现的。CDC设备需要配置三个端点:一个中断端点(用于状态通知)、两个批量端点(用于数据收发)。在cdc_class.c中可以看到如下的配置逻辑:
static struct udcd_ops _udc_ops = { .init = _cdc_init, .ep_pipe = _cdc_ep_pipe, .ep_set_stall = _cdc_ep_set_stall, .ep_clear_stall = _cdc_ep_clear_stall, .ep_read = _cdc_ep_read, .ep_write = _cdc_ep_write, .ep_rx_done = _cdc_ep_rx_done, }; static struct udevice _cdc_device = { .dev = &_cdc_dev, .parent = &_cdc_dev_parent, .uclass = &_cdc_class, .drv = &_cdc_drv, .config = &_cdc_config, };这里的_cdc_config结构体定义了CDC设备的全部配置信息,包括设备描述符的引用、配置描述符的引用、字符串描述符的引用等。在_cdc_config中,你可以看到它引用了_device_descriptor、_config_descriptor、_string_descriptor等描述符。
端点配置的关键点是端点号的选择和端点类型的匹配。CDC设备中,批量端点用于数据传输,适合大数据量的读写操作;中断端点只用于状态通知,数据量很小,通常8字节或16字节。USBD Classic框架允许你自由选择端点号,但要注意:端点0是控制端点,不能用作批量或中断传输。我在配置时使用端点1作为TX批量端点(设备到主机),端点2作为RX批量端点(主机到设备),端点3作为中断端点。
4.3 CDC设备的收发链路解析
CDC设备的数据收发链路是理解整个驱动框架的关键。以设备向主机发送数据为例,数据流向是这样的:应用层调用rt_device_write(),数据传入CDC类驱动的_cdc_write()函数,框架将数据写入_cdc_ep_tx_buffer缓冲区,然后调用ep_pipe的写函数,把数据通过硬件端点发送到主机。
在cdc_class.c中,发送函数的实现大致如下:
static rt_size_t _cdc_write(rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size) { struct cdcd *data = (struct cdcd *)dev->user_data; rt_size_t ret = 0; if (data->ep_tx_status == 0) { >#include <rtthread.h> #include <rtdevice.h> #include "usbdevice_core.h" #include "usbdevice_cdc.h" #include "stm32h5xx_hal.h" #include "usbd_conf.h" static PCD_HandleTypeDef hpcd; static rt_err_t _h5_usbd_init(rt_device_t device) { HAL_PCD_Init(&hpcd); HAL_PCD_Start(&hpcd); return RT_EOK; } static rt_err_t _h5_usbd_ep_pipe(rt_device_t device, rt_uint8_t ep_addr, rt_uint8_t *buffer, rt_size_t size) { if (ep_addr & 0x80) { /* IN endpoint, device to host */ HAL_PCD_EP_Transmit(&hpcd, ep_addr & 0x7F, buffer, size); } else { /* OUT endpoint, host to device */ HAL_PCD_EP_Receive(&hpcd, ep_addr, buffer, size); } return RT_EOK; } static rt_err_t _h5_usbd_ep_set_stall(rt_device_t device, rt_uint8_t ep_addr) { HAL_PCD_EP_SetStall(&hpcd, ep_addr); return RT_EOK; } static rt_err_t _h5_usbd_ep_clear_stall(rt_device_t device, rt_uint8_t ep_addr) { HAL_PCD_EP_ClrStall(&hpcd, ep_addr); return RT_EOK; } static struct udcd_ops _h5_usbd_ops = { .init = _h5_usbd_init, .ep_pipe = _h5_usbd_ep_pipe, .ep_set_stall = _h5_usbd_ep_set_stall, .ep_clear_stall = _h5_usbd_ep_clear_stall, }; struct udcd _h5_usbd = { .ops = &_h5_usbd_ops, };注意,上面代码中的_h5_usbd_ops缺少了几个关键函数,包括ep_read、ep_write、ep_rx_done。在完整的实现中,你需要完善这些函数的逻辑,并在中断回调中正确调用它们。完整的适配层实现还包括对USB中断的处理,需要在stm32h5xx_it.c中添加对应的中断服务函数:
void USB_IRQHandler(void) { HAL_PCD_IRQHandler(&hpcd); }然后在HAL_PCD_DataInStageCallback()和HAL_PCD_DataOutStageCallback()中调用Classic框架的完成回调,通知数据收发完成。
5.3 中断回调与事件处理
USB的中断回调是底层和框架之间传递事件的桥梁,这里也是移植工作中最容易遗漏的部分。H5的HAL库定义了多个USB中断回调函数,每个函数对应不同的事件类型。
首先看数据发送完成回调。当设备向主机发送数据完成后,会触发HAL_PCD_DataInStageCallback()。在这个回调中,需要判断是哪个端点完成了发送,然后调用Classic框架的完成函数:
static void HAL_PCD_DataInStageCallback(PCD_HandleTypeDef *hpcd, uint8_t epnum) { rt_device_t device = &_h5_usbd; struct udcd *udcd = (struct udcd *)device->user_data; udcd->ops->ep_write(device, epnum | 0x80); }再看数据接收完成回调。当主机发数据到设备时,会触发HAL_PCD_DataOutStageCallback()。这个回调需要区分两种情况:一种是接收到了控制传输的数据(端点0),另一种是接收到了批量端点的数据。对于端点0的接收数据,通常不需要在这里处理,因为Classic框架在控制传输状态机中已经处理了。对于批量端点的数据,需要调用HAL_PCD_EP_GetRxCount()获取实际接收的字数,然后把数据从硬件FIFO中拷贝出来:
static void HAL_PCD_DataOutStageCallback(PCD_HandleTypeDef *hpcd, uint8_t epnum) { rt_device_t device = &_h5_usbd; struct udcd *udcd = (struct udcd *)device->user_data; uint16_t len = HAL_PCD_EP_GetRxCount(hpcd, epnum); if (epnum == CDC_RX_EP) { udcd->ops->ep_read(device, epnum, _cdc_ep_rx_buffer, len); udcd->ops->ep_rx_done(device, epnum); } }这里有一个容易踩的坑:HAL_PCD_EP_GetRxCount()必须在HAL_PCD_DataOutStageCallback()回调中立即调用并拷贝数据,因为USB控制器的FIFO在回调返回后可能被清空。如果数据没有被及时读取,后面的数据包就会覆盖之前的数据,导致数据丢失。
此外,HAL_PCD_SetupStageCallback()处理控制传输的Setup包,HAL_PCD_SOFCallback()处理SOF中断,HAL_PCD_ResetCallback()处理USB总线复位事件。总线复位事件需要特别关注,因为主机在枚举设备之前会发送复位信号,设备必须在复位回调中重新初始化端点状态。
5.4 在RT-Thread中注册CDC设备
底层适配层完成后,需要在RT-Thread中把USB设备和CDC类驱动注册起来。这个过程由usbdevice_cdc.c中的初始化函数完成,它会调用rt_usbd_class_register()注册CDC类驱动,然后调用rt_usbd_device_register()注册USB设备。
在RT-Thread的启动过程中,USB设备初始化通常在rt_hw_usbd_init()函数中完成。这个函数需要被调用,可以在board.c的初始化函数中显式调用,也可以通过INIT_DEVICE_EXPORT()宏进行自动初始化。
一个实用的做法是写一个独立的初始化函数:
static int rt_hw_usbd_init(void) { rt_err_t ret = RT_EOK; /* Init USBD Classic framework */ ret = rt_usbd_class_register(&cdc_class); if (ret != RT_EOK) { rt_kprintf("usb cdc class register failed: %d\n", ret); return ret; } ret = rt_usbd_device_register(&cdc_device); if (ret != RT_EOK) { rt_kprintf("usb device register failed: %d\n", ret); return ret; } /* Init hardware */ _h5_usbd.ops->init(&_h5_usbd); return ret; } INIT_DEVICE_EXPORT(rt_hw_usbd_init);这里需要注意INIT_DEVICE_EXPORT()的初始化优先级。USB设备的初始化依赖于HAL库的初始化,而HAL库的初始化通常在SystemClock_Config()中完成。如果USB初始化优先级过高,可能在HAL库还没初始化时就调用了USB的初始化函数,导致硬件初始化失败。在RT-Thread中,设备初始化通常在板级初始化之后,因此建议在board.c的rt_hw_board_init()函数中调用USB初始化,或者在INIT_DEVICE_EXPORT()中调整优先级。
5.5 应用层使用CDC设备
设备注册完成后,应用层就可以通过标准的RT-Thread设备接口使用CDC虚拟串口了。打开设备和读写设备的代码如下:
rt_device_t cdc_dev = rt_device_find("usb_cdc"); if (cdc_dev != RT_NULL) { rt_device_open(cdc_dev, RT_DEVICE_FLAG_RDWR); } /* 发送数据 */ rt_device_write(cdc_dev, 0, "Hello USB CDC\n", strlen("Hello USB CDC\n")); /* 接收数据 */ rt_uint8_t buffer[128]; rt_size_t len = rt_device_read(cdc_dev, 0, buffer, sizeof(buffer));需要指出的是,CDC虚拟串口的数据收发是流式的,没有固定的帧格式。在使用时,需要自己定义应用层协议来区分不同的消息。比如在数据包的头部加上消息类型和长度字段,接收端根据这些字段解析出完整的数据包。这在多线程环境下尤其重要,因为USB收发的数据可能来自不同线程,如果没有同步机制,数据会出现错乱。
上面代码中有一个细节:rt_device_open()的返回值和rt_device_read()的返回值都需要检查,尤其是打开失败的情况。在USB设备未正确枚举时,rt_device_open()可能返回错误码,此时读写操作也会失败。
6. 常见问题与排查技巧
6.1 设备枚举失败
设备枚举失败是USB移植中最常见的问题,表现形式是:插上USB后,主机提示"无法识别的USB设备"或者设备管理器中出现黄色感叹号。从我的经验来看,80%的枚举失败都是由三个原因造成的:电源问题、时钟问题和描述符错误。
电源问题首先要排查。USB设备的D+线上需要一个上拉电阻,全速设备要求1.5kΩ上拉电阻连接到3.3V。绝大多数开发板上已经集成了这个上拉电阻,但你需要确认它是否正确接到USB控制器的DP引脚上。一个简单的测试方法:用万用表量D+引脚电压,正常应该接近3.3V,并且接入主机后电压能被拉低到2.0V左右。
时钟问题也常见于H5平台。USB控制器需要精准的48MHz时钟,如果时钟频率偏差太大,设备可能无法完成枚举。检查CubeMX的时钟树配置,确保USB时钟源选择正确,并且输出频率确实是48MHz。可以在调试器中读取RCC→CFGR寄存器的值,确认PLL3Q输出。
描述符错误比较隐蔽。比如设备描述符中的bMaxPacketSize0字段,对于全速设备必须为64,如果写成其他值,主机可能无法正确处理。还有配置描述符中的wTotalLength字段,如果和实际描述符的总长度不一致,主机会认为描述符数据不完整,从而拒绝枚举。
一个实用的排查方法是,用USB协议分析仪抓取枚举过程的数据。没有条件的话,可以在设备端打印调试信息,在端点0的Setup包处理函数中加日志,看看主机发过来的请求是什么,设备的响应是否正确。
6.2 USB识别成功但打不开串口
设备枚举成功,设备管理器中也能看到CDC设备的COM口,但打开串口时提示"打开失败"或者"端口被占用"。这个问题通常和CDC驱动或数据端点配置有关。
首先要检查CDC驱动的配置描述符是否正确。串口打开失败的一个常见原因是CDC的ACM功能描述符配置错误。如果主机端的CDC驱动无法正确解析功能描述符,它可能无法为设备分配COM口。可以查看设备管理器中的设备状态,如果提示"该设备无法启动"或者"代码10",大概率是配置描述符有问题。
另一个原因和虚拟串口的线路状态有关。CDC设备有一个"Set Control Line State"类请求,主机在打开串口时会发送这个请求,用于设置DTR(Data Terminal Ready)和RTS(Request To Send)信号。在USBD Classic框架中,这个请求的处理函数是_cdc_control(),如果这个函数没有正确实现,或者没有调用rt_device_ready_notify()通知应用层,应用层可能认为串口状态异常。
我的建议是在_cdc_control()函数中添加调试日志,输出接收到的请求类型和参数。这样可以快速定位是主机没有发送控制请求,还是设备没有正确处理。
6.3 收发数据乱码或丢包
数据乱码和丢包问题主要出现在高速数据传输场景下。CDC设备的批量端点最大包大小是64字节,当应用层发送超过64字节的数据时,需要分包传输。如果发送缓冲区和接收缓冲区管理不当,就会出现数据覆盖或丢失。
USBD Classic框架的CDC驱动使用的是单缓冲模式,即发送缓冲区只有一个,在数据发送完成前,如果应用层再次调用rt_device_write(),数据会被丢弃。解决这个问题有两种方案:一种是应用层保证在数据发送完成后再发送下一包数据,另一种是修改CDC驱动,使用双缓冲或环形缓冲区。
我实际项目中的做法是,在应用层使用一个环形缓冲区配合RT-Thread的信号量实现流量控制。应用层把要发送的数据全部写入环形缓冲区,然后通过信号量通知一个专门的数据发送线程,由这个线程负责把数据从环形缓冲区读取并调用rt_device_write()发送。这样即使CDC驱动的发送缓冲区被占用,应用层也不会丢失数据。
接收方向的乱码问题,通常是USB的收包处理和串口的接收波特率不匹配造成的。当USB侧一包64字节数据到达,而串口侧波特率设置较低时,数据会在串口的内部缓冲区中堆积,导致最后的字节丢失。解决方案是在应用层实现流控:当串口发送缓冲区满时,暂停从USB读取数据,或者适当增大串口的发送缓冲区。
6.4 H5特有的TrustZone相关问题
STM32H5的TrustZone功能是H5系列特有的,它对USB移植有直接影响。如果芯片的TrustZone被使能,USB外设的安全属性会被设置为安全或非安全。如果USB设备的中断被配置为非安全中断,但中断服务函数运行在安全状态,中断无法触发会导致设备无响应。
解决方法是:在工程启动代码中,检查是否定义了TRUSTZONE_ENABLED宏。如果启用了TrustZone,需要在设备初始化时把USB外设的安全属性设置为非安全:
#if defined(TRUSTZONE_ENABLED) /* Set USB as non-secure */ TZ_SAU_Config(1, 0x40000000, 0x20000000); #endif另外要注意,如果使用了TrustZone,中断服务函数也需要在非安全向量表中注册。在RT-Thread中,可以通过RT_USING_NON_SECURE_VECTOR宏来启用非安全向量表支持。这个配置在rtconfig.h中完成。
根据我的经验,大多数基础应用的场景可以不开启TrustZone,这样能减少很多安全隐患排查的工作量。等整个USB功能跑通后,再考虑如何迁移到安全环境。
7. 性能优化与稳定性增强建议
7.1 缓冲区策略优化
CDC的收发缓冲区大小直接决定了数据传输的性能。缓冲区大小受限于硬件SRAM容量和应用的实时性要求。H5的SRAM容量较大(根据不同型号,从256KB到640KB不等),但CDC驱动在Classic框架中的缓冲区是静态分配的,默认大小在usbdevice/class/cdc_class.h中定义:
#define CDC_RX_BUFSIZE 1024 #define CDC_TX_BUFSIZE 1024如果应用需要更大的缓冲,可以修改这两个宏。但要注意,随着缓冲区增大,CPU拷贝数据的开销也会增大。对于实时性要求高的应用,建议采用DMA方式接收数据。不过,在Classic框架中集成DMA接收需要修改ep_rx_done的调用时机,复杂度较高,如果只是做数据采集或简单的指令控制,1024字节的缓冲区通常够用了。
7.2 中断优先级与实时性平衡
USB中断优先级设置会影响系统的实时性能。USB的中断服务函数需要及时响应,尤其是端点0的Setup包处理,如果响应太慢,主机会因为超时而重新枚举设备。但USB中断优先级过高,又会抢占其他实时任务的执行。
一个经验值是:把USB中断优先级设置为中等偏上。在Cortex-M33内核中,数字越小优先级越高。如果把USB中断设为最高优先级(0),可能会影响其他高实时性任务;设为较低优先级(比如7),在系统负载较高的时候,USB可能来不及响应主机的请求,导致设备掉线。
我通常的配置是:USB中断优先级设为2或3,并确保主要的外设中断优先级低于USB。同时,在USB中断服务函数中,尽量缩短临界区,将耗时的数据处理工作放入中断下半部(比如RT-Thread的工作队列或信号量唤醒的线程)执行。
7.3 断线重连与热插拔处理
USB设备的拔插是日常生活中很常见的场景。对于CDC虚拟串口,如果设备被拔出后重新插入主机,USB控制器会被复位,设备需要重新枚举。在Classic框架中,HAL_PCD_ResetCallback()会在设备被复位时调用,此时需要重新初始化端点和设备状态。
在处理断线重连时,有一个细节需要留意:设备的字符串描述符中有一个iSerialNumber字段,建议给它分配一个固定的序列号。如果不配置序列号,主机可能会为相同的设备分配新的COM口编号,导致应用层打开的还是旧的COM口。给设备添加固定序列号后,每次插入主机都会使用相同的COM口编号,对应用层更友好。
USB挂起唤醒也是稳定性的一部分。当主机进入睡眠状态时,USB总线会产生挂起信号,设备应进入低功耗模式。当主机唤醒时,设备需要能够检测到唤醒信号并恢复正常工作。在H5上,要实现这个功能需要开启USB的LPM支持,并且配置正确的唤醒源。如果不需要USB唤醒功能,可以简单地在挂起回调中不做任何处理,待主机唤醒后USB会自动恢复正常。
8. 总结与我的心态
这次STM32H5的USBD Classic CDC移植,前后花了大约两天时间。第一天主要是在CubeMX配置USB外设、搭工程框架,结果第一次跑起来时设备无法枚举,查了半天发现是时钟配置问题。USB的48MHz时钟没有正确输出,导致USB控制器的同步和复位信号不对,D+线上的上拉电平也不对。第二天修复时钟问题后,枚举顺利通过,但收发数据时又发现接收数据丢失比较频繁,排查下来是接收缓冲区和FIFO读取时机的问题。
回头看整个移植过程,最有价值的经验可以归纳为三条:
第一条,先确认时钟和电源,再动代码。USB外设对时钟的精度要求极高,H5的USB时钟源选择又和F4/H7不同,一步错步步错,浪费了大半天时间。排查USB问题,先量电压、测时钟,确认硬件没问题再怀疑软件。
第二条,理解框架的调用链再动手。USBD Classic框架不是简单的库,它定义了一套完整的事件处理机制。移植前花点时间阅读usbdevice_core.c中的核心流程,理解setup请求怎么被解析、class driver怎么被调用、数据收发的完整链路,后面调试的效率能提高一倍以上。
第三条,调试信息一定要充足。在适配层的关键回调中添加日志,比如HAL_PCD_SetupStageCallback()、HAL_PCD_ResetCallback()、HAL_PCD_DataInStageCallback()等,能帮你快速定位是硬件问题还是协议问题。调试完成后,再把日志关掉,以免影响性能。
后续如果要扩展这个工程,我建议可以从两个方向入手。一是增加复合设备支持,把CDC和MSC类驱动组合在一起,实现一个既能虚拟串口又能U盘存储的设备。二是迁移到USBD Device新框架,虽然Classic框架简单直接,但新框架在抽象和可维护性上更胜一筹,长期维护的项目值得考虑。不过在迁移之前,务必评估新框架的稳定性和社区支持情况,避免踩到还没人填平的坑。