☰
VC++驱动源程序实战:DriverEntry、IRP分发与IOCTL通信
2026/10/24 6:47:32 网站建设 项目流程

简介:这份VC++驱动开发源程序包面向已具备C++基础、希望进入Windows内核模式编程的开发者,围绕驱动入口、IRP处理、设备对象创建、中断服务例程、同步互斥、内存与资源管理、WDF框架、代码签名及WinDbg调试等环节提供可编译的工程范例,帮助读者理解硬件设备与操作系统之间的衔接方式,并在此基础上完成定制化设备功能与性能优化。压缩包为rar格式,共657个文件、约7.73MB,其中304个.h头文件与149个.lib库文件构成主要依赖,34个.c、12个.cpp承载核心驱动逻辑,另有.rc资源脚本、.inf安装信息、.sources与Makefile构建配置、.sys驱动映像及.idl接口定义等,工程结构相对完整,便于按模块查阅与复用。该资源已有155人学习,适合驱动开发入门者对照源码梳理驱动加载、请求分发与调试排错流程,也可作为理解KMDF/UMDF框架与签名要求的参考素材。

1. 内核态的第一课:一份 VC++ 驱动源程序省掉的试错成本

不少人第一次接触 VC++ 驱动源程序,都会默认难点在 C 语法,心想平时写用户态也是 C++,无非换个编译开关。真正卡住人的却是另外三件事:编译链要 WDK 而不是普通 SDK,加载得走服务而不是双击 exe,代码跑在 IRQL 被抬高的上下文里,一个野指针换来的是 0x0000007E 蓝屏,而不是一个异常弹窗。这份 VC++ 驱动源程序给的是完整工程骨架,从 .sln 工程、DriverEntry 入口、设备对象与符号链接、IRP 分发、IOCTL 通信,一直到 .inf 安装信息文件,一整套能编译、能加载、能断点的东西都在里面。它适合已经会写 C++、想从用户态往内核态迈一步的人,也适合手里有具体过滤或采集需求、需要拿一份干净基线工程往上加功能的人。这不是一份教程,而是一份可编译、可调试、可改造的底稿。

2. 工程骨架与编译链:从 .sln 到 .sys 的文件分工

把压缩包解开,第一眼看到的是一份标准的 Visual Studio 解决方案。判断它属于 WDM 还是 KMDF,看两个地方就够了:入口是不是DriverEntry、创建设备是不是IoCreateDevice,如果是,就是老式 WDM 写法;如果是EvtDriverDeviceAdd配WdfDeviceCreate,那是 KMDF 框架。这份源程序走的是 WDM 路线,代码更贴近底层,也更能看清一个 IRP 从进入到完成的完整走向。

2.1 目录结构与文件职责

一份写得能维护的驱动工程,不会把所有东西塞进一个 .c 文件。分发函数一多,几百行 switch 叠在一起,后续改一个 IOCTL 要上下翻三遍。下面这张表是我拆这类工程时最先整理的,照着它对一遍,能很快判断这份源码的完成度。

