1. 项目概述与核心价值
最近在做一个嵌入式数据采集设备,需要把采集到的数据实时传输到上位机,同时还得允许用户像操作U盘一样,通过拖拽文件来更新设备固件或配置文件。这需求听起来是不是挺常见?没错,很多工控、物联网设备都有类似场景:既要一个稳定的数据通道(虚拟串口),又要一个便捷的文件管理接口(虚拟U盘)。如果外挂两个USB芯片,成本、板子面积和功耗都上去了,不划算。所以,我决定在STM32这颗MCU上,利用其内置的USB OTG FS/HS外设,实现一个USB复合设备,同时模拟出MSC(大容量存储设备)和CDC(通信设备类,即虚拟串口)两个功能。
这个方案的核心优势在于“一体两用”。物理上只有一个USB接口,连接到电脑后,系统会识别出两个独立的设备:一个可移动磁盘和一个COM口。数据采集程序可以通过虚拟串口(CDC)以固定的波特率向上位机发送数据流,稳定可靠;而设备维护人员则可以直接在“U盘”(MSC)里放入新的配置文件,设备重启后读取,或者实现USB DFU(设备固件升级)的类似功能,用户体验非常直观。STM32CubeMX这个工具极大地简化了USB协议栈的初始化和配置过程,让我们能更专注于业务逻辑。接下来,我就把从CubeMX配置到代码调试的完整过程,以及我踩过的几个“坑”详细分享一下。
2. 方案设计与CubeMX关键配置解析
实现USB复合设备,首要任务是理清USB协议栈中的“设备描述符”结构。一个USB设备只能有一个设备描述符,但可以包含多个配置描述符。我们通常使用一个配置(Configuration),在这个配置下包含两个接口(Interface):一个给MSC用,一个给CDC用。每个接口再包含必要的端点和描述符。STM32的USB库(比如基于HAL的USB Device库)已经为我们搭建好了框架,我们的工作就是在CubeMX里正确地“组装”它们。
2.1 CubeMX工程初始化与USB外设使能
首先,当然是打开STM32CubeMX,新建工程,选择你的目标芯片型号(比如我用的STM32F407ZG)。在Pinout & Configuration标签页下,找到Connectivity分类里的USB_OTG_FS或USB_OTG_HS。根据你的硬件设计选择模式,对于全速设备,通常使用USB_OTG_FS,并将其模式设置为Device_Only。
关键一步来了:转到Middleware中间件配置部分。这里你会看到USB_DEVICE。点击它,在Class For FS IP下拉菜单中,你会发现并没有直接的“MSC+CDC”选项。这是因为我们需要手动构建复合设备。所以,我们先选择一个基础类,比如先选Communication Device Class (Virtual Port Com)。这个操作会把CDC相关的驱动和框架代码加入工程。但别急,MSC我们稍后通过手动添加代码来实现,不过CubeMX里有些基础配置现在就可以做。
在USB_DEVICE配置页面,注意Device Descriptor部分。你需要合理设置Vendor ID和Product ID。如果是产品,需要申请自己的VID/PID;如果是学习和测试,可以使用ST的测试ID(比如VID: 0x0483),但要注意驱动签名问题,后面会提到。Product String可以设为你的设备名称,比如“DataLogger Composite Device”。
2.2 CDC接口配置详解
在Class For FS IP选择了CDC后,下面会出现CDC的配置子项。这里有几个关键参数:
- CDC Communication Interface:这是CDC的控制接口(默认接口0)。你需要为其分配一个IN端点(比如
EP1_IN)和一个OUT端点(比如EP1_OUT)用于传输命令和状态。端点类型选择Bulk(批量传输)或Interrupt(中断传输)均可,库通常默认配置好。 - CDC Data Interface:这是CDC的数据接口(默认接口1)。用于实际的数据收发。必须分配一对Bulk传输类型的端点,例如
EP2_IN和EP2_OUT。这是虚拟串口数据流的主通道。 - USB CDC Parameters:这里设置虚拟串口的参数。
Line Coding可以保持默认,因为波特率等参数可以在上位机端动态设置。USB CDC String Descriptor建议填写一个清晰的名字,比如“DataLogger VCOM”,这样在设备管理器里容易识别。
注意:CubeMX生成的CDC代码,其数据接口(Bulk端点)的包大小(
wMaxPacketSize)默认通常是64字节(全速USB)。如果你的数据吞吐量很大,需要确认这个值是否够用。对于全速USB,最大包大小就是64字节,这是上限。
2.3 为MSC接口预留资源与规划
CubeMX的图形界面没有直接添加第二个设备类(MSC)的选项,所以MSC相关的端点需要我们在配置中手动规划,并在代码中实现。这需要我们提前算好端点资源。
进入Pinout & Configuration标签页的System Core->USB(或者直接在Connectivity->USB_OTG_FS的配置里找),这里可以看到端点的详细配置表。CDC已经占用了EP1_IN, EP1_OUT, EP2_IN, EP2_OUT。我们需要为MSC分配额外的端点。
USB MSC类通常需要至少两个Bulk端点:一个IN用于向主机发送数据(比如读磁盘),一个OUT用于接收主机数据(比如写磁盘)。我们为MSC分配EP3_IN和EP3_OUT。在配置表中,找到EP3,将其类型设置为Bulk,并设置合适的包大小(全速USB下同样最大64字节)。
实操心得:端点编号(EPx)是硬件资源,务必规划好,避免冲突。CDC和MSC的端点必须分开。通常CDC用EP1和EP2,MSC用EP3和EP4,这样比较清晰。同时,记得在
USB_DEVICE的Device Descriptor里,把bNumConfigurations设置为1,但bNumInterfaces最终会是3(CDC控制接口+CDC数据接口+MSC接口),这个值CubeMX可能不会自动更新,需要我们在代码中修正设备描述符。
3. 代码工程生成与复合设备描述符整合
配置好引脚、时钟树(确保USB时钟是48MHz)和基础参数后,点击Project Manager,设置好工程名称、路径、IDE(MDK-ARM或STM32CubeIDE等),在Code Generator里选择“Copy only necessary library files”以节省空间,然后点击GENERATE CODE。
3.1 分析生成的代码结构
生成代码后,重点关注Core/Src和Core/Inc下的usb_device.c/.h,以及USB_DEVICE/App和USB_DEVICE/Target文件夹。
usbd_conf.c/.h: USB底层驱动配置,包括端点缓冲区的分配、时钟配置等。我们之前规划的端点(EP3)需要在这里的USBD_LL_Init函数或相关的端点初始化部分进行启用和配置。usbd_desc.c/.h: USB设备描述符、字符串描述符的定义。这是我们修改的重点,需要将单一的CDC描述符修改为包含CDC和MSC的复合设备描述符。USB_DEVICE/App/usbd_cdc_if.c/.h: CDC应用层接口。实现了CDC_Transmit_FS等函数,供用户调用发送串口数据。USB_DEVICE/Target/usbd_storage_if.c/.h: 这个文件默认可能不存在。它是MSC类与用户存储介质(如SPI Flash、SD卡、内部Flash模拟)的桥梁。我们需要手动创建并实现其中的磁盘操作函数。
3.2 修改设备描述符构建复合设备
复合设备的核心是描述符。我们需要修改usbd_desc.c中的USBD_FS_DeviceDescriptor和USBD_FS_ConfigDescriptor。
1. 设备描述符 (USBD_FS_DeviceDescriptor):主要修改bDeviceClass,bDeviceSubClass,bDeviceProtocol。对于复合设备,这些字段通常设置为0x00,0x00,0x00,表示设备类定义在接口描述符中。同时,确保bNumConfigurations为1。
2. 配置描述符 (USBD_FS_ConfigDescriptor):这是最复杂也是最重要的一部分。我们需要将CDC的两个接口描述符(通信接口和数据接口)和MSC的一个接口描述符拼接在一起,并正确计算总长度。
原始的配置描述符可能只包含CDC的部分。你需要找到它,并在此之后追加MSC接口的描述符。MSC接口描述符通常包括:
- 接口描述符(Interface Descriptor)
- 端点描述符(IN Endpoint Descriptor)
- 端点描述符(OUT Endpoint Descriptor)
你需要从ST的USB库例程(比如STM32Cube_FW_F4_Vxx.x/Projects/STM32xxx_YYY/Applications/USB_Device/MSC_Standalone)中拷贝MSC相关的描述符数组,并拼接到CDC描述符后面。
关键计算:
wTotalLength: 配置描述符的总长度。必须等于(CDC描述符长度 + MSC描述符长度)。一定要手动计算准确,否则主机枚举会失败。bNumInterfaces: 配置描述符中的接口总数。应该是3(CDC通信接口0 + CDC数据接口1 + MSC接口2)。- 每个接口的
bInterfaceNumber必须唯一且连续(0, 1, 2)。 - 每个端点的地址(
bEndpointAddress)必须与我们硬件规划的一致(CDC用0x81/0x01, 0x82/0x02;MSC用0x83/0x03等)。
避坑指南:描述符拼接是新手最容易出错的地方。一个字节错位都会导致枚举失败。强烈建议先用USB协议分析软件(如Bus Hound、Wireshark with USB capture)监听枚举过程,对比一个正常的复合设备(或者两个独立设备的)描述符,来检查自己生成的描述符。也可以将描述符数组以十六进制形式打印到调试串口,与标准文档逐字节核对。
3.3 实现MSC存储介质接口
创建或修改USB_DEVICE/Target/usbd_storage_if.c文件。这个文件需要实现USBD_StorageTypeDef结构体定义的函数指针表:
USBD_StorageTypeDef USBD_Storage_Interface_fops_FS = { STORAGE_Init_FS, STORAGE_GetCapacity_FS, STORAGE_IsReady_FS, STORAGE_IsWriteProtected_FS, STORAGE_Read_FS, STORAGE_Write_FS, STORAGE_GetMaxLun_FS, };你需要根据你的实际存储硬件(比如SPI Flash芯片W25Q64)来实现这些函数:
STORAGE_Init_FS: 初始化你的Flash/SD卡。STORAGE_GetCapacity_FS: 返回磁盘的总扇区数和扇区大小(通常是512字节)。STORAGE_IsReady_FS: 返回存储介质是否就绪。STORAGE_Read_FS/STORAGE_Write_FS: 最重要的两个函数,实现指定扇区的读写。参数lun逻辑单元号通常为0。这里需要调用你Flash驱动的读写函数,并处理好地址映射(将逻辑扇区号转换为物理地址)。
扇区读写示例(伪代码):
int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { // lun: 逻辑单元号,通常为0 // buf: 数据读取缓冲区 // blk_addr: 起始逻辑扇区号 // blk_len: 要读取的扇区数 uint32_t start_addr = blk_addr * STORAGE_BLK_SIZ; // 转换为字节地址 for(int i=0; i<blk_len; i++) { // 调用你的Flash读函数,读取一个扇区数据到buf my_flash_read(start_addr + i*STORAGE_BLK_SIZ, buf + i*STORAGE_BLK_SIZ, STORAGE_BLK_SIZ); // 注意处理读写错误,返回正确状态 } return USBD_OK; }注意事项:MSC的读写操作是异步的,且可能发生在中断上下文。你的存储介质驱动必须是非阻塞的,并且读写操作要尽可能高效。如果使用SPI Flash,确保SPI时钟够快,并利用好DMA。低效的读写会导致U盘拷贝文件时速度极慢甚至超时断开。
4. 应用层逻辑与双功能协同工作
描述符和底层驱动搞定后,设备就能被电脑正确识别为一个复合设备了。接下来是让两个功能在应用层协同工作。
4.1 初始化流程
在main.c的初始化部分,确保按正确顺序初始化:
- 初始化你的存储介质(Flash/SD卡)。
- 调用
MX_USB_DEVICE_Init()初始化USB设备协议栈。这个函数会调用我们修改过的描述符,并注册CDC和MSC类。 - 初始化你的数据采集外设(ADC, TIM等)。
4.2 CDC数据收发处理
CDC数据接收通常采用中断回调方式。在usbd_cdc_if.c中,找到CDC_Receive_FS函数(或者类似的CDC_Itf_Receive)。当USB主机(电脑)通过虚拟串口发送数据下来时,这个函数会被调用。
static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { // Buf: 接收到的数据缓冲区 // Len: 接收到的数据长度 // 在这里处理上位机发送下来的命令或数据 // 例如,可以将数据存入环形缓冲区,供主循环解析 user_command_parser(Buf, *Len); // 重新启动接收,准备下一次数据 USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return USBD_OK; }数据发送则通过调用CDC_Transmit_FS函数实现。注意,这个函数是非阻塞的,但如果你在上一次传输完成前再次调用,可能会失败。需要做好状态管理或使用队列。
4.3 MSC与CDC的线程安全与资源冲突
这是实现双功能设备的一个关键挑战。当电脑在读写U盘(MSC)时,你的Flash正在被频繁访问。同时,你的数据采集程序可能也需要写Flash来存储历史数据,或者CDC正在接收数据。
必须避免同时访问共享资源(这里是存储介质)!否则会导致数据损坏或程序崩溃。
解决方案:使用互斥锁(Mutex)或信号量(Semaphore)。在CubeMX中启用FreeRTOS可以很方便地管理任务和同步。你可以创建两个任务:一个USB_MSC_Task,一个Data_Logger_Task。对Flash的读写操作必须放在临界区或通过互斥信号量保护。
例如,在STORAGE_Read_FS/Write_FS函数中,尝试获取一个二值信号量。在Data_Logger_Task需要写Flash时,也尝试获取同一个信号量。这样就能确保同一时间只有一个执行实体在操作Flash。
// 在usbd_storage_if.c中 extern SemaphoreHandle_t xFlashMutex; // 在别处定义 int8_t STORAGE_Write_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { if(xSemaphoreTake(xFlashMutex, pdMS_TO_TICKS(100)) == pdTRUE) { // 执行Flash写操作 // ... xSemaphoreGive(xFlashMutex); return USBD_OK; } else { // 获取信号量超时,返回错误 return USBD_FAIL; } }实操心得:如果没有使用RTOS,可以通过关中断
__disable_irq()和开中断__enable_irq()来制造临界区,但要注意这会增加中断延迟,影响USB和其他的实时性。对于复杂应用,强烈推荐上RTOS。
4.4 设备枚举与驱动安装问题排查
即使代码编译通过,烧录后设备也可能无法被电脑正确识别。最常见的问题是驱动。
Windows系统:
- CDC驱动:ST提供的USB CDC驱动通常需要手动安装(
STM32Cube_FW_xx\Drivers\BSP\Components\stm32_virtual_comport_driver目录下的.inf文件)。如果设备管理器里出现“USB串行设备”或带感叹号的未知设备,右键更新驱动,手动指定到这个.inf文件。 - MSC驱动:Windows通常自带USB大容量存储设备驱动,无需额外安装。
- 复合设备:有时Windows会为整个复合设备安装一个驱动,并为其中的CDC接口再安装一个驱动。确保两个驱动都正确安装。
- 签名问题:如果你使用了非官方的VID/PID,或者修改了
.inf文件,在64位Windows上可能会遇到驱动签名强制(Driver Signature Enforcement)问题。需要在高级启动选项中暂时禁用驱动签名强制,或者购买代码签名证书对驱动进行签名。
Linux/Mac系统:通常内核自带cdc_acm和usb-storage驱动,插上就能用。在Linux下可以通过dmesg和lsusb命令查看设备枚举详情和分配的ttyACMx设备节点。
枚举失败调试:
- 检查硬件:USB的DP/DM线是否接反?上拉电阻(1.5kΩ)是否接在DP(全速)上?电源是否稳定?
- 打印调试信息:利用板载的串口(非USB)打印USB库的初始化状态和枚举过程中的关键事件(如
USBD_Init,USBD_RegisterClass,USBD_Start的返回值,以及USBD_CDC_Init,USBD_MSC_Init的回调)。 - 使用USB分析工具:这是最强大的手段。连接USB分析仪(如Beagle USB Protocol Analyzer)或使用软件抓包(Linux的
usbmon),可以清晰地看到主机发送的请求和设备返回的描述符,精准定位是哪个描述符字段出了问题。
5. 性能优化与稳定性提升实战
设备能工作只是第一步,要稳定可靠地工作,还需要进行优化。
5.1 端点缓冲区与包大小优化
在usbd_conf.h中,定义了每个端点的缓冲区大小CDC_DATA_HS_MAX_PACKET_SIZE和MSC_EPIN_SIZE等。对于全速USB,最大包大小是64字节。但CDC的数据吞吐率可能很高。如果应用需要高波特率(比如921600bps),意味着每秒要传输约92KB数据,远超64字节/帧 * 1000帧/秒 = 64KB/s的理论极限(实际上由于协议开销更低)。
解决方案:
- 使用高速USB(USB OTG HS):如果芯片支持,这是根本性提升。包大小可以到512字节。
- 全速USB下的优化:确保
CDC_Transmit_FS的调用尽可能快,不要阻塞。在主循环中,一旦有数据就立即发送,或者使用DMA将数据从其他外设(如ADC)直接搬运到USB端点缓冲区。同时,适当增大CDC_DATA_FS_MAX_PACKET_SIZE到64(最大值)。
对于MSC,读写速度主要受限于你的存储介质速度(如SPI Flash的时钟)。在STORAGE_Read_FS/Write_FS函数中,尽量使用块操作(一次读写多个扇区),并利用存储介质本身的缓存或DMA。
5.2 电源管理与连接稳定性
USB设备在枚举和传输过程中,对电源的稳定性要求较高。如果MCU的3.3V电源纹波过大,可能导致枚举失败或传输中意外断开。
- 硬件上:在USB的VBUS和GND之间并联一个10uF以上的钽电容或电解电容,靠近USB接口放置。在DP/DM线上串联小电阻(如22欧姆)有助于抑制信号反射。
- 软件上:实现完善的USB断开重连机制。在
HAL_PCD_DisconnectCallback回调中,可以延时一段时间后,重新调用MX_USB_DEVICE_Init()进行初始化。同时,处理好在设备断开时,应用层对CDC和MSC功能的禁用和清理。
5.3 文件系统与U盘格式化
如果你的MSC背后是模拟一个块设备(如Flash的一部分),你还需要考虑文件系统。电脑格式化U盘时,会在存储介质上创建文件系统(通常是FAT32)。
- 首次使用:设备第一次插入,电脑识别出磁盘后,会提示“需要格式化”。你可以选择在设备初始化时,就在Flash的指定区域预先写入一个有效的FAT32引导扇区和空的文件系统结构(可以使用PC工具生成一个空的磁盘镜像,然后将其二进制内容烧录到Flash起始地址)。这样电脑识别后就直接是可用的U盘,无需格式化。
- 动态文件系统:更高级的做法是在MCU端集成一个文件系统库(如FatFs)。这样MCU不仅可以作为U盘被电脑读写,自身也能通过FatFs API读写文件,实现更灵活的数据管理。但这就需要在
STORAGE_Read_FS/Write_FS函数中处理好FatFs的物理层访问。
6. 常见问题排查与解决实录
在实际开发中,我遇到了不少问题,这里列几个典型的:
问题1:电脑只识别出一个设备(要么是U盘,要么是串口),另一个不见了。
- 排查:99%是配置描述符(
USBD_FS_ConfigDescriptor)拼接错误。检查wTotalLength是否正确。使用Bus Hound抓取枚举数据,查看主机获取到的配置描述符总长度是否与你代码中定义的一致。检查每个接口描述符的bInterfaceNumber是否连续且唯一。
问题2:U盘可以识别,但拷贝大文件时经常失败,提示“设备未就绪”或传输中断。
- 排查:
- 存储介质速度慢:在
STORAGE_Write_FS函数中加入调试信息,计算写一个扇区实际耗时。如果超过几十毫秒,对于USB MSC的预期来说就太慢了。优化Flash的SPI时钟,启用四线模式(QSPI)如果芯片支持。 - 未正确处理写保护:确保
STORAGE_IsWriteProtected_FS函数返回0(非写保护)。 - 缓冲区不足:检查
MSC_EPIN_SIZE和MSC_EPOUT_SIZE是否设置正确(至少等于端点最大包大小)。 - 线程安全:确保在写Flash时,没有其他任务(如数据记录)打断。使用信号量保护。
- 存储介质速度慢:在
问题3:虚拟串口能识别,但上位机软件打开COM口失败,或收发数据乱码/丢失。
- 排查:
- 驱动问题:确认安装了正确的VCP驱动。在设备管理器中查看端口属性,确认资源无冲突。
- 波特率不匹配:虽然CDC是虚拟串口,不依赖实际波特率,但有些上位机软件在打开端口时会发送设置波特率的控制请求。确保
usbd_cdc_if.c中的CDC_Control_FS函数正确处理了SET_LINE_CODING请求,并正确解析了主机下发的波特率参数(虽然你可能用不到它)。 - 数据流控制:在
usbd_cdc_if.c的CDC_Receive_FS回调中,务必调用USBD_CDC_ReceivePacket来重新使能接收,否则收一次数据后就不再接收了。 - 发送阻塞:
CDC_Transmit_FS是异步的,但在上一次传输完成前(TxState不为0),再次调用会返回USBD_BUSY。应用层需要检查返回值,或者实现一个发送队列。
问题4:设备工作时,偶尔会整个USB断开重连。
- 排查:
- 电源问题:用示波器测量板子3.3V电源在USB数据传输时的纹波。过大则增加滤波电容。
- 软件看门狗:检查是否因为某个任务阻塞导致看门狗复位。如果是,调整任务优先级或优化耗时操作。
- 堆栈溢出:USB中断处理函数和回调函数可能会使用较多栈空间。在FreeRTOS中,适当增加USB相关任务的栈大小。
- ESD静电:检查USB接口是否有ESD保护器件,良好的接地有助于避免静电导致芯片复位。
实现STM32 USB双设备确实比单一功能要复杂不少,需要对USB协议栈和STM32的HAL库有更深的理解。但一旦调通,其带来的便利性和产品价值的提升是非常显著的。整个过程就像在搭积木,CubeMX帮你搭好了主框架(CDC),你需要自己把另一块积木(MSC)严丝合缝地拼上去,并处理好它们之间的“邻里关系”(资源冲突)。调试过程虽然可能充满挑战,但每一次用Bus Hound抓到正确的描述符,看到设备管理器里同时出现“U盘”和“串口”的那个瞬间,成就感绝对是满满的。