STM32 USB复合设备开发:CDC虚拟串口与MSC虚拟U盘一体化实现
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下拉菜单中你会发现并没有直接的“MSCCDC”选项。这是因为我们需要手动构建复合设备。所以我们先选择一个基础类比如先选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和EP2MSC用EP3和EP4这样比较清晰。同时记得在USB_DEVICE的Device Descriptor里把bNumConfigurations设置为1但bNumInterfaces最终会是3CDC控制接口CDC数据接口MSC接口这个值CubeMX可能不会自动更新需要我们在代码中修正设备描述符。3. 代码工程生成与复合设备描述符整合配置好引脚、时钟树确保USB时钟是48MHz和基础参数后点击Project Manager设置好工程名称、路径、IDEMDK-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: 配置描述符中的接口总数。应该是3CDC通信接口0 CDC数据接口1 MSC接口2。每个接口的bInterfaceNumber必须唯一且连续0, 1, 2。每个端点的地址bEndpointAddress必须与我们硬件规划的一致CDC用0x81/0x01, 0x82/0x02MSC用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 i0; iblk_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的理论极限实际上由于协议开销更低。解决方案使用高速USBUSB 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是否连续且唯一。问题2U盘可以识别但拷贝大文件时经常失败提示“设备未就绪”或传输中断。排查存储介质速度慢在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盘”和“串口”的那个瞬间成就感绝对是满满的。