1 概述
驱动程序大体可分为两类三种:
第一类:传统型驱动
传统型驱动的特点就是所有的IRP都需要自己去处理,自己实现针对不同IRP的派发函数。其可以分
为以下两种:
1. Nt式驱动:此驱动通过注册系统服务来加载,并且不支持即插即用功能(即没有处理IRP_MJ_PNP
这个IRP)。
2. WDM驱动:此驱动不通过注册系统服务来加载,需啊哟自己编写inf文件。同时,它与NT式驱动相
比最大的特点就是支持即插即用功能。
第二类:微过滤驱动
微过滤驱动是微软推出的一个驱动框架。它将驱动程序内创建设备对象之类的操作全部封装了,让
用户无需理会此部分繁杂的工作。用户只需要针对不同的IRP处理好他们响应的前-后操作还有用户态与
内核态的通信即可,即可以理解为微过滤驱动对IRP的处理类似于用户态的API HOOK。
对于刚开始编写驱动程序的新人来说,使用微过滤驱动是最好不过的了。因为它将大量的内部逻辑
进行了封装,我们只需要实现相应的处理逻辑即可。
驱动的编译方式有很多种。
.微软官方推荐使用WDK提供的Build Environments来对相应系统编译驱动,此方法需要用户自行创
建Source文件编译,对于不熟悉的人略显困难
.VS+ddkwizard。ddkwizard插件会在VS中添加一个DDK工程,它可以生成基本的WDM驱动模板,相对
来讲会稍微方便些,但是还是需要一些配置。
.直接使用VS编译。对于做惯用户态开发的人来讲,这个是最方便的,目前我用的也是这种方式。它
的配置项会相对多一点,但是相信这不是障碍。下面就介绍一下如何配置VS(以VS2005为例)
VS2005配置驱动编译环境方法:
1. 安装WDK,并配置系统环境变量:WDKROOT-D:\WinDDK\7600.16385.1
2. 启动VS2005,在菜单栏“工具”-“选项”内选择“项目和解决方案”-“VC++目录”依次添加所需的
目录,具体配置如下图:
注意:$(WDKROOT)\inc\api一定要放在第一个,否则会导致编译失败
3. 具体的项目属性按照如下设置:
注:创建项目时选择“Win32项目”
按照以上图片配置好后就可以直接编译驱动程序了,此配置是XP系统的,其他系统的链接不同的库就可
以了。
注:如果在编译过程中遇到如下错误:
error LNK2019: 无法解析的外部符号 @__security_check_cookie@4
请您将“项目属性”-“C/C++”-“代码生成”的“缓冲区安全检查”设为“否”
2 构建和加载
将代码植入内核中的直接方式是使用可加载模块。
正如其名称所示,设备驱动程序通常是用于设备的。然而,通过驱动程序可以引入任何代码。
DDK提供了两种不同的构建环境:检查(checked)构建和自由(free)构建环境。
在开发设备驱动程序时使用检查构建环境,对于发行代码则使用自由构建环境。
检查构建环境可以是“开始”菜单的“程序”中 Windows DDK图标组之下的一个链接。打开了构建环境
的命令shell后,将当前目录改为驱动程序目录,并输入命令“build”。理想情况下.不会出现任何错
误,此时就得到了我们的第一个驱动程序。一个提示:要确保驱动程序目录所在位置的完整路径中不包
含任任何空格。
已建立的驱动程序正确加载方法是使用服务控制管理器(Service Control Manager,SCM)。
使用SCM需要创建注册表键,当驱动程序通过SCM加载时,它是不可分页的。
系统上的每个进程都维护唯一的页目录,都拥有自己私有的CR3寄存器的值。
因此,两个不同的进程可以同时访问内存地址0x00400000,但将其转换成两个独立的物理内存地址;这
也是为何一个进程无法查看另一个进程内存的原因。
CPU负责跟踪为软件代码和内存分配环的情况,并在各环之间实施访问限制。
3 基础数据结构
1) DRIVER_OBJECT驱动对象
WDK中对驱动对象的定义
每个驱动程序都会有一个唯一的驱动对象与之对应
它是在驱动加载时被内核对象管理程序创建的
DRIVER_OBJECT 成员说明:
DeviceObject : 每个驱动程序都会有至少一个设备对象。每个设备对象都有一个指向下一个设备对象的
指针,最后一个设备对象指向空。此参数指的是驱动对象的第一个设备对象。设备对象的创建与删除都
是由程序员自行处理的。
DriverName : 驱动名称,由UNICODE_STRING记录。一般格式为\Driver\[DriverName]。
HardwareDatabase : 设备的硬件数据库名称。一般格式为\REGISTRY\MACHINE\HARDWARE\DESCRIPTION
\SYSTEM。
DriverStartIo : 记录StartIO派发函数地址,用于序列化操作。
DriverUnload : 指定驱动卸载时的回调函数地址。
MajorFunction : 记录处理IRP的派发函数的函数地址。
FastIoDispatch : 文件驱动中会用到此成员,用于处理快速IO请求。
2) DEVICE_OBJECT设备对象
WDK定义的设备对象
DEVICE_OBJECT成员说明:
DriverObject : 指向驱动程序中的驱动对象。如果多个设备对象属于同一个驱动程序,则它们所指的驱
动对象是相同的。
NextDevice : 指向下一个设备对象。
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 : 设备类型,常用的设备类型有:
FILE_DEVICE_BEEP:蜂鸣器设备对象。
FILE_DEVICE_CD_ROM:CD光驱设备对象。
FILE_DEVICE_CD_ROM_FILE_SYSTEM:CD光驱文件系统设备对象。
FILE_DEVICE_CONTROLLER:控制器设备对象。
FILE_DEVICE_DATALINK:数据链设备对象。
FILE_DEVICE_DFS:DFS设备对象。
FILE_DEVICE_DISK:磁盘设备对象。
FILE_DEVICE_DISK_FILE_SYSTEM:磁盘文件系统设备对象。
FILE_DEVICE_FILE_SYSTEM:文件系统设备对象。
FILE_DEVICE_INPORT_PORT:输入端口设备对象。
FILE_DEVICE_KEYBOARD:键盘设备对象。
FILE_DEVICE_MAILSLOT:邮槽设备对象。
FILE_DEVICE_MIDI_IN:MIDI输入设备对象。
FILE_DEVICE_MIDI_OUT:MIDI输出设备对象。
FILE_DEVICE_MOUSE:鼠标设备对象。
FILE_DEVICE_MULTI_UNC_PROVIDER:多UNC设备对象。
FILE_DEVICE_NAMED_PIPE:命名管道设备对象。
FILE_DEVICE_NETWORK:网络设备对象。
FILE_DEVICE_NETWORK_BROWSER:网络浏览器设备对象。
FILE_DEVICE_NETWORK_FILE_SYSTEM:网络文件系统设备对象。
FILE_DEVICE_NULL:空设备对象。
FILE_DEVICE_PARALLEL_PORT:并口设备对象。
FILE_DEVICE_PHYSICAL_NETCARD:物理网卡设备对象。
FILE_DEVICE_PRINTER:打印机设备对象。
FILEDEVICE_SCANNER:扫描仪设备对象。
FILE_DEVICE_SERIAL_MOUSE_PORT:串口鼠标设备对象。
FILE_DEVICE_SERIAL_PORT:串口设备对象。
FILE_DEVICE_SCREEN:屏幕设备对象。
FILE_DEVICE_SOUND:声音设备对象。
FILE_DEVICE_STREAMS:流设备对象。
FILE_DEVICE_TAPE:磁带设备对象。
FILE_DEVICE_TAPE_FILE_SYSTEM:磁带文件系统设备对象。
FILE_DEVICE_TRANSPORT:传输设备对象。
FILE_DEVICE_UNKNOW:未知设备对象。
FILE_DEVICE_VIDEO:视频设备对象。
FILE_DEVICE_VIRTUAL_DISK:虚拟磁盘设备对象。
FILE_DEVICE_WAVE_IN:声音输入设备对象。
FILE_DEVICE_WAVE_OUT:声音输出设备对象。
FILE_DEVICE_8042_PORT:8042端口设备。
FILE_DEVICE_NETWORK_REDIRECTOR:网卡设备对象。
FILE_DEVICE_BATTERY:电池设备对象。
FILE_DEVICE_BUS_EXTENDER:总线扩展设备对象。
FILE_DEVICE_MODEM:调制解调器设备对象。
FILE_DEVICE_VDM:VDM设备对象。
FILE_DEVICE_MASS_STORAGE:大容量存储设备对象。
FILE_DEVICE_SMB:SMB设备对象。
FILE_DEVICE_KS:内核流设备对象。
FILE_DEVICE_CHANGER:充电设备对象。
FILE_DEVICE_SMARTCARD:智能卡设备对象。
FILE_DEVICE_ACPI:ACPI设备对象。
FILE_DEVICE_DVD:DVD设备对象。
根据设备的需要,需要填写响应的设备类型。当制作虚拟设备时,应当选择FILE_DEVICE_UNKONWN类型的
设备。
StackSize : 在多层驱动情况下,驱动与驱动之间会形成类似堆栈的结构。IRP会依次从最高层传递到最
底层。StackSize就是驱动的层数。
AlignmentRequirement : 设备在大容量传输的时候,需要内存对齐,以保证传输速度。
3) 设备扩展
设备对象中只包含了设备的基本信息,如果需要保存其他的信息可以使用设备扩展。
设备扩展是由程序员自定义的,可以按照自己的需要添加相关的信息。设备扩展保存在非分页内存中。
在驱动程序中应该尽量避免使用全局函数,因为全局函数往往导致函数的不可重入性。将全局变量以设
备扩展方式储存,加以适当的同步保护措施是一个很好的解决方案。除此之外设备扩展往往还会记录一
下信息:
设备对象的反向指针。
设备状态或驱动环境信息。
中断对象指针。
控制器对象指针。
由于设备扩展是驱动程序专用的,它的结构必须在驱动程序的头文件中定义。
4 字符串
4.1 使用字符串结构
传统 C语言的字符串相当的不安全。很容易导致缓冲溢出漏洞。这是因为没有任何地
方确切的表明一个字符串的长度。仅仅用一个’\0’字符来标明这个字符串的结束。一旦碰
到根本就没有空结束的字符串(可能是攻击者恶意的输入、或者是编程错误导致的意外),
程序就可能陷入崩溃。
使用高级C++特性的编码者则容易忽略这个问题。因为常常使用std::string和CString
这样高级的类。不用去担忧字符串的安全性了。
在驱动开发中,一般不再用空来表示一个字符串的结束。而是定义了如下的一个结构:
typedef struct _UNICODE_STRING {
USHORT Length; // 字符串的长度(字节数)
USHORT MaximumLength; // 字符串缓冲区的长度(字节数)
PWSTR Buffer; // 字符串缓冲区
} UNICODE_STRING, *PUNICODE_STRING;
以上是 Unicode 字符串,一个字符为双字节。与之对应的还有一个 Ansi 字符串。Ansi
字符串就是 C 语言中常用的单字节表示一个字符的窄字符串。
typedef struct _STRING {
USHORT Length;
USHORT MaximumLength;
PSTR Buffer;
} ANSI_STRING, *PANSI_STRING;
在驱动开发中四处可见的是 Unicode 字符串。因此可以说:Windows 的内核是使用
Uincode 编码的。ANSI_STRING 仅仅在某些碰到窄字符的场合使用。
UNICODE_STRING 并不保证Buffer 中的字符串是以空结束的。因此,类似下面的做法都
是错误的,可能会会导致内核崩溃:
UNICODE_STRING str;
…
len = wcslen(str.Buffer); // 试图求长度。
DbgPrint(“%ws”,str.Buffer); // 试图打印 str.Buffer。
如果要用以上的方法,必须在编码中保证 Buffer 始终是以空结束。但这又是一个麻烦
的问题。所以,使用微软提供的 Rtl系列函数来操作字符串,才是正确的方法。
4.2 字符串的初始化
UNICODE_STRING 结构中并不含有字符串缓冲的空间。以下的代码是完全错误的,内核会立
刻崩溃:
UNICODE_STRING str;
wcscpy(str.Buffer,L”my first string!”);
str.Length = str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
以上的代码定义了一个字符串并试图初始化它的值。这样做是不对的。因为 str.Buffer 只是一个未初始化的指针。它并没有指向有意义的空间。相反以下的方法是
正确的:
// 先定义后,再定义空间
UNICODE_STRING str;
str.Buffer = L”my first string!”;
str.Length = str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
… …
上面代码的第二行手写的常数字符串在代码中形成了“常数”内存空间。这个空间位于
代码段。将被分配于可执行页面上。一般的情况下不可写。为此,要注意的是这个字符串空
间一旦初始化就不要再更改。否则可能引发系统的保护异常。实际上更好的写法如下:
//请分析一下为何这样写是对的:
UNICODE_STRING str = {
sizeof(L”my first string!”) – sizeof((L”my first string!”)[0]),
sizeof(L”my first string!”),
L”my first_string!” };
但是这样定义一个字符串实在太繁琐了。但是在头文件 ntdef.h中有一个宏方便这种定
义。使用这个宏之后,我们就可以简单的定义一个常数字符串如下:
UNICODE_STRING str = RTL_CONSTANT_STRING(L“my first string!”);
这只能在定义这个字符串的时候使用。为了随时初始化一个字符串,可以使用
RtlInitUnicodeString。示例如下:
UNICODE_STRING str;
RtlInitUnicodeString(&str,L”my first string!”);
用本小节的方法初始化的字符串,不用担心内存释放方面的问题。因为我们并没有分配
任何内存。
4.3 字符串的拷贝
因为字符串不再是空结束的,所以使用 wcscpy来拷贝字符串是不行的。UNICODE_STRING
可以用 RtlCopyUnicodeString来进行拷贝。在进行这种拷贝的时候,需要注意:拷贝目的字符串的 Buffer必须有足够的空间。如果 Buffer的空间不足,字符串会拷贝不完
全。这是一个比较隐蔽的错误。
下面举一个例子。
UNICODE_STRING dst; // 目标字符串
WCHAR dst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRING src = RTL_CONST_STRING(L”My source string!”);
// 把目标字符串初始化为拥有缓冲区长度为 256的UNICODE_STRING 空串。
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷贝!
以上这个拷贝之所以可以成功,是因为 256比 L” My source string!”的长度要大。
如果小,则拷贝也不会出现任何明示的错误。但是拷贝结束之后,字符串实际上被截短了。
我曾经犯过的一个错误是没有调用 RtlInitEmptyString。结果 dst 字符串被初始化认
为缓冲区长度为 0。虽然程序没有崩溃,却实际上没有拷贝任何内容。
在拷贝之前,最谨慎的方法是根据源字符串的长度动态分配空间。
4.4 字符串的连接
会常常碰到这样的需求:要把两个字符串连接到一起。简单的追加一个字符串并不困难。重
要的依然是保证目标字符串的空间大小。下面是范例:
NTSTATUS status;
UNICODE_STRING dst; // 目标字符串
WCHAR dst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRING src = RTL_CONST_STRING(L”My source string!”);
// 把目标字符串初始化为拥有缓冲区长度为 256的UNICODE_STRING 空串
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷贝!
status = RtlAppendUnicodeToString(
&dst,L”my second string!”);
if(status != STATUS_SUCCESS)
{
……
}
NTSTATUS 是常见的返回值类型。如果函数成功,返回 STATUS_SUCCESS。否则的话,是
一个错误码。RtlAppendUnicodeToString 在目标字符串空间不足的时候依然可以连接字符
串,但是会返回一个警告性的错误 STATUS_BUFFER_TOO_SMALL。
另 外 一 种 情 况 是 希 望 连 接 两 个 UNICODE_STRING , 这 种 情 况 请 调 用
RtlAppendUnicodeStringToString。这个函数的第二个参数也是一个 UNICODE_STRING 的指
针。
4.5 字符串的打印
字符串的连接另一种常见的情况是字符串和数字的组合。有时数字需要被转换为字符
串。有时需要把若干个数字和字符串混合组合起来。这往往用于打印日志的时候。日志中可
能含有文件名、时间、和行号,以及其他的信息。
熟悉 C语言的读者会使用sprintf。这个函数的宽字符版本为 swprintf。该函数在驱动
开发中可以使用,但不安全。微软建议使用 RtlStringCbPrintfW 来代替它。
RtlStringCbPrintfW 需要包含头文件 ntstrsafe.h。在连接的时候,还需要连接库
ntsafestr.lib。
下面的代码生成一个字符串,字符串中包含文件的路径,和这个文件的大小。
#include
// 任何时候,假设文件路径的长度为有限的都是不对的。应该动态的分配
// 内存。但是动态分配内存的方法还没有讲述,所以这里再次把内存空间
// 定义在局部变量中,也就是所谓的“在栈中”
WCHAR buf[512] = { 0 };
UNICODE_STRING dst;
NTSTATUS status;
……
// 字符串初始化为空串。缓冲区长度为 512*sizeof(WCHAR)
RtlInitEmptyString(dst,dst_buf,512*sizeof(WCHAR));
// 调用 RtlStringCbPrintfW 来进行打印
status = RtlStringCbPrintfW(
dst->Buffer,L”file path = %wZ file size = %d \r\n”,
&file_path,file_size);
// 这里调用 wcslen没问题,这是因为 RtlStringCbPrintfW打印的
// 字符串是以空结束的。
dst->Length = wcslen(dst->Buffer) * sizeof(WCHAR);
RtlStringCbPrintfW 在目标缓冲区内存不足的时候依然可以打印,但是多余的部分被
截去了。返回的 status 值为 STATUS_BUFFER_OVERFLOW。调用这个函数之前很难知道究竟需
要多长的缓冲区。一般都采取倍增尝试。每次都传入一个为前次尝试长度为2 倍长度的新缓
冲区,直到这个函数返回STATUS_SUCCESS 为止。
值得注意的是 UNICODE_STRING 类型的指针,用%wZ 打印可以打印出字符串。在不能保
证字符串为空结束的时候,必须避免使用%ws 或者%s。其他的打印格式字符串与传统 C 语言
中的 printf 函数完全相同。可以尽情使用。
另外就是常见的输出打印。printf 函数只有在有控制台输出的情况下才有意义。在驱
动中没有控制台。但是 Windows内核中拥有调试信息输出机制。可以使用特殊的工具查看打
印的调试信息(请参阅附录 1“WDK的安装与驱动开发的环境配置”)。
驱动中可以调用DbgPrint()函数来打印调试信息。这个函数的使用和printf基本相同。
但是格式字符串要使用宽字符。DbgPrint()的一个缺点在于,发行版本的驱动程序往往不希
望附带任何输出信息,只有调试版本才需要调试信息。但是 DbgPrint()无论是发行版本还
是调试版本编译都会有效。为此可以自己定义一个宏:
#if DBG
KdPrint(a) DbgPrint##a
#else
KdPrint (a)
#endif
不过这样的后果是,由于 KdPrint (a)只支持 1 个参数,因此必须把 DbgPrint 的所有
参数都括起来当作一个参数传入。导致 KdPrint 看起来很奇特的用了双重括弧:
// 调用 KdPrint 来进行输出调试信息
status = KdPrint ((
L”file path = %wZ file size = %d \r\n”,
&file_path,file_size));
这个宏没有必要自己定义,WDK 包中已有。所以可以直接使用 KdPrint 来代替DbgPrint
取得更方便的效果。
5 内存与链表
5.1 内存的分配与释放
内存泄漏是 C 语言中一个臭名昭著的问题。作为内核开发者,将有必要自己来面对它。在传统的 C 语言中,分配内存常常使用的函数是 malloc。这个函数的使用非常简
单,传入长度参数就得到内存空间。在驱动中使用内存分配,这个函数不再有效。驱动中分
配内存,最常用的是调用ExAllocatePoolWithTag。
一个字符串被复制到另一个字符串的时候,最好根据源字符串的空间长度来分配目标字符串的长度。下面的举例,是把一个字符串 src拷贝到字
符串 dst。
// 定义一个内存分配标记
#define MEM_TAG ‘MyTt’
// 目标字符串,接下来它需要分配空间。
UNICODE_STRING dst = { 0 };
// 分配空间给目标字符串。根据源字符串的长度。
dst.Buffer =
(PWCHAR)ExAllocatePoolWithTag(NonpagedPool,src->Length,MEM_TAG);
if(dst.Buffer == NULL)
{
// 错误处理
status = STATUS_INSUFFICIENT_RESOUCRES;
……
}
dst.Length = dst.MaximumLength = src->Length;
status = RtlCopyUnicodeString(&dst,&src);
ASSERT(status == STATUS_SUCCESS);
ExAllocatePoolWithTag 的第一个参数 NonpagedPool 表明分配的内存是锁定内存。这
些内存永远真实存在于物理内存上。不会被分页交换到硬盘上去。第二个参数是长度。第三
个参数是一个所谓的“内存分配标记”。
内存分配标记用于检测内存泄漏。想象一下,我们根据占用越来越多的内存的分配标记,
就能大概知道泄漏的来源。一般每个驱动程序定义一个自己的内存标记。也可以在每个模块
中定义单独的内存标记。内存标记是随意的 32 位数字。即使冲突也不会有什么问题。
此外也可以分配可分页内存,使用 PagedPool即可。
ExAllocatePoolWithTag 分配的内存可以使用 ExFreePool 来释放。如果不释放,则永
远泄漏。并不像用户进程关闭后自动释放所有分配的空间。即使驱动程序动态卸载,也不能
释放空间。唯一的办法是重启计算机。
ExFreePool 只需要提供需要释放的指针即可。举例如下:
ExFreePool(dst.Buffer);
dst.Buffer = NULL;
dst.Length = dst.MaximumLength = 0;
ExFreePool 不能用来释放一个栈空间的指针。否则系统立刻崩溃。像以下的代码:
UNICODE_STRING src = RTL_CONST_STRING(L”My source string!”);
ExFreePool(src.Buffer);
会招来立刻蓝屏。所以请务必保持 ExAllocatePoolWithTag和 ExFreePool 的成对关系。
5.2 使用 LIST_ENTRY
Windows 的内核开发者们自己开发了部分数据结构,比如说 LIST_ENTRY。
LIST_ENTRY 是一个双向链表结构。它总是在使用的时候,被插入到已有的数据结构中。
一个例子。我构筑一个链表,这个链表的每个节点,是一个文件名和一个文件大小两
个数据成员组成的结构。此外有一个 FILE_OBJECT的指针对象。在驱动中,这代表一个文件
对象。本书后面的章节会详细解释。这个链表的作用是:保存了文件的文件名和长度。只要
传入 FILE_OBJECT 的指针,使用者就可以遍历链表找到文件名和文件长度。
typedef struct {
PFILE_OBJECT file_object;
UNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
一些读者会马上注意到文件的长度用 LARGE_INTEGER 表示。这是一个代表长长整型的数
据结构。这个结构我们在下一小小节“使用长长整型数据”中介绍。
为了让上面的结构成为链表节点,我必须在里面插入一个 LIST_ENTRY 结构。至于插入
的位置并无所谓。可以放在最前,也可以放中间,或者最后面。但是实际上读者很快会发现
把 LIST_ENTRY放在开头是最简单的做法:
typedef struct {
LIST_ENTRY list_entry;
PFILE_OBJECT file_object;
UNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
list_entry 如果是作为链表的头,在使用之前,必须调用 InitializeListHead 来初始
化。下面是示例的代码:
// 我们的链表头
LIST_ENTRY my_list_head;
// 链表头初始化。一般的说在应该在程序入口处调用一下
void MyFileInforInilt()
{
InitializeListHead(&my_list_head);
}
// 我们的链表节点。里面保存一个文件名和一个文件长度信息。
typedef struct {
LIST_ENTRY list_entry;
PFILE_OBJECT file_object;
PUNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
// 追加一条信息。也就是增加一个链表节点。请注意 file_name 是外面分配的。
// 内存由使用者管理。本链表并不管理它。
NTSTATUS MyFileInforAppendNode(
PFILE_OBJECT file_object,
PUNICODE_STRING file_name,
PLARGE_INTEGER file_length)
{
PMY_FILE_INFOR my_file_infor =
(PMY_FILE_INFOR)ExAllocatePoolWithTag(
PagedPool,sizeof(MY_FILE_INFOR),MEM_TAG);
if(my_file_infor == NULL)
return STATUS_INSUFFICIENT_RESOURES;
// 填写数据成员。
my_file_infor->file_object = file_object;
my_file_infor->file_name = file_name;
my_file_infor->file_length = file_length;
// 插入到链表末尾。请注意这里没有使用任何锁。所以,这个函数不是多
// 多线程安全的。在下面自旋锁的使用中讲解如何保证多线程安全性。
InsertHeadList(&my_list_head, (PLIST_ENTRY)& my_file_infor);
return STATUS_SUCCESS;
}
以上的代码实现了插入。可以看到 LIST_ENTRY 插入到 MY_FILE_INFOR 结构的头部的好
处。这样一来一个 MY_FILE_INFOR 看起来就像一个 LIST_ENTRY。不过糟糕的是并非所有的
情况都可以这样。比如 MS 的许多结构喜欢一开头是结构的长度。因此在通过 LIST_ENTRY
结构的地址获取所在的节点的地址的时候,有个地址偏移计算的过程。可以通过下面的一个
典型的遍历链表的示例中看到:
for(p = my_list_head.Flink; p != &my_list_head.Flink; p = p->Flink)
{
PMY_FILE_INFOR elem =
CONTAINING_RECORD(p,MY_FILE_INFOR, list_entry);
// 在这里做需要做的事…
}
}
其中的 CONTAINING_RECORD 是一个 WDK 中已经定义的宏,作用是通过一个 LIST_ENTRY
结构的指针,找到这个结构所在的节点的指针。定义如下:
#define CONTAINING_RECORD(address, type, field) ((type *)( \
(PCHAR)(address) - \
(ULONG_PTR)(&((type *)0)->field)))
从上面的代码中可以总结如下的信息:
LIST_ENTRY 中的数据成员Flink指向下一个 LIST_ENTRY。
整个链表中的最后一个 LIST_ENTRY的 Flink不是空。而是指向头节点。
得到 LIST_ENTRY 之后,要用 CONTAINING_RECORD 来得到链表节点中的数据。
5.3 使用长长整型数据
这里解释前面碰到的 LARGE_INTEGER 结构。与可能的误解不同,64 位数据并非要在 64
位操作系统下才能使用。在 VC中,64 位数据的类型为__int64。定义写法如下:
__int64 file_offset;
上面之所以定义的变量名为 file_offset,是因为文件中的偏移量是一种常见的要使用
64 位数据的情况。同时,文件的大小也是如此(回忆上一小节中定义的文件大小)。32位数
据无符号整型只能表示到4GB。而众所周知,现在超过 4GB的文件绝对不罕见了。但是实际
上__int64 这个类型在驱动开发中很少被使用。基本上被使用到的是一个共用体:
LARGE_INTEGER。这个共用体定义如下:
typedef __int64 LONGLONG;
typedef union _LARGE_INTEGER {
struct {
ULONG LowPart;
LONG HighPart;
};
struct {
ULONG LowPart;
LONG HighPart;
} u;
LONGLONG QuadPart;
} LARGE_INTEGER;
这个共用体的方便之处在于,既可以很方便的得到高 32位,低 32 位,也可以方便的得
到整个 64位。进行运算和比较的时候,使用 QuadPart 即可。
LARGE_INTEGER a,b;
a.QuadPart = 100;
a.QuadPart *= 100;
b.QuadPart = a.QuadPart;
if(b.QuadPart > 1000)
{
KdPrint(“b.QuadPart < 1000, LowPart = %x HighPart = %x”,
b.LowPart,b.HighPart);
}
上面这段代码演示了这种结构的一般用法。在实际编程中,会碰到大量的参数是
LARGE_INTEGER类型的。
5.4 使用自旋锁
链表之类的结构总是涉及到恼人的多线程同步问题,这时候就必须使用锁。
锁存在的意义? 这和多线程操作有关。在驱动开发的代码中,大多是
在于多线程执行环境的。就是说可能有几个线程在同时调用当前函数。
这样一来,前文中提及的追加链表节点函数就根本无法使用了。因为
FileInforAppendNode这个函数只是简单的操作链表。如果两个线程同时调用这个函数来
作链表的话:注意这个函数操作的是一个全局变量链表。换句话说,无论有多少个线程同
执行,他们操作的都是同一个链表。这就可能发生,一个线程插入一个节点的同时,另一
线程也同时插入。他们都插入同一个链表节点的后边。这时链表就会发生问题。到底最后
入的是哪一个呢?要么一个丢失了。要么链表被损坏了。
如下的代码初始化获取一个自选锁:
KSPIN_LOCK my_spin_lock;
KeInitializeSpinLock(&my_spin_lock);
KeInitializeSpinLock 这个函数没有返回值。下面的代码展示了如何使用这个
SpinLock。在 KeAcquireSpinLock 和KeReleaseSpinLock之间的代码是只有单线程执行的。
其他的线程会停留在 KeAcquireSpinLock等候。直到 KeReleaseSpinLock 被调用。KIRQL是
一个中断级。KeAcquireSpinLock 会提高当前的中断级。但是目前忽略这个问题。中断级在
后面讲述。
KIRQL irql;
KeAcquireSpinLock(&my_spin_lock,&irql);
// To do something …
KeReleaseSpinLock(&my_spin_lock,irql);
初学者要注意的是,像下面写的这样的“加锁”代码是没有意义的,等于没加锁:
void MySafeFunction()
{
KSPIN_LOCK my_spin_lock;
KIRQL irql;
KeInitializeSpinLock(&my_spin_lock);
KeAcquireSpinLock(&my_spin_lock,&irql);
// 在这里做要做的事情…
KeReleaseSpinLock(&my_spin_lock,irql);
}
原因是 my_spin_lock 在堆栈中。每个线程来执行的时候都会重新初始化一个锁。只有
所有的线程共用一个锁,锁才有意义。所以,锁一般不会定义成局部变量。可以使用静态变
量、全局变量,或者分配在堆中(见前面的 1.2.1 内存的分配与释放一节)。请读者自己写
出正确的方法。
LIST_ENTRY 有一系列的操作。这些操作并不需要使用者自己调用获取与释放锁。只需
要为每个链表定义并初始化一个锁即可:
LIST_ENTRY my_list_head; // 链表头
KSPIN_LOCK my_list_lock; // 链表的锁
// 链表初始化函数
void MyFileInforInilt()
{
InitializeListHead(&my_list_head);
KeInitializeSpinLock(&my_list_lock);
}
链表一旦完成了初始化,之后的可以采用一系列加锁的操作来代替普通的操作。比如插
入一个节点,普通的操作代码如下:
InsertHeadList(&my_list_head, (PLIST_ENTRY)& my_file_infor);
换成加锁的操作方式如下:
ExInterlockedInsertHeadList(
&my_list_head,
(PLIST_ENTRY)& my_file_infor,
&my_list_lock);
注 意 不 同 之 处 在 于 , 增 加 了 一 个 KSPIN_LOCK 的 指 针 作 为 参 数 。 在
ExInterlockedInsertHeadList 中,会自动的使用这个 KSPIN_LOCK 进行加锁。类似的还有
一个加锁的 Remove函数,用来移除一个节点,调用如下:
my_file_infor = ExInterlockedRemoveHeadList (
&my_list_head,
&my_list_lock);
这个函数从链表中移除第一个节点。并返回到 my_file_infor中。