
简介面向Windows内核驱动开发学习者与底层软件工程师的VC源程序合集围绕驱动框架搭建与I/O请求处理展开适合在掌握C基础后进一步理解内核模式编程、设备控制接口及WDF模型。压缩包共657个文件约7.73MB以304个h头文件、149个lib库文件、34个c与12个cpp源码为主并配有rc资源、dsp/dsw工程、sources、inf、sys、def、idl等驱动工程与安装描述文件便于还原编译环境、查阅接口声明和对照模块结构。已有155人学习。内容覆盖DriverEntry初始化、IRP处理、设备对象创建、ISR、同步互斥、内存与资源管理、代码签名及WinDbg调试等关键环节可作为梳理驱动开发流程、理解硬件与操作系统交互的参考素材也便于按工程组织方式定位声明、实现和构建配置。1. 一份 VC 驱动源程序内核里写 C 和你想的不一样拿到这份 VC 语言编程驱动源程序时我先翻的不是它实现了什么功能而是它敢在内核里用多少 C。答案很克制没有异常、没有 RTTI、没有标准库容器new/delete要自己重载到池上类只能当纯数据结构用。这不是作者保守而是 Windows 驱动这个运行环境本来就只认 C 的 ABIC 只提供一层封装。这套源程序的价值在于它把DriverEntry、设备创建、IOCTL 队列、INF 安装和用户态测试程序连成了一条能编译、能加载、能通信的链路适合会写 C 但没碰过内核的开发者也适合手里有设备、想找一份骨架直接改的人。驱动开发真正的门槛不在语法而在加载失败后没有任何提示的调试过程这份源程序把那些“看不见的约定”都摆了出来。2. 把 .vcxproj 配对VC 驱动工程的编译链路与四个必改选项2.1 为什么这份源码不能直接塞进控制台工程很多人第一次接触驱动源程序习惯性新建一个空的控制台项目把.c、.cpp文件拖进去点生成然后被一屏链接错误劝退。这套源码里DriverEntry是内核入口链接器要的是内核导出符号而应用工程默认链的是用户态运行时驱动里也没有 CRT 的启动代码没有mainCRTStartup没有任何人帮你初始化全局对象。判断一个工程有没有配对最快的办法就是看链接错误出现_malloc、__security_cookie、__CxxThrowException、_purecall这类未解析外部符号基本可以断定工程还在按用户态程序编译。WDK 装好之后会给 Visual Studio 注入一套内核模式驱动的属性表它会替你改掉入口点、附加依赖库、目标机器类型和段布局。所以还原这份源码的正确顺序是先用驱动模板建一个空工程再把源文件按原来的目录结构放进去最后对照属性页把选项一项一项校回来而不是反过来把源码拖进现成工程。提示属性表里那套内核配置是按平台生效的切换 x64 和 Win32 时要分别检查一遍很多人只改了当前配置就以为万事大吉。2.2 四个必改选项改错一个就链接不过或加载即崩属性位置应用工程默认驱动必须改成不改的后果配置类型应用程序 (.exe)Driver模型选 KMDF生成的是 .exe服务控制管理器根本不认C/C → 异常处理/EHsc关闭链接__CxxThrowException失败C/C → 运行时类型信息/GR/GR-链type_info相关符号失败镜像还变大链接器 → 附加依赖项继承用户态库只保留 WDK 注入项混入用户态运行库加载阶段直接失败前两项是最容易被忽略的因为报错发生在链接阶段看起来像是“源码缺文件”。第三项在 64 位下尤其重要开着 RTTI 会引入type_info的比较逻辑而内核里根本没有对应的运行时支撑。第四项最隐蔽某些模板会顺手把libcmt加进附加依赖项编译链接都过但镜像里带着一堆用户态导入sc start的时候才失败错误码还看不出原因。工程文件的差异最终会落在.vcxproj里下面这段是必须自己对上的部分PropertyGroup LabelConfiguration !-- 配置类型必须是 DriverDriverType 选 KMDF -- ConfigurationTypeDriver/ConfigurationType DriverTypeKMDF/DriverType DriverTargetPlatformDesktop/DriverTargetPlatform /PropertyGroup ItemDefinitionGroup ClCompile !-- 内核里没有异常展开运行时开了就是链接错误 -- ExceptionHandlingfalse/ExceptionHandling !-- RTTI 同理dynamic_cast 在内核里不可用 -- RuntimeTypeInfofalse/RuntimeTypeInfo PreprocessorDefinitions_KERNEL_MODE;POOL_NX_OPTIN1;%(PreprocessorDefinitions)/PreprocessorDefinitions LanguageStandardstdcpp17/LanguageStandard /ClCompile /ItemDefinitionGroup_KERNEL_MODE这个宏是整个源码包的分水岭头文件里大量#ifdef _KERNEL_MODE用它决定是走ExAllocatePool2还是走malloc。POOL_NX_OPTIN1是为了让老代码在新系统上也能申请到不可执行内存缺了它某些分配会返回空指针。LanguageStandard不用开太高C17 里的constexpr、if constexpr在内核里能用但std::那一套基本都得删掉。2.3 编译产物与符号少一个 .pdb 后面全靠猜源码包编译完之后会掉出好几个文件新手容易只盯着.sys其实后面调试时救命的是另外几个。产物谁生成作用.sys链接器被内核真正加载的镜像.pdb链接器双机调试时把地址翻译成函数名和行号.inf手写或模板描述设备、服务安装和 KMDF 版本绑定.cat签名流程生成驱动包的签名目录缺失会被安装策略拒绝.pdb的路径要单独记下来调试机上的符号路径要指到这个目录否则!analyze -v出来的调用栈只有一串十六进制地址等于没有信息。我一般会在源码包旁边建一个symbols\目录把每次编译出来的.pdb按日期归档蓝屏复现时能对上当时那个版本这个习惯救过我好几次。3. 从 DriverEntry 到 IOCTL源码里那条最短的可用调用链3.1 DriverEntry 只做三件事内核加载驱动时调用DriverEntry它等价于用户态的main但限制多得多IRQL 是PASSIVE_LEVEL可以申请内存、可以创建对象但不能长时间阻塞也不能假设它只被调用一次。这份源程序里它只做三件事——初始化设备名和符号链接名、配置EvtDeviceAdd回调、调用WdfDriverCreate。#include wdf.h // 必须指向静态存储期内存不能指向 DriverEntry 栈上的缓冲区 static UNICODE_STRING g_DeviceName; static UNICODE_STRING g_SymLinkName; NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { WDF_DRIVER_CONFIG config; NTSTATUS status; // \Device\ 是内核命名空间用户态看不到 RtlInitUnicodeString(g_DeviceName, L\\Device\\VcDrvDemo); // \DosDevices\ 才是用户态 CreateFile 能打开的符号链接 RtlInitUnicodeString(g_SymLinkName, L\\DosDevices\\VcDrvDemo); WDF_DRIVER_CONFIG_INIT(config, EvtDeviceAdd); config.DriverPoolTag vcDR; // 池标签排查泄漏时按它过滤 status WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, config, WDF_NO_HANDLE); return status; }两个UNICODE_STRING声明成静态变量是关键。如果写成DriverEntry里的局部变量再传给WdfDeviceCreateSymbolicLink函数返回后那片栈内存就被回收了后面用户态CreateFile要么找不到设备要么在某个随机的时刻蓝屏。DriverPoolTag是四个字符按小端存的写vcDR在池调试器里显示成RDcv一开始看会觉得对不上其实是正常的。3.2 EvtDeviceAdd设备、符号链接、队列一次建好EvtDeviceAdd在设备被枚举时调用PnP 驱动可能被调用多次所以这里不能放“只执行一次”的逻辑。这份源码用的是最朴素的顺序设置 IO 方式、创建设备、建符号链接、建默认队列。typedef struct _DEVICE_CONTEXT { WDFSPINLOCK Lock; ULONG Counter; } DEVICE_CONTEXT, *PDEVICE_CONTEXT; // 生成 GetDeviceContext() 内联函数本质是 WdfObjectGetTypedContext WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, GetDeviceContext) NTSTATUS EvtDeviceAdd(_In_ WDFDRIVER Driver, _Inout_ PWDFDEVICE_INIT DeviceInit) { WDF_OBJECT_ATTRIBUTES attr; WDF_IO_QUEUE_CONFIG qcfg; WDFDEVICE device; NTSTATUS status; UNREFERENCED_PARAMETER(Driver); // 交给框架做缓冲METHOD_BUFFERED 的 IOCTL 全靠这一行 WdfDeviceInitSetIoType(DeviceInit, WdfDeviceIoBuffered); WdfDeviceInitSetDeviceType(DeviceInit, FILE_DEVICE_UNKNOWN); WdfDeviceInitSetExclusive(DeviceInit, FALSE); // 上下文跟着设备对象走设备删了它自动回收 WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(attr, DEVICE_CONTEXT); status WdfDeviceCreate(DeviceInit, attr, device); if (!NT_SUCCESS(status)) return status; // 想改用户态可见的名字就改 g_SymLinkName 这一处 status WdfDeviceCreateSymbolicLink(device, g_SymLinkName); if (!NT_SUCCESS(status)) return status; WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(qcfg, WdfIoQueueDispatchParallel); qcfg.EvtIoDeviceControl EvtIoDeviceControl; return WdfIoQueueCreate(device, qcfg, WDF_NO_OBJECT_ATTRIBUTES, WDF_NO_HANDLE); }WdfIoQueueDispatchParallel表示多个请求可以并发进入回调源程序选它是因为 IOCTL 处理都是短平快的复制没有共享状态。如果把Counter那种全局状态改成读写就必须换成WdfIoQueueDispatchSequential或者在回调里自己拿锁这一点后面避坑章节还会提。WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE这套机制是 C 工程写驱动时最值得抄的部分它把“对象 上下文”绑在一起父对象销毁时子上下文自动释放省掉大量手工清理。注意上下文结构里不要放 C 类对象因为它的构造和析构不会被调用只能放 POD 类型。3.3 IOCTL 的缓冲区约定METHOD_BUFFERED 下谁拷给谁IOCTL 的编号由CTL_CODE生成四个参数分别是设备类型、功能号、传输方式和访问权限。这份源码把编号和结构体放在同一个共享头里驱动和用户态各包含一次避免两边手抄出错。// shared.h —— 驱动和用户态同时包含CTL_CODE 才不会两边对不上 #define VC_DEVICE_TYPE FILE_DEVICE_UNKNOWN // 功能号 0x800 起步0x000~0x7FF 是系统保留区 #define IOCTL_VC_GET_VERSION CTL_CODE(VC_DEVICE_TYPE, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) typedef struct _VC_VERSION_INFO { ULONG Major; ULONG Minor; ULONG BuildTag; } VC_VERSION_INFO, *PVC_VERSION_INFO;处理函数里最关键的一行是WdfRequestRetrieveOutputBuffer的第二个参数VOID EvtIoDeviceControl(_In_ WDFQUEUE Queue, _In_ WDFREQUEST Request, _In_ size_t OutputBufferLength, _In_ size_t InputBufferLength, _In_ ULONG IoControlCode) { NTSTATUS status STATUS_INVALID_DEVICE_REQUEST; size_t info 0; UNREFERENCED_PARAMETER(Queue); switch (IoControlCode) { case IOCTL_VC_GET_VERSION: { PVC_VERSION_INFO out NULL; // 第二个参数是最小可接受长度填 0 会让后面的写入越界 status WdfRequestRetrieveOutputBuffer(Request, sizeof(VC_VERSION_INFO), (PVOID*)out, NULL); if (NT_SUCCESS(status)) { out-Major 1; out-Minor 0; out-BuildTag 100; info sizeof(VC_VERSION_INFO); } break; } default: // 不认识的 IOCTL 必须明确返回错误静默成功会骗过用户态 break; } WdfRequestCompleteWithInformation(Request, status, info); }METHOD_BUFFERED的含义是框架把用户态的输入缓冲拷进内核的SystemBuffer处理完再把输出拷回用户态。所以回调里拿到的指针是内核地址直接读写是安全的反过来如果CTL_CODE用了METHOD_NEITHERWdfRequestRetrieveOutputBuffer拿到的就是原始用户态地址必须在try/except里访问这是新手最容易踩的坑之一。WdfRequestCompleteWithInformation的第三个参数是“实际传输字节数”用户态DeviceIoControl的lpBytesReturned拿到的就是它填 0 会让调用方以为没数据。3.4 用户态那一侧CreateFile 与 DeviceIoControl用户态测试程序用标准 C 写就行不需要任何额外库。唯一要记住的是设备路径的写法内核符号链接是\DosDevices\VcDrvDemo用户态要写成\\.\VcDrvDemo四个反斜杠在 C 字符串里转义成两个。#include windows.h #include cstdio #include shared.h int wmain() { // 路径必须和驱动里 WdfDeviceCreateSymbolicLink 的名字严格一致 HANDLE h CreateFileW(L\\\\.\\VcDrvDemo, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (h INVALID_HANDLE_VALUE) { printf(open failed: %lu\n, GetLastError()); return 1; } VC_VERSION_INFO v{}; DWORD returned 0; BOOL ok DeviceIoControl(h, IOCTL_VC_GET_VERSION, nullptr, 0, // 无输入 v, sizeof(v), // 输出缓冲 returned, nullptr); printf(ok%d err%lu ret%lu ver%lu.%lu\n, ok, GetLastError(), returned, v.Major, v.Minor); CloseHandle(h); return ok ? 0 : 2; }CreateFileW的第二个参数给GENERIC_READ | GENERIC_WRITE是为了跟驱动里的队列权限对齐如果驱动那边没做额外检查只给0查询权限也能打开但后续 IOCTL 可能被拒。DeviceIoControl的输入缓冲传nullptr、长度传0是合法的前提是驱动那边用InputBufferLength判断而不是盲目解引用。4. 双机内核调试与加载卸载让 .sys 跑起来并看得见崩溃点4.1 双机内核调试的链路怎么搭驱动调试不能在本机做因为一断点系统就冻住了。标准做法是两台机器一台跑驱动目标机装在虚拟机里最省事一台跑调试器主机。网络调试比串口方便配置命令在目标机上以管理员身份执行# 允许测试签名否则自己编的驱动根本装不上 bcdedit /set testsigning on # 打开内核调试并指定网络传输 bcdedit /set debug on bcdedit /dbgsettings net hostip:192.168.56.1 port:50000 key:1.2.3.4hostip是主机在虚拟网络里的地址key是一串自定义的口令主机端连接时要填一样的值。改完必须重启目标机在设备管理器里能看到调试传输已经启用才算生效。主机端启动调试器时用-k net:port50000,key1.2.3.4连上之后先跑三条命令.symfix .reload lm.symfix把符号路径指向公共符号服务器.reload重新加载模块lm列出当前加载的内核模块。如果列表里能看到自己的驱动名并且后面跟着.pdb的路径说明符号对上了如果显示的是deferred说明符号还没下载再reload一次。这一步不做后面!analyze -v出来的栈全是地址。4.2 INF 安装与手动卸载KMDF 版本绑定这一步不能省.sys编出来不能直接双击安装得配一个 INF。源码包里的 INF 是给根枚举设备用的结构大致如下[Version] Signature$WINDOWS NT$ ClassSystem ClassGuid{4D36E97D-E325-11CE-BFC1-08002BE10318} Provider%ProviderName% CatalogFileVcDrvDemo.cat DriverVer01/01/2024,1.0.0.0 [Manufacturer] %ProviderName%VcDrvDemo,NT$ARCH$ [VcDrvDemo.NT$ARCH$] %DeviceName%VcDrvDemo_Install, root\VcDrvDemo [VcDrvDemo_Install] KmdfService VcDrvDemo, VcDrvDemo_KMDF [VcDrvDemo_Install.Services] AddServiceVcDrvDemo,%SPSVCINST_ASSOCSERVICE%,VcDrvDemo_Service [VcDrvDemo_KMDF] KmdfLibraryVersion $(KMDF_VERSION) [VcDrvDemo_Service] DisplayName%ServiceName% ServiceType1 StartType3 ErrorControl1 ServiceBinary%12%\VcDrvDemo.sysKmdfService和KmdfLibraryVersion这两段是 KMDF 驱动独有的作用是在服务注册表项下写一个版本号告诉加载器“这个驱动要哪个版本的框架库”。$(KMDF_VERSION)会在 WDK 生成 INF 时自动替换成你编译时选的版本手写 INF 最容易漏的就是这一段症状后面避坑章节会讲。ServiceType1表示内核驱动StartType3表示按需启动调试阶段这样设避免开机就加载然后开机蓝屏。装完之后用sc控制启停比每次都重新装一遍快得多sc stop VcDrvDemo sc start VcDrvDemo sc query VcDrvDemosc start返回的错误码是最重要的线索0xC00000E5一般是框架版本绑定缺失0xC0000034是文件路径不对577是签名校验没过。看错误码之前先看sc query里STATE的值STOPPED和STOP_PENDING的排查方向完全不同。4.3 蓝屏之后先看什么dump、BugCheck 与 wdfkd蓝屏之后不用慌重启进调试器第一件事是!analyze -v它会给出 BugCheck 码、出问题的模块名和调用栈。第二件事是确认自己的驱动是不是真的在栈上!analyze -v !wdfkd.wdfldr !wdfkd.wdfdriverinfo VcDrvDemo !wdfkd.wdflogdump VcDrvDemo!wdfkd.wdfldr列出系统里所有已加载的 KMDF 驱动和它们绑定的框架版本是排查版本问题最快的入口。!wdfkd.wdfdriverinfo看驱动对象和设备的层次!wdfkd.wdflogdump把框架内部的日志打出来请求在哪个队列排队、超时还是被取消都能看到。常见的几个码和成因BugCheck名称驱动里最常见的成因0x000000D1DRIVER_IRQL_NOT_LESS_OR_EQUAL在 DISPATCH_LEVEL 访问分页内存或已释放指针0x0000000AIRQL_NOT_LESS_OR_EQUAL中断或 DPC 路径里调用了只能在 PASSIVE_LEVEL 用的函数0x00000050PAGE_FAULT_IN_NONPAGED_AREA把用户态地址直接在内核里解引用0x000000C4DRIVER_VERIFIER_DETECTED_VIOLATION开了驱动程序验证器后暴露的句柄、IRQL 违规0x0000009FDRIVER_POWER_STATE_FAILURE电源回调里做了阻塞等待没在超时前完成0xD1 和 0xA 占了新手蓝屏的一大半共同点都是 IRQL 用错。判断方法很简单看崩溃时的 IRQL 值如果大于PASSIVE_LEVEL0那么任何可能引发缺页的访问都有风险包括访问分页池、访问用户态指针、调用会等待的 API。5. 避坑记录五类让驱动加载失败或直接蓝屏的现场5.1 编译全过、服务起不来报 0xC00000E5现象sc start失败事件日志里写着“驱动程序无法加载”错误码0xC00000E5.sys文件明明在指定路径下。原因KMDF 驱动加载前要先经过框架加载器它去服务注册表项下面读框架版本号。用 INF 安装的那次没问题后来为了省事直接sc create手搓服务这一段没写加载器就不知道该用哪个版本的框架库。解决别用sc create装 KMDF 驱动老老实实走 INF确实要手工建服务得在HKLM\SYSTEM\CurrentControlSet\Services\服务名\Parameters\Wdf下面补一个KmdfLibraryVersion值和你编译时选的框架版本一致。查实际绑定值可以用!wdfkd.wdfldr对照。5.2 CreateFile 一直 ERROR_FILE_NOT_FOUND现象用户态程序打开设备GetLastError()返回2但驱动明明已经sc query显示RUNNING。原因符号链接名和设备名搞混了。\Device\VcDrvDemo是内核对象的名字用户态看不到只有通过WdfDeviceCreateSymbolicLink建出来的\DosDevices\VcDrvDemo才能在用户态用\\.\VcDrvDemo打开。另外大小写、结尾多一个空格都会导致匹配失败。解决先在调试器里用!wdfkd.wdfdevice看设备的符号链接名再和用户态字符串逐字符比对。我一般会把这个名字写成头文件里的宏驱动和用户态各引用同一个宏从根上避免手抄。5.3 一调 IOCTL 就 0xD1 蓝屏现象设备能打开一调DeviceIoControl就蓝屏BugCheck 0xD1栈顶是自己的处理函数。原因最常见的是把METHOD_BUFFERED和METHOD_NEITHER弄混了。前者的缓冲区是内核拷贝出来的直接读写没问题后者的指针就是用户态地址在高于PASSIVE_LEVEL的 IRQL 下访问会触发缺页。另一种情况是WdfRequestRetrieveOutputBuffer第二个参数填了 0框架不做长度校验后面按结构体大小写入就踩到了缓冲区外面。解决CTL_CODE的传输方式全工程统一别混用WdfRequestRetrieveOutputBuffer的最小长度必须填结构体的实际大小确认无输入时用InputBufferLength判断不要盲目解引用。5.4 DeviceIoControl 返回 ERROR_INSUFFICIENT_BUFFER 或字节数不对现象功能其实执行了但用户态拿到的lpBytesReturned是 0或者直接报122缓冲区不够。原因WdfRequestCompleteWithInformation的第三个参数填了 0。这个参数表示实际传输的字节数用户态完全依赖它判断“有没有拿到数据”即使缓冲区够大、数据也写了填 0 依然会让用户态认为失败。解决每个分支都算出准确长度再完成请求。注意METHOD_BUFFERED下输入输出共用一块SystemBuffer输出长度不能超过用户态传入的输出缓冲长度超了要返回STATUS_BUFFER_TOO_SMALL而不是硬写。5.5 卸载之后再加载就蓝屏关机时报 0x9F现象第一次加载正常sc stop之后再sc start蓝屏或者关机时蓝屏BugCheck 0x9F。原因卸载路径里少做了清理。定时器没取消、工作项还挂在队列上、WDFSPINLOCK没删、符号链接没随设备销毁这些都会在下次加载或关机时炸出来。0x9F 通常是电源回调里做了阻塞等待系统等不到完成就超时。解决资源一律挂在 WDF 对象上用父对象生命周期管理EvtCleanupCallback和EvtDestroyCallback里不要做任何可能阻塞的操作停服务之前先确保没有未完成的请求!wdfkd.wdflogdump能看到队列里的残留。6. 把这份源码改成自己的模板三个可复用的改造点这份源程序当作模板用时真正要改的不是功能而是那些“以后一定用得上”的骨架。第一个改造点是把全局变量全部挪进设备上下文。源码里Counter那种写法在单设备场景够用一旦同一个驱动被枚举出多个实例就会串数据。做法是在WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE之后用GetDeviceContext(device)拿到那份上下文锁、计数器、缓冲区全放进去多实例天然隔离卸载时也不用单独清理。第二个改造点是把队列从并行改成手动分发。WdfIoQueueDispatchParallel处理短 IOCTL 没问题但只要有共享状态或者需要排队顺序就得换成手动队列IOCTL 回调里只做参数校验把请求WdfRequestForwardToIoQueue转到另一个串行队列或者直接挂起等后续处理。代价是必须自己管住“请求完成”这条路径任何分支漏掉WdfRequestComplete都会让用户态永远阻塞。第三个改造点是交付前打开驱动程序验证器。开发阶段用verifier /standard /driver VcDrvDemo.sys注册重启后生效它会把 IRQL 违规、释放后使用、内存泄漏这些平时不显形的问题直接打成 0xC4。WDF 那边还有独立的验证器通过注册表里的VerifierOn打开框架会检查句柄误用和对象生命周期。这两样东西在开发机上跑通再交付比事后拿 dump 分析省十倍时间。# 交付前的基础校验改完驱动重启后生效 verifier /standard /driver VcDrvDemo.sys verifier /query verifier /reset血泪经验是验证器一定要在功能“跑通”之后、交付之前开别等到出问题了才想起来。我最早做的一个采集卡驱动功能测试全过交付到现场第三天才蓝屏dump 拉回来一看是释放后使用而验证器一开本地第一次 IOCTL 就报了。从那以后我每次改完驱动都强制走一遍“验证器打开 → 跑完整测试流程 → 关机重启再跑一遍”的流程宁可多花半小时也不想在别人的机器上复现。希望帮到你。本文还有配套的精品资源点击获取