简介本资源面向STM32嵌入式开发者提供F103C8T6上CustomHID与MSC的USB复合设备实现方案重点解决键鼠共用接口在AIX7等boot启动模式主机下无法枚举报告描述符、导致键鼠失效的问题。方案将EP0用于枚举EP1、EP2分别承载键盘与鼠标支持绝对和相对模式EP3用于MSC设备并配有官方demo版本与FAT16版本默认全部采用双端口设计。压缩包共1420个文件以854个c源文件、274个h头文件、52个s汇编文件及48个d、46个crf、28个icf等工程与编译文件为主另含uvprojx工程、hex固件与pdf说明整体约22.53MB。目前已有838人学习下载。读者可据此掌握HIDMSC复合设备的接口分配与报告描述符处理思路并迁移到F1、F4、F0等系列实现HIDMSC、CDCMSC、HIDCDC等双复合乃至三复合方案。1. 从一块 F103C8T6 说起为什么 USB 复合设备总在枚举阶段翻车手头这块 F103C8T6 最小系统板Flash 64KB、RAM 20KB价格便宜到可以随手焊在项目里。很多人第一次尝试把 CUSTOM HID 和 MSC 塞进同一颗芯片、共用同一个 USB 外设时都会遇到一个反直觉的现象单独跑 HID 正常单独跑 MSC 也正常一旦复合主机要么只认出一个设备要么干脆枚举失败、设备管理器里挂个黄色感叹号。问题往往不在代码逻辑而在描述符的组织方式和端点分配策略上。这份F103C8T6_USB_CUSTOMHIDMSC.rar就是针对这个场景的完整工程。它把 CUSTOM HID自定义人机接口用于双向透传自定义报文和 MSC大容量存储把芯片内部 Flash 或外部 SPI Flash 模拟成 U 盘做成一个 USB 复合设备插上电脑后同时出现一个 HID 接口和一个可读写磁盘。适合正在做数据采集棒、参数配置器、固件升级 U 盘这类产品的嵌入式工程师也适合想搞懂 USB 复合设备枚举流程的学习者。下面按「资源是什么 → 怎么用 → 坑在哪」的顺序拆开讲。2. 复合设备的描述符结构配置描述符、接口关联与端点分配2.1 复合设备的枚举原理与描述符组织USB 复合设备的核心在于一个设备、一个配置但配置下挂多个接口Interface。主机通过接口关联描述符IADInterface Association Descriptor把多个接口绑定成一个功能或者干脆让每个接口独立成类。CUSTOM HID 和 MSC 属于两个不同的设备类标准做法是用 IAD 把 HID 接口和 MSC 接口分别声明或者让它们各自独立枚举。描述符的组织顺序是有讲究的设备描述符 → 配置描述符 → 接口关联描述符可选→ 接口描述符 → 类特定描述符 → 端点描述符。顺序错了主机解析到一半就停了。常见做法是把配置描述符的总长度wTotalLength算准这个值等于配置下所有描述符字节数之和少算一个字节都会导致枚举失败。端点分配是第二个关键点。F103C8T6 的 USB 外设只有 8 个双向端点EP0 到 EP7EP0 固定做控制传输。CUSTOM HID 通常用一对中断端点IN OUTMSC 用一对批量端点IN OUT。这样算下来EP0 控制 EP1 中断 IN EP2 中断 OUT EP3 批量 IN EP4 批量 OUT刚好够用。如果 HID 只做单向上报可以省掉 OUT 端点把端点留给其他功能。提示端点号不能重复方向可以相同。比如 EP1 IN 和 EP1 OUT 是两个不同的端点但 EP1 IN 和 EP2 IN 是两个独立端点分配时要查清楚芯片的端点 FIFO 分配表。2.2 描述符数组的写法与长度校验工程里描述符通常写成一个const uint8_t数组按字节拼接。下面是一个配置描述符的骨架包含 IAD、HID 接口和 MSC 接口// 配置描述符集合IAD HID接口 MSC接口 const uint8_t Composite_ConfigDescriptor[] { // 配置描述符 0x09, // bLength 0x02, // bDescriptorType CONFIGURATION LOBYTE(0x0062), HIBYTE(0x0062), // wTotalLength 0x62必须精确 0x02, // bNumInterfaces 2 0x01, // bConfigurationValue 0x00, // iConfiguration 0x80, // bmAttributes Bus Powered 0x32, // bMaxPower 100mA // 接口关联描述符 IAD把 HID 接口绑定为一个功能 0x08, // bLength 0x0B, // bDescriptorType INTERFACE_ASSOCIATION 0x00, // bFirstInterface 0 0x01, // bInterfaceCount 1 0x03, // bFunctionClass HID 0x00, // bFunctionSubClass 0x00, // bFunctionProtocol 0x00, // iFunction // HID 接口描述符 0x09, 0x04, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, // HID 描述符 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, LOBBYTE(0x0029), HIBYTE(0x0029), // 端点描述符EP1 IN 中断 0x07, 0x05, 0x81, 0x03, 0x08, 0x00, 0x0A, // 端点描述符EP2 OUT 中断 0x07, 0x05, 0x02, 0x03, 0x08, 0x00, 0x0A, // MSC 接口描述符 0x09, 0x04, 0x01, 0x00, 0x02, 0x08, 0x06, 0x50, 0x00, // 端点描述符EP3 IN 批量 0x07, 0x05, 0x83, 0x02, 0x40, 0x00, 0x00, // 端点描述符EP4 OUT 批量 0x07, 0x05, 0x04, 0x02, 0x40, 0x00, 0x00 };这段代码里几个参数需要重点核对wTotalLength必须等于数组实际长度可以用sizeof(Composite_ConfigDescriptor)在编译期校验bNumInterfaces是接口总数这里是 2HID 接口的bInterfaceClass是 0x03MSC 是 0x08端点描述符里的wMaxPacketSize对全速 USB 来说中断端点最大 64 字节批量端点最大 64 字节这里中断端点用了 8 字节批量端点用了 64 字节。如果wTotalLength写错主机会在获取配置描述符时只读到一部分后续接口描述符全部丢失表现就是「设备能识别但功能不全」。我一般会在枚举失败时用 USB 分析仪抓包看主机请求的wLength和实际返回的字节数是否一致。2.3 端点 FIFO 分配与缓冲区设置F103C8T6 的 USB 外设有一块 512 字节的专用 SRAM 用于端点缓冲区通过BTABLE寄存器管理分配。每个端点需要分配发送和接收缓冲区地址必须是 2 字节对齐批量端点要求 64 字节对齐更稳妥。工程里通常在usb_conf.h或初始化函数里设置// 端点缓冲区分配示例单位是字节 #define ENDP0_RXADDR (0x00) #define ENDP0_TXADDR (0x40) #define ENDP1_TXADDR (0x80) // HID IN #define ENDP2_RXADDR (0xC0) // HID OUT #define ENDP3_TXADDR (0x100) // MSC IN #define ENDP4_RXADDR (0x140) // MSC OUT分配时要注意EP0 的收发缓冲区各占 64 字节HID 中断端点如果包大小是 8 字节分配 8 字节即可但为了对齐方便通常也分配 64 字节。MSC 批量端点包大小 64 字节必须分配 64 字节且地址 64 字节对齐。如果地址重叠会出现「发送的数据被接收缓冲区覆盖」这种玄学问题抓包看数据内容对不上查半天才发现是缓冲区地址算错了。3. CUSTOM HID 与 MSC 的代码落地从报告描述符到 SCSI 命令3.1 CUSTOM HID 报告描述符与收发回调CUSTOM HID 的关键是报告描述符Report Descriptor它告诉主机这个 HID 设备发送和接收的数据格式。自定义 HID 不需要遵循标准键鼠格式可以自己定义报文长度和用途。下面是一个 64 字节输入输出的报告描述符示例const uint8_t CUSTOMHID_ReportDescriptor[] { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 0x09, 0x01, // Usage (0x01) 0xA1, 0x01, // Collection (Application) // 输入报告64 字节 0x09, 0x02, // Usage (0x02) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8) 0x95, 0x40, // Report Count (64) 0x81, 0x02, // Input (Data,Var,Abs) // 输出报告64 字节 0x09, 0x03, // Usage (0x03) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8) 0x95, 0x40, // Report Count (64) 0x91, 0x02, // Output (Data,Var,Abs) 0xC0 // End Collection };报告描述符里Report Count决定了单次传输的字节数这里设为 64对应端点包大小。主机端用HidD_GetFeature或ReadFile读取输入报告用WriteFile发送输出报告。设备端的收发回调在usb_endp.c里实现收到 OUT 端点数据后触发回调把数据存入环形缓冲区主循环再取走处理。常见坑是报告描述符长度和wDescriptorLength不一致。HID 描述符里的wDescriptorLength必须等于报告描述符数组的实际长度否则主机会认为描述符损坏拒绝加载。我一般用sizeof(CUSTOMHID_ReportDescriptor)直接赋值避免手算。3.2 MSC 的 SCSI 命令处理与存储介质对接MSC 设备对主机来说就是一个 U 盘主机通过 SCSI 命令集读写扇区。设备端需要实现几个核心 SCSI 命令INQUIRY返回设备信息、READ_CAPACITY返回容量和扇区大小、READ_10读扇区、WRITE_10写扇区、REQUEST_SENSE返回错误状态。工程里通常有一个usb_msc_scsi.c文件用switch-case分发命令。// SCSI 命令处理骨架 void SCSI_ProcessCmd(uint8_t *cmd, uint8_t *data, uint32_t *len) { switch (cmd[0]) { case SCSI_INQUIRY: // 返回 36 字节设备信息包括厂商、产品名、版本 memcpy(data, INQUIRY_Data, 36); *len 36; break; case SCSI_READ_CAPACITY: // 返回最后扇区号大端和扇区大小通常 512 data[0] (BLOCK_NUM - 1) 24; data[1] (BLOCK_NUM - 1) 16; data[2] (BLOCK_NUM - 1) 8; data[3] (BLOCK_NUM - 1); data[4] 0x00; data[5] 0x00; data[6] 0x02; data[7] 0x00; // 512 字节 *len 8; break; case SCSI_READ_10: // 从 cmd[2..5] 解析起始扇区从 cmd[7..8] 解析扇区数 MSC_ReadSectors(startSector, sectorCount, data); *len sectorCount * 512; break; case SCSI_WRITE_10: MSC_WriteSectors(startSector, sectorCount, data); *len 0; break; default: // 不支持的命令返回失败并设置 sense 数据 SCSI_SetSense(ILLEGAL_REQUEST, INVALID_COMMAND); break; } }存储介质可以是芯片内部 Flash 的一段区域也可以是外部 SPI Flash。内部 Flash 的好处是不用额外硬件缺点是擦写次数有限约 1 万次不适合频繁写入。外部 SPI Flash 容量大、擦写次数多但需要额外的驱动代码。工程里如果对接内部 Flash要注意 Flash 擦除是按页通常 1KB 或 2KB进行的写之前必须先擦除而且擦除期间 CPU 会阻塞USB 中断可能丢失需要做好缓冲。3.3 主循环中的任务调度与端点数据搬运复合设备的主循环要同时处理 HID 报文和 MSC 命令不能互相阻塞。常见做法是用一个简单的状态机USB 中断负责收发端点数据存入缓冲区并置标志位主循环轮询标志位处理 HID 数据或 MSC 命令。MSC 的读写操作可能耗时较长尤其是 Flash 擦除处理期间要保证 USB 中断能及时响应否则主机会认为设备超时。// 主循环任务调度示例 while (1) { if (HID_OutFlag) { HID_OutFlag 0; ProcessHIDReport(HID_OutBuffer, HID_OutLen); } if (MSC_ReadFlag) { MSC_ReadFlag 0; // 读操作较快直接处理 MSC_HandleRead(); } if (MSC_WriteFlag) { MSC_WriteFlag 0; // 写操作可能涉及 Flash 擦除先回应主机再后台处理 MSC_HandleWrite(); } }如果 MSC 写操作耗时超过主机超时时间通常 5 秒主机就会弹出「设备未响应」的错误。解决办法是把写操作拆成两步先接收数据到 RAM 缓冲区立即回应主机然后在主循环空闲时再写入 Flash。这样主机感觉不到延迟但要注意 RAM 缓冲区大小F103C8T6 只有 20KB RAM不能缓存太多扇区。4. 避坑与排查枚举失败、端点冲突、Flash 擦写异常4.1 枚举失败设备管理器显示未知设备或黄色感叹号现象插上 USB 后设备管理器里出现「未知 USB 设备设备描述符请求失败」或黄色感叹号主机无法识别任何接口。原因最常见的是wTotalLength算错或者描述符数组里有字节写错导致主机解析中断。其次是设备描述符里的bMaxPacketSize0不是 64全速 USB 控制端点必须支持 64 字节或者bcdUSB版本号与描述符结构不匹配。解决用 USB 分析仪抓取枚举过程重点看主机发送GET_DESCRIPTOR请求后设备返回的字节数。如果返回长度小于请求长度检查wTotalLength和数组实际大小。如果没有分析仪可以在代码里加串口打印把描述符数组逐字节输出核对。我一般会在USBD_GetDescriptor回调里加一句长度校验不匹配就点亮一个 LED方便快速定位。4.2 端点冲突HID 正常但 MSC 不识别或反之现象复合设备枚举后HID 接口能正常工作但 MSC 磁盘不出现或者 MSC 能识别但 HID 收发无数据。原因端点号分配冲突或者端点 FIFO 缓冲区地址重叠。比如 HID 用了 EP1 INMSC 也用了 EP1 IN主机只会认一个。又比如 EP3 TX 缓冲区和 EP4 RX 缓冲区地址重叠MSC 读写时数据互相覆盖。解决列出所有端点的编号、方向和缓冲区地址确保没有重复。F103C8T6 的 USB 端点缓冲区分配表可以在参考手册里找到每个端点的地址必须按包大小对齐。如果地址重叠调整BTABLE里的偏移量。另外注意端点使能顺序也有影响一般先使能控制端点 EP0再使能其他端点。4.3 Flash 擦写异常U 盘能识别但写入失败或数据丢失现象MSC 磁盘能正常识别容量显示正确但写入文件时提示「写入失败」或写入后读出来是乱码。原因内部 Flash 擦除是按页进行的如果写入的扇区跨越了页边界或者没有先擦除就写入数据会出错。另外Flash 擦除期间 CPU 阻塞如果此时主机发送了新的 SCSI 命令设备无法响应导致超时。解决在MSC_WriteSectors里先判断目标地址是否跨页跨页则分多次擦除和写入。擦除前把要写的数据缓存到 RAM擦除完成后立即写入。如果 RAM 不够可以只缓存一个扇区512 字节逐扇区处理。另外Flash 擦写次数有限如果应用需要频繁写入建议外挂 SPI Flash内部 Flash 只做只读或少量配置存储。4.4 主机兼容性同一设备在不同操作系统上表现不一致现象在 Windows 上能识别 HID 和 MSC在 Linux 或 macOS 上只识别其中一个或者枚举时间明显变长。原因不同操作系统对 USB 复合设备的枚举策略有差异。Windows 对 IAD 的支持较好Linux 更依赖接口描述符的顺序macOS 对 HID 报告描述符的校验更严格。如果 IAD 的bFirstInterface和bInterfaceCount与实际接口不匹配某些系统会直接忽略 IAD把接口当成独立设备处理。解决确保 IAD 的bFirstInterface指向第一个接口的编号bInterfaceCount等于该功能包含的接口数。如果不需要 IAD也可以去掉让每个接口独立枚举但这样主机可能把 HID 和 MSC 识别成两个独立设备而不是一个复合设备。根据产品需求选择。我一般会在 Windows 和 Linux 上各测一遍确认枚举结果一致。4.5 供电不足设备枚举成功但读写大文件时掉线现象设备能正常识别小文件读写正常但读写大文件或长时间传输时突然掉线设备管理器里设备消失。原因F103C8T6 最小系统板通常直接从 USB 取电如果板上有其他外设如 LED、传感器耗电较大加上 MSC 读写时电流波动可能导致电压跌落USB 外设复位。另外配置描述符里的bMaxPower如果声明过小比如 50mA主机可能限制供电。解决在配置描述符里把bMaxPower设为 100mA 或 250mA根据实际需求确保主机分配足够电流。如果板上外设多建议外部供电USB 只做数据线。另外在 USB 初始化时加一个电源检测电压低于阈值时主动断开连接避免不稳定工作。5. 进阶技巧用双缓冲和描述符校验提升稳定性5.1 端点双缓冲配置F103C8T6 的 USB 外设支持端点双缓冲对于批量端点尤其有用。双缓冲的意思是一个端点分配两块缓冲区主机可以连续发送两个包设备在处理第一个包的同时第二个包已经存入另一块缓冲区。这样能减少丢包提高吞吐量。配置方法是在端点初始化时设置EP_KIND和STATUS_OUT位具体寄存器操作参考芯片手册。// 批量端点双缓冲配置示例EP3 IN SetEPType(ENDP3, EP_BULK); SetEPDoubleBuff(ENDP3); SetEPDblBuffAddr(ENDP3, ENDP3_BUF0_ADDR, ENDP3_BUF1_ADDR); SetEPDblBuffCount(ENDP3, EP_DBUF_IN, 64); ClearDTOG_RX(ENDP3); ClearDTOG_TX(ENDP3); ToggleDTOG_TX(ENDP3); SetEPRxStatus(ENDP3, EP_RX_VALID); SetEPTxStatus(ENDP3, EP_TX_VALID);双缓冲的坑在于地址对齐和状态切换。两块缓冲区地址必须按包大小对齐状态切换要用ToggleDTOG_TX和ToggleDTOG_RX否则会出现「发送完成但主机收不到」的情况。我一般只在批量端点用双缓冲中断端点数据量小单缓冲足够。5.2 描述符自动校验脚本描述符写错是枚举失败的头号原因手动核对容易漏。我习惯在编译前跑一个 Python 脚本自动检查描述符数组的长度和关键字段# descriptor_check.py解析 C 数组并校验长度 import re def parse_array(text, name): pattern rconst\suint8_t\s name r\s*\[\s*\]\s*\s*\{(.*?)\}; match re.search(pattern, text, re.S) if not match: return None body match.group(1) # 去掉注释和空白提取十六进制字节 body re.sub(r//.*, , body) bytes_list re.findall(r0x[0-9A-Fa-f]{2}, body) return [int(b, 16) for b in bytes_list] with open(usb_desc.c, r, encodingutf-8) as f: content f.read() config parse_array(content, Composite_ConfigDescriptor) if config: total_len config[2] | (config[3] 8) print(fwTotalLength {total_len}, 实际长度 {len(config)}) if total_len ! len(config): print(长度不匹配请检查描述符数组) else: print(长度校验通过)这个脚本能快速发现wTotalLength和实际数组长度不一致的问题。参数说明parse_array函数用正则提取数组内容total_len从配置描述符的第 2、3 字节读取小端和实际长度对比。如果项目里描述符分散在多个文件可以把文件名作为参数传入批量校验。5.3 枚举日志与超时处理在 USB 中断里加一个简单的枚举状态记录把主机请求的bRequest、wValue、wLength存到数组里枚举失败时通过串口输出。这样不用分析仪也能大致判断卡在哪一步。另外MSC 命令处理要加超时机制如果某个命令处理超过 3 秒主动返回失败并重置端点状态避免主机一直等待。// 枚举日志记录 typedef struct { uint8_t bRequest; uint16_t wValue; uint16_t wLength; } USB_EnumLog; USB_EnumLog enumLog[32]; uint8_t enumLogIndex 0; void USB_LogEnum(uint8_t req, uint16_t val, uint16_t len) { if (enumLogIndex 32) { enumLog[enumLogIndex].bRequest req; enumLog[enumLogIndex].wValue val; enumLog[enumLogIndex].wLength len; enumLogIndex; } }在USBD_SetupStage回调里调用USB_LogEnum枚举完成后把日志通过串口打印出来。如果主机在某个请求后不再发送新请求说明设备返回的数据有问题对照日志里的wLength和实际返回长度排查。从那以后我每次做 USB 复合设备都会先跑一遍描述符校验脚本再抓一次枚举日志确认wTotalLength、端点分配和 IAD 字段全部正确后才开始调功能代码。这个习惯帮我省掉了大量「设备插上没反应」的排查时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?