Windows 驱动开发核心数据结构与驱动模型深度解析:从 NT 式到 WDM 驱动全攻略
- 作者: 刘杰
- 来源: 技术那些事
- 阅读:209
- 发布: 2025-07-04 09:34
- 最后更新: 2025-07-04 09:34
驱动的相关数据结构
驱动对象
由内核中的对象管理程序创建,由内核中的IO管理器进行加载。
- DeviceObject:每个驱动程序会有一个或者多个设备对象。其中每个设备对象都有一个指针,指向下一个设备对象,最后一个设备对象指向空。设备对象是有程序员自己创建的,而非操作系统完成,在驱动被卸载的时候,遍历每个设备对象,并将其删除。
- DriverStartIo:记录 StartIO 例程的函数地址,用于串行化操作。
- DriverUnload:指定驱动卸载时,所用的回调函数地址。
- MajorFunciton:记录的是一个函数指针数组,也就是说 MajorFunction 是一个数组,数组中的每个成员记录着一个指针,每一个指针指向的是一个函数。这个函数就是 IRP 的派遣函数。
驱动扩展
设备对象
每个驱动程序会创建一个或者多个设备对象,用 DeviceObject 数据结构进行表示。每个设备对象都会有一个指针,指向下一个设备对象,因此就形成了一个设备链。设备链的第一个设备是由 DriverObject 结构体中的 DeviceObject 对象指明的。设备对象保存设备特征和状态信息。
-
DriverObject:指向驱动程序中的驱动对象。同属于一个驱动程序的驱动对象,指向的是同一的驱动对象。
-
NextDevide:指向下一个设备对象。这里指的下一个设备对象,是同属于一个驱动对象的设备,也就是同一个驱动程序创建的若干个设备对象。每个设备对象,根据 NextDevice 域形成链表,从而可以枚举每个对象的值。
-
AttachedDevice:指向下一个设备对象。这里指的是,如果有更高一层的驱动附加到这个驱动的时候,AttachedDevice 指向的就是那个更高一层的驱动。
-
CurrentIrp:在使用 StartIo 例程的时候,此域指向的是当前 IRP 结构。
-
Flags:此域是一个无符号的 32 位无符号整形数。每个位有具体的含义,主要位如下所示:
标志 描述 DO_BUFFERED_IO 读写操作使用缓冲方式(系统复制缓冲区)访问用户模式数据 DO_EXCLUSIVE 一次只允许一个线程打开设备句柄 DO_DIRECT_IO 读写操作使用直接方式(内存描述符表)访问用户模式数据 DO_DEVICE_INITIALIZING 设备对象正在初始化 DO_POWER_PAGABLE 必须在 PASSIVE_LEVEL 级上处理 IRP_MJ_PNP 请求 DO_POWER_INRUSH 设备上电期需要大电流 -
DeviceExtension:指向的是设备扩展对象。每个设备都会指定一个设备扩展对象,设备扩展对象记录的是设备自己特殊定义的结构体,也就是由程序员自己定义的结构体。另外,在驱动程序中,应当尽量避免全局变量的使用,因为全局变量涉及不容易同步的问题。解决的办法,将全局变量存在设备扩展里。
-
DeviceType:指明设备的类型。常用类型太多,请自行搜索。
设备扩展
设备对象记录“通用”设备信息,而另外一些“特殊”信息记录在设备扩展里。各个设备扩展由程序员自己定义,每个设备扩展也不尽相同。设备扩展是由程序员指定内容和大小,由 IO 管理器创建的,并保存在非分页内存中。
在驱动程序中,尽量避免使用全局函数,因为全局函数往往导致函数的不可重入性。重入性指的是,在多线程的程序中,多个函数并行运行,函数的运行结果不会根据函数的调用先后顺序而导致不同。解决的办法是,将全局变量以设备扩展的形式存储,并加以适当的同步保护措施。
NT式驱动的基本结构
对于NT式驱动来说,主要的函数是 DriverEntry 例程,卸载例程,以及各个 IRP 的派遣例程。
驱动加载过程与驱动入口函数
和编写普通应用程序一样,驱动程序也有个入口函数,也就是首先被执行的函数。这个函数通常被命名为 DriverEntry,读者可以指定另外的名字,但最好遵循这个名字。该函数的原型如下:
c
NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject, IN PUNICODE_STRING pRegistryPath);
DriverEntry 主要是对驱动程序进行初始化工作,它是由系统进程所调用的。在 Windows 中有个特殊的进程叫做系统进程,打开进程管理器,里面有个 System 的进程就是系统进程。系统进程在系统启动的时候就已经被创建了。
驱动加载的时候,系统进程启动新的线程,调用执行体组件中的对象管理器,创建一个驱动对象。这个驱动对象是一个 DRIVER_OBJECT 的结构体。另外,系统进程调用执行体组件中的配置管理程序,查询此驱动程序对应注册表中的项。
系统进程调用驱动程序的 DriverEntry 例程时,同时传入两个参数,分别是 pDriverObject 和 pRegistryPath。其中一个是指向刚才被创建的驱动对象的指针,另外一个是指向设备服务键的键名字字符串的指针。在 DriverEntry 中,主要功能是对系统进程创建的驱动对象进行初始化。另外,设备服务键的键名有时候需要保存下来,因为这个字符串不是长期存在的(函数返回后可能消失)。如果以后想使用这个 Unicode 字符串,就必须先把它复制到安全的地方。
这个字符串的内容一般是\REGISTRY\MACHEINE\SYSTEM\ControlSet\Services\[服务名]。在驱动程序中,字符串用 Unicode 来表示。UNICODE 是宽字符集,每个字符用 16 位来表示。
其中 UNICODE 用数据结构 UNICODE_STRING 来表示:
c
typedef struct _UNICODE_STRING {
USHORT lenght;
USHORT MaximumLenght;
PWSTR Buffer;
} UNICODE_STRING, *PUNICODE_STRING;
- Length:记录这个字符串用多少个字节记录。如果字符串有 N 个字符,那么 Length 将会是 N 的 2 倍。
- MaximumLength:记录 Buffer 的大小,也就是这个结构能最大记录的字节数。MaximumLength 要大于等于 Length。
- Buffer:记录字符串的指针。与 ASCII 字符串不同,这里的字符串每个字符都是 16 位。
在驱动程序中可以使用 KdPrint 打印 UNICODE 的信息。其语法:
c
KdPrint(("%S\n", pRegistryPath->Buffer));
// 或者
KdPrint(("%ws", pRegitstryPath->Buffer));
DriverEntry 返回的是 NTSTATUS 的数据,NTSTATUS 被定义位 32 位无符号长整型。在驱动开发中,人们习惯用NTSTATUS 返回状态。其中 0 ~ 0x7FFFFFFF,被认为是正确的状态,而0x80000000 ~ 0xFFFFFFFF,被认为是错误的状态。有个非常有用的宏,NT_SUCCESS,被用来检测状态是否正确。
常用的 NTSTATUS 值有:
c++
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#define STATUS_BUFFER_OVERFLOW ((NTSTATUS)0x80000005L)
#define STATUS_UNSUCCESSFUL ((NTSTATUS)0xC0000001L)
#define STATUS_NOT_IMPLEMENTED ((NTSTATUS)0xC0000002L)
#define STATUS_ACCESS_VIOLATION ((NTSTATUS)0xC0000005L)
#define STATUS_INVALID_HANDLE ((NTSTATUS)0xC0000008L)
#define STATUS_INVALID_PARAMETER ((NTSTATUS)0xC000000DL)
创建设备对象
在 NT 式驱动程序中,创建设备对象是由 IoCreateDevice 内核函数完成的。
c
NTSTATUS
IoCreateDevice(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSiz,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
OUT PDEVICE_OBJECT *DeviceObject
)
- DriverObject:输入参数,每个驱动程序中,会有唯一的驱动对象与之对应,但每个驱动对象都会有若干个设备对象。DriverObject 指向的就是驱动对象的指针。
- DeviceExtensionSize:输入参数,制定设备扩展的大小,IO管理器会根据这个大小,在内存中创建设备扩展,并于驱动对象关联。
- DeviceName:输入参数,设置设备对象的名字。
- DeviceCharacteristics:输入参数,设置设备对象的特征。
- Exclusive:输入参数,设置设备对象是否为内核模式下使用,一般设置为 TRUE。
- DeviceObject:输出参数,IO管理器负责创建这个设备对象,并返回设备对象的地址。
- 返回值:返回此函数的调用状态。
设备名字用 UNICODE 字符串指定,如果不指定设备名字,IO管理器会自动分配一个数字作为设备的设备名。例如:\Device\00000001,\Device\00000002,\Device\00000003。
如果指定了设备名,只能被内核模式下的其他驱动程序所识别。但是用户模式下的应用程序无法识别这个设备。让用户模式下的应用程序是被这个设备有两种办法,第一种是通过符号链接找到设备,第二种是通过设备接口找到设备。设备接口的办法在 NT 驱动中很少使用。
符号链接,可以理解为对设备对象起了一个别名。设备对象的名称只能被内核模式的驱动识别,而别名也可以被用户模式下的应用程序识别。例如,常说的 C 盘,D 盘就是符号链接,所谓 C 盘,指的就是名为 "C:" 的符号链接,其真正的设备对象是 "\DeviceHarddiskVolume1",而 "D:" 所代表的真正的设备对象是 "\DeviceHarddiskVolume2"。创建符号链接的函数是 IoCreateSymbolicLink,其函数声明如下。
c
NTSTATUS
IoCreateSymbolicLink(
IN PUNICODE_STRING SymbolicLinkName,
IN PUNICODE_STRING DeviceName
);
在内核模式下,符号链接是以 \??\开头的(或者是\DosDevices\开头的),如 C 盘就是 \??\C:(或者 \DosDevices\C:)。而在用户模式下,则是以 \\.\开头的,如 C 盘就是 \\.\C:。
WDM 式驱动的基本结构
在 Windows 2000 之后,微软公司加入了新的驱动模型,这就是 WDM,WDM 驱动模型是建立在 NT式驱动模型的基础之上的。对于 WDM 驱动程序来说,一般都是基于分层的,也就是说,完成一个设备的操作,至少需要由两个设备共同完成。
物理设备对象与功能设备对象
在 WDM 驱动模型中,完成一个设备的操作,至少需要两个设备对象共同完成。其中一个是物理设备对象(Physical Device Object,以下简称 PDO)另一个是功能设备对象(Function Device Object,以下简称 FDO)。其关系式附件与被附加的关系。
当 PC 插入某个设备时,PDO 会自动创建。确切的说,是由总线驱动创建的。PDO 不能单独操作设备,需要配合 FDO 一起使用。系统会提示检测到新设备,需要安装驱动程序。这里的驱动程序指的就是 WDM 驱动程序,此驱动程序负责创建 FDO,并且附加到 PDO 之上。
当一个 FDO 附加到 PDO 之上的时候,PDO 设备对象的子域 AttachedDevice 会记录 FDO 的位置。PDO 被称作底层驱动或者下层驱动,而 FDO 被称作高层驱动或者上层驱动。这里的"上"指的是接近发出 IO 请求的地方,而"下"层指的是靠近物理设备的地方。
WDM 驱动程序的入口程序
和 NT 驱动程序一样,WDM 驱动程序的入口程序也是 DriverEntry,但是初始化的作用被分派到其他的例程中。例如,创建设备对象的责任就不在 DriverEntry 中,而被放在了 AddDevice 例程中。同时,在 DriverEntry 中,需要设置对 IRP_MJ_PNP 的处理的分派函数。
NT 式驱动和 WDM 式驱动的 DriverEntry 主要不同点如下:
- 增加了对 AddDevice 的函数设置,这是 WDM 和 NT 式驱动非常重要的不同点。因为 NT 驱动是主动加载设备的,也就是驱动一旦加载就创建设备。而 WDM 驱动是被动加载设备的,操作系统必须加载 PDO 之后,调用驱动的 AddDevice 例程中负责创建 FDO,并且附加到 PDO 之上。
- 创建设备对象已经不在这个函数中了,而在 AddDevice 例程中创建。
- 必须加入 IRP_MJ_PNP 的派遣回调函数,IRP_MJ_PNP 主要是负责计算机中即插即用的处理,在 WDM 驱动中加入了很多即插即用的处理。
WDM 驱动的 AddDevice 例程
AddDevice 例程是 WDM 驱动所独有的。在 NT 式驱动中没有此例程。在 DriverEntry 中需要设置 AddDevice 例程的函数地址。设置的方式是驱动对象中有个 DriverExtension 子域,DriverExtension 子域中有个 AddDevice 子域,该子域指向 AddDevice 例程函数地址。
c
pDriverObject->DriverExtension->AddDevice = HelloWDMAddDevice;
和 DriverEntry 不同,AddDevice 例程的名字可以任意命名。程序员可以使用更有意义的名字来作为这个函数的名字。
IoAttachDeviceToDeviceStack 的声明如下:
c
NTSTATUS
IoAttachDeviceToDeviceStack(
IN PDEVICE_OBJECT SourceDevice,
IN PDEVIDE_OBJECT TargetDevice
);
- SourceDevice:要附加在别的设备对象设备之上的设备。将 FDO 附加早 PDO之上时,这个填写的是 FDO 的地址。
- TargetDevice:被附加的设备。将 FDO 附加到 PDO 之上时,这个填写的是 PDO 的地址。当 FDO 附加到 PDO 上时,有时会在 PDO 和 FDO 上附加过滤驱动。此时,FDO 其实是附加到过滤设备之上,而过滤设备附加到 PDO 之上。
- 返回值:附加以后,返回附加设备的下层设备。如果中间没有过滤驱动的话,返回就是 PDO,如果中间有过滤驱动设备,返回的就是过滤驱动设备。
前面介绍过,当 FDO 附加到 PDO 时,PDO 会通过 AttachedDevice 子域知道它上面的设备是 FDO(或者过滤驱动设备)。但是 FDO 却不知道自己的下层设备是什么设备。解决的办法是,通过设备扩展记录记录下 FDO 下层设备。
DriverUnload 例程
在 NT 式驱动中,DriverUnload 例程主要负责删除设备,和取消符号链接。而在 WDM 驱动中,这部分操作被 IRP_MN_REMOVE_DEVICE IRP 的处理函数所负责,而 DriverUnload 例程变的相对简单。如果在 DriverEntry 中有申请内存的操作,可以在 DriverUnload 例程中回收这些内存。
对 IRP_MN_REMOVE_DEVICE IRP 的处理
驱动程序内部是由 IRP 驱动的。创建 IRP 的原因有很多,IRP_MN_REMOVE_DEVICE 这个 IRP 是当设备需要被卸载的时候,由即插即用管理器创建,并发送到驱动程序中的。IRP 一般由两个号码指定该 IRP 的具体意义,一个是主 IRP 号(Major IRP),另一个是辅 IRP 号(Minor IRP)。每个 IRP 都有对应的派遣函数所处理,派遣函数是在 DriverEntry 中指定的。
驱动程序的垂直层次结构和水平层次结构
垂直层次
设备的创建顺序是,先创建底层的 PDO,在创建高层的 FDO,这也就是设备堆栈的生长方向,即从底层设备到高层设备。在 PDO 和 FDO 之间可能夹杂着各种过滤驱动。每层的设备对象由不同的驱动程序创建,或者说每层的设备,对应着不同的驱动程序。有的驱动程序是系统自带的,有的是需要程序员自己编写。底层设备对象寻找上一层设备对象,是依靠底层设备对象的 AttachedDevice 子域来寻找的。如果某一设备的 AttachedDevice 为空,说明已经找到了设备堆栈的栈顶。
而高层设备寻找底层设备对象,设备对象没有相关子域可以使用。解决的办法是,通过程序员自定义设备扩展,在设备扩展记录低一层的设备对象。这样,从最底层的设备对象到顶部的设备,再从设备顶部到达设备堆栈底部,都有了相应的方法。
水平层次
为了区分设备堆栈的垂直结构,同一驱动程序创建出来的设备对象关系称之为水平层次关系。水平层次的第一个设备对象,由它的驱动程序所制定。每个设备通过子域 NextDevice 可以寻找到水平层次的下一个设备对象。
例如,电脑中插入两块型号相同的网卡,当插入第一个网卡的时候,PCI 总线会检测到有 PCI 设备被插入到电脑中,创建 PCI 的物理设备对象(PDO),随后,加载相应的FDO。插入第二块网卡时候会有同样的过程,产生另外的 PDO 和 FDO。这样两个PDO之间就是同一水平层次的,两个 FDO 也是处于同一水平层次上的。
驱动程序的复杂层次结构
由于设备对象的水平结构和垂直结构,组成了Windows 设备的树形结构图。在 Windows 中,初始的时候会有一个跟设备,为了理解简单,我们将PCI总线想象成根总线(根总线实际不是PCI总线,这里只是为了理解方面)。插到 PCI 总线上的设备,PCI 总线会枚举每一个插到 PCI 总线上的设备,并为每个设备创建 PDO,然后每个 PDO 上面必须有一个 FDO。比如网卡这样的设备,通过 FDO 和 PDO 就可以操作这个物理设备了。