Windows驱动开发要点总结一

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中。 

你可能感兴趣的:(转载,VC++,操作系统研发和研究)