☰
USB HID上位机开发:C#/C++选型与报告长度避坑指南
2026/10/8 4:00:56 网站建设 项目流程

简介:一份聚焦 USB HID 开发的完整源码包,面向嵌入式工程师、上位机开发人员及学习 USB 协议栈的读者,用于解决 PC 与自定义 HID 设备之间的通信问题。压缩包共 30 个文件,包含 17 个头文件与 13 个 C 源文件,整体约 54KB,其中头文件用于接口与寄存器定义,源文件实现核心逻辑;内容涵盖 STM32_USB-FS-Device 库、Custom_HID 工程及 USBPCDriver 相关驱动代码,既有上位机调用实例,也有底层设备端固件。已有 214 人学习,目录结构清晰,便于按模块查阅。其中 C# 部分展示了通过 VID/PID 枚举设备、打开句柄并读写 HID 报告的完整流程,包含设备列表刷新与数据收发;C++ 驱动部分覆盖 PnP 设备安装、IRP 处理和读写例程,帮助理解 Windows 下 USB 驱动的运作机制;STM32 固件则实现 HID 报告的解析与应答。研读后可直接搭建一套端到端的 USB HID 通信链路,免去从零开发的环境配置与协议调试,适合作为二次开发的参考模板。

1. 做 usbHID 上位机的第一步:确定 C# 还是 C++,以及报告长度这个命门

做上位机这行绕不开 USB HID:免驱、即插即用、收发固定长度报告,C# 和 C++ 都有现成 API 可调。常见做法是拿 usbHID 例程包里的枚举逻辑,把设备对应的 VID/PID 和报告长度对上,然后开始读写。相比 USB 转串口要装驱动、抢 COM 口号,网口又要配 IP 还要处理重连,这种方案更适合小批量数据采集、工装治具控制、传感器读取这类现场。如果你正为设备端与 PC 通信选型,或手上有一份 C#/C++ 例程包看不懂,下面从选型、代码骨架一直讲到联调踩坑,照着就能跑起来。

2. HID 报告机制是选型前提:C# 与 C++ 各自的技术路线

HID 设备在插入瞬间会向主机上报一串报告描述符,告诉系统自己有哪些端点和报告结构。这套机制由 Windows 内置的 HID 类驱动托管,上位机不需要自己写驱动,只需要调用系统 API 把报告塞进去或读出来。所以 usbpcdriver 这类名字容易让人误以为要碰驱动,实际上做 PC 上位机,代码里几乎不接触驱动文件,真正花时间的是把设备端的报告长度和端点类型搞清楚。

2.1 HID 报告描述符:决定上位机能否收发的三个数字

HID 设备的通信单位是“报告”,不是字节流。一条报告由报告ID(可选)和数据域组成,输入报告从设备流向主机,输出报告从主机流向设备,Feature 报告双向但不走中断端点。描述符里具体写的就是输入报告多长、输出报告多长、用了哪个端点、端点最大包长几字节。

上位机设计时只需要从描述符里抽出三个数:InputReportByteLength、OutputReportByteLength、是否启用报告ID。前两个都是“含报告ID位”的总长度。很多设备固件把报告长度定义成 64,上位机拿 65 字节缓冲去读,虽然只多一个字节,HID 类驱动会严格按报告长度拆包,多出来的那个字节要么一直补零,要么整个读操作挂起。这个错位问题在后两章排障里会反复出现。

选型上先定个原则:中低速率数据采集(几百 Hz 以内),中断端点报告就足够。不要指望 HID 跑大流量,它设计之初是按低频人机交互留的传输能力,硬拿它传视频或日志收益很低。我一般建议从 64 字节报告、50ms 轮询节奏起步,后面按实际带宽需求再调。

提示:报告长度、报告ID、端点配置这类底层参数,永远以设备端调试工具的枚举结果和固件源码为准,不要凭例程名去猜。

2.2 C# 侧:HidLibrary 这类封装库与 P/Invoke 怎么选

C# 做 usbhid 上位机有两条路。一条是引 NuGet 上流行的 HID 封装库,比如 HidLibrary 这类,把枚举、打开、读写全封装成类和回调,几十行就能跑通最小收发。另一条是自己 P/Invoke 调 hid.dll、setupapi.dll,完全掌控句柄、超时和并发。

