简介该资源面向STM32嵌入式开发者尤其是需要实现USB复合设备的中高级工程师提供F103C8T6上CustomHID与MSC复合的完整工程。其核心价值在于解决键鼠共用接口时部分主机如IBM AIX7在boot模式下不请求报告描述符导致设备失效的问题通过为键盘、鼠标分配独立接口EP1、EP2鼠标支持绝对与相对模式并将MSC置于EP3EP0负责枚举全部采用双端口设计。压缩包共1420个文件以854个C源文件、274个头文件、52个汇编文件及48个工程文件为主另含链接脚本、库文件与说明文档整体约22.53MB。资源同时提供官方demo版本和FAT16版本默认并附有PDF说明。其思路可迁移至F1、F4、F0等系列单片机的HIDMSC、CDCMSC、HIDCDC等双复合甚至三复合场景为USB复合设备开发提供可复用的参考框架与排错思路。目前已有838人学习下载。1. 一个 .rar 包里藏着的复合 USB 设备F103C8T6 上跑通 Custom HID MSC如果你手头有一块 F103C8T6 最小系统板想让它插上电脑后同时被识别成一个自定义 HID 设备和一个 U 盘那这个标题说的就是这件事。Custom HID 负责双向收发自定义报文MSC 负责把板载 Flash 或外挂存储暴露成可读写的磁盘两者共用同一个 USB 外设。难点不在写业务逻辑而在 USB 描述符的组织、端点分配和复合设备枚举——很多人的板子插上去要么只认出一个设备要么干脆枚举失败变成未知设备。这篇笔记面向已经能点亮 LED、会用 Keil 或 GCC 编译、但对 USB 协议栈只停留在“改改例程”阶段的嵌入式开发者把从描述符配置到端点缓冲、从枚举排查到读写验证的完整路径拆开讲清楚。2. 复合设备为什么容易翻车从 USB 枚举流程说起2.1 单功能设备与复合设备的本质差别单个 HID 设备的描述符结构很直白一个设备描述符、一个配置描述符、一个接口描述符、一个 HID 描述符、一个端点描述符收工。主机拿到这套描述符后按接口加载 HID 类驱动通信就建立了。复合设备不一样。它需要在同一个配置下挂两个接口每个接口有自己的类描述符和端点主机枚举时要先读配置描述符的总长度再逐个接口解析。如果配置描述符里 wTotalLength 字段写错或者接口编号、端点地址有冲突主机在枚举阶段就会直接放弃。常见做法是用一个接口关联描述符IAD把两个接口绑成一组功能单元Windows 对 IAD 的支持比较成熟Linux 下也能正常识别。F103C8T6 的 USB 外设只有 8 个双向端点其中 EP0 固定用于控制传输剩下 7 个可分配。Custom HID 通常占一个中断输入端点和一个中断输出端点MSC 占一个批量输入端点和一个批量输出端点加起来 4 个资源够用但不算宽裕。端点地址不能重复比如 HID 用了 EP1 INMSC 就不能再用 EP1 IN。2.2 描述符配置把两个功能塞进一个配置里下面是一段配置描述符的骨架代码用结构体数组的方式组织方便后续按偏移量填充。这里假设 HID 用 EP1 IN 和 EP1 OUTMSC 用 EP2 IN 和 EP2 OUT。// 配置描述符集合配置 接口1(HID) HID描述符 端点 接口2(MSC) 端点 __ALIGN_BEGIN static uint8_t USBD_Composite_CfgDesc[] __ALIGN_END { // 配置描述符 0x09, // bLength 0x02, // bDescriptorType CONFIGURATION LO_UINT16(0x0062), // wTotalLength 低字节总长度需精确计算 HI_UINT16(0x0062), // wTotalLength 高字节 0x02, // bNumInterfaces 2 0x01, // bConfigurationValue 0x00, // iConfiguration 0x80, // bmAttributes: bus powered 0x32, // bMaxPower 100mA // 接口1Custom HID 0x09, 0x04, // bLength, INTERFACE 0x00, // bInterfaceNumber 0 0x00, // bAlternateSetting 0x02, // bNumEndpoints 2 0x03, // bInterfaceClass HID 0x00, // bInterfaceSubClass 0x00, // bInterfaceProtocol 0x00, // iInterface // HID 描述符 0x09, 0x21, // bLength, HID 0x11, 0x01, // bcdHID 1.11 0x00, // bCountryCode 0x01, // bNumDescriptors 0x22, // bDescriptorType REPORT LO_UINT16(0x0022), // wDescriptorLength 低字节 HI_UINT16(0x0022), // wDescriptorLength 高字节 // HID 端点 IN 0x07, 0x05, // bLength, ENDPOINT 0x81, // bEndpointAddress EP1 IN 0x03, // bmAttributes Interrupt 0x40, 0x00, // wMaxPacketSize 64 0x0A, // bInterval 10ms // HID 端点 OUT 0x07, 0x05, 0x01, // bEndpointAddress EP1 OUT 0x03, 0x40, 0x00, 0x0A, // 接口2MSC 0x09, 0x04, 0x01, // bInterfaceNumber 1 0x00, 0x02, // bNumEndpoints 2 0x08, // bInterfaceClass Mass Storage 0x06, // bInterfaceSubClass SCSI transparent 0x50, // bInterfaceProtocol Bulk-Only 0x00, // MSC 端点 IN 0x07, 0x05, 0x82, // bEndpointAddress EP2 IN 0x02, // bmAttributes Bulk 0x40, 0x00, 0x00, // bInterval 0 for bulk // MSC 端点 OUT 0x07, 0x05, 0x02, // bEndpointAddress EP2 OUT 0x02, 0x40, 0x00, 0x00, };这段代码里最容易出错的是 wTotalLength。它必须等于整个配置描述符集合的字节数多一个少一个主机都会枚举失败。我一般会在编译后用 sizeof 打印出来核对或者用 USB 分析仪抓包看主机读到的长度和实际发送的是否一致。bNumInterfaces 要写 2bInterfaceNumber 分别填 0 和 1不能重复。HID 的 wDescriptorLength 指向报告描述符的长度这个值也要和实际报告描述符数组的长度严格一致。端点的 wMaxPacketSize 对全速 USB 来说中断端点最大 64 字节批量端点最大 64 字节这里都填 0x40 没问题。bInterval 对中断端点有效单位是毫秒填 10 表示主机每 10ms 轮询一次批量端点填 0 即可。2.3 端点缓冲分配与 PMA 管理F103 的 USB 外设有一块 512 字节的 Packet Memory AreaPMA所有端点的收发缓冲都从这里分配。EP0 通常占 64 字节发送加 64 字节接收剩下 384 字节给其他端点。如果 HID 和 MSC 各用 64 字节双缓冲4 个端点就是 256 字节加上 EP0 的 128 字节总共 384 字节刚好够用。但如果你把 HID 的报告长度设成 128 字节或者 MSC 用 512 字节块PMA 就不够分了。常见做法是 HID 端点各分配 64 字节单缓冲MSC 端点各分配 64 字节双缓冲。双缓冲的意义在于主机连续发送数据时USB 外设可以在一个缓冲被读取的同时往另一个缓冲写入减少 NAK 次数。配置 PMA 的代码通常在 USB 中断初始化里完成用 SetEPTxAddr、SetEPRxAddr 这类宏设置每个端点的缓冲起始地址地址要按 2 字节对齐且不能重叠。// PMA 地址分配示例单位是字节偏移 #define PMA_EP0_TX_ADDR 0x00 // EP0 发送缓冲64字节 #define PMA_EP0_RX_ADDR 0x40 // EP0 接收缓冲64字节 #define PMA_EP1_TX_ADDR 0x80 // HID IN64字节 #define PMA_EP1_RX_ADDR 0xC0 // HID OUT64字节 #define PMA_EP2_TX_ADDR 0x100 // MSC IN64字节 #define PMA_EP2_RX_ADDR 0x140 // MSC OUT64字节 // 初始化时调用 void USB_PMA_Init(void) { SetEPTxAddr(ENDP0, PMA_EP0_TX_ADDR); SetEPRxAddr(ENDP0, PMA_EP0_RX_ADDR); SetEPTxAddr(ENDP1, PMA_EP1_TX_ADDR); SetEPRxAddr(ENDP1, PMA_EP1_RX_ADDR); SetEPTxAddr(ENDP2, PMA_EP2_TX_ADDR); SetEPRxAddr(ENDP2, PMA_EP2_RX_ADDR); }这段代码的关键是地址不能重叠且每个端点的缓冲大小要够放一个最大包。如果 MSC 用 512 字节块传输端点缓冲就得设成 512 字节但 F103 的 PMA 总共才 512 字节根本不够。所以 MSC 的块大小通常设成 64 字节通过多次批量传输拼成一个 512 字节的 SCSI 读写命令。这也是为什么很多 F103 的 U 盘例程读写速度只有几十 KB/s瓶颈就在 PMA 和端点缓冲上。3. 从零搭建工程Custom HID 与 MSC 的代码整合3.1 工程文件结构与初始化顺序一个可用的工程通常包含这些文件USB 中断服务程序、USB 描述符定义、HID 类处理、MSC 类处理、SCSI 命令解析、Flash 读写驱动。初始化顺序很重要先初始化时钟和 GPIO再初始化 USB 外设最后使能中断。如果顺序反了USB 枚举可能不稳定。// main.c 中的初始化片段 int main(void) { SystemInit(); // 配置系统时钟USB 需要 48MHz GPIO_Config(); // USB 引脚配置为复用推挽 USB_Clock_Init(); // 使能 USB 时钟配置 48MHz USB_Device_Init(); // 初始化 USB 外设和 PMA HID_Init(); // 初始化 HID 报告描述符和状态 MSC_Init(); // 初始化 MSC 存储介质 NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn); // 使能 USB 低优先级中断 while (1) { // 主循环处理 HID 报文和 MSC 状态机 } }USB_Clock_Init 里要把 PLL 配置成 48MHz 输出给 USB 外设F103 的 USB 对时钟精度有要求偏差太大会导致枚举失败。我一般用 8MHz 晶振倍频到 72MHz 给系统再分频出 48MHz 给 USB。如果板子上没有晶振用内部 RC 也能跑但枚举成功率会下降尤其是温度变化时。3.2 Custom HID 报告描述符与收发逻辑报告描述符定义了主机和设备之间交换的数据格式。下面是一个简单的自定义报告描述符包含一个输入报告和一个输出报告各 64 字节。// HID 报告描述符64字节输入 64字节输出 __ALIGN_BEGIN static uint8_t HID_ReportDesc[] __ALIGN_END { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined) 0x09, 0x01, // Usage (Vendor Usage 1) 0xA1, 0x01, // Collection (Application) // 输入报告64字节 0x09, 0x02, // Usage (Vendor Usage 2) 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 (Vendor Usage 3) 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02, // Output (Data,Var,Abs) 0xC0 // End Collection };Report Count 填 0x40 表示 64 个字节Report Size 填 0x08 表示每个字节 8 位。输入报告用 0x81 开头输出报告用 0x91 开头。主机端用 HIDAPI 或 libusb 打开设备后可以通过中断传输读写这 64 字节。发送数据时调用 USBD_HID_SendReport接收数据在 EP1 OUT 中断里读取。// HID 发送数据 void HID_SendData(uint8_t *data, uint16_t len) { if (len 64) len 64; UserToPMABufferCopy(data, PMA_EP1_TX_ADDR, len); SetEPTxCount(ENDP1, len); SetEPTxValid(ENDP1); // 触发发送 } // HID 接收回调在 EP1 OUT 中断中调用 void HID_ReceiveCallback(void) { uint16_t len GetEPRxCount(ENDP1); PMAToUserBufferCopy(hid_rx_buf, PMA_EP1_RX_ADDR, len); SetEPRxValid(ENDP1); // 重新使能接收 // 处理 hid_rx_buf 中的数据 }这里要注意SetEPTxValid 之后不能立刻往同一个缓冲写新数据要等发送完成中断触发后再写。如果连续发送频率太高会出现数据覆盖。我一般会在发送函数里加一个忙标志发送完成中断里清标志主循环检测到标志为空才发下一包。3.3 MSC 存储介质与 SCSI 命令处理MSC 的核心是把主机的 SCSI 读写命令翻译成对 Flash 的读写操作。F103C8T6 片内 Flash 只有 64KB拿来做 U 盘太小通常外挂一颗 SPI Flash 或者 SD 卡。下面以 SPI Flash 为例说明 SCSI 命令的处理流程。// SCSI 命令处理入口 void MSC_SCSI_Process(uint8_t *cmd, uint8_t *data, uint32_t *len) { switch (cmd[0]) { case 0x12: // INQUIRY MSC_Inquiry(data, len); break; case 0x1A: // MODE SENSE MSC_ModeSense(data, len); break; case 0x28: // READ(10) MSC_Read10(cmd, data, len); break; case 0x2A: // WRITE(10) MSC_Write10(cmd, data, len); break; case 0x00: // TEST UNIT READY MSC_TestUnitReady(data, len); break; default: MSC_CommandFailed(data, len); break; } }READ(10) 命令里包含了起始逻辑块地址和块数量每个块通常是 512 字节。处理时要先把 LBA 转换成 SPI Flash 的字节地址再分多次读取每次最多 64 字节因为端点缓冲只有 64 字节。写操作同理主机发来的数据先缓存在端点缓冲里攒够 512 字节再写入 Flash 的一页。// READ(10) 处理从 SPI Flash 读取数据到端点缓冲 void MSC_Read10(uint8_t *cmd, uint8_t *data, uint32_t *len) { uint32_t lba (cmd[2] 24) | (cmd[3] 16) | (cmd[4] 8) | cmd[5]; uint16_t block_count (cmd[7] 8) | cmd[8]; uint32_t addr lba * 512; uint32_t total block_count * 512; uint32_t offset 0; while (offset total) { uint16_t chunk (total - offset 64) ? 64 : (total - offset); SPI_Flash_Read(addr offset, msc_tx_buf, chunk); UserToPMABufferCopy(msc_tx_buf, PMA_EP2_TX_ADDR, chunk); SetEPTxCount(ENDP2, chunk); SetEPTxValid(ENDP2); // 等待发送完成实际工程中应使用中断或状态机 while (GetEPTxStatus(ENDP2) ! EP_TX_NAK); offset chunk; } *len 0; // 数据已通过端点发送CSW 阶段不再带数据 }这段代码里用轮询等待发送完成实际工程中应该改成状态机否则会阻塞其他任务。SPI Flash 的读取速度受 SPI 时钟限制F103 的 SPI 最高 18MHz读一页 256 字节大概几十微秒但 USB 全速批量传输的理论上限是 1.2MB/s实际受 PMA 和中断延迟影响能跑到 200KB/s 就算不错了。4. 避坑与排查枚举失败、读写超时、蓝屏的常见原因4.1 设备管理器显示未知设备或枚举失败现象板子插上电脑后设备管理器里出现“未知 USB 设备设备描述符请求失败”或者干脆没反应。原因最常见的是 wTotalLength 字段和实际描述符长度不一致主机读到的配置描述符不完整解析到一半就放弃了。其次是端点地址冲突比如 HID 和 MSC 都用了 EP1 IN主机在分配端点时发现重复直接拒绝配置。还有一种情况是 PMA 地址重叠两个端点的缓冲指向同一块内存数据互相覆盖枚举阶段读到的描述符就是乱的。解决先用 USB 分析仪抓包看主机发送 GET_DESCRIPTOR 请求后设备返回的数据长度和内容。如果没有分析仪可以在代码里把描述符数组用串口打印出来逐字节核对。重点检查 wTotalLength、bNumInterfaces、bInterfaceNumber、bEndpointAddress 这四个字段。PMA 地址分配可以用宏定义统一管理避免手写偏移量出错。4.2 U 盘能识别但无法格式化或读写超时现象电脑上能看到盘符但双击打开提示“请插入磁盘”或者格式化失败复制文件时进度条卡住然后报错。原因MSC 的 SCSI 命令处理不完整比如 INQUIRY 命令返回的数据长度不对主机认为设备不符合规范。或者 READ CAPACITY 命令返回的块数量和实际存储容量不匹配主机按错误的地址读写自然超时。还有一种情况是 CSWCommand Status Wrapper的签名或状态字段填错主机收不到正确的完成状态一直重试。解决用 Bus Hound 或 Wireshark 抓 USB 批量传输的数据看主机发了哪些 SCSI 命令设备回了什么。重点核对 INQUIRY 返回的 36 字节标准数据READ CAPACITY 返回的 8 字节数据最后 4 字节是块数量以及 CSW 的 13 字节结构签名 0x53425355Tag 要和 CBW 一致Status 为 0 表示成功。如果 CSW 的 Tag 对不上主机就会认为命令乱序直接复位设备。4.3 同时收发时 HID 数据丢失或 MSC 传输卡死现象HID 和 MSC 同时工作时HID 上报的数据偶尔丢包或者 MSC 传输大文件时卡死不动。原因USB 中断服务程序里处理时间太长导致其他端点的中断被延迟响应。比如在 EP1 OUT 中断里直接调用 SPI Flash 写函数SPI 写一页要几毫秒这期间 EP2 的批量传输中断进不来主机等不到 ACK 就会超时。另外如果 HID 和 MSC 共用了同一个全局缓冲区中断嵌套时可能发生数据竞争。解决中断服务程序里只做最少的操作把耗时的 Flash 读写放到主循环里用状态机驱动。HID 和 MSC 各自使用独立的缓冲区缓冲区大小按最大包长度分配。如果中断优先级可配把 USB 中断设成相同优先级避免嵌套。主循环里轮询 HID 发送标志和 MSC 状态标志分时处理。4.4 电脑蓝屏或提示“USB 设备超过其集线器端口的电源限制”现象插上板子后电脑直接蓝屏或者弹出提示说设备耗电超过端口限制。原因配置描述符里的 bMaxPower 字段填得太大比如填了 0xFA500mA而板子实际从 USB 取电超过 100mA 但没做电源管理。或者 bmAttributes 字段配置成了自供电模式但实际没有外部电源主机检测到电压异常就报错。解决bMaxPower 按实际电流填F103 最小系统板加 SPI Flash 通常不超过 100mA填 0x32100mA就够了。bmAttributes 填 0x80 表示总线供电不要填 0xC0自供电。如果板子上有多个外设同时工作总电流可能超过 100mA那就需要外部供电同时 bmAttributes 改成自供电模式。4.5 枚举成功但 HID 报告描述符被主机拒绝现象设备管理器里能看到 HID 设备但用 HIDAPI 打开时报错或者读到的报告长度和预期不符。原因报告描述符的语法有误比如 Collection 没有正确闭合或者 Report Count 和 Report Size 的乘积和实际端点包长不匹配。主机解析报告描述符时如果发现逻辑矛盾会拒绝加载 HID 驱动设备就变成一个“符合 HID 标准的供应商定义设备”但无法通信。解决用 USB 描述符工具比如 HID Descriptor Tool检查报告描述符的语法树确保每个 Collection 都有对应的 End CollectionUsage Page 和 Usage 的层级关系正确。Report Count 乘以 Report Size 要等于端点包长的 8 倍比如 64 字节就是 512 位Report Count 填 64Report Size 填 8乘积 512对得上。5. 进阶技巧用双缓冲和状态机把 MSC 读写速度提上去MSC 的读写速度瓶颈主要在 PMA 缓冲和中断响应。单缓冲模式下主机发来一包数据设备要等 CPU 把数据从 PMA 搬到用户缓冲区再重新使能接收这期间主机只能等。双缓冲模式下USB 外设可以在 CPU 处理第一包的同时接收第二包理论上能把吞吐量提高一倍。F103 的 USB 外设支持端点双缓冲但配置起来比较绕。以 EP2 OUT 为例需要设置 EP2 的缓冲地址为两个相邻的 64 字节块然后在接收中断里切换当前使用的缓冲。下面是一个简化的双缓冲接收状态机。// EP2 OUT 双缓冲接收状态机 typedef enum { BUF_IDLE, BUF_RECEIVING, BUF_PROCESSING } buf_state_t; static buf_state_t ep2_state BUF_IDLE; static uint8_t ep2_buf[2][64]; static uint8_t ep2_active 0; void EP2_OUT_IRQHandler(void) { if (ep2_state BUF_RECEIVING) { // 当前缓冲接收完成切换到另一个缓冲 uint16_t len GetEPRxCount(ENDP2); PMAToUserBufferCopy(ep2_buf[ep2_active], PMA_EP2_RX_ADDR ep2_active * 64, len); ep2_active ^ 1; // 切换缓冲 SetEPRxValid(ENDP2); // 使能另一个缓冲接收 ep2_state BUF_PROCESSING; // 通知主循环处理数据 } } // 主循环中处理数据 void MSC_ProcessBuffer(void) { if (ep2_state BUF_PROCESSING) { // 处理 ep2_buf[ep2_active ^ 1] 中的数据 // 处理完成后重新使能接收 ep2_state BUF_RECEIVING; SetEPRxValid(ENDP2); } }这段代码里ep2_active 在中断里翻转主循环处理的是上一个缓冲的数据。实际工程中还要考虑 SCSI 命令的 CBW 和 CSW 阶段CBW 是 31 字节CSW 是 13 字节数据阶段可能有多包。状态机要能区分当前处于哪个阶段不能把 CBW 当成数据写到 Flash 里。另一个提速技巧是减少中断次数。批量传输的端点中断在每包数据到达时触发如果每包 64 字节传 1KB 数据要触发 16 次中断。可以把端点包长设成 512 字节但 F103 的 PMA 不够。折中方案是把 MSC 的块大小设成 512 字节但端点包长保持 64 字节主机发来的数据分 8 包到达设备在中断里攒够 512 字节再写 Flash。这样中断次数不变但 Flash 写入次数减少整体速度会快一些。验证速度的方法很简单在电脑上复制一个 1MB 的文件到 U 盘用秒表计时或者用 CrystalDiskMark 跑一下。F103 的 USB 全速批量传输读速度通常能到 300KB/s 左右写速度 100KB/s 左右。如果低于这个值检查 SPI 时钟是否设到了 18MHzFlash 是否支持页写入以及中断里有没有阻塞操作。我自己的习惯是每次改完描述符或端点配置先用 USB 分析仪抓一次完整枚举过程确认主机读到的描述符和代码里定义的一致再开始调业务逻辑。这个习惯帮我省了很多“玄学”调试时间——很多时候问题不在代码逻辑而在描述符的一个字节填错了。希望帮到你。本文还有配套的精品资源点击获取