第2章 开始内核开发
安装 Visual Studio 与 WDK,创建最小 WDM 驱动项目,实现 DriverEntry 与 DriverUnload,完成签名、部署、加载、卸载与调试跟踪的完整生命周期。
学习目标
- 能安装配置 Visual Studio + WDK,创建并构建一个最小 WDM 驱动项目,产出 .sys 内核模块
- 能改出 DriverEntry 中 DbgPrint 输出自定义字符串的效果,并在 DebugView 中观察到自己的日志
- 能回答:为什么 64 位 Windows 加载未签名驱动会失败?整条生命周期中是哪一步出了问题?
为什么写内核驱动要先走通一整条流水线
写一个驱动,就像把一块钢锭送进工厂流水线,最后变成一台能用的机器再回收拆解。原料要按对图纸,铸造要控好温度,出厂要盖章,运到现场装好才能通电运行,用完还得按规程拆解回收。
这条流水线任何一环出岔,机器都跑不起来——或者更糟,跑起来后失控伤人。驱动和系统共用同一片电路,一处短路就可能让整台机器烧掉。
这一章带你从写第一行代码,走到把成品装进机器、再安全拆出来。把这条流水线完整走一遍,后面所有功能都是在这条线上加料。
驱动生命周期:七步流水线
↡Windows 驱动的经典模型,用 DriverEntry、Unload 回调和 IRP 派遣函数组织驱动代码(Windows Driver Model)是本章用的驱动模型。一个 WDM 驱动从写代码到卸载要依次走过七步:编写、构建、签名、部署、加载、运行、卸载。前四步在用户模式工具链里完成,后三步发生在内核里。
流水线的起点和终点是两个回调函数。↡驱动的入口函数,类似 C 的 main,加载时由内核调用,返回 STATUS_SUCCESS 才算加载成功 在驱动加载时被内核调用,负责创建设备对象、注册↡驱动卸载时内核调用的回调函数,负责释放所有资源、设置派遣函数。DriverUnload 在卸载时被调用,负责删除设备、释放内存。这一进一出,定义了驱动的生命周期边界。
中间的“运行”阶段,驱动通过 ↡I/O 请求包,内核把用户请求打包后交给驱动处理的标准化数据结构(I/O Request Packet)接收工作。用户程序调用 DeviceIoControl 时,内核构造 IRP 派发给驱动的派遣函数,驱动处理完标记完成,请求才返回用户模式。“签名”这一步则是 64 位 Windows 的强制要求——开发期用 ↡开发期临时关闭签名强制的模式,用 bcdedit /set testsigning on 开启,允许加载自签名驱动 模式加载自签证书签名的驱动。下面的流水线图把七步串起来看。
动手:驱动生命周期流水线
下面的可视化把七步流水线画成一条可点击的管线。点任一阶段看详情;打开“注入故障”开关,高亮三个最常见的失败点:架构错配(构建)、未签名(签名)、忘记卸载(卸载)。
猜一猜:开启“注入故障”后,“签名”阶段会标红——未签名的驱动会在第 5 步加载时发生什么?动手看看,再对照下面的代码。
编写 DriverEntry 与 DriverUnload 的 C 代码。DriverEntry 是驱动的入口函数(类似 main),接收 DriverObject 指针和注册表路径两个参数。在这里注册 Unload 回调、创建设备对象、设置派遣函数。这是整条生命周期的起点——代码错误会在后续阶段被放大。
代码对照:DriverEntry 与 DriverUnload
这一节给出最小可运行 WDM 驱动的完整代码。它做三件事:在 DriverEntry 里创建设备对象和符号链接、注册 Unload 和派遣函数;在 DriverUnload 里删除设备和符号链接;在 DispatchDefault 里完成所有 IRP。三个 CodeTabs 分别对应流水线的“加载入口”“卸载出口”“运行处理”。
段一:DriverEntry —— 加载时内核第一个调用的函数
DriverEntry 是内核调用驱动的地方。它先用 IoCreateDevice 建一个设备对象(驱动的“身份证”),再用 IoCreateSymbolicLink 给它起一个用户模式能访问的名字(\\.\HelloDriver)。然后把 DriverUnload 和 DispatchDefault 填进 DriverObject 的函数指针表。最后返回 STATUS_SUCCESS——返回任何错误码,驱动都不会被加载。
#include <ntddk.h>
#define DEVICE_NAME L"\\Device\\HelloDriver"
#define SYMLINK_NAME L"\\??\\HelloDriver"
void DriverUnload(PDRIVER_OBJECT);
NTSTATUS DispatchDefault(PDEVICE_OBJECT, PIRP);
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
DbgPrint("[HelloDriver] DriverEntry\n");
UNICODE_STRING devName = RTL_CONSTANT_STRING(DEVICE_NAME);
PDEVICE_OBJECT dev;
NTSTATUS st = IoCreateDevice(DriverObject, 0, &devName,
FILE_DEVICE_UNKNOWN, 0, FALSE, &dev);
if (!NT_SUCCESS(st)) return st;
UNICODE_STRING link = RTL_CONSTANT_STRING(SYMLINK_NAME);
IoCreateSymbolicLink(&link, &devName);
DriverObject->DriverUnload = DriverUnload;
for (int i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; i++)
DriverObject->MajorFunction[i] = DispatchDefault;
return STATUS_SUCCESS;
}内核 (NtLoadDriver)
│
│ 1. 为 .sys 分配内核内存、修正重定位
│ 2. 解析导入表(链接 ntoskrnl 导出)
│ 3. 调用 DriverEntry(DriverObject, RegistryPath)
│
├─→ IoCreateDevice(...) // 建 DeviceObject
├─→ IoCreateSymbolicLink(...) // 暴露给用户模式
├─→ DriverObject->DriverUnload // 注册卸载回调
├─→ MajorFunction[i] = Dispatch // 注册派遣
│
└─→ return STATUS_SUCCESS // 成功;返回错误码则加载失败段二:DriverUnload —— 卸载时必须与入口对称
DriverUnload 是 DriverEntry 的镜像:入口创建了什么,卸载就要删什么。这里删除符号链接和设备对象。如果忘了删除,这些内核对象会一直挂着,下次加载同名驱动会冲突。注意 DbgPrint 的输出可以在 DebugView 或 WinDbg 里看到——这是开发期跟踪驱动行为的主要手段。
// 复用 DriverEntry 中的 DEVICE_NAME / SYMLINK_NAME
void DriverUnload(PDRIVER_OBJECT DriverObject)
{
DbgPrint("[HelloDriver] DriverUnload\n");
UNICODE_STRING link = RTL_CONSTANT_STRING(SYMLINK_NAME);
IoDeleteSymbolicLink(&link);
IoDeleteDevice(DriverObject->DeviceObject);
}:: 管理员命令行
sc stop HelloDriver :: 触发 DriverUnload
sc delete HelloDriver :: 从 SCM 删除服务记录
:: 没注册 DriverUnload?sc stop 会静默失败,
:: 驱动驻留内核,只能重启才能清掉段三:DispatchDefault —— 运行期处理 IRP
驱动加载成功后就驻留内核等待工作。每次用户模式调用 DeviceIoControl,内核构造一个 IRP,根据请求类型查 MajorFunction 表派发给对应函数。这里的 DispatchDefault 把所有请求都标记为成功并完成。真实驱动会按 IO_STACK_LOCATION 里的 IoControlCode 分发到不同的处理分支。
NTSTATUS DispatchDefault(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
DbgPrint("[HelloDriver] IRP major=%u\n", stack->MajorFunction);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}HANDLE h = CreateFileW(L"\\\\.\\HelloDriver",
GENERIC_READ | GENERIC_WRITE, 0, NULL,
OPEN_EXISTING, 0, NULL);
DWORD ret = 0;
DeviceIoControl(h, 0x80000000, NULL, 0, NULL, 0, &ret, NULL);
CloseHandle(h);容易踩的坑
小结
- WDM 驱动生命周期七步:编写、构建、签名、部署、加载、运行、卸载
- DriverEntry 是入口,注册 Unload 和派遣函数,返回 STATUS_SUCCESS 才加载成功
- DriverUnload 必须与入口对称,删除设备和符号链接,否则资源泄漏
- IRP 是驱动接收工作的单位,用户模式用 DeviceIoControl 触发
- 64 位强制签名;开发期用测试签名模式加载自签驱动
练习
问题 1: 为什么 64 位 Windows 加载未签名驱动会失败?测试签名模式解决了其中的什么问题?
问题 2(独立实现题): 修改上面的 DriverEntry,在 DbgPrint 里输出你自己的名字(如 “Hello from <你的名字>”),并额外打印 RegistryPath 指向的注册表路径字符串内容。说明如何在 DebugView 中验证输出确实来自你的驱动。
提示:PUNICODE_STRING 的字符串内容在 RegistryPath->Buffer,长度(字节)在 RegistryPath->Length;可以用 %wZ 格式符直接打印 UNICODE_STRING。
问题 3: 如果你 sc stop 一个没有注册 DriverUnload 的驱动会发生什么?为什么?如何恢复?
知识点对照
2.1 安装工具
安装内核开发工具先装 Visual Studio 与 Windows SDK,再安装 WDK,最后在 VS 中勾选驱动程序开发负载。
2.2 创建一个驱动程序项目
创建一个驱动程序项目通常用 Visual Studio 的驱动程序模板,自动生成工程文件与 DriverEntry 骨架。
2.3 DriverEntry和Unload例程
DriverEntry和Unload例程是驱动的两个入口:前者在加载时初始化,后者在卸载时对称释放资源。
2.4 部署驱动程序
部署驱动程序先用 sc create 注册服务并指向 .sys 路径,再 sc start 启动并确认加载成功。
2.5 简单的跟踪
简单的跟踪用 DbgPrint 输出调试信息,配合 DebugView 或 WinDbg 即可查看内核日志。
2.7 总结
本章总结了从安装工具到部署第一个驱动的完整流程,为后续章节的深入学习打下坚实基础,这也是本章的核心要点。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- WDM
Windows Driver Model,Windows 驱动的经典模型。它规定了一个驱动从加载到卸载怎么组织代码:入口函数 DriverEntry、卸载回调 DriverUnload、以及处理 I/O 请求的派遣函数。可以理解为“写 Windows 驱动的一套老规则”,本章用它把生命周期看透。
- DriverEntry
驱动的入口函数,类似 C 程序的 main。驱动被加载时内核第一个调用它,它接收 DriverObject 指针和注册表路径。在这里创建设备、注册卸载和派遣函数。返回 STATUS_SUCCESS 才算加载成功,返回错误码驱动就不会被加载。
- DriverUnload
驱动卸载时内核调用的回调函数,是 DriverEntry 的镜像。入口创建了什么(设备、符号链接、回调、内存),卸载就要删什么。忘了实现或注册它,驱动将无法卸载,只能重启系统才能清掉。
- IRP
I/O Request Packet,I/O 请求包。内核把用户请求(读、写、设备控制等)打包成的标准数据结构,派发给驱动的派遣函数处理。驱动处理完必须标记 IRP 完成,请求才能返回用户模式。可以理解为“内核递给驱动的一张工单”。
- 测试签名
开发期临时关闭 64 位 Windows 签名强制的模式。用
bcdedit /set testsigning on开启并重启后,系统允许加载用自签证书签名的驱动。它只影响开发机,发布到生产环境仍必须用正式 EV 证书走 Microsoft 仪表板签名。