对比项封装库(HidLibrary 这类)P/Invoke 自己调 API
上手成本低,枚举和读写已封装高,要自己组织枚举
报告长度控制依赖库内部逻辑完全可控
多设备并发一般,句柄管理不透明按句柄区分,可控
长期后台运行看具体实现,异常可能被吞稳定

我的选择:调试小工具或一次性治具程序用封装库,长期挂在产线上的检测软件用 P/Invoke。P/Invoke 没有黑匣子,出问题可以直接在 API 层打断点观察。封装库虽然快,可一旦遇到读超时、句柄占用这类问题,你往往得翻开它的源码才能定位到底调了哪个参数,反而更慢。

P/Invoke 的最小骨架长这样:

[DllImport("hid.dll", SetLastError = true)] static extern bool HidD_GetHidGuid(out Guid HidGuid); [DllImport("hid.dll", SetLastError = true)] static extern bool HidD_GetAttributes(IntPtr HidDeviceObject, ref HIDD_ATTRIBUTES Attributes);

这里有个经典翻车点:结构体传进去之前必须把 Size 字段赋成结构体大小,否则 HidD_GetAttributes 返回 false,而且 SetLastError 不给出任何有效错误码。我见过不少同事在这上面耗掉一下午,查来查去最后发现只是少了一行初始化。

2.3 C++ 侧:SetupDi 枚举设备接口的技术原理与工程配置

C++ 做 USB HID 基本没人用 DirectInput,那是给手柄键盘设计的,不能随心所欲读写任意报告。工控上位机要的是完整控制,所以例程包里最常见的组合是 SetupDi 系列枚举设备接口,再由 CreateFile 拿句柄,用 hid.dll 完成收发。

枚举固定四步:

  1. HidD_GetHidGuid 拿到 HID 类 GUID
  2. SetupDiGetClassDevs 建立设备信息集
  3. SetupDiEnumDeviceInterfaces 循环找接口,SetupDiGetDeviceInterfaceDetail 取设备路径
  4. CreateFile 打开路径得到句柄,HidD_GetAttributes 确认 VID/PID

C++ 工程里最容易忽略的是链接配置。用 Visual Studio 开发,头文件只需要 windows.h、setupapi.h、hidsdi.h,但链接器必须加 setupapi.lib 和 hid.lib,否则编译通过、链接报一堆未解析符号。直接在源文件顶部写下面三行,就不用去工程属性里翻:

#include <windows.h> #include <setupapi.h> #include <hidsdi.h> #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "hid.lib")

顺带说一句,新电脑第一次跑原生程序如果提示缺 VCRUNTIME 相关 dll,装 Visual C++ 2015-2022 Redistributable 就能解决。这事和 USB 代码无关,但很多刚接触 C++ usbhid 例程的人都会在第一步被它绊一下,先排查这个再查代码,省时间。

3. 用 C# 写最小 usbhid 上位机:枚举、打开、写读报告的落地代码

这一章把代码拆成三块:枚举设备并拿到句柄,写报告和读报告,最后是业务帧协议。每块代码都能直接抄进控制台程序里,跑通一个最小循环。

3.1 枚举 VID/PID 并打开设备:核心代码与参数说明

以常见封装库的 API 为例,打开目标设备最快的方式是枚举所有 HID 设备后按 VID/PID 过滤:

