STM32 USB复合设备实战:CDC虚拟串口与MSC存储共享接口
最近做固件升级工具遇到一个硬需求设备通过USB连电脑后既要枚举出一个虚拟串口用来输出运行日志又要枚举出一个U盘用户把升级包拖进去就自动开始烧录。最初我想的是直接上两颗USB芯片后来冷静下来一算STM32自带的USB口本身就支持把多个功能组合成复合设备。配合Azure RTOS里面的USBX设备栈我在一颗STM32F407上实现了CDC虚拟串口加MSC大容量存储的composite class一个USB物理端口同时承担调试和升级两件事电脑端看到的是一台复合设备下面挂着两个功能节点。这篇文章把从协议原理、CubeMX配置到代码调试的完整过程整理出来供所有打算做同类USB复合设备的工程朋友参考。如果你已经在裸机上调过USB鼠标键盘或者用HAL库调过虚拟串口读起来会非常顺如果完全没接触过USB协议我也尽量把概念讲浅重点放在“为什么这么改”和“踩到坑怎么查”上。1. 复合类到底是什么一个USB设备如何“分裂”成两个外设1.1 从USB协议视角看设备、配置、接口、端点实现之前我建议先把这个概念在脑子里摆正。USB设备端的逻辑层次从高到低是设备、配置、接口、端点。一个物理USB设备只有一个设备描述符它说明厂商ID、产品ID、设备版本等信息。设备描述符下面可以有多个配置描述符但大多数设备只实现一个配置。每个配置里包含若干个接口描述符接口才是主机端类驱动真正识别的单位。接口下面有端点端点是实际收发数据的通道。普通单功能设备比如一个简单的CDC虚拟串口配置里只包含和串口功能相关的两个接口一个通信接口和一个数据接口。这两个接口在逻辑上是一套主机驱动通过接口关联把它们组合成一个串口节点。复合设备则不同它会在同一个配置描述符里同时塞入多个独立功能的接口集合。主机端枚举时先读到设备描述符再读配置描述符当发现配置里有多个不同功能的接口时就会把整体识别为“USB composite device”随后把每个功能分离成对应的子设备。这个逻辑层级是最重要的地基。后面改描述符遇到的所有问题几乎都能归到三件事接口数量不对、接口顺序不对、端点地址冲突。1.2 复合设备与普通多接口设备的区别很多人会把“一个配置里有很多接口”和“复合设备”直接画等号实际上要分情况。有些设备一个配置里确实有多个接口但这些接口属于同一个功能。CDC就是典型它必须用两个接口才能模拟出完整串口这叫接口关联不是复合。真正的复合设备组合的是两个或更多互相独立的类功能每个功能都能被主机端识别成独立设备。常见组合有哪些我用一个表格梳理功能组合典型应用场景注意事项CDC MSC调试日志加拖拽升级量产工装仪器仪表两个功能都需要占用批量端点带宽要估算CDC HID串口配置加键盘鼠标模拟HID免驱交互HID占用中断端点需要预留调度MSC HID数据存储加多媒体控制消费电子外设对设备端驱动能力要求不高但主机端行为要测透CDC MSC HID三合一复杂设备适合演示和验证极限内存占用和描述符复杂度明显上升不建议作为起点我实现的是CDCMSC组合因为升级工具最需要的就是一个串口交互命令一个U盘接收固件文件。两个功能在逻辑上完全独立互相干扰最小也最适合作为第一次接触复合设备的练手项目。1.3 为什么偏偏选Azure RTOS里的USBX裸机写USB Device栈不是不行但想同时维护多类驱动的枚举状态、端点调度和类回调工作量会爆表。尤其USB协议里每个类都有独立的请求和状态机特别是MSC的Bulk-Only传输有CBW、CSW、命令状态三个阶段写错一个字节就可能在Windows里弹出“此驱动器存在问题”。Azure RTOS里的USBX设备栈把类驱动做成了可注册的模块。每个类是一个独立实例有统一的读写接口和IOCTL请求接口。它本身和ThreadX深度绑定天然支持多线程访问可以把CDC接收线程和MSC读写线程分开避免一个功能卡住拖死另一个。再加上STM32CubeMX对Azure RTOS中间件有现成集成工程生成后初始化框架和描述符文件已经搭好我们要做的是在框架里补全复合设备逻辑而不是从零写协议栈。不过这里要泼一盆冷水CubeMX能生成“多类”的框架不等于生成的描述符天生就是正确的复合设备。尤其当你手动增加类、调整端点时必须打开生成的文件亲手核对字节。后面第三节会详细说。2. 动手前的先决条件硬件和中间件的一次“体检”2.1 选型哪种STM32适合跑USB复合类不是所有STM32都带USB外设带USB外设的型号实现方式也不同。F1/F3系列通常内置USB FS Device电路上需要外部上拉电阻和精确的48MHz时钟设计时要多留几个检查点。F4、L4、H7这类带OTG外设的型号更灵活可以配置成Device Only、Host、OTG等模式内部有独立的上拉控制硬件上少一些麻烦。我用的是STM32F407外设是OTG_FS配置为Device Only模式。为什么选它RAM足够大跑ThreadX加USBX缓冲绰绰有余主频高MSC做固件升级时不会因为CPU处理慢拖累USB吞吐。如果你手头是F1也不是不能做但建议把一部分精力放在时钟精度和外部上下拉上先让裸枚举通过再谈多类。选型时还要看USB外设和引脚冲突。F4的OTG_FS是PA11作为DMPA12作为DP。CubeMX里把USB_OTG_FS选成Device_Only之后引脚会自动复用。如果你在其他功能里占用了PA11/PA12后面会有严重冲突必须提前规划。2.2 时钟、引脚和电源的常见隐患USB FS要求48MHz时钟精度不够会导致枚举时断时续。CubeMX在Clock Configuration里会尝试自动配置但你要亲自确认PLL的USB输出是否为48MHz。比如F407主频168MHz时PLLQ经常是48MHz没问题但如果为了省电把主频调低USB时钟可能跟着变这是最容易被忽略的坑。电源方面F4系列带USB功能的芯片通常有独立的VDDUSB引脚有些开发板没有接或者没有按手册要求接地会造成设备端完全无法进入枚举。经验是拿到一块新板子第一件事查VDDUSB和VDDA确认供电正常再去看USB信号线上的ESD保护和串联电阻。DM和DP走线要短尽量等长串联电阻常用22欧姆靠近MCU侧放置。这些看起来低级的硬件条件恰恰是复合设备调试时最大的隐性风险。软件写得再对如果硬件在临界状态枚举可能时好时坏非常难查。我的建议是在写任何类驱动代码之前先让USBX不带任何类裸枚举一次确认主机能读到厂商ID和产品ID再继续往下加功能。2.3 CubeMX里的Azure RTOS中间件版本与配置入口现在Azure RTOS以Eclipse ThreadX的名义开源在CubeMX的Software Packs组件管理里可以直接下载。不同CubeMX版本生成的结构略有差异但核心API名字基本稳定。进入CubeMX后在Middleware and Software Packs一栏勾选Azure RTOS先启用ThreadX然后启用USBX。USBX的配置界面里有Device和Host两个方向必须选Device。选择后可以进一步勾选需要的类比如CDC ACM、MSC、HID等。这里能多选说明USBX设计上就是支持多类共存的。我建议在项目开始前先确认中间件版本。旧版USBX可能缺少一些针对复合设备的修复如果项目允许尽量升级到较新的包。另外要提前检查编译选项里是否定义了UX_DEVICE_CLASS_CDC_ACM和UX_DEVICE_CLASS_MSC这类宏不然即使你在CubeMX勾选了类预处理阶段也会把相关源码编译掉回调函数根本进不了工程。3. CubeMX生成多类USBX工程从勾选到描述符微调3.1 开ThreadX和USBX Device并同时勾选CDC、MSC具体操作路径是CubeMX - Middleware and Software Packs - Azure RTOS - ThreadX启用它再进入USBX启用Device。在USBX Device配置页里勾选CDC_ACM和MSC两个类。许多版本的CubeMX在勾选之后还会要求你给每个类设置接口号和属性保持默认也是一种稳妥选择但最好理解这些字段的意义。ThreadX配置里有一个关键参数是内存池大小。USBX依赖ThreadX的内存分配能力常见做法是在ThreadX配置中预留一个较大的字节池再把这个池的地址和大小传给USBX。我习惯在tx_user.h或app_threadx.h里定义一个APP_MEM_POOL_SIZEWindows端至少给16KB以上如果加入MSC大块读写建议给到32KB。内存池不够最典型的现象是两个类同时工作时某个类突然卡死或设备从总线断开。完成配置后生成代码。生成的目录里会有一个usbx_device文件夹里面包含ux_device_descriptors.c、ux_device_initialize.c等文件。先不要急着改先看一眼初始化函数确认它调用了USBX的标准初始化步骤再往下走。3.2 描述符文件里到底发生了什么打开ux_device_descriptors.c你会看到一堆UCHAR device_framework_full_speed[]之类的大数组。USBX不是通过结构体直接操作描述符而是把描述符作为原始字节数组传给设备栈因此你必须清楚每个字节的含义。先做一道加法题CDC占两个接口MSC占一个接口所以配置描述符里的bNumInterfaces必须是3。bNumInterfaces在配置描述符的第5字节从0开始算第4个字节位置。如果你打开数组发现这个值仍然是1说明CubeMX生成的只是单类工程需要手动修改。再看接口描述符块。每个接口描述符以0x09开头表示长度9字节。CDC会依次出现两个接口描述符第一个是通信接口类代码bInterfaceClass是0x02子类0x02协议0x01第二个是数据接口类代码是0x0A。MSC则是一个接口描述符类代码是0x08子类0x06协议0x50。你需要在配置描述符数组里依次找到这三个块并确认它们按顺序排列。我遇到过一次非常诡异的问题接口描述符顺序是CDC通信接口、MSC接口、CDC数据接口。Windows枚举时把MSC独立识别了CDC却变成未知设备因为主机找不到配对的通信接口和数据接口。所以描述符里接口顺序和类注册顺序必须一致最好保持CDC两个接口连续MSC跟在后面。3.3 端点冲突和接口编号的修复复合设备最常见的崩溃级问题就是端点地址重复。USB FS设备单个端点有编号限制F4的OTG_FS支持多个IN端点和OUT端点但每个端点地址必须唯一。CDC通常需要一对批量端点和可能的中断端点MSC也需要一对批量端点。如果CubeMX生成的端点分配有重叠设备虽然能枚举成功但传输时会出现STALL或busy。修改端点前建议先画一张端点分配表。CDC用端点1做批量IN端点2做批量OUTMSC用端点3做批量IN端点4做批量OUT。这样相对清晰。如果使用中断端点也要确保地址唯一。在描述符数组里找到对应的bEndpointAddress字节改成自己想要的地址同时还要改对应端点描述符里的wMaxPacketSize和bInterval等字段。为了后续好维护我把描述符里的长数组拆开来定义设备描述符一个数组字符串描述符一个数组配置描述符用若干个小数组拼接比如cdc_interface_descriptor[]、msc_interface_descriptor[]、ep_descriptor_in[]等然后在配置描述符数组里用memcpy组装。代码会多几十行但出问题排查时能少掉不少头发。4. 初始化流程和两个类驱动的协同逻辑4.1 mx_usbx_device_init里发生了什么CubeMX生成的初始化入口一般是MX_USBX_Device_Init()它内部通常由几个重要步骤组成调用ux_system_initialize初始化整个USBX系统调用ux_device_stack_initialize初始化设备栈然后逐个注册类。ux_system_initialize会传入USBX自己的内存池。这一步直接决定复合设备能跑到什么程度。我用的配置是系统池给到24KB每个类再单独分配缓冲。MSC的传输缓冲至少要能容纳一个或几个扇区常用512字节*2或者4KBCDC的环形缓冲给4KB。算下来内存约30KB左右对F407来说完全够用。如果芯片RAM本来就小就要仔细权衡两个类的缓冲宁可把MSC缓冲缩到2KB也不要让系统池告急。注册类时USBX需要传入类初始化函数和类实例。比如CDC注册时用ux_device_class_cdc_acm_initializeMSC注册时用ux_device_class_storage_initialize。这些函数在CubeMX生成代码里已经自动接好但你要去看一眼传入的参数有没有问题比如UX_SLAVE_CLASS_CDC_ACM_PARAMETER里的端点地址是否和描述符一致。4.2 CDC虚拟串口和MSC存储介质的回调实现要点CDC这套东西看着简单实际用起来有几个细节。发送数据前必须确认主机端已经打开了串口并且DTR/RTS信号有效。否则调用ux_device_class_cdc_acm_write会把数据积压直到主机端打开端口才发送。我在应用层做了一个环形队列日志先写进队列等CDC状态变成ready后再取出发送这样就不会因为主机状态导致业务线程被阻塞。接收方向类似。USBX会把主机发来的数据通过类回调送上来回调里不要做耗时处理。我一般只做数据拷贝然后丢给一个专门的解析线程做AT指令或协议解析。在回调里直接调用阻塞的写函数是最容易犯的错误USBX很多接口要求从ThreadX线程上下文调用而不是中断上下文。MSC部分的核心是存储介质回调。USBX存储类驱动会调用你实现的读写接口完成CBW命令解析和CSW状态回复。为了不把Flash底层的磨损均衡逻辑一开始就卷进来我建议先用一个大数组作为RAM盘测试。把扇区大小设为512字节容量设为1MB回调函数里对应的是memcpy操作。RAM盘能正常挂载FAT32再切换到真实Flash或SD卡驱动问题定位范围会小很多。4.3 两个类共享一条USB总线时的缓冲和任务调度复合设备虽然在主机端拆成了两个节点但物理上仍然共享同一条USB总线和同一个控制器。MSC做连续大块读写时会占掉大量批量传输带宽如果CDC也在往外吐数据两个类之间就会出现资源竞争。我的解决办法是给两个类分配不同的传输任务优先级。CDC日志任务优先级高于MSC读写任务当有串口数据要发时MSC任务可以让出CPU和总线保证交互反馈不卡顿。MSC传输的缓冲可以给大一点例如64KB让它在一个调度周期里连续搬移更多数据减少线程切换和总线重协调频率。还要控制单次发送的尺寸。CDC单次发送不要超过4KB否则容易挤占MSC带宽MSC读写不要一次提交一个超大缓冲区除非你确认USBX的内存池和端点缓冲能接受。实测下来CDC单包2KB、MSC单次16KB的组合在我这个项目里非常稳定。5. 插上电脑之后枚举验证和避坑记录5.1 怎么判断复合设备是否真正枚举成功把设备插到Windows电脑上打开设备管理器如果成功会先看到“USB composite device”展开之后下面会挂“USB串行设备”和“USB大容量存储设备”。这说明复合设备已经正确拆分成两个子设备。如果只看到一个未知设备或者只有一个类被识别基本可以断定是描述符问题。这时候用USB Device Tree Viewer这类工具抓完整描述符重点检查配置描述符里的bNumInterfaces和每个接口描述符的类代码。Windows对复合设备的拆分依赖这些字段任何一处字节错位都会导致无法识别。我遇到过一种情况设备管理器显示复合设备但CDC子设备有黄色感叹号MSC正常。后来发现是CDC数据接口的类代码写成了0x02正确值应该是0x0A。主机把数据接口当成通信接口处理驱动加载失败。这类问题靠肉眼看描述符数组很难发现必须对着协议规范逐字节比对。5.2 CDC回环和MSC读写压测一次完整的验证流程CDC部分最基础的验证是回环测试。PC端用串口助手发送一段字符STM32收到后原样返回。回环通了再测双向大流量PC端持续以115200甚至更高波特率发送数据设备端接收后返回固定长度的包看是否有丢失。MSC部分先用RAM盘验证。在PC上把设备格式化FAT32拷入一个文件再从U盘读回用哈希校验文件一致性。如果一致说明MSC的CBW、CSW和数据传输链路没问题。然后换真实Flash驱动重复同样操作。两个类单独通过后才做联合压测。写一个脚本同时打开串口和拷贝文件一边用串口持续发日志一边往MSC写入几十MB数据。如果过程中出现串口丢包、U盘读写中断甚至系统提示“设备无法识别”就把问题记录到排查表里。5.3 高频问题排查表现象可能原因解决建议枚举成功但CDC设备不出现CDC接口描述符缺失或DTR/线路编码未处理检查接口数确认应用处理SET_CONTROL_LINE_STATE和SET_LINE_CODINGMSC能枚举但显示0字节扇区大小或介质容量回调参数错误用RAM盘排除介质驱动问题检查容量与扇区大小返回值两个类同时工作必死内存池耗尽端点缓冲区冲突加大USBX内存池检查端点地址是否重复设备能识别但Windows提示无法启动字符串描述符索引越界或配置描述符长度错误用协议抓包工具比对所有长度字段拔插后第一次正常第二次蓝屏类实例释放未清理检查总线复位后的重新初始化逻辑确保资源重复创建不冲突有MSC读写时CDC丢包总线带宽争用CDC发送无背压降低单次发送尺寸提高CDC任务优先级这些坑我基本都踩过。尤其是“MSC能枚举但显示0字节”真正原因往往是read回调里读的扇区数和你设置的逻辑块大小不一致和Flash驱动关系不大。6. 调试顺序和两个容易被忽略的描述符参数6.1 从单类到复合先让每个功能独立跑通我可以很负责地说复合设备最忌讳一上来就同时调两个类。正确顺序是先只注册CDC跑通虚拟串口然后把CDC注释掉只注册MSC跑通U盘枚举和读写最后再把两个类同时打开。每次只引入一个变量出问题时不用猜。这个顺序看着保守实际上能省下大量时间。因为复合设备的故障往往隐藏在两个类互相影响的过程中如果单个类就没跑通那复合阶段的排查会变成叠buff很难定位。我在第三次做类似项目时已经把这个顺序当成纪律来执行。6.2 bcdUSB和wMaxPacketSize两个参数不能随手抄描述符里有几个参数非常容易被忽略。第一个是bcdUSB它表示设备支持的USB规范版本。如果你写的是0x0200但实际USB外设跑在FS模式最大包长度是64字节如果写0x0300则表示USB3.0但你根本没有SuperSpeed PHY主机可能会按错误方式协商。复合设备尤其要把这个参数对齐到硬件真实能力。第二个是wMaxPacketSize。FS模式下批量端点最大包长64字节HS模式最大512字节。如果配置描述符里写的是512但你的外设实际没有启用HSWindows会按HS包长度去调度导致设备端收到的数据被截断。老实按FS的64字节写宁可速度低一点稳定性最优先。这两个参数在生成代码里不一定正确特别是当CubeMX版本或芯片封装模板有差异时。调试时不要想当然用USBLyzer或USB Device Tree Viewer读一遍实际枚举结果再和意图对比。6.3 什么情况下不要硬上复合设备复合设备确实强大但它不是银弹。第一个限制是总线带宽。USB FS总带宽只有12MbpsCDC和MSC同时大量传输时带宽会被迅速耗尽。如果你的应用需要长时间满速跑MSC又要求CDC实时性复合设备可能扛不住建议评估Host和Device是否都用HS或者把交互通道改成HID减少开销。第二个限制是端点数。多一个类就多占用端点资源FS模式下端点资源有限。如果你计划组合三个及以上功能必须提前画端点分配表确认每个类都能分到唯一的IN/OUT端点对。如果端点不够就要砍功能或者改用外部USB Hub加多芯片方案。最后是复杂度风险。复合设备对描述符的正确性要求极高一点错位就可能导致某个子设备驱动加载失败。产品项目如果时间紧功能又不需要共享同一个USB口选择两颗独立USB芯片反而更可靠。做技术选型时不要被“复合设备很酷”绑架要回到真实需求去权衡。最后再分享一个我保留了很久的调试习惯在工程里挂一个USB状态回调把总线复位、设置配置、类请求这些关键事件用调试串口打出来。这个习惯帮我少走了很多弯路尤其是复合设备这种多状态叠加的场景看到事件日志和枚举现象一一对应心里才有底。