首页 / 资讯中心 / 文章详情

GD32E508 WinUsb免驱开发实战:从工程搭建到高速数据采集改造

GD32E508 WinUsb免驱开发实战:从工程搭建到高速数据采集改造 ★ FEATURED ARTICLE
简介这份工程资源面向使用GD32E508单片机开发USB高速设备的嵌入式工程师重点解决Windows下免驱通信的实现问题。GD32E508基于ARM Cortex-M33内核内置USB OTG支持USB 2.0全速与高速模式配合WinUSB驱动模型可让用户态程序直接与设备通信省去专用驱动开发。资源包共295个文件约18.07MB以115个h头文件与92个c源文件为核心涵盖USB设备类驱动、描述符配置、端点管理与批量传输等模块另含23个d依赖文件、23个o目标文件、8个s汇编文件及uvprojx、ewp等工程文件并附4份pdf与4份txt说明文档便于直接编译与移植。内容涉及设备描述符编写、IO控制请求处理、状态管理与错误恢复以及Windows端WinUsb_Initialize、WinUsb_ControlTransfer等API调用可帮助读者掌握从固件到上位机的完整免驱通信链路。目前已有626人学习下载适合具备一定USB协议基础、希望快速搭建GD32E508 HS Device免驱工程的开发者参考。1. 从一根 USB 线说起GD32E508 上跑 WinUsb 免驱到底省了多少事前阵子帮一个做数据采集的朋友调板子他用的主控是 GD32E508上位机要实时收 8 通道 ADC 采样值。最开始走的是传统 CDC 串口方案结果现场一插上就翻车——Windows 认不出设备得让客户自己装驱动、手动选 COM 口售后电话被打爆。后来换成 WinUsb 免驱方案插上就能用上位机用现成的 WinUSB API 直接读写一行驱动都不用装。这个 GD32E508 HS Device 工程就是干这件事的把 GD32E508 的高速 USB 设备控制器配置成 WinUsb 兼容设备通过微软操作系统自带的 WinUSB 驱动实现免驱通信。它适合正在用 GD32 系列做 USB 数据采集、HID 替代方案或者自定义高速传输的嵌入式开发者尤其是那些被 CDC 驱动安装问题折磨过的单片机工程师。整个工程基于 GD32E508 的 HS USB 外设配合标准 USB 描述符和微软 OS 描述符让设备在 Windows 下自动被识别为 WinUSB 设备不需要 INF 文件不需要签名插上就能跑。2. 工程架构拆解从 USB 外设初始化到 WinUsb 描述符的完整链路2.1 硬件层GD32E508 的 HS USB 外设怎么配GD32E508 内部集成了一个高速 USB OTG 控制器支持 Device 模式和 Host 模式。这个工程用的是 Device 模式走的是内部 HS PHY不需要外挂 ULPI 芯片。初始化的时候要先把 USB 时钟源打开GD32E508 的 USB HS 时钟一般来自 PLL需要确认系统时钟树里 USBHS 的时钟频率是 60MHz 还是 48MHz这个直接影响枚举能不能成功。/* usb_hs_init.c - GD32E508 HS USB 设备初始化 */ #include gd32e50x.h #include usb_hs_device.h void usb_hs_gpio_config(void) { /* USB HS 使用内部 PHYDM/DP 引脚配置为复用功能 */ rcu_periph_clock_enable(RCU_GPIOB); gpio_mode_set(GPIOB, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_14 | GPIO_PIN_15); gpio_output_options_set(GPIOB, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_14 | GPIO_PIN_15); gpio_af_set(GPIOB, GPIO_AF_12, GPIO_PIN_14 | GPIO_PIN_15); } void usb_hs_clock_config(void) { /* USB HS 需要 60MHz 时钟来自 PLL 分频 */ rcu_usbhs_clock_config(RCU_USBHS_CK_PLL); rcu_periph_clock_enable(RCU_USBHS); } void usb_hs_device_init(void) { usb_hs_gpio_config(); usb_hs_clock_config(); /* 复位 USB HS 内核 */ usbhs_deinit(); usbhs_core_init(hs_core_handle); /* 配置设备模式使用内部 FIFO */ usbhs_set_mode(USBHS_MODE_DEVICE); usbhs_device_init(hs_device_handle); /* 使能 USB HS 中断 */ nvic_irq_enable(USBHS_IRQn, 2, 0); }这段代码里usb_hs_gpio_config负责把 PB14 和 PB15 配成 USB 的 DM/DP 复用功能注意 GD32E508 的 USB HS 引脚复用编号是 AF12不是 AF10这个在数据手册里要翻到引脚复用表确认。usb_hs_clock_config里RCU_USBHS_CK_PLL表示 USB HS 时钟直接来自 PLL不需要额外分频但前提是 PLL 配置里已经给 USBHS 留了 60MHz 的时钟源。usb_hs_device_init里先复位内核再初始化顺序不能反否则 FIFO 分配会出错。中断优先级设成 2 是为了保证 USB 传输不被其他低速中断打断尤其是做高速批量传输的时候。2.2 描述符层WinUsb 免驱的关键——微软 OS 描述符普通 USB 设备要让 Windows 自动加载 WinUSB 驱动光有标准设备描述符和配置描述符是不够的还得在配置描述符后面挂一个微软自定义的 OS 描述符。这个描述符告诉 Windows“我是 WinUSB 设备请用 winusb.sys 来驱动我。”没有这个描述符Windows 会去匹配 INF 文件匹配不到就显示黄色感叹号。/* usb_descriptor.c - WinUsb 兼容描述符定义 */ /* 微软 OS 字符串描述符索引 0xEE */ const uint8_t ms_os_string_desc[] { 0x12, /* bLength: 18 字节 */ 0x03, /* bDescriptorType: STRING */ M, 0x00, S, 0x00, F, 0x00, T, 0x00, 1, 0x00, 0, 0x00, 0, 0x00, 0x00 /* bVendorCode: 0x00 表示不支持扩展描述符请求 */ }; /* 配置描述符集合包含 WinUsb 兼容 ID 特性描述符 */ const uint8_t config_descriptor_set[] { /* 标准配置描述符 */ 0x09, 0x02, 0x20, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32, /* 接口描述符 */ 0x09, 0x04, 0x00, 0x00, 0x02, 0xFF, 0x00, 0x00, 0x00, /* 端点 OUT 描述符 */ 0x07, 0x05, 0x01, 0x02, 0x00, 0x02, 0x00, /* 端点 IN 描述符 */ 0x07, 0x05, 0x81, 0x02, 0x00, 0x02, 0x00, /* WinUsb 兼容 ID 特性描述符 */ 0x08, 0x0B, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00 };ms_os_string_desc是微软要求的 OS 字符串描述符索引必须是 0xEE内容固定为 “MSFT100”最后的bVendorCode设为 0x00 表示设备不支持通过控制传输获取扩展描述符这样 Windows 会直接使用兼容 ID 来匹配驱动。config_descriptor_set里接口的bInterfaceClass设成 0xFF厂商自定义bInterfaceSubClass和bInterfaceProtocol都设成 0x00这样 Windows 不会把它当成 HID 或者 CDC 设备。最后的 WinUsb 兼容 ID 特性描述符是关键0x08, 0x0B表示这是一个特性描述符后面跟着的0x01表示兼容 ID 数量为 1再后面的0x00, 0x00, 0x00, 0x00是兼容 ID 的具体内容通常填 0 表示使用默认的 WinUSB 驱动。2.3 端点层批量传输端点的配置与 FIFO 分配WinUsb 免驱设备通常用批量传输端点来做数据通信因为批量传输不占带宽、有重传机制适合大数据量可靠传输。GD32E508 的 HS USB 内核有专门的 FIFO 控制器每个端点要分配独立的 FIFO 空间。/* usb_endpoint.c - 端点初始化与 FIFO 分配 */ #define EP_OUT_NUM 1 #define EP_IN_NUM 1 #define EP_OUT_FIFO_SIZE 512 #define EP_IN_FIFO_SIZE 512 void usb_endpoint_init(void) { /* 分配 FIFO 空间注意 HS 模式下 FIFO 深度以 32 位字为单位 */ usbhs_fifo_config(EP_OUT_NUM, EP_OUT_FIFO_SIZE / 4); usbhs_fifo_config(EP_IN_NUM, EP_IN_FIFO_SIZE / 4); /* 配置 OUT 端点 1 为批量传输 */ usbhs_ep_config(EP_OUT_NUM, USBHS_EP_TYPE_BULK, USBHS_EP_DIR_OUT); usbhs_ep_enable(EP_OUT_NUM); /* 配置 IN 端点 1 为批量传输 */ usbhs_ep_config(EP_IN_NUM, USBHS_EP_TYPE_BULK, USBHS_EP_DIR_IN); usbhs_ep_enable(EP_IN_NUM); }usbhs_fifo_config的第二个参数是 FIFO 深度单位是 32 位字所以 512 字节要除以 4 得到 128。这里 OUT 和 IN 各分配 512 字节总共 1KBGD32E508 的 HS USB 内核总 FIFO 是 4KB够用。usbhs_ep_config里USBHS_EP_TYPE_BULK表示批量端点USBHS_EP_DIR_OUT和USBHS_EP_DIR_IN分别对应主机到设备和设备到主机的方向。端点使能之后主机枚举时读到配置描述符里的端点信息就会知道这个设备有两个批量端点可用。2.4 中断处理USB 事件与数据传输的响应逻辑USB 通信是主机主导的设备端靠中断来响应各种事件。GD32E508 的 USB HS 中断里要处理枚举完成、端点传输完成、错误恢复等事件。/* usb_isr.c - USB HS 中断服务函数 */ void USBHS_IRQHandler(void) { uint32_t intr usbhs_intr_get(); /* 枚举完成事件 */ if (intr USBHS_INT_ENUMFIN) { usbhs_intr_clear(USBHS_INT_ENUMFIN); /* 枚举完成后重新配置端点 */ usb_endpoint_init(); } /* OUT 端点接收完成 */ if (intr USBHS_INT_EPOUT) { uint32_t ep usbhs_epout_intr_get(); if (ep (1 EP_OUT_NUM)) { usbhs_epout_intr_clear(EP_OUT_NUM); /* 读取接收到的数据 */ uint32_t len usbhs_epout_read(EP_OUT_NUM, rx_buffer, sizeof(rx_buffer)); /* 处理数据比如回显或者解析命令 */ usb_data_process(rx_buffer, len); } } /* IN 端点发送完成 */ if (intr USBHS_INT_EPIN) { uint32_t ep usbhs_epin_intr_get(); if (ep (1 EP_IN_NUM)) { usbhs_epin_intr_clear(EP_IN_NUM); /* 发送完成可以准备下一包数据 */ usb_tx_ready 1; } } }中断里先读全局中断标志然后分别判断枚举完成、OUT 端点中断和 IN 端点中断。枚举完成事件里重新调用usb_endpoint_init是因为 USB 复位后端点配置会丢失必须在枚举完成后重新配一遍。OUT 端点中断里用usbhs_epout_read把数据从 FIFO 读到缓冲区然后交给usb_data_process处理。IN 端点中断里只是置一个标志位表示上一包数据已经发完主循环里可以准备下一包了。注意中断里不要做太耗时的操作比如浮点运算或者大块内存拷贝否则会影响 USB 响应速度。3. 跑通工程从零搭建 GD32E508 WinUsb 开发环境3.1 工具链准备与工程导入这个工程可以用 Keil MDK 或者 IAR 打开也可以用 GCC Makefile 的方式编译。我一般用 Keil MDK因为 GD32 的官方固件库对 Keil 支持最好调试器用 J-Link 或者 GD-Link 都行。需要准备的软件环境包括Keil MDK 5.30 以上版本、GD32E50x 固件库、对应的器件支持包。固件库可以从 GD32 官网下载安装完之后在 Keil 里新建工程选择 GD32E508 型号然后把工程里的源文件按目录结构加进去。工程目录结构大概是这样的GD32E508_WinUsb/ ├── CMSIS/ # 内核支持文件 ├── GD32E50x_standard_peripheral/ # 标准外设库 ├── USBHS/ # USB HS 驱动 │ ├── usb_hs_core.c │ ├── usb_hs_device.c │ └── usb_hs_endpoint.c ├── User/ │ ├── main.c │ ├── usb_descriptor.c │ └── usb_isr.c └── Project/ └── GD32E508_WinUsb.uvprojx导入的时候注意把USBHS目录和User目录都加到 include path 里否则编译会报找不到头文件。如果用的是 GCCMakefile 里要加上-I参数指定头文件路径链接脚本用 GD32E508 对应的 flash 和 ram 地址。3.2 编译、烧录与设备枚举验证编译之前先确认几个宏定义USE_USBHS要打开USBHS_DEVICE_MODE要定义系统时钟配置里SYSCLK要跑到 200MHz 以上保证 USB HS 有足够的时钟源。编译通过后用调试器烧录到板子上然后拔插一次 USB 线让设备重新枚举。在 Windows 设备管理器里应该能看到一个“WinUsb Device”或者“USB 设备”出现在“通用串行总线设备”下面没有黄色感叹号。如果看到的是“未知 USB 设备设备描述符请求失败”说明枚举过程中描述符返回有问题重点检查ms_os_string_desc的索引是不是 0xEE以及配置描述符集合的总长度是不是和wTotalLength字段一致。验证免驱是否成功可以打开设备管理器右键设备属性看“驱动程序”选项卡里是不是“WinUSB 设备”驱动程序提供商是不是 Microsoft。如果是说明 Windows 已经自动加载了 WinUSB 驱动不需要任何手动安装。3.3 上位机通信测试用 WinUSB API 收发数据设备端跑起来之后上位机可以用现成的 WinUSB API 来通信。下面是一个简单的 C 测试代码用 Visual Studio 编译枚举设备、打开管道、发送和接收数据。// winusb_test.cpp - 上位机 WinUSB 通信测试 #include windows.h #include winusb.h #include setupapi.h #include iostream int main() { // 通过 GUID 找到 WinUSB 设备 GUID guid { 0xA5DCBF10, 0x6530, 0x11D2, { 0x90, 0x1F, 0x00, 0xC0, 0x4F, 0xB9, 0x51, 0xED } }; HDEVINFO devInfo SetupDiGetClassDevs(guid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (devInfo INVALID_HANDLE_VALUE) return -1; // 枚举设备接口打开第一个找到的设备 SP_DEVICE_INTERFACE_DATA ifData { sizeof(ifData) }; SetupDiEnumDeviceInterfaces(devInfo, NULL, guid, 0, ifData); // 获取设备路径并创建文件句柄 DWORD requiredSize 0; SetupDiGetDeviceInterfaceDetail(devInfo, ifData, NULL, 0, requiredSize, NULL); PSP_DEVICE_INTERFACE_DETAIL_DATA detail (PSP_DEVICE_INTERFACE_DETAIL_DATA)malloc(requiredSize); detail-cbSize sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); SetupDiGetDeviceInterfaceDetail(devInfo, ifData, detail, requiredSize, NULL, NULL); HANDLE hDevice CreateFile(detail-DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hDevice INVALID_HANDLE_VALUE) return -1; // 初始化 WinUSB WINUSB_INTERFACE_HANDLE winusbHandle; WinUsb_Initialize(hDevice, winusbHandle); // 获取端点管道信息 USB_INTERFACE_DESCRIPTOR ifDesc; WinUsb_QueryInterfaceSettings(winusbHandle, 0, ifDesc); UCHAR pipeOut 0x01; // OUT 端点地址 UCHAR pipeIn 0x81; // IN 端点地址 // 发送数据 UCHAR sendBuf[] Hello GD32E508; ULONG sent 0; WinUsb_WritePipe(winusbHandle, pipeOut, sendBuf, sizeof(sendBuf), sent, NULL); // 接收数据 UCHAR recvBuf[64] { 0 }; ULONG received 0; WinUsb_ReadPipe(winusbHandle, pipeIn, recvBuf, sizeof(recvBuf), received, NULL); std::cout Received: recvBuf std::endl; WinUsb_Free(winusbHandle); CloseHandle(hDevice); return 0; }这段代码先用SetupDiGetClassDevs枚举所有 WinUSB 设备接口GUID 是 Windows 固定的 WinUSB 设备接口类 GUID。找到设备后创建文件句柄然后WinUsb_Initialize初始化 WinUSB 接口。WinUsb_WritePipe往 OUT 端点写数据WinUsb_ReadPipe从 IN 端点读数据。注意端点地址要和设备端配置的一致OUT 端点一般是 0x01IN 端点一般是 0x81。如果WinUsb_WritePipe返回失败检查一下设备端 OUT 端点 FIFO 是不是满了或者端点有没有正确使能。4. 避坑指南GD32E508 WinUsb 调试中常见的五个坑4.1 枚举失败设备管理器显示“未知 USB 设备”现象是插上 USB 线之后设备管理器里出现一个带黄色感叹号的“未知 USB 设备”属性里显示“设备描述符请求失败”。原因通常是 USB 时钟配置不对GD32E508 的 HS USB 需要精确的 60MHz 时钟如果 PLL 配置里 USBHS 时钟源不是 60MHz或者时钟使能顺序错了枚举就会在第一步读设备描述符的时候失败。解决办法是先用示波器或者逻辑分析仪抓一下 USB 的 DM/DP 信号看有没有正常的枚举波形。如果没有波形检查 GPIO 复用配置和时钟使能如果有波形但枚举失败重点查usb_hs_clock_config里的时钟源选择确保RCU_USBHS_CK_PLL对应的 PLL 输出是 60MHz。4.2 设备能识别但上位机打不开管道现象是设备管理器里能看到 WinUSB 设备驱动也正常但上位机调用WinUsb_Initialize或者WinUsb_WritePipe的时候返回错误。原因一般是端点配置和描述符里的端点地址不匹配比如描述符里写的是端点 1 OUT但代码里配置的是端点 2。还有一种可能是 FIFO 分配不够批量传输的时候数据被截断。解决办法是先用 USB 分析仪抓一下枚举过程确认主机读到的端点描述符和代码里配置的一致。然后检查usbhs_fifo_config分配的 FIFO 大小是不是够一包最大传输单元HS 批量端点的最大包长是 512 字节FIFO 至少要能放下一包。4.3 批量传输速度上不去只有几 MB/s现象是免驱通信能跑通但实测传输速度远低于 USB HS 的理论值 480Mbps。原因通常是中断处理里做了太多事情比如在 USB 中断里直接处理数据协议导致下一包数据来不及接收。或者是 FIFO 分配太小主机发一包设备要分多次读。解决办法是把数据处理从 USB 中断里挪到主循环或者 DMA 里中断里只做数据搬运。另外把 FIFO 尽量分配大一点GD32E508 的 HS USB 总 FIFO 有 4KB可以给批量端点多分一些。如果用了 DMA确认 DMA 通道和 USB 端点的对应关系GD32E508 的 USB HS 有专用的 DMA 通道。4.4 拔插之后设备不再被识别现象是第一次插上能用拔下来再插上就认不到了必须重新上电。原因是 USB 复位后端点配置没有重新初始化或者设备状态机没有正确处理复位事件。解决办法是在 USB 复位中断里把设备状态清空重新调用usb_endpoint_init。另外检查一下usbhs_deinit和usbhs_core_init的调用时机不要在枚举过程中反复复位内核。如果用的是 RTOS注意 USB 中断优先级和任务优先级的配合避免中断被任务阻塞。4.5 上位机发送大数据包时设备端丢数据现象是上位机连续发送几 KB 的数据设备端只能收到前面一部分后面的丢了。原因是 OUT 端点的 FIFO 溢出主机发得太快设备端中断响应不及时。解决办法是启用 USB HS 的 DMA 接收让 DMA 自动把 FIFO 里的数据搬到内存减少 CPU 干预。如果没有 DMA可以在中断里快速读取 FIFO 到环形缓冲区主循环再从环形缓冲区取数据处理。另外上位机发送的时候要控制节奏每发一包等设备端回应或者延时一下不要一次性灌太多。5. 二次开发改造从 WinUsb 工程到自定义高速数据采集设备拿到这个工程之后最直接的改造方向就是把它变成一个自定义的高速数据采集设备。我一般会从三个地方下手描述符定制、端点扩展和数据协议设计。先说描述符定制。工程里默认的 VID 和 PID 是通用的二次开发的时候要改成自己的。在usb_descriptor.c里找到设备描述符把idVendor和idProduct改成自己的编号。注意 WinUsb 免驱不依赖 VID/PID 匹配所以改不改都不影响免驱但改了之后上位机可以用 VID/PID 来精确筛选设备避免打开错误的设备。另外iManufacturer、iProduct和iSerialNumber这三个字符串描述符索引也可以改成自己的产品信息这样设备管理器里显示的名字就是你的产品名。然后是端点扩展。默认工程只配了一对批量端点做数据采集的话通常需要多个 IN 端点来传不同通道的数据或者加一个中断端点来做命令控制。GD32E508 的 HS USB 内核支持最多 6 个端点除了端点 0 之外还可以配 5 个。我一般会这样分配端点 1 IN 传 ADC 采样数据端点 2 IN 传状态信息端点 3 OUT 接收上位机命令端点 4 做中断传输处理紧急事件。每个端点的 FIFO 大小根据数据量来定采样数据量大的话给端点 1 多分一些比如 1KB命令端点 64 字节就够了。/* 多端点配置示例 */ #define EP_ADC_IN 1 #define EP_STATUS_IN 2 #define EP_CMD_OUT 3 #define EP_EVENT_IN 4 void usb_endpoint_init_custom(void) { /* ADC 数据端点1KB FIFO */ usbhs_fifo_config(EP_ADC_IN, 1024 / 4); usbhs_ep_config(EP_ADC_IN, USBHS_EP_TYPE_BULK, USBHS_EP_DIR_IN); usbhs_ep_enable(EP_ADC_IN); /* 状态端点256 字节 FIFO */ usbhs_fifo_config(EP_STATUS_IN, 256 / 4); usbhs_ep_config(EP_STATUS_IN, USBHS_EP_TYPE_BULK, USBHS_EP_DIR_IN); usbhs_ep_enable(EP_STATUS_IN); /* 命令端点64 字节 FIFO */ usbhs_fifo_config(EP_CMD_OUT, 64 / 4); usbhs_ep_config(EP_CMD_OUT, USBHS_EP_TYPE_BULK, USBHS_EP_DIR_OUT); usbhs_ep_enable(EP_CMD_OUT); /* 事件端点中断传输 */ usbhs_fifo_config(EP_EVENT_IN, 64 / 4); usbhs_ep_config(EP_EVENT_IN, USBHS_EP_TYPE_INT, USBHS_EP_DIR_IN); usbhs_ep_enable(EP_EVENT_IN); }这段代码把四个端点都配上了ADC 数据端点用批量传输保证可靠性事件端点用中断传输保证实时性。FIFO 分配上ADC 端点给了 1KB状态端点 256 字节命令和事件各 64 字节总共不到 1.5KBGD32E508 的 4KB FIFO 完全够用。注意 FIFO 分配的总和不能超过内核的 FIFO 总容量否则后面的端点会配置失败。最后是数据协议设计。免驱通信只是解决了物理层和驱动层的问题应用层协议还得自己定。我一般会设计一个简单的帧格式帧头 2 字节、命令字 1 字节、数据长度 2 字节、数据区 N 字节、校验和 1 字节。上位机发命令的时候按这个格式打包设备端解析后执行对应操作。ADC 数据上传的时候也用类似的格式但可以省掉命令字直接用帧头加通道号加数据。协议设计的时候要注意字节对齐和大小端问题GD32 是小端模式上位机如果是 x86 也是小端一般不用转换但如果上位机跑在 ARM 或者网络字节序环境里就要注意了。改造完之后验证方法也很直接先用 USB 分析仪抓包确认枚举过程和端点配置符合预期然后用上位机测试工具发几包数据看设备端能不能正确解析和回应最后跑一个压力测试连续传输几 MB 数据看有没有丢包或者速度下降。我一般会在设备端加一个计数器每收到一包数据就加一上位机发完之后读这个计数器对比一下就知道有没有丢包。从那以后我每次改 USB 描述符或者端点配置都强制走一遍“枚举抓包 → 端点验证 → 压力测试”的流程再也不敢直接烧录就完事。希望这个工程和这些踩坑记录能帮到你少走一些弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站