第4章 驱动程序:从头到尾
从 DriverEntry 初始化、注册表参数、SDDL 访问控制到 Create/Close 与 DeviceIoControl 分发例程,走完一个控制设备驱动的完整生命周期并实现 IOCTL 通信协议。
学习目标
- 能实现 DriverEntry 中的设备创建、符号链接暴露、SDDL 访问控制与分发例程注册
- 能改出 DeviceIoControl 分发例程中校验输入长度并返回错误码的效果,并解释用户模式端收到什么
- 能回答:驱动卸载时为什么必须先删符号链接再删设备对象?颠倒顺序会发生什么?
为什么驱动和程序之间需要一扇正式的门
想象一栋大楼。每个来访者不能直接闯进机房,得先到前台登记、领一张门禁卡,凭卡进出特定楼层;用完还卡、注销权限。大楼的保安系统——门禁、登记表、访客权限——就是本章要建的“门”。
没有这扇门会怎样?任何程序都能直接改驱动内部的数据:长度不检查、权限不校验,一次越界写入就能让整栋楼断电。而有了门,访问必须走固定流程:登记(打开)、办事(控制)、注销(关闭),每步都有保安检查。
这一章把驱动从头到尾建一遍:前台怎么建(初始化)、门禁规则怎么写(访问控制)、来访者怎么办事(通信协议)、下班怎么清场(卸载)。建完这扇门,后面的章节都在这扇门上装新功能。
控制设备与符号链接:大楼的前台
↡驱动创建的、用于接收用户模式 I/O 请求的特殊设备对象,本身不对应任何硬件 是驱动与用户程序通信的入口。它不是真实硬件,而是一个纯粹的逻辑节点:用户程序对它发起打开、控制、关闭操作,内核把这些操作打包成请求交给驱动处理。
控制设备在内核里叫 ↡设备对象(Device Object),用户模式看不到它,因为它的名字在 \Device\ 命名空间——那是内核的地盘。驱动再用 ↡用户模式能访问的别名,把内核命名空间的设备映射到 \??\ 下的可打开路径 把它暴露出来:用户程序用 CreateFile(“\\\\.\\MyDriver”) 打开的就是这个链接。前台的位置图就是这样:设备对象是机房里的接待台,符号链接是大门口挂的指路牌。
通信协议:来访者与保安的暗号
驱动和用户程序各说各话是行不通的,必须预先约定一套 ↡用户程序与驱动之间约定的请求格式,包括 IOCTL 码、输入/输出缓冲区布局和错误码语义。协议的核心是 IOCTL 码:一个 32 位整数,编码了设备类型、功能号、缓冲区传输方法和访问权限。双方各自定义一个头文件,同一套结构体、同一组码值——驱动侧解析,用户侧构造。
协议还规定缓冲区怎么传:输入放哪、输出写哪、长度怎么校验。双方必须对同一份协议实现达成一致,否则驱动以为输入在第 3 个字段,程序其实写在第 2 个——解码结果全是乱的。协议不健全是驱动安全事故的第一大来源。
分发例程:大楼里的三个办事窗口
用户程序每次对控制设备发起操作,内核都会构造一个请求包,按操作类型查表调用驱动注册的处理函数,这些函数统称 ↡驱动注册在内核请求分发表中的处理函数,分别响应打开、关闭、控制等操作(dispatch routines)。一个控制设备驱动最少有三个窗口:Create(来访者登记,发放句柄)、Close(还卡注销)、DeviceIoControl(办事窗口,处理真正的业务请求)。
分发例程是驱动的主战场:请求来了,检查协议、校验参数、执行操作、返回结果。请求包在后续章节会展开细讲,这里先把三个窗口的职责和顺序搞清楚。窗口职责错位(比如把业务逻辑写进 Create)是新手最常见的结构错误。
SDDL:门禁规则怎么写
控制设备默认允许任何用户打开——这对很多驱动是事故。Windows 提供 ↡用一段字符串描述访问权限的语言,Device 对象用它声明谁能打开、能做什么操作(Security Descriptor Definition Language)来写门禁规则:在创建设备对象时传入一段描述字符串,系统据此检查打开者的权限。
SDDL 字符串长这样:“D:P(A;;GA;;;SY)(A;;GA;;;BA)”——括号每段是一个允许规则,SY 是系统账户、BA 是管理员组。写成“只有管理员能访问”,普通用户打开就会得到 STATUS_ACCESS_DENIED。门禁写在创建时,一次声明、内核强制,比驱动内部自己判断可靠得多。
动手:走一遍驱动生命周期与 IOCTL 协议流
下面的可视化把整章内容串成一张图:上半部分是生命周期三阶段(初始化 → 运行 → 卸载),下半部分是用户模式的三种请求(打开、控制、关闭)如何流向对应的分发例程。点击任意区域看详情;点请求按钮观察路径;打开门禁开关模拟 SDDL 拒绝。
猜一猜:先打开“拒绝打开”开关,再点“DeviceIoControl”按钮——请求会被哪个环节拦下?用户程序拿到的错误码是什么?动手试试再看下面的代码。
控制设备是用户程序与驱动通信的入口,符号链接(如 \\.\MyDriver)是用户模式能访问的名字。创建设备时传入的 SDDL 字符串决定谁能打开它——SDDL 是访问控制的声明式语言,写成 "D:P(A;;GA;;;SY)(A;;GA;;;BA)" 表示只允许系统和管理员访问。
代码对照:一个完整控制设备驱动
这一节给出一个最小但完整的控制设备驱动,四个代码段对应生命周期的四个阶段:初始化(建前台+写门禁)、打开/关闭(登记/注销)、业务处理(IOCTL 分发)、卸载(清场)。完整的可运行工程(.sys + 客户端 .exe + INF)见章末来源链接,正文只放讲解段。
段一:DriverEntry —— 建前台、挂门牌、写门禁
DriverEntry 做四件事:从注册表参数读配置(协议号、缓冲上限)、用 IoCreateDevice 创建控制设备、用 IoCreateSymbolicLink 挂符号链接、把三个分发例程填进 DriverObject 的函数指针表。注意创建设备时传入的 SDDL 字符串——它同时锁定了打开权限。
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
// 1. 从注册表读协议配置(上层服务键的 Parameters 子键)
UNICODE_STRING keyPath = RegistryPath->Buffer[0]
? *RegistryPath : RTL_CONSTANT_STRING(L"");
RTL_QUERY_REGISTRY_TABLE table = {0};
ULONG maxBuf = 4096; // 协议缓冲上限,可被注册表覆盖
// ... RtlQueryRegistryValues 填充 maxBuf ...
// 2. 创建控制设备,附带 SDDL 门禁:只允许系统与管理
UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\MyDriver");
PDEVICE_OBJECT devObj = nullptr;
NTSTATUS st = IoCreateDevice(DriverObject, sizeof(MY_CONTEXT),
&devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &devObj);
if (!NT_SUCCESS(st)) return st;
// 3. 挂符号链接,让用户模式能通过 \\.\MyDriver 打开
UNICODE_STRING linkName = RTL_CONSTANT_STRING(L"\\??\\MyDriver");
st = IoCreateSymbolicLink(&linkName, &devName);
if (!NT_SUCCESS(st)) { IoDeleteDevice(devObj); return st; }
// 4. 注册分发例程(三个办事窗口 + 兜底)
DriverObject->MajorFunction[IRP_MJ_CREATE] = MyCreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = MyCreateClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MyDeviceControl;
return STATUS_SUCCESS;
}NtLoadDriver 加载 .sys
│
├─→ 读注册表 Parameters\MaxBuffer → 协议上限
├─→ IoCreateDevice(名字, SDDL) → 建控制设备 + 门禁
├─→ IoCreateSymbolicLink(\??\MyDriver → \Device\MyDriver)
│ → 用户模式可见的门牌
└─→ MajorFunction[CREATE/CLOSE/CONTROL] = 分发例程
│
└─→ 返回 STATUS_SUCCESS → 驱动进入"运行"状态注意失败路径:符号链接创建失败时必须回滚已创建设备,否则下次加载会撞名。入口处每个成功步骤都要有对应的失败清理——这是内核代码的纪律,用户模式程序可以偷懒,内核不行。
段二:Create / Close —— 登记与注销
两个分发例程共用一个函数:Create 时检查是否已有客户端占用(排他协议)、初始化客户上下文;Close 时释放上下文。请求包里带着本次操作的类型,用 IoGetCurrentIrpStackLocation 取出来判断。
NTSTATUS MyCreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
MY_CONTEXT* ctx = (MY_CONTEXT*)DeviceObject->DeviceExtension;
if (stack->MajorFunction == IRP_MJ_CREATE) {
if (ctx->clientCount >= 1) { // 排他协议:只许一个客户
Irp->IoStatus.Status = STATUS_ACCESS_DENIED;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_ACCESS_DENIED;
}
ctx->clientCount++; // 登记:占用计数 +1
} else {
ctx->clientCount--; // 注销:占用计数 -1
}
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}// 打开控制设备(对应内核 IRP_MJ_CREATE)
HANDLE h = CreateFileW(L"\\\\.\\MyDriver",
GENERIC_READ | GENERIC_WRITE,
0, nullptr, OPEN_EXISTING, 0, nullptr);
if (h == INVALID_HANDLE_VALUE) {
// 第二个客户端在此失败:ERROR_ACCESS_DENIED
}
// 关闭控制设备(对应内核 IRP_MJ_CLOSE)
CloseHandle(h);排他检查要放在请求包完成之前。注意:ctx 来自设备扩展(DeviceExtension)——每个设备对象都自带一小块私有内存,是驱动存放每个设备状态的标准位置。
段三:DeviceIoControl —— 业务窗口
这是协议的核心实现。先用传输方法(METHOD_BUFFERED:输入输出都经过系统缓冲区)取出输入、计算长度,按 IOCTL 码分发到具体功能。长度校验必须先于任何缓冲区访问——输入长度大于协议上限就立即返回 STATUS_BUFFER_TOO_SMALL,绝不解引用。
NTSTATUS MyDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG code = stack->Parameters.DeviceIoControl.IoControlCode;
ULONG inLen = stack->Parameters.DeviceIoControl.InputBufferLength;
ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength;
ULONG info = 0;
NTSTATUS st = STATUS_SUCCESS;
switch (code) {
case IOCTL_MY_ADD: { // 自定义协议命令
auto* in = (MY_ADD_IN*)Irp->SystemBuffer;
if (inLen < sizeof(MY_ADD_IN)) { st = STATUS_BUFFER_TOO_SMALL; break; }
if (outLen < sizeof(MY_ADD_OUT)) { st = STATUS_BUFFER_TOO_SMALL; break; }
auto* out = (MY_ADD_OUT*)Irp->SystemBuffer;
out->sum = in->a + in->b; // 真正的业务逻辑
info = sizeof(MY_ADD_OUT);
break;
}
default:
st = STATUS_INVALID_DEVICE_REQUEST; // 未知 IOCTL 码
break;
}
Irp->IoStatus.Status = st;
Irp->IoStatus.Information = info;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return st;
}// 客户端构造同样的协议结构体,调用一次设备控制
MY_ADD_IN in = { .a = 5, .b = 7 };
MY_ADD_OUT out = { 0 };
DWORD returned = 0;
BOOL ok = DeviceIoControl(h,
IOCTL_MY_ADD, // 双方约定的 IOCTL 码
&in, sizeof(in), // 输入缓冲区
&out, sizeof(out), // 输出缓冲区
&returned, nullptr);
if (!ok) {
// 失败时 GetLastError() 映射自驱动的 NTSTATUS
}
// 成功:out.sum == 12,returned == sizeof(MY_ADD_OUT)段四:Unload —— 清场顺序
卸载是初始化的镜像,必须严格反向:先删符号链接(摘掉门牌,新访客进不来),再删设备对象(拆掉前台)。设备对象删掉后,任何未完成的请求都会失败——这正是我们要的:先断新客,再清旧账。
void DriverUnload(PDRIVER_OBJECT DriverObject)
{
PDEVICE_OBJECT devObj = DriverObject->DeviceObject;
// 1. 摘门牌:新客户端无法再打开
UNICODE_STRING linkName = RTL_CONSTANT_STRING(L"\\??\\MyDriver");
IoDeleteSymbolicLink(&linkName);
// 2. 拆前台:删除设备对象(含设备扩展内存)
if (devObj) {
IoDeleteDevice(devObj);
DriverObject->DeviceObject = nullptr;
}
// 3. 释放驱动级资源(池内存、事件、锁……)
// 每个客户端上下文应已在 Close 时释放完毕
}:: 管理员命令行:卸载驱动
sc stop MyDriver :: 触发 DriverUnload
sc delete MyDriver :: 删除服务记录
:: 若客户端句柄未关闭,sc stop 会失败或挂起——
:: 驱动层应先排空所有打开句柄再执行卸载容易踩的坑
小结
- DriverEntry 建控制设备、挂符号链接、写 SDDL 门禁并注册分发例程
- 协议用 IOCTL 码 + 结构体约定,双方头文件共享,长度必须先校验
- Create/Close 管理句柄计数与客户上下文,DeviceIoControl 是业务入口
- 卸载严格反向:删链接、删设备、释放资源,先断新客再清旧账
- SDDL 让内核在打开阶段就强制访问控制,不在驱动内部重复判断
练习
问题 1: 在 Demo 中开启“拒绝打开”后点 DeviceIoControl,观察请求在哪里被拦下。用本章的 SDDL 知识解释:为什么门禁生效的位置在打开阶段而不是 IOCTL 分发阶段?
问题 2: 以下 DriverEntry 片段有什么问题?列出至少两处。
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\BadDriver");
PDEVICE_OBJECT dev;
IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &dev);
UNICODE_STRING link = RTL_CONSTANT_STRING(L"\\??\\BadDriver");
IoCreateSymbolicLink(&link, &devName);
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}问题 3(独立实现题): 为 MyDeviceControl 增加一个 IOCTL_MY_QUERY 命令:协议结构为 MY_QUERY_OUT { ULONG version; ULONG maxBuffer; },输出缓冲区长度校验后填入版本号 1 和协议缓冲上限(注册表读到的 maxBuf)。要求:先校验长度再写缓冲区,设置正确的 Information 字段,未知 IOCTL 码返回 STATUS_INVALID_DEVICE_REQUEST。
知识点对照
4.2 驱动程序初始化
驱动程序初始化在 DriverEntry 中完成:创建设备对象、符号链接并注册分发例程,失败时返回错误。
4.2.1 将信息传递给驱动程序
将信息传递给驱动程序通过注册表参数键完成,DriverEntry 在加载时读取并解析这些配置。
4.2.2 客户程序/驱动程序之间的通信协议
客户程序/驱动程序之间的通信协议由 IOCTL 码定义,规定输入输出缓冲区的布局与返回方式。
4.3 客户代码
客户代码先用 CreateFile 打开设备句柄,再调用 DeviceIoControl 与驱动交互。
4.1 简介
本章简介完整驱动开发的全部环节:从工程创建、初始化到通信与安装测试,一个环节都不遗漏,这也是本章的核心要点。
4.4 Create和Close分发例程
Create和Close分发例程处理文件对象的打开与关闭,成功返回即可允许访问并配对清理。
4.6 安装与测试
安装与测试用 sc 命令注册服务并启动驱动,再用客户程序调用验证驱动功能是否真正正常工作。
4.7 总结
本章总结了驱动从初始化到通信的完整开发流程、常见关键陷阱以及客户代码的写法要点和注意事项。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 控制设备
驱动创建的、不对应任何硬件的特殊设备对象,是用户程序与驱动通信的入口。可以理解为大楼的前台:所有来访(打开、控制、关闭)都先到它这里登记。
- 设备对象
内核中代表一个设备实例的数据结构,由 IoCreateDevice 创建。控制设备驱动用它接收 I/O 请求,每个设备对象自带一块设备扩展内存存放驱动私有状态。类比大楼里的接待台本身。
- 符号链接
把内核命名空间(\Device\X)映射到用户模式可访问路径(??\X)的别名,用户程序用 \.\X 打开它。相当于大楼门口挂的指路牌——接待台在楼内,访客靠牌子找到门。
- 通信协议
用户程序与驱动之间约定的请求格式:IOCTL 码、输入/输出结构体布局、长度与错误码语义。双方按同一份头文件实现,驱动解析、客户端构造。相当于来访者与保安约定好的暗号。
- 分发例程
驱动注册到内核请求分发表里的处理函数,按请求类型分别响应:Create 登记、Close 注销、DeviceIoControl 办事。内核每次操作都查表调用对应的窗口。
- SDDL
用一段字符串描述访问权限的语言。创建设备对象时传入,内核在打开阶段强制检查。写成“D:P(A;;GA;;;SY)(A;;GA;;;BA)”只放行系统和管理员。相当于写在大门口的门禁规则,由保安(内核)强制执行。