第4章 驱动程序:从头到尾

从 DriverEntry 初始化、注册表参数、SDDL 访问控制到 Create/Close 与 DeviceIoControl 分发例程,走完一个控制设备驱动的完整生命周期并实现 IOCTL 通信协议。

学习目标

  • 能实现 DriverEntry 中的设备创建、符号链接暴露、SDDL 访问控制与分发例程注册
  • 能改出 DeviceIoControl 分发例程中校验输入长度并返回错误码的效果,并解释用户模式端收到什么
  • 能回答:驱动卸载时为什么必须先删符号链接再删设备对象?颠倒顺序会发生什么?

为什么驱动和程序之间需要一扇正式的门

想象一栋大楼。每个来访者不能直接闯进机房,得先到前台登记、领一张门禁卡,凭卡进出特定楼层;用完还卡、注销权限。大楼的保安系统——门禁、登记表、访客权限——就是本章要建的“门”。

没有这扇门会怎样?任何程序都能直接改驱动内部的数据:长度不检查、权限不校验,一次越界写入就能让整栋楼断电。而有了门,访问必须走固定流程:登记(打开)、办事(控制)、注销(关闭),每步都有保安检查。

这一章把驱动从头到尾建一遍:前台怎么建(初始化)、门禁规则怎么写(访问控制)、来访者怎么办事(通信协议)、下班怎么清场(卸载)。建完这扇门,后面的章节都在这扇门上装新功能。

控制设备与符号链接:大楼的前台

是驱动与用户程序通信的入口。它不是真实硬件,而是一个纯粹的逻辑节点:用户程序对它发起打开、控制、关闭操作,内核把这些操作打包成请求交给驱动处理。

控制设备在内核里叫 (Device Object),用户模式看不到它,因为它的名字在 \Device\ 命名空间——那是内核的地盘。驱动再用 把它暴露出来:用户程序用 CreateFile(“\\\\.\\MyDriver”) 打开的就是这个链接。前台的位置图就是这样:设备对象是机房里的接待台,符号链接是大门口挂的指路牌。

通信协议:来访者与保安的暗号

驱动和用户程序各说各话是行不通的,必须预先约定一套 。协议的核心是 IOCTL 码:一个 32 位整数,编码了设备类型、功能号、缓冲区传输方法和访问权限。双方各自定义一个头文件,同一套结构体、同一组码值——驱动侧解析,用户侧构造。

协议还规定缓冲区怎么传:输入放哪、输出写哪、长度怎么校验。双方必须对同一份协议实现达成一致,否则驱动以为输入在第 3 个字段,程序其实写在第 2 个——解码结果全是乱的。协议不健全是驱动安全事故的第一大来源。

分发例程:大楼里的三个办事窗口

用户程序每次对控制设备发起操作,内核都会构造一个请求包,按操作类型查表调用驱动注册的处理函数,这些函数统称 (dispatch routines)。一个控制设备驱动最少有三个窗口:Create(来访者登记,发放句柄)、Close(还卡注销)、DeviceIoControl(办事窗口,处理真正的业务请求)。

分发例程是驱动的主战场:请求来了,检查协议、校验参数、执行操作、返回结果。请求包在后续章节会展开细讲,这里先把三个窗口的职责和顺序搞清楚。窗口职责错位(比如把业务逻辑写进 Create)是新手最常见的结构错误。

SDDL:门禁规则怎么写

控制设备默认允许任何用户打开——这对很多驱动是事故。Windows 提供 (Security Descriptor Definition Language)来写门禁规则:在创建设备对象时传入一段描述字符串,系统据此检查打开者的权限。

SDDL 字符串长这样:“D:P(A;;GA;;;SY)(A;;GA;;;BA)”——括号每段是一个允许规则,SY 是系统账户、BA 是管理员组。写成“只有管理员能访问”,普通用户打开就会得到 STATUS_ACCESS_DENIED。门禁写在创建时,一次声明、内核强制,比驱动内部自己判断可靠得多。

动手:走一遍驱动生命周期与 IOCTL 协议流

下面的可视化把整章内容串成一张图:上半部分是生命周期三阶段(初始化 → 运行 → 卸载),下半部分是用户模式的三种请求(打开、控制、关闭)如何流向对应的分发例程。点击任意区域看详情;点请求按钮观察路径;打开门禁开关模拟 SDDL 拒绝。

猜一猜:先打开“拒绝打开”开关,再点“DeviceIoControl”按钮——请求会被哪个环节拦下?用户程序拿到的错误码是什么?动手试试再看下面的代码。

⚡ 驱动生命周期与 IOCTL 协议流
驱动生命周期 · 初始化 → 运行 → 卸载初始化DriverEntry注册表参数 → 创建设备注册分发例程运行Create / Close 分发IOCTL 分发处理客户请求卸载DriverUnload删链接 → 删设备释放全部资源用户模式内核模式用户程序客户端1. CreateFile(\\.\MyDriver)2. DeviceIoControl(h, IOCTL_CODE, ...)3. CloseHandle(h)访问门禁:SDDL 决定谁能打开设备CreateFileDeviceIoControlCloseHandleCreate / Close分发例程DeviceIoControl分发例程设备对象\Device\MyDriverSDDL 门禁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;
}

注意失败路径:符号链接创建失败时必须回滚已创建设备,否则下次加载会撞名。入口处每个成功步骤都要有对应的失败清理——这是内核代码的纪律,用户模式程序可以偷懒,内核不行。

段二: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;
}

排他检查要放在请求包完成之前。注意: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;
}

段四: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 时释放完毕
}

容易踩的坑

小结

  • 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)”只放行系统和管理员。相当于写在大门口的门禁规则,由保安(内核)强制执行。

讨论

评论区加载中…