文件 / 目录职责备注
*.sln/*.vcxprojVS 工程,决定最终编译出.sys里面有 Driver 类型、目标平台、签名配置
DriverEntry.cpp入口函数,注册卸载例程与 IRP 分发表全工程唯一被系统直接调用的函数
Device.cpp设备对象、符号链接的创建与删除出错时必须成对回滚
Dispatch.cpp各 IRP 主功能号的处理逻辑CREATE/CLOSE/DEVICE_CONTROL 为主要三个
Public.h应用层与驱动共享的 IOCTL 控制码定义头文件被两边同时包含,必须放在公共路径
*.inf安装信息:服务名、硬件 ID、加载方式手工装用sc create,正式装才用 inf
*.cat签名目录文件由 Inf2Cat + Signtool 在后处理里生成

有一点值得单独说:Public.h这份头文件是驱动和应用层的唯一契约,改 IOCTL 号必须两边同步。见过不少人图省事,在驱动里写死0x800,应用层另写一个0x801,调试半天发现是号对不上,纯属自己给自己挖坑。

2.2 编译环境:VS 与 WDK 必须成对

装环境这一步,最容易翻车的地方是版本配对。VS 安装器里勾上「使用 C++ 的桌面开发」,再加「Windows 驱动开发」这个工作负载,装完才能在新建项目里看到 Kernel Mode Driver 模板。如果只看到普通空项目,说明 WDK 的 VS 扩展没装上,或者 WDK 版本和 VS 版本对不上——这种情况下项目能打开,但一编译就找不到ntddk.h。

工程属性里几个必须确认的项:

  • Driver Settings → General → Target Platform:选 Desktop,除非目标是 UWP 驱动。
  • Target OS Version:决定能用哪些 API。选得太低,ExAllocatePool2这类新函数链接不上;选得太高,老系统加载不了。
  • C/C++ → Code Generation:Spectre 缓解库要对上,否则报LNK2001: unresolved external symbol __security_cookie一类。
  • C/C++ → Language:关掉 C++ 异常和 RTTI。内核里没有对应的运行时,/EHsc打开必然在链接阶段炸出__CxxThrowException未解析。

命令行构建比在 VS 里点按钮更可控,尤其是自动化打包的时候:

:: 以 x64 Release 为例,先彻底重建再确认产物 msbuild MyDriver.vcxproj /p:Configuration=Release /p:Platform=x64 /t:Rebuild :: 产物默认落在 x64\Release\ 下面,.sys/.inf/.cat/.pdb 应该成套出现 dir /b x64\Release\MyDriver\*.sys dir /b x64\Release\MyDriver\*.inf

/p:Platform必须和工程里配置的平台字符串完全一致,写X64和x64在某些 VS 版本下会解析成两套配置,报「找不到目标」。/t:Rebuild而不是增量 Build,是因为驱动的中间产物里包含 .inf 的校验信息,增量编译有时候不会重跑 Inf2Cat,导致 .cat 和 .inf 对不上,装的时候报签名无效。

还有一个几乎人人踩一次的坑:Inf2Cat error 22.9.1: DriverVer set to a date in the future。这不是代码问题,是系统时间或者 .inf 里DriverVer写了个未来日期。把日期改成当前时间之前,或者校准系统时间,重新生成即可。

2.3 DriverEntry:入口函数里的分发表

WDM 驱动的入口长这样,整份工程从这里开始跑:

// DriverEntry.cpp —— 系统加载驱动时第一个被调用的函数 extern "C" NTSTATUS DriverEntry( _In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); // 1. 注册卸载例程,没有它驱动就是"只进不出",卸载时必蓝屏 DriverObject->DriverUnload = DriverUnload; // 2. 先把所有主功能号指向兜底函数,避免未处理的 IRP 直接返回垃圾状态 for (ULONG i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; ++i) { DriverObject->MajorFunction[i] = DispatchUnsupported; } // 3. 只挂我们真正关心的三个 DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreateClose; DriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchCreateClose; DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchDeviceControl; // 4. 建设备对象和符号链接 NTSTATUS status = CreateDeviceAndLink(DriverObject); if (!NT_SUCCESS(status)) { return status; // 这里局部变量都是栈上的,直接返回不会泄漏 } return STATUS_SUCCESS; }

逐段解释一下。DriverObject->DriverUnload是卸载时被调用的清理入口,很多人写了IoCreateDevice却忘了赋值这个指针,结果sc stop时系统发现没有卸载例程,直接报错甚至蓝屏。第二步那个 for 循环是保命的兜底写法,IRP_MJ_MAXIMUM_FUNCTION是系统定义的最大主功能号,把整张表先填满,后续只覆盖需要的,就不会出现某个未定义主功能号对应空指针的情况。第三步的三个功能号对应:应用层CreateFile触发 CREATE、CloseHandle触发 CLOSE、DeviceIoControl触发 DEVICE_CONTROL。

RegistryPath是注册表里这个服务的键路径,用来读配置参数,比如设备名、缓冲区大小。UNREFERENCED_PARAMETER只是告诉编译器「我知道这个参数没用」,避免开/W4时刷警告。真正要注意的是第 4 步失败直接返回status,CreateDeviceAndLink内部必须自己把已经创建的资源回滚干净,否则下一次加载会撞上「设备名已存在」。

3. 核心链路拆解:设备对象、IRP 分发与 DeviceIoControl 通信

入口函数只是把架子搭起来,真正干活的是设备对象和分发函数。这一章按「应用层一次调用怎么落到驱动里」的顺序走一遍,代码全部来自这份源程序的骨架,改改名字就能用。

3.1 设备对象创建与符号链接绑定

驱动里的设备对象是内核对象,应用层看不见,中间必须靠符号链接做一层映射:

// Device.cpp —— 建内核设备对象,再给它挂一个应用层可见的名字 #define DEVICE_NAME L"\\Device\\SampleDrv" #define SYMLINK_NAME L"\\??\\SampleDrv" NTSTATUS CreateDeviceAndLink(_In_ PDRIVER_OBJECT DriverObject) { PDEVICE_OBJECT devObj = nullptr; UNICODE_STRING devName, linkName; NTSTATUS status; RtlInitUnicodeString(&devName, DEVICE_NAME); // FILE_DEVICE_UNKNOWN:自定义设备类型,避免和系统已有设备号冲突 // FILE_DEVICE_SECURE_OPEN:让 IO 管理器先做一遍安全描述符检查 status = IoCreateDevice(DriverObject, 0, // DeviceExtension 大小 &devName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, // 非独占打开 &devObj); if (!NT_SUCCESS(status)) { return status; } RtlInitUnicodeString(&linkName, SYMLINK_NAME); status = IoCreateSymbolicLink(&linkName, &devName); if (!NT_SUCCESS(status)) { IoDeleteDevice(devObj); // 链接失败,把设备对象还回去 return status; } return STATUS_SUCCESS; }

IoCreateDevice的第二个参数是设备扩展大小。写 0 表示这个设备不带私有上下文;如果需要在设备级保存状态(比如一个队列、一个计数器),就把它设成sizeof(MY_DEVICE_CONTEXT),之后通过devObj->DeviceExtension取值。我一般建议带上扩展,哪怕现在用不上,后续加功能不用改创建代码。

FILE_DEVICE_SECURE_OPEN这个标志容易被忽略。不带它,设备对象的安全检查会延后到具体 IRP,某些场景下普通用户就能打开设备。带上之后,系统先根据设备的安全描述符(默认继承驱动对象)判断一次,绝大多数场景下这是更稳妥的选择。

符号链接名\??\SampleDrv和\DosDevices\SampleDrv是等价的,前者在驱动里更常见。注意\Device\开头的名字是内核命名空间,应用层CreateFile直接传它是打不开的。

3.2 IRP 分发函数的三段式写法

每个分发函数都逃不开三段:取栈位置、处理、完成请求。写法统一了,排查起来才不费劲:

// Dispatch.cpp —— 处理 DeviceIoControl 下发的控制码 NTSTATUS DispatchDeviceControl(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp) { UNREFERENCED_PARAMETER(DeviceObject); PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp); PVOID sysBuf = Irp->AssociatedIrp.SystemBuffer; ULONG inLen = stack->Parameters.DeviceIoControl.InputBufferLength; ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength; ULONG code = stack->Parameters.DeviceIoControl.IoControlCode; NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST; ULONG_PTR info = 0; switch (code) { case IOCTL_SAMPLE_GET_VERSION: // 输出缓冲区不够时明确报错,不要瞎写 if (outLen < sizeof(ULONG)) { status = STATUS_BUFFER_TOO_SMALL; break; } *(ULONG*)sysBuf = 0x00010000; // 主版本 1,次版本 0 info = sizeof(ULONG); status = STATUS_SUCCESS; break; case IOCTL_SAMPLE_SET_BUFFER: if (inLen < sizeof(ULONG)) { status = STATUS_BUFFER_TOO_SMALL; break; } g_ConfigSize = *(ULONG*)sysBuf; info = 0; status = STATUS_SUCCESS; break; default: break; // 保持 STATUS_INVALID_DEVICE_REQUEST } Irp->IoStatus.Status = status; Irp->IoStatus.Information = info; // 必须填实际字节数 IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; }

IoGetCurrentIrpStackLocation拿到的是属于本层驱动的栈位置,Parameters.DeviceIoControl里的四个字段就是这个 IOCTL 的全部入参。Irp->AssociatedIrp.SystemBuffer在METHOD_BUFFERED方式下是输入和输出共用的一块系统缓冲区,写入前必须用outLen卡一下边界。

Irp->IoStatus.Information是返回给应用层DeviceIoControl的lpBytesReturned。漏填或者填错,应用层拿到的返回长度就是 0,遇到严格检查的代码会直接判失败。IoCompleteRequest的第二个参数是优先级提升值,IO_NO_INCREMENT对绝大多数自定义设备是正确的;只有键盘、鼠标、串口这类对延迟敏感的驱动才需要传具体增量。

3.3 应用层与内核层通信:控制码与调用约定

控制码定义在一份两边共用的头文件里:

// Public.h —— 驱动和 exe 都 include 这一份,改号时同步改 #define IOCTL_SAMPLE_GET_VERSION \ CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_SAMPLE_SET_BUFFER \ CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

CTL_CODE四个参数分别是设备类型、功能号(0x800 以上留给自定义)、传输方式和所需访问权限。METHOD_BUFFERED最常用,系统帮你把用户缓冲区拷到内核SystemBuffer,省掉手动ProbeForRead;代价是多一次内存拷贝,大数据量时换METHOD_NEITHER但要自己校验指针。FILE_ANY_ACCESS表示任何句柄都能发,如果某个 IOCTL 涉及危险操作,应该改成FILE_WRITE_ACCESS,让权限系统先挡一道。

用户态这头就是一个标准的CreateFile加DeviceIoControl:

// client.cpp —— 打开设备并下发一个 IOCTL HANDLE h = CreateFileW(L"\\\\.\\SampleDrv", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); if (h == INVALID_HANDLE_VALUE) { // ERROR_FILE_NOT_FOUND:符号链接没建出来或驱动没加载 return GetLastError(); } DWORD version = 0, returned = 0; BOOL ok = DeviceIoControl(h, IOCTL_SAMPLE_GET_VERSION, nullptr, 0, // 无输入 &version, sizeof(version), // 输出缓冲区 &returned, nullptr); if (!ok || returned != sizeof(DWORD)) { // 失败时看 GetLastError,驱动返回的 NTSTATUS 会被映射成 Win32 错误码 } CloseHandle(h);

路径\\\\.\\SampleDrv对应内核里的\??\SampleDrv,两个反斜杠转义后再拼上设备名,这是固定写法。CreateFileW的dwDesiredAccess要和 IOCTL 的RequiredAccess匹配,FILE_ANY_ACCESS的情况下传0也能打开。返回长度对不上时,一般先看驱动的IoStatus.Information有没有赋上,再看缓冲区大小判定是不是太严。

4. 内存与同步:内核态最容易翻车的三段代码

用户态写错内存,进程崩掉,重启就好。驱动写错内存,整台机器蓝屏,而且往往是随机的、复现困难的。这一章挑三个高频出问题的点:内存池选择、自旋锁与 IRQL、字符串和链表的生命周期。

4.1 分页与非分页:ExAllocatePool2 的两种标志

// 四字符 tag,反向拼写成 'pmaS',池转储里一眼能认出来是自己申请的 #define SAMPLE_TAG 'pmaS' PVOID buf = ExAllocatePool2(POOL_FLAG_NON_PAGED, 4096, SAMPLE_TAG); if (buf == nullptr) { return STATUS_INSUFFICIENT_RESOURCES; } // ... 使用 buf ExFreePoolWithTag(buf, SAMPLE_TAG); // tag 必须和申请时一致

POOL_FLAG_NON_PAGED申请的是非分页内存,任何 IRQL 下都能访问;POOL_FLAG_PAGED申请的是分页内存,省物理内存,但只能在PASSIVE_LEVEL和APC_LEVEL访问,DISPATCH_LEVEL以上碰它就是 0x000000D1。判断标准很简单:这块内存会不会在自旋锁里、DPC 里、中断服务例程里被访问?会,就用非分页。

ExAllocatePool2是 Win10 2004 之后的新接口,老系统上不存在。要兼容的话,用一层宏隔开:

#if (NTDDI_VERSION >= NTDDI_WIN10_VB) #define SampleAlloc(size, tag) ExAllocatePool2(POOL_FLAG_NON_PAGED, (size), (tag)) #else #define SampleAlloc(size, tag) ExAllocatePoolWithTag(NonPagedPool, (size), (tag)) #endif

tag 一定要成对。申请用'pmaS'、释放用别的,池跟踪直接报泄漏,Driver Verifier 打开后一跑一个准。ExAllocatePool2在申请失败时不会像老接口那样自动尝试等待,返回nullptr就是真没内存了,老老实实返回STATUS_INSUFFICIENT_RESOURCES。

顺带提一句整数溢出。分配之前算大小,inLen - 1这种写法在inLen == 0时会绕成一个巨大的无符号数,接着就是一次必然失败的大块申请,或者更糟,一次刚好成功的小块申请加一次越界写。

4.2 自旋锁与 IRQL:临界区里不能做的事

KSPIN_LOCK g_CounterLock; ULONG g_Counter; VOID IncreaseCounter(VOID) { KIRQL oldIrql; KeAcquireSpinLock(&g_CounterLock, &oldIrql); // 临界区:只能做纯计算和访问非分页内存 g_Counter++; KeReleaseSpinLock(&g_CounterLock, oldIrql); }

KeAcquireSpinLock内部会把当前 IRQL 抬到DISPATCH_LEVEL,并把原来的 IRQL 写进oldIrql。这意味着临界区里有一条硬性禁令:不能访问分页内存、不能调用任何可能阻塞的 API(Zw*系列、文件操作、ExAllocatePool2的分页标志、等待事件),否则轻则死锁,重则蓝屏。

oldIrql必须是局部变量,不能存成全局。嵌套加锁时用全局变量保存 IRQL,释放时会恢复成错误的级别,这类 bug 单步调试看不出来,只有并发压力下才冒头,属于典型的玄学问题。

如果代码本来就在DISPATCH_LEVEL(比如 DPC 里),不要用KeAcquireSpinLock,它会试图再次抬升 IRQL 并触发断言。这时用KeAcquireSpinLockAtDpcLevel/KeReleaseSpinLockFromDpcLevel这一对。

4.3 UNICODE_STRING 与 LIST_ENTRY:生命周期比语法更要紧

UNICODE_STRING ustr; RtlInitUnicodeString(&ustr, L"\\Registry\\Machine\\Software\\Sample"); // RtlInitUnicodeString 不复制内容,只是把 Buffer 指向常量字符串 // Length 和 MaximumLength 的单位是字节,不是字符数 KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "len=%u max=%u\n", ustr.Length, ustr.MaximumLength));

RtlInitUnicodeString只是把Buffer指向传入的常量,不接管内存。如果传入的是栈上的WCHAR buf[64],函数返回后这个UNICODE_STRING就悬空了,后续任何一次访问都是野指针。要真正持有一份字符串,得自己申请内存再RtlCopyUnicodeString,用完ExFreePoolWithTag。另一个常见错误是把Length当成字符数,实际它是字节数,处理中文路径时按字符数算长度,缓冲区会少一半。

链表这部分,CONTAINING_RECORD是绕不开的:

typedef struct _SAMPLE_ENTRY { LIST_ENTRY Link; // 必须放在结构体内部 ULONG Id; WCHAR Name[32]; } SAMPLE_ENTRY, *PSAMPLE_ENTRY; // 插入 PSAMPLE_ENTRY entry = (PSAMPLE_ENTRY)SampleAlloc(sizeof(SAMPLE_ENTRY), SAMPLE_TAG); if (entry) { entry->Id = 1; InsertTailList(&g_List, &entry->Link); } // 遍历 for (PLIST_ENTRY p = g_List.Flink; p != &g_List; p = p->Flink) { PSAMPLE_ENTRY e = CONTAINING_RECORD(p, SAMPLE_ENTRY, Link); KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "id=%u\n", e->Id)); }

CONTAINING_RECORD靠的是「已知成员地址 + 成员在结构体里的偏移」反推结构体首地址,所以Link必须是结构体的直接成员,不能是外部另存的指针。遍历时循环条件写p != &g_List,不是p != nullptr,链表头是个哨兵节点,这点和用户态的单链表习惯不一样。

5. 避坑与排查:加载失败、蓝屏、签名被拒的现场记录

驱动这东西,出问题的时候没有异常弹窗,只有一句错误码或者一屏蓝字。下面这些都是我在拆这类源程序时反复遇到的现场,按「现象 → 原因 → 解决」记下来,遇到了照着对。

5.1 加载阶段:服务建好了但启动不了

现象一:sc create返回成功,sc start报「系统错误 577」,提示文件已损坏或签名无效。

原因是驱动的数字签名没通过校验,或者.cat文件没跟着一起装。sc create只登记服务,不校验文件,所以创建阶段永远是成功的,真正校验发生在start。

解决分两步:开发阶段用测试签名,bcdedit /set testsigning on之后重启,再用sc start;正式发布则必须有有效的签名链,.cat和.sys放在同一目录。另外用sc qc SampleDrv看一眼BINARY_PATH_NAME,路径里带空格没加引号是最常见的低级错误。

现象二:sc start返回成功,但应用层CreateFile报ERROR_FILE_NOT_FOUND。

DriverEntry返回了STATUS_SUCCESS,系统就认为加载成功,哪怕设备对象根本没建出来。多半是符号链接名写错,或者IoCreateSymbolicLink失败但返回值被忽略了。

解决:用 WinObj 这类工具看一眼\??\下面有没有对应的名字。没有就回到CreateDeviceAndLink,把每个NTSTATUS都打出来,别用UNREFERENCED_PARAMETER把返回值糊过去。

现象三:第一次加载正常,卸载后再加载报「设备名已存在」。

这是清理顺序反了。正确的卸载顺序是先IoDeleteSymbolicLink,再IoDeleteDevice;反过来删,符号链接会指向一个已释放的设备对象,第二次创建就撞名。

解决:把DriverUnload里的两行调换过来,并且两个函数的返回值都要检查。顺手把DeviceExtension里申请的池内存也一起释放掉,池泄漏在 Verifier 下跑几分钟就报警。

5.2 运行阶段:三类高频蓝屏的定位

现象四:随机蓝屏,错误码 0x0000007E 或 0x0000008E。

KERNEL_MODE_EXCEPTION_NOT_HANDLED,本质是内核里发生了一次没人接的异常,绝大多数是空指针解引用或者数组越界。内核态没有 SEH 兜底,nullptr->field直接带走整机。

解决:挂双机内核调试,!analyze -v看崩溃时的调用栈和出错指令地址,再ln定位到具体行。临时验证可以先用 Verifier 打开 Special Pool,让越界写尽快暴露成受控崩溃。

现象五:0x000000D1DRIVER_IRQL_NOT_LESS_OR_EQUAL。

在DISPATCH_LEVEL或更高 IRQL 上访问了分页内存,或者访问了一个非法地址。常见诱因是在自旋锁临界区里调用了ExAllocatePool2(POOL_FLAG_PAGED, ...),或者 DPC 里碰了用户态传进来的裸指针。

解决:把所有可能在高 IRQL 执行的路径过一遍,凡是访问的内存都换成非分页池。用KeGetCurrentIrql()打日志确认实际执行级别,比凭感觉猜靠谱得多。

现象六:0x000000C2BAD_POOL_CALLER。

池操作的参数不对,典型是重复释放、释放了不属于自己的指针、或者申请和释放的 tag 不一致。

解决:tag 全部用宏统一定义,不要在文件里手写字符常量。Verifier 打开 Pool Tracking,泄漏和重复释放会被精确报出来,比肉眼看代码快十倍。

5.3 调试与签名环境:改完记得回滚

内核调试、日志、Verifier 这几样配好,排查效率完全是两个量级:

:: 打开测试签名模式,重启生效 bcdedit /set testsigning on :: 给指定驱动开标准检查(特殊池、池跟踪、IRQL 检查等) verifier /standard /driver SampleDrv.sys :: 排查结束后务必关掉,否则机器会一直很慢 verifier /reset bcdedit /set testsigning off

日志用DbgPrintEx配合DPFLTR_IHVDRIVER_ID这个通道,DebugView 里勾上对应过滤就能看到,比KdPrint更可控。注意发布版本里要把高频日志去掉,DbgPrintEx在 Release 下虽然被裁掉一部分,但留在热路径上仍然会影响性能。

测试签名模式打开后系统的启动校验会放宽,调试完一定要关回去,别留在机器上。这一步我吃过亏,一台机器忘了关,后面排查别的问题时一直怀疑是驱动,最后发现是自己留的环境。

6. 二次开发的改造顺序:把样例驱动换成自己的功能模块

拿到这类源程序,最忌讳的是上来就大改。我的习惯是按契约层、分发层、应用层这个顺序动,每改一处就验证一处,出问题能立刻定位到是哪一层。

要改的东西改哪里怎么验证
新增一个功能Public.h加CTL_CODE,功能号顺延应用层DeviceIoControl返回ERROR_INVALID_PARAMETER说明号没对上
加设备级状态IoCreateDevice第二参数改成sizeof(上下文)卸载后重载不蓝屏,Verifier 无泄漏
换设备名DEVICE_NAME/SYMLINK_NAME两处同时改WinObj 里能看到新名字,旧名字消失
加并发访问引入KSPIN_LOCK或FAST_MUTEX多线程压测 30 分钟,Verifier 无 IRQL 报错

改造顺序具体是三步。第一步只动Public.h,把新 IOCTL 号加进去,驱动和应用各包含一次,先确认能编译通过——这一步不改任何逻辑,纯粹验证头文件路径没写错。第二步在DispatchDeviceControl里补一个 case,先用最简单的方式返回一个固定值,编译、加载、应用层调通,把链路走通。第三步才往里面填真正的业务逻辑,比如读写硬件寄存器、遍历进程、过滤文件操作。

这个顺序看起来笨,但能省掉大量「到底是通信没通还是逻辑写错」的纠结。我见过太多人一上来就把业务代码全写完,然后CreateFile就打不开,只能从头一层层剥。

还有两个细节值得提前规划。一是缓冲区协议,METHOD_BUFFERED够用就别换METHOD_NEITHER,后者要自己ProbeForRead/ProbeForWrite,还要处理 try/except,一不小心就是个可被利用的漏洞面。二是卸载路径,每加一个资源申请,就同步在DriverUnload里加对应的释放,别攒着最后一起写,攒着的那部分代码你大概率会忘。

从那以后我每次改完 IOCTL,都强制走一遍固定流程:先在 Verifier 全开的情况下跑一轮压力,再上真机连续加载卸载二十次,最后才敢说这个改动是干净的。内核态没有后悔药,蓝屏那一刻能救你的只有提前埋好的检查点。希望帮到你。

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

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

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

立即咨询