using System; using HidLibrary; class HidHost { private const int TargetVid = 0x0483; // 设备端 VID,来自硬件原理图 private const int TargetPid = 0x5750; // 设备端 PID,同上 private HidDevice _device; public bool Open() { var devices = HidDevice.GetDevices(); foreach (var dev in devices) { if (dev.Attributes.VendorId == TargetVid && dev.Attributes.ProductId == TargetPid) { _device = dev; break; } } if (_device == null) { Console.WriteLine("未找到目标 HID 设备,检查 USB 线和枚举状态"); return false; } _device.OpenDevice(DeviceMode.NonOverlapped, DeviceMode.NonOverlapped); _device.ReadReport(OnInputReport); return true; } }

逻辑说明与参数说明:

  • GetDevices() 枚举当前系统里全部 HID 设备,返回的设备对象里带 Attributes,其中 VendorId、ProductId 对应 VID/PID。
  • TargetVid 和 TargetPid 不是猜的,要从设备端固件源码或 USB 枚举软件里抄,写错一个就什么都打不开。
  • OpenDevice 的第一个参数是读模式,第二个是写模式。NonOverlapped 是同步阻塞模式,适合简单工具;后台常驻程序建议用 Overlapped 配事件回调,避免读操作把主线程卡死。
  • ReadReport 注册异步读回调,设备上报一条报告就触发一次,不需要自己起线程循环读。

设备掉线时回调会返回空引用,所以回调入口第一行要做判空,否则拔一下 USB 线程序直接崩:

private void OnInputReport(HidReport report) { if (report == null || report.Data == null) return; byte[] data = report.Data; // 封装库的 Data 可能已经去掉报告ID,按实际打印验证后再解析 Console.WriteLine($"收到 {data.Length} 字节"); }

注意这里不同封装库处理报告ID的方式不一样,有的把报告ID留在 data[0],有的直接去掉。写解析前先用一条空报告打印原始字节,确认库的行为,省得后面整个协议错位。

3.2 写报告与读报告:报告长度、超时重试与异步读

写报告的核心是长度必须等于设备 OutputReportByteLength。设备固件按描述符里声明的长度接收,短了或长了一字节都可能被驱动层丢弃:

public bool SendCommand(byte command, byte param) { int outLen = _device.Capabilities.OutputReportByteLength; byte[] buf = new byte[outLen]; buf[0] = 0x00; // 报告ID占位,未启用报告ID时固定填0 buf[1] = 0xAA; // 帧头,业务协议的一部分 buf[2] = command; // 命令字 buf[3] = param; // 参数 buf[4] = (byte)(buf[1] ^ buf[2] ^ buf[3]); // 与固件约定好的校验 return _device.WriteReport(buf); }

逻辑说明与参数说明:

  • OutputReportByteLength 包含报告ID这一位。设备没启用报告ID时,第一位填 0 占位。
  • 校验算法必须和固件一致。这里用了最简单的异或,只是演示占位;实际项目里建议用 CRC8 或 CRC16,异或在数据位长的时候冲突率偏高。
  • WriteReport 返回 false 时不要立刻重试。常见做法是间隔 50-100ms 重发,连续三次失败就报错。太快重发会让设备端的端点缓冲堵死。

读取端的超时控制也容易踩坑。封装库的 ReadReport 在设备长时间不上报数据时会一直挂着,程序退出时容易卡在回调里。我的做法是给读线程加一个退出标志,配合 Task.Run 包一层:

private CancellationTokenSource _cts = new CancellationTokenSource(); public void Stop() { _cts.Cancel(); _device.CloseDevice(); }

CloseDevice 会打断挂起的读操作,配合 Cancel 标志,退出路径基本不会卡死。靠这个组合,产线电源一拔一插,程序不用重启。

3.3 把数据帧拼成业务指令:分包、轮询节奏与帧协议

HID 报告单条通常在 64 字节以内,业务数据超过一条报告时,要在上层做分包和拼接。先约定帧协议,比如帧头 + 长度 + 命令 + 数据 + 校验:

public byte[] BuildFrame(byte cmd, byte[] payload) { int len = payload.Length + 4; byte[] frame = new byte[len]; frame[0] = 0xA5; // 帧头 frame[1] = (byte)len; // 整帧长度 frame[2] = cmd; // 命令 Array.Copy(payload, 0, frame, 3, payload.Length); frame[len - 1] = Crc8(frame, 0, len - 1); // 尾校验 return frame; }

逻辑说明:帧协议负责“业务层怎么切分数据”,报告描述符负责“每次 USB 传输多长字节”,这是两层东西。很多新手把这两层混在一起,固件改了报告长度,上位机这边拼命改协议,其实是改错了地方。

轮询节奏上,我一般把查询间隔放在 50ms-200ms 之间,上位机主动下发查询命令后等待应答。低于 10ms 的轮询对 USB HID 没有意义,反而容易把设备端 MCU 的中断处理挤崩。要更高频率就用设备主动上报模式,让固件按自己的节拍推数据,上位机只做接收。

4. C++ USBHID 上位机:用 Windows HID API 收发的骨架与三种典型写法

这一章写给 C++ 读者,也写给那些打算把 C++ 逻辑封装成 DLL 给 C# 调的读者。前面选型时说过,C++ 路径是四个 API 组合的骨架,这里逐步补齐,并说明读写的通路差异。

4.1 枚举 HID 设备接口:SetupDi 系列 API 的固定四连

#include <windows.h> #include <setupapi.h> #include <hidsdi.h> #include <initguid.h> #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "hid.lib") void FindHidDevices() { GUID hidGuid; HidD_GetHidGuid(&hidGuid); HDEVINFO devInfo = SetupDiGetClassDevs(&hidGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (devInfo == INVALID_HANDLE_VALUE) return; for (DWORD i = 0; ; i++) { SP_DEVICE_INTERFACE_DATA ifData{}; ifData.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA); if (!SetupDiEnumDeviceInterfaces(devInfo, nullptr, &hidGuid, i, &ifData)) break; // ERROR_NO_MORE_ITEMS 枚举结束 // 第一次调用 SetupDiGetDeviceInterfaceDetail 获取所需缓冲区大小 DWORD needSize = 0; SetupDiGetDeviceInterfaceDetail(devInfo, &ifData, nullptr, 0, &needSize, nullptr); // 第二次调用才真正拿到设备路径 auto detail = static_cast<PSP_DEVICE_INTERFACE_DETAIL_DATA>(malloc(needSize)); detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); BOOL ok = SetupDiGetDeviceInterfaceDetail(devInfo, &ifData, detail, needSize, nullptr, nullptr); if (ok) { // detail->DevicePath 就是 CreateFile 要用的路径 } free(detail); } SetupDiDestroyDeviceInfoList(devInfo); }

逻辑说明与参数说明:

  • DIGCF_PRESENT 表示只枚举当前在线设备,不写它会把拔掉过的幽灵设备也列出来,导致 CreateFile 时报设备不存在。
  • SetupDiGetDeviceInterfaceDetail 必须调两次,第一次拿缓冲区大小,第二次才取路径。跳过第一次直接传小缓冲区,会得到 ERROR_INSUFFICIENT_BUFFER,这个错在 usbhid 例程里出现频率极高。
  • cbSize 要按结构体实际大小初始化,否则枚举结果不可靠。

4.2 CreateFile 打开设备:共享模式与同步读写参数

拿到 DevicePath 后,创建句柄是整段代码里参数最敏感的一步:

HANDLE hDev = CreateFile( devicePath, // 设备接口路径 GENERIC_READ | GENERIC_WRITE, // 读写权限 FILE_SHARE_READ | FILE_SHARE_WRITE, // 共享,否则别的程序打开时你打不开 nullptr, OPEN_EXISTING, 0, // 同步模式;超时控制用 FILE_FLAG_OVERLAPPED nullptr); if (hDev == INVALID_HANDLE_VALUE) { // GetLastError() == 5 -> 设备被独占,常见于调试工具还开着 // GetLastError() == 1167-> 设备未就绪,常见于固件枚举未完成 } HIDD_ATTRIBUTES attr{}; attr.Size = sizeof(HIDD_ATTRIBUTES); HidD_GetAttributes(hDev, &attr); // attr.VendorID / attr.ProductID / attr.VersionNumber

逻辑说明与参数说明:

  • FILE_SHARE_READ | FILE_SHARE_WRITE 务必保留。很多上位机只写 GENERIC_READ|GENERIC_WRITE,共享模式默认 0,结果设备被系统识别、却只允许一个进程打开,调试工具一开你的程序就打不开。
  • 第六个参数 0 是同步模式。ReadFile 会一直阻塞直到有报告或设备断开。想在 UI 线程里加超时,就用 FILE_FLAG_OVERLAPPED 配合 WaitForSingleObject。
  • HIDD_ATTRIBUTES 的 Size 不初始化,HidD_GetAttributes 也是静默失败,和 C# 侧那个坑同源。

4.3 WriteFile、HidD_SetOutputReport 与 ReadFile 的区别

HID 设备的发送通路有两条,很多人到这里开始把报告长度当玄学调,其实规则很明确。WriteFile 走中断输出端点,发送缓冲区长度 = 端点最大包长 + 1(报告ID位);HidD_SetOutputReport 走控制端点,按报告ID直接发,不需要中断端点在设备端真正存在。

判断用哪个看设备描述符:固件实现了中断 OUT 端点就用 WriteFile,这种包发得快,适合持续下发;固件只回了输入端点、把命令处理放控制端点,就用 HidD_SetOutputReport。

BYTE outBuf[65] = {0}; outBuf[0] = 0x00; // 报告ID占位 outBuf[1] = 0xA5; // 帧头 outBuf[2] = 0x10; // 命令字 outBuf[3] = 0x01; // 参数 DWORD written = 0; BOOL ok = WriteFile(hDev, outBuf, sizeof(outBuf), &written, nullptr); if (!ok) { // 常见错误码:1784 缓冲区无效、87 长度参数错 }

ReadFile 读输入报告同理,缓冲长度按输入报告长度定义。读不到数据先确认设备端中断 IN 端点有没有在轮询上报,再看缓冲长度是否与描述符一致,顺序别反。

4.4 把 C++ 封装成 DLL 给 C# 用:句柄边界与线程安全

很多 C# 上位机项目会把采集这段用 C++ DLL 重写,图的是处理实时性可控。封装的关键是别把 HANDLE 直接暴露给 C#,C# 那边无法安全地持有原生句柄,跨语言、跨线程一折腾就出随机故障。常见做法是 DLL 内部维护一张句柄表,给外部只暴露设备 ID:

extern "C" __declspec(dllexport) int __stdcall HidOpen(int vid, int pid) { // 内部完成 SetupDi 枚举 + CreateFile,成功后把句柄放进表里 // 返回 >=0 的设备ID,小于0 表示失败 return assignedId; }

逻辑说明与参数说明:

  • C# 侧统一用 int 接设备 ID,后续 HidWrite、HidRead、HidClose 都传这个 ID,避免 IntPtr 跨层滥用。
  • DLL 里的读写操作建议加临界区或 SRW Lock,HID 驱动本身不保证同一句柄的并发写安全。
  • 回调从 C# 传入时要特别小心:原生线程直接调托管委托会踩托管堆,正确做法是 DLL 线程把报告放进环形缓冲,C# 侧用自己的定时器取数据。

C++ 路径的骨架、可选通路和 DLL 边界都在这了,联调时真正让人头疼的是下一章要讲的这一堆坑。

5. USB HID 上位机联调避坑:usbpcdriver 与设备端配合的 5 个排查方向

usbpcdriver 在例程包里通常指 PC 端驱动适配目录,真正决定你能不能免驱的是 Windows 自带的 HID 类驱动和设备的 INF 节点。上位机代码里接触不到它,但如果设备枚举有问题,先查的就是这一层。避免被看起来是“驱动问题”的现象带偏,下面 5 个现象按实际频率排序。

5.1 设备能枚举,但 CreateFile 报拒绝访问

现象:设备管理器里设备正常,HID 枚举工具也能看到,但上位机一打开句柄就失败,GetLastError 返回 5。

原因:最常见的是之前有个调试工具或另一个上位机实例还占着设备句柄。HID 设备在同一时间只允许特定共享模式下的多个句柄,如果占用方以独占方式打开,你的程序就进不去。另外,自己把 CreateFile 的共享模式填成 0,也会导致同样的表现。

解决:先关掉所有可能占用该设备的工具,确认只有一个上位机实例;然后看自己 CreateFile 是否写了 FILE_SHARE_READ | FILE_SHARE_WRITE。这两个排除完,再考虑设备端报告描述符是否有异常。

5.2 写报告返回成功,设备端却没动作

现象:WriteFile 或 HidD_SetOutputReport 返回 TRUE,但设备端日志没收到任何命令帧。

原因:报告长度不一致是头号嫌疑。设备固件按固定长度接收,上位机把报告ID位算错,比如实际 64 字节有效数据 + 报告ID位应该填 65,你填了 64,驱动层按描述符补上报告ID,固件收到的数据整体错位一帧。另一个可能是走错了通路:固件只处理中断端点,上位机却发了控制端点的 SetOutputReport。

解决:先用调试工具读设备枚举后的输出报告长度,按这个长度构造缓冲区;再退一步,用设备端的打印日志确认中断端点是否有包进入。通路选错的话,改成 WriteFile 试一发即可。

5.3 读操作一直阻塞,收不到设备上报

现象:ReadFile 挂起,等多久都没回调;CancelIo 之后才能退出。

原因:输入报告缓冲长度和固件实际上报长度不匹配,或设备端中断 IN 端点根本没使能。HID 驱动严格按 InputReportByteLength 拆包,缓冲给大了或给小了,读操作都可能一直不返回。

解决:先用 HidD_GetInputReport 主动读一次,看返回的真实字节数;然后按这个数对齐 ReadFile 的缓冲长度。如果设备端就是没有数据,用 USB 分析工具看中断 IN 端点的计数,一帧都不进就是固件的问题,别在上位机里死等。

5.4 换一台电脑就失灵,有的系统还要管理员权限才能跑

现象:同一套 USB 设备,在 A 电脑免驱即插即用,插到 B 电脑要么设备管理器里有黄色感叹号,要么程序必须管理员运行才能打开。

原因:这部分才真正和 usbpcdriver 目录相关。B 电脑可能缺少 HID 类驱动节点,或者设备端报告描述符里 bcdHID 版本写得太新,系统解析不过。设备枚举失败时,系统会回退到经验性模式,表现就是时好时坏。

解决:设备管理器里看“人体学输入设备”下有没有该设备,没有就先把系统自带的 HID 类驱动组件补全。设备端固件把 bcdHID 写成 1.11 的兼容值,报告描述符别上太新的特性,跨系统兼容性会好很多。

5.5 C# 调 C++ DLL,句柄时好时坏、随机失败

现象:C# 主程序调 C++ 写的 HID DLL,第一次收发正常,跑一会儿后随机返回句柄无效,重连后恢复。

原因:句柄生命周期没管理好。C# 侧拿到 HANDLE 后跨线程使用,或 DLL 内部句柄表在多线程下竞争,导致句柄被复用或覆盖。HID 句柄本身也是系统句柄,值小时容易被回收复用,症状就是“时好时坏”。

解决:DLL 内部统一管理句柄,导出的接口只有设备 ID,不暴露 HANDLE;所有接口内部加线程安全锁,保证 Open 和 Close 成对。C# 侧不要自己持有句柄去调 API,把读写全部收敛到 DLL 接口里。血泪经验:宁可多走一层封装,别让句柄跨语言裸奔。

6. 用 HID 调试工具反向确认报告描述符:联调不顺时的最后一招

当上位机代码按前面步骤跑通但数据还是不对,我的习惯是停掉所有代码,先拿 USB 调试工具看设备枚举后的报告描述符。这类工具不用装驱动,打开就能看到一棵 USB 设备树,点开设备后直接显示输入报告长度、输出报告长度、报告ID、端点类型这些关键参数。

把工具里看到的三个值和固件源码里的端点配置、缓冲区定义逐一核对。对不上的那一项,就是问题所在。比如工具显示输出报告长度是 65,你上位机填 64,无论怎么改协议都白搭;工具显示输入报告长度只有 8,你却按 64 去读,读操作大概率一直挂着。这个核对动作做一次,等于把设备端和上位机两边的“黑匣子”同时打开。

另一个值得长期保留的习惯是:新设备进项目的第一天,就把调试工具里的枚举信息截图放进需求文档。报告长度、报告ID是否启用、端点号这几个值,在项目后期改固件时会被反复问到,先留底省得追溯。

对排查特别有用的一招是让固件实现一个 Feature 报告回显指令。上位机不拼帧、不读中断端点,直接调 HidD_GetFeature 把设备端当前配置、错误状态读回来。Feature 报告不走中断端点,也不受轮询节奏影响,用来做诊断非常干净。当读取线彻底黑屏的时候,这个通道往往还能工作。

回到开头那个问题,值不值得做:如果你的业务是传感采集、治具控制、小数据量双向通信,USB HID 这条路用 C# 或 C++ 做上位机都合适,代码量小、免驱、调试工具齐全,是投入产出比很高的选型。注意别拿它硬传大流量,也别把报告长度当玄学去试。我现在接新设备的第一个动作,永远是开调试工具看描述符截图,再写第一行代码,这个习惯帮我少踩了很多坑,也希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询