STM32F103实现USB复合设备:自定义HID+MSC双接口详解
简介这是基于STM32F103C8T6的USB复合设备工程源码实现自定义HID设备与MSC大容量存储功能在同一设备上协同工作。设计上将键盘、鼠标分别绑定到独立接口避免部分主机在启动枚举阶段不请求报告描述符导致键鼠无法识别的问题四个端点分工明确EP0负责枚举EP1、EP2分别承载键盘与鼠标EP3支持MSC存储并配有官方演示版与FAT16文件系统版本默认采用FAT16。鼠标同时支持绝对定位与相对定位两种模式便于不同场景使用。整个压缩包共1420个文件大小22.53MB主体为854个C源码文件与274个头文件另有工程配置、链接脚本、库文件等结构清晰方便直接导入编译或按需修改。这一复合方案不只限于当前芯片也可迁移到F1、F4、F0等系列组合HID与MSC、CDC与MSC、HID与CDC甚至三重复合设备。目前已有839人学习尤其适合USB协议栈学习者与嵌入式开发工程师参考。1. CUSTOMHIDMSC复合设备一块F103凭什么同时当U盘和自定义HIDUSB_CUSTOMHIDMSC这个工程解决的是一个很实在的问题一块STM32F103C8T6只有一个USB口却想让上位机既能像U盘一样直接读写存储介质又能用自定义HID协议做低延迟的双向数据通信。我拆这个工程时先确认了结论F103的标准USB设备库完全支持这种复合设备但描述符和接口分发逻辑必须自己动手改不能拿官方HID例程和MSC例程简单拼凑。这个工程适合两类人一类是做采集设备、加密狗、配置工具这类需要“存储通信”双功能的嵌入式开发另一类是刚接触USB协议栈、想搞清楚Windows到底怎么识别多接口设备的初学者。2. 整体架构一个设备描述符如何装下两个接口2.1 复合设备的定义为什么bDeviceClass必须写成0xEF先厘清概念USB复合设备Composite Device和USB组合设备Compound Device是两回事。复合设备是同一个芯片、同一个USB地址下挂多个接口每个接口独立成为一个功能组合设备则是多个USB控制器通过内部集线器组合成一个物理设备。这个工程属于前者F103只有一个USB设备控制器但枚举出来有两个功能接口分别是HID和MSC。Windows对复合设备的识别有个硬性条件设备描述符里的bDeviceClass必须写成0xEFbDeviceSubClass写成0x02bDeviceProtocol写成0x01。看到这三个值Windows会调用usbccgp.sysUSB Generic Parent Driver接管设备再根据IADInterface Association Descriptor接口关联描述符把每个接口拆出来绑定到各自的功能驱动。如果你仍然写0x00Windows会把整个设备当单一功能设备处理两个接口抢同一个驱动结果通常是HID正常、MSC挂不上或者反过来。/* usb_desc.c —— 设备描述符 */ const uint8_t DeviceDescriptor[] { 0x12, /* bLength */ 0x01, /* bDescriptorType */ 0x00, 0x02, /* bcdUSB 2.00 */ 0xEF, /* bDeviceClass */ 0x02, /* bDeviceSubClass */ 0x01, /* bDeviceProtocol */ 0x40, /* bMaxPacketSize0 */ 0xXX, 0xXX, /* idVendor */ 0xXX, 0xXX, /* idProduct */ 0x00, 0x01, /* bcdDevice */ 0x01, /* iManufacturer */ 0x02, /* iProduct */ 0x03, /* iSerialNumber */ 0x02 /* bNumConfigurations */ };这段描述符里bMaxPacketSize0 0x40即64字节是全速USB设备控制端点的最大包长。bNumConfigurations 0x02这里其实只用一个配置写1就行写2也没有第二个配置描述符供主机请求反而会多一次无谓的枚举请求。idVendor和idProduct在开发阶段可以用任意值但量产时必须换成自己申请的VID/PID不然和别的设备撞了会导致驱动冲突。2.2 工程目录标准USB库中真正需要改的四个文件STM32F103C8T6用的是内置USB设备控制器配套的USB Device库就是标准外设库里那个STM32_USB-FS-Device_Driver提供了完整的协议栈。工程目录大致长这样USB_CUSTOMHID_MSC/ ├── User/ │ ├── main.c // 主循环 外设初始化 │ ├── stm32f10x_it.c // 中断向量表 │ └── usb_conf.h // USB配置端点地址、缓冲区分配 ├── USB/ │ ├── usb_desc.c // 设备/配置/字符串/报告描述符 │ ├── usb_desc.h │ ├── usb_prop.c // 标准请求和类请求的分发 │ ├── usb_prop.h │ ├── usb_endp.c // 非控制端点的数据收发 │ ├── usb_istr.c // USB总中断入口 │ ├── usb_pwr.c // 挂起/唤醒电源管理 │ ├── usb_scsi.c // SCSI命令实现 │ ├── usb_bot.c // BOT传输状态机 │ └── usb_mem.c // 数据拷贝到Packet Buffer ├── Libraries/ │ ├── CMSIS/ │ ├── STM32F10x_StdPeriph_Driver/ │ └── STM32_USB-FS-Device_Driver/ └── MDK-ARM/ └── USB_CUSTOMHID_MSC.uvproj别看它文件多真正需要手改的只有四个usb_desc.c管描述符usb_prop.c管请求分发usb_endp.c管端点收发usb_conf.h管端点和缓冲区规划。usb_bot.c和usb_scsi.c是MSC部分的业务逻辑如果要换存储介质、调命令集改这里。其余文件大部分是标准库原封不动的实现不用碰。这个工程的核心思路是在官方USB库的HID和MSC两个例程基础上做合并。合并的难点不在代码而在状态机的耦合控制端点的标准请求处理被usb_prop.c统一接管类请求又要按接口号分别派发给HID和MSC的处理例程。pInformation-USBwIndex这个字段是关键它保存了SETUP包里的接口号。2.3 描述符布局从配置描述符到端点的完整拓扑配置描述符是USB枚举时最长、最容易写错的数据块。复合设备的配置描述符必须严格按这个顺序排列配置描述符 → IAD → HID接口描述符 → HID描述符 → 端点描述符 → 端点描述符 → IAD → MSC接口描述符 → 端点描述符 → 端点描述符。顺序反了或者漏了IADWindows枚举直接报错。/* usb_desc.c —— 配置描述符 */ const uint8_t CustomHID_MSC_ConfigDescriptor[] { /* 1. Configuration Descriptor */ 0x09, 0x02, 0x50, 0x00, 0x02, 0x01, 0x00, 0xA0, 0x32, /* 2. IAD 1: 关联HID接口 */ 0x08, 0x0B, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, /* 3. Interface 0: HID */ 0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00, /* 4. HID Descriptor */ 0x09, 0x21, 0x10, 0x01, 0x00, 0x01, 0x22, 0x22, 0x00, /* 5. Endpoint: 0x81 IN Interrupt */ 0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x10, /* 6. Endpoint: 0x01 OUT Interrupt */ 0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x10, /* 7. IAD 2: 关联MSC接口 */ 0x08, 0x0B, 0x01, 0x01, 0x08, 0x06, 0x50, 0x00, /* 8. Interface 1: Mass Storage */ 0x09, 0x04, 0x01, 0x00, 0x02, 0x08, 0x06, 0x50, 0x00, /* 9. Endpoint: 0x82 IN Bulk */ 0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00, /* 10. Endpoint: 0x02 OUT Bulk */ 0x07, 0x05, 0x02, 0x02, 0x40, 0x00, 0x00, };逐字节拆解一下配置描述符开头0x50 0x00是总长度低字节在前等于80字节。数一数后面每一项的长度9 8 9 9 7 7 8 9 7 7 80正好对上。0x02是接口数量HID和MSC各占一个。0xA0表示总线供电且支持远程唤醒0x32是最大电流100mA单位是2mA倍数。IAD的作用是把连续的接口“打包”成一个功能。第一个IAD的bFirstInterface 0x00、bInterfaceCount 0x01HID接口号是0第二个IAD的bFirstInterface 0x01对应MSC接口号1。bFunctionClass、bFunctionSubClass、bFunctionProtocol在IAD里的值必须和接口描述符一致0x08, 0x06, 0x50分别代表Mass Storage类、SCSI指令集、Bulk-Only传输协议。3. 核心实现USB中断、HID报告和SCSI指令的配合3.1 端点分配8个端点如何规划给HID和MSCF103的USB控制器有8个双向端点每个端点有独立的IN和OUT方向。这个工程占用的是EP0、EP1、EP2三个端点号规划如下端点方向用途传输类型最大包长EP0双向控制传输/枚举Control64BEP1 ININHID数据上行到PCInterrupt64BEP1 OUTOUTHID数据下行到设备Interrupt64BEP2 ININMSC批量数据上行Bulk64BEP2 OUTOUTMSC批量数据下行Bulk64B为什么HID用EP1、MSC用EP2其实没有硬件强制只要描述符里写的端点地址和实际初始化一致就行。但端点地址必须唯一两个接口不能都用0x81端点。HID适合用中断端点优点是延迟固定适合小包实时通信MSC用批量端点吞吐高但延迟不可控适合大数据块传输。usb_conf.h里还要给每个端点分配Packet Buffer地址这是F103 USB特别容易踩坑的地方/* usb_conf.h —— USB内嵌缓冲区分区 */ #define ENDP0_RX_ADDR 0x18 #define ENDP0_TX_ADDR 0x58 #define ENDP1_RX_ADDR 0x98 /* HID OUT 缓冲 */ #define ENDP1_TX_ADDR 0xD8 /* HID IN 缓冲 */ #define ENDP2_RX_ADDR 0x118 /* MSC OUT 缓冲 */ #define ENDP2_TX_ADDR 0x158 /* MSC IN 缓冲 */地址之间间隔0x40也就是64字节正好是一个端点的最大包长。USB缓冲区是专用SRAM按32位对齐处理地址写重叠了不会报编译错误但运行到端点收发时数据会互相覆盖表现出来的症状非常玄学HID收一个包MSC的返回数据就被改了。3.2 自定义HID报告描述符与64字节双向传输自定义HID和标准键盘鼠标的区别在报告描述符。键盘鼠标用的是系统分配好的Usage Page0x01自定义HID用厂商自定义的Page0xFF00这样系统不会抢占输入焦点也不会误触发快捷键。/* usb_desc.c —— 自定义HID报告描述符 */ const uint8_t CustomHID_ReportDescriptor[] { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 0x09, 0x01, // Usage (0x01) 0xA1, 0x01, // Collection (Application) 0x09, 0x02, // Usage (0x02) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x40, // Report Count (64) 0x81, 0x02, // Input (Data, Var, Abs) 0x09, 0x03, // Usage (0x03) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x40, // Report Count (64) 0x91, 0x02, // Output (Data, Var, Abs) 0xC0 // End Collection };这个报告描述符定义了输入和输出两种报告各64字节。Input对应设备发给PC的数据走EP1 INOutput对应PC发给设备的数据走EP1 OUT。Logical Maximum设成255意味着每个字节的取值范围是0~255不做缩放。如果你想加报告ID在Usage Page之后插一个0x85, 0x01但注意报告里第一个字节就要变成报告ID有效数据只剩63字节。HID的发送函数是这个工程里最短也是最容易用错的代码/* usb_endp.c —— HID发送函数 */ void CustomHID_SendPacket(uint8_t *buf, uint16_t len) { if (len 64) len 64; UserToPMABufferCopy(buf, ENDP1_TX_ADDR, len); SetEPTxCount(ENDP1, len); SetEPTxStatus(ENDP1, EP_TX_VALID); }UserToPMABufferCopy把用户数据拷贝到USB硬件缓冲区SetEPTxCount设置本次传输长度SetEPTxStatus把端点状态改成VALIDUSB控制器自动把数据发出去。调用前一定要先查状态如果上一次数据还没发完再次调用CustomHID_SendPacket会覆盖缓冲导致上一包丢失。标准做法是用GetEPTxStatus(ENDP1)判断当前是不是EP_TX_NAK是NAK状态才能写入新数据。3.3 MSC的BOT协议从CBW到SCSI指令再到CSWMSC部分是整个工程逻辑最重的模块。BOTBulk-Only Transport协议是串行化的三幕剧PC先发一个31字节的CBWCommand Block Wrapper命令块设备解析里头的SCSI指令执行完再回一个13字节的CSWCommand Status Wrapper状态块。数据结构长这样/* usb_bot.c —— CBW与CSW结构体 */ typedef struct { uint32_t dCBWSignature; /* 固定0x43425355即USBC */ uint32_t dCBWTag; /* 命令标签回CSW时必须原样返回 */ uint32_t dCBWDataTransferLength; /* 期望传输的数据字节数 */ uint8_t bmCBWFlags; /* 0x00主机到设备0x80设备到主机 */ uint8_t bCBWLUN; uint8_t bCBWCBLength; /* CBWCB数组有效长度 */ uint8_t CBWCB[16]; /* SCSI命令块 */ } CBW_t; typedef struct { uint32_t dCSWSignature; /* 固定0x53425355即USBS */ uint32_t dCSWTag; uint32_t dCSWDataResidue; /* 未传输的字节数 */ uint8_t bCSWStatus; /* 0成功1失败2相位错误 */ } CSW_t;dCSWTag必须回显CBW里的值这是PC用来匹配请求的关键。dCSWDataResidue表示没传完的数据长度如果主机要读2个扇区设备只回了1个residue就是512。bCSWStatus是三个值的决定点正常为0命令执行失败为1数据阶段传输方向和CBW里声明的不一致则为2。SCSI指令解析放在usb_scsi.c里一个U盘能被Windows挂载只需要实现下面这些命令SCSI指令操作码作用TEST_UNIT_READY0x00查询设备是否就绪INQUIRY0x12返回厂商、产品名、版本号MODE_SENSE_60x1A返回介质写保护状态等参数READ_CAPACITY0x25返回总扇区数和扇区大小READ_100x28读取指定LBA开始的若干扇区WRITE_100x2A写入指定LBA开始的若干扇区START_STOP_UNIT0x1B启动/停止介质核心是READ_10和WRITE_10这两个命令用LBA逻辑块地址和TransferLength字段描述读写范围每个块固定512字节/* usb_scsi.c —— READ_10处理 */ static int32_t SCSI_Read10(uint8_t *buf, uint32_t lba, uint16_t transfer_len) { const uint32_t sector_size 512; if (lba transfer_len TOTAL_SECTORS) { return -1; /* 越界直接失败 */ } for (uint16_t i 0; i transfer_len; i) { SPI_FLASH_ReadBuffer(buf i * sector_size, (lba i) * sector_size, sector_size); } return 0; }LBA到Flash地址的换算很简单lba * 512就是字节偏移。但要注意transfer_len不能盲目信任PC端可能会一次请求256个扇区128KB而缓冲区只有64或128字节这时候要做拆包按端点缓冲上限分多次发送。越界检查也不能省否则恶意构造的CBW可能让单片机访问非法地址直接进HardFault。3.4 存储介质映射SPI Flash与内部Flash的取舍U盘数据最终要落到存储介质上。工程里通过USE_SPI_FLASH宏切换两种介质抽象层接口保持一致/* 存储介质抽象层 */ #ifdef USE_SPI_FLASH /* 外挂W25Q64 SPI NOR Flash8MB */ #define MEDIA_Init SPI_FLASH_Init #define MEDIA_ReadSector SPI_FLASH_ReadBuffer #define MEDIA_WriteSector SPI_FLASH_WriteBuffer #define MEDIA_EraseBlock SPI_FLASH_EraseSector #define TOTAL_SECTORS 16384 /* 8MB / 512B */ #else /* 使用内部Flash最后16KB */ #define MEDIA_Init IntFlash_Init #define MEDIA_ReadSector IntFlash_ReadSector #define MEDIA_WriteSector IntFlash_WriteSector #define MEDIA_EraseBlock IntFlash_ErasePage #define TOTAL_SECTORS 32 /* 16KB / 512B */ #endifSPI NOR Flash的写入有块擦除限制W25Q系列按4KB扇区擦除。写一个512字节扇区时内部流程是先把整个4KB扇区读进RAM改掉其中512字节再整块擦除写回。这让WRITE_10的速度明显比读取慢连续写1MB数据时尤其明显这不是代码bug是NOR Flash的固有属性。如果你要做日志型写入频繁的场景内部Flash方案反而不适合频繁擦写寿命撑不住SPI Flash的擦写次数普遍在10万次以上更耐造。4. 编译烧录与运行验证从点亮板子到电脑认出HIDU盘4.1 工程配置芯片选择、时钟与编译选项工程用Keil MDK打开后第一件事是确认芯片型号是STM32F103C8T6对应STM32F10X_MD中等容量宏。C8T6是64KB Flash、20KB RAM对于USB复合设备这种代码量Flash稍紧张编译时开-O2优化可以把代码缩到50KB以内。时钟配置是整个USB能否工作的前提。F103的USB控制器需要48MHz时钟这个48MHz来自PLL。用8MHz外部晶振的话PLL倍频9倍到72MHz再1.5分频得到48MHz。如果工程里用的是内部RC振荡器USB会因为时钟精度不够而枚举不稳定强烈建议用外部8MHz晶振。/* system_stm32f10x.c 或 main.c 中的时钟配置 */ RCC_PLLConfig(RCC_PLLSource_PLLCLK, 9, 1); /* PLL72MHz */ RCC_USBCLKConfig(RCC_USBCLKSource_PLLCLK_1Div5); /* 72/1.548MHz */这两行配置缺一不可。RCC_PLLConfig的第一个参数选择PLL时钟源第二个是倍频系数9RCC_USBCLKConfig选择1.5分频。如果USB时钟不是精确的48MHz设备枚举会时好时坏表现为有时插上识别正常、有时报设备描述符请求失败治这种问题先别怀疑代码用示波器量一下USB_D上是否稳定。4.2 烧录与枚举第一次插上时Windows的提示用ST-Link V2连接SWD接口Keil里配置好下载算法后就能烧录。烧录完拔掉ST-Link用USB线连接F103板子上的USB口到电脑。第一次插入时Windows右下角会弹出“正在安装设备驱动程序”的提示然后设备管理器里会出现两个条目一个“USB输入设备”HID部分一个“磁盘驱动器”MSC部分。注意观察磁盘驱动器是否显示正确容量——SPI Flash方案显示8MB内部Flash方案显示16KB。如果设备管理器里出现的是“未知USB设备设备描述符请求失败”说明枚举没走通。先用USB树状查看器比如USBTreeView抓取设备返回的原始描述符重点看配置描述符的长度字段是否和实际返回一致。这一步比看任何调试日志都高效因为USB协议栈在枚举失败时往往根本跑不到应用代码。4.3 联调测试验证HID和MSC都工作正常HID通信验证可以用Python的pywinusb库写一个几十行的脚本就能收发数据import pywinusb.hid as hid def read_handler(data): print(收到设备数据:, bytes(data)) # VID/PID 需要和 usb_desc.c 里一致 filter hid.HidDeviceFilter(vendor_id0x1234, product_id0x5678) device filter.get_devices()[0] device.open() device.set_raw_data_handler(read_handler) device.send_output_report(bytes(range(64))) # 发送 64 字节 Output input(按回车退出...) device.close()这段脚本会枚举VID0x1234、PID0x5678的HID设备发送一个0x00到0x3F的递增序列。如果设备端把数据原样回显到Input报告终端里就能看到同样的序列说明HID双向通路是通的。MSC验证更直观在“此电脑”里看到新磁盘后右键格式化选FAT32然后拷贝一个文件进去拔掉USB再重新插上文件还在且能正常打开。更严谨一点拷入文件后用校验工具算一下MD5拔插后再算一次两个值一致说明写入没有静默错误。格式化本身就是在做全盘读写测试如果格式化能顺利完成BOT协议栈和存储介质基本是健康的。5. 避坑与排查复合设备最常踩的五个坑5.1 枚举阶段的三类翻车现场现象一设备管理器报“设备描述符请求失败”设备始终在未知设备状态。原因九成是描述符数据错误排名第一的是配置描述符总长度字段和对不齐。比如在2.3节末尾那80字节的描述符如果0x50 0x00写成了0x3F 0x00主机请求描述符时收到的长度与设备实际返回的长度不一致Windows直接放弃枚举。解决用USB树状查看器抓取设备返回的原始描述符拿原始字节和源码里的数组逐字节比对。没有抓包工具就用Bus Hound枚举阶段的GET_DESCRIPTOR请求记录都能看到。现象二HID能识别但磁盘驱动器出不来设备管理器里多一个带感叹号的“大容量存储设备”。原因IAD的bFirstInterface和接口描述符的bInterfaceNumber对不上。比如IAD里写了bFirstInterface 0x00但MSC接口号实际是1Windows按IAD去找接口0对应的接口描述符找错对象后驱动加载失败。解决把IAD的bFirstInterface改成和MSC接口描述符的bInterfaceNumber一致这是复合设备最容易忽略的关联关系。现象三插上USB后电脑提示“无法识别的USB设备”但用示波器看D引脚波形正常。原因USB时钟频率不是精确的48MHz。F103的USB控制器对时钟精度要求较高如果系统用的是内部RC而不是外部晶振即使PLL配置看起来正确实际频率偏差也可能超过USB规范允许的范围。解决用外部8MHz晶振确认RCC_USBCLKConfig配置为PLL 1.5分频。改了晶振后要同时检查SystemInit里的RCC_PLLConfig倍频系数8MHz乘以9等于72MHz这个是标准值。5.2 读写阶段的两个隐蔽问题现象四U盘能格式化但拷贝大文件时中途报错小文件没问题。原因BOT状态机对数据阶段长度的处理有缺陷。READ_10或WRITE_10如果返回的数据长度和CBW里声明的dCBWDataTransferLength不一致CSW的dCSWDataResidue就要正确计算写错的话Windows会认为数据传输“意外结束”。解决检查每个SCSI命令执行完后的CSW回包确认dCSWDataResidue dCBWDataTransferLength - 实际传输字节数如果实际传输的比声明少residue必须非零否则Windows判定为协议错误。现象五HID发送函数调用后上位机偶尔收不到数据但重新上电后又好了。原因CustomHID_SendPacket在上一次数据还没发完时被再次调用数据覆盖。这个问题在中断里跑发送逻辑时特别常见主循环在不断调用发送函数而USB中断还没完成上一次EP1的传输。解决发送前检查端点状态GetEPTxStatus(ENDP1)返回EP_TX_NAK时才允许写入新数据。或者用一个发送忙标志位在IN端点发送完成中断里清掉这样既不会丢失数据也不会阻塞主循环。6. 二次开发HID协议定制与MSC介质替换改HID协议先改报告描述符。比如你要加一个报告ID在Usage Page之后插入0x85, 0x01PC端发送的Output报告第一个字节就会变成报告ID有效数据从第二个字节开始。这个工程里设备端收到数据后会把pBuffer[0]当作普通数据填充要改成按报告ID分发到不同业务逻辑。想加Feature报告的话在报告描述符里多定义一个字段用0xB1Feature替换0x81然后配置一个方向对应的端点。换MSC存储介质最省事的做法是保持MEDIA_Init/ReadSector/WriteSector/EraseBlock四个函数签名不变内部换成新介质的驱动。如果换成SD卡注意SD卡需要先SPI模式初始化4GB容量对应TOTAL_SECTORS 8388608要把READ_CAPACITY返回值同步改掉否则Windows识别成错误容量的磁盘。加双LUN也不难让GET_MAX_LUN返回1然后bCBWLUN字段区分LUN 0和LUN 1可以做到一个LUN当只读配置盘、另一个LUN当可写数据盘。我自己改这类复合设备项目有一套固定的验证流程每次改完描述符先用USB树状查看器确认枚举字节流再跑一遍HID回环测试最后对MSC分区格式化并做一次完整文件写入校验。三步都通过才敢说这个改动是稳的。之前吃过一次亏只改了报告长度没改描述符里的Report Count结果Windows枚举HID接口时报描述符校验错误整个设备都识别不了又得抓包看原始描述符才定位到问题。从那以后我每次改USB描述符都强制走一遍这三板斧。希望帮到你。本文还有配套的精品资源点击获取