WDM 驱动程序 笔记

 

注册表的角色
有三种注册表键负责配置。它们是硬件(hardware)键、类(class)键、服务(service)键。必须明确一点,
这些名字(指hardware、class、service)并不是某个专用子键的名称:它们是这三种键的一般称谓,
其具体的路径名要取决于它们所属的设备。概括地讲,硬件键包含单个设备的信息,类键涉及所有相同类型设备的共同信息,
服务键包含驱动程序信息。有时人们用“实例(instance)键”和“软件(software)键”
来代表硬件键和服务键。这个命名差异是由于Windows 95/98与Windows 2000是由两个不同的项目组开发所造成的。

....................................................................................................................

如何命名注册表键 
注册表顶级键的命名可能使第一次看到它的人感到迷惑。当你用用户模式的Win32API函数访问注册表时,
可以用HKEY_CLASSES_ROOT、HKEY_CURRENT_USER、HKEY_LOCAL_MACHINE,等等的预定义句柄常量代表顶级注册表键。
注册表编辑器REGEDIT.EXE也使用这些预定义常量,如图2-3。你还可以用缩写HKCR、HKCU、HKLM代替它们。
即HKCR是HKLM\Software\Classes的别名,HKCU是HKEY_USER的某个子键的别名,这两个别名所代表的具体键值要取决于被处理的具体情况。

在内核模式中,你应使用另一种基于内核命名空间的命名方案。在这种命名方案中,顶级键命名为\Registry\User和\Registry\Machine。
Machine分支就是用户模式中的HKLM分支,在这里你可以找到与设备驱动程序相关的所有信息。除非另有所指,
否则你应该假定本文中所引用的注册表键都在\Registry\Machine中。

..........................................................................................................................
从用户模式中访问设备键 
应用程序经常需要访问注册表中关于硬件设备的信息。为了使这成为可能而同时又不暴露重要的Enum键,
Microsoft提供了一组SetupDiXxx函数。

假设你的驱动程序使用IoRegisterDeviceInterface函数寄存了一个设备接口,并且你有一个该接口的符号连接名
(通过枚举该接口GUID的所有实例或者从WM_DEVICECHANGE消息的参数中获得这个名字)。为了从硬件键中获得Manufacturer名字,
你可以使用下面代码:

#include <setupapi.h>
...
LPTSTR lpszDeviceName;
HDEVINFO info = SetupDiCreateDeviceInfoList(NULL, NULL);
SP_DEVICE_INTERFACE_DATA ifdata = {sizeof(SP_DEVICE_INTERFACE_DATA)};
SetupDiOpenDeviceInterface(info, lpszDeviceName, 0, &ifdata);
SP_DEVINFO_DATA did = {sizeof(SP_DEVINFO_DATA)};
SetupDiGetDeviceInterfaceDetail(info, &ifdata, NULL, 0, NULL, &did);
TCHAR buffer[256];
SetupDiGetDeviceRegistryProperty(info,
     &did,
     SPDRP_MFG,
     NULL,
     (PBYTE) mfgname,
     sizeof(mfgname),
     NULL);
SetupDiDestroyDeviceInfoList(info);
 

lpszDeviceName是一个象“USB\Vid_0547&Pid_102A\7&2”一样的串。

............................................................................................................................

关于函数原型还有一点要提的是“IN”关键字。IN、OUT、INOUT在DDK中都被定义成空串,
它们的功能就象程序注释,当你看到一个IN参数时,应该认定该参数是纯粹用于输入目的。OUT参数的内容无意义,
它仅用于函数的输出信息,INOUT用于既可以输入又可以输出的参数。DDK头文件并不真正使用这些关键字。例如DriverEntry例程,
它的DriverObject指针是IN参数,即你不能改变这个指针本身,但你完全可以改变它指向的对象。

关于函数原型最后一点要注意的是返回值类型,DriverEntry函数返回一个NTSTATUS值。NTSTATUS实际就是一个长整型,
但你应该使用NTSTATUS定义该函数的返回值而不是LONG,这样代码的可读性会更好。大部分内核模式支持例程都返回NTSTATUS状态代码,
你可以在DDK头文件NTSTATUS.H中找到NTSTATUS的代码列表。
............................................................................................................................
注册设备接口 调用IoRegisterDeviceInterface函数,功能驱动程序的AddDevice函数可以注册一个或多个设备接口:

#include <initguid.h>       <--1
#include "guids.h"       <--2
...
NTSTATUS AddDevice(...)
{
  ...
  IoRegisterDeviceInterface(pdo, &GUID_SIMPLE, NULL, &pdx->ifname); <--3
  ...
}
 
..........................................................................................................................
初始化设备扩展
设备扩展的内容和管理全部由用户决定。该结构中的数据成员应直接反映硬件的专有细节以及对设备的编程方式。大多数驱动程序都会在这里放入一些数据项,下面代码声明了一个设备扩展结构:

typedef struct _DEVICE_EXTENSION {    <--1
  PDEVICE_OBJECT DeviceObject;     <--2
  PDEVICE_OBJECT LowerDeviceObject;    <--3
  PDEVICE_OBJECT Pdo;      <--4
  UNICODE_STRING ifname;     <--5
  IO_REMOVE_LOCK RemoveLock;     <--6
  DEVSTATE devstate;      <--7
  DEVSTATE prevstate;
  DEVICE_POWER_STATE devpower;
  SYSTEM_POWER_STATE syspower;
  DEVICE_CAPABILITIES devcaps;     <--8
  ...
} DEVICE_EXTENSION, *PDEVICE_EXTENSION;
 

我模仿DDK中官方的结构声明模式声明了这个结构。 
我们可以用设备对象中的DeviceExtension指针定位自己的设备扩展。同样,我们有时也需要在给定设备扩展时能定位设备对象。
因为某些函数的逻辑参数就是设备扩展本身(这里有设备每个实例的全部信息)。所以,我认为这里应该有一个DeviceObject指针。 
我在一些地方曾提到过,在调用IoAttachDeviceToDeviceStack函数时,应该把紧接着你下面的设备对象的地址保存起来。
LowerDeviceObject成员用于保存这个地址。 
有一些服务例程需要PDO的地址,而不是堆栈中某个高层设备对象的地址。由于定位PDO非常困难,所以最好的办法是在AddDevice
执行时在设备扩展中保存一个PDO地址。 
无论你用什么方法(符号连接或设备接口)命名你的设备,都希望能容易地获得这个名字。所以,这里我用一个Unicode串成员ifname
来保存设备接口名。如果你使用一个符号连接名而不是设备接口,应该使用一个有相关含义的成员名,例如“linkname”。 
当你调用IoDeleteDevice删除这个设备对象时,需要使用一个自旋锁来解决同步安全问题,我将在第六章中讨论同步问题。
因此,需要在设备扩展中分配一个IO_REMOVE_LOCK对象。AddDevice有责任初始化这个对象。 
你可能需要一个成员来记录设备当前的PnP状态和电源状态。DEVSTATE和POWERSTATE是枚举类型变量,
我假设事先已经在头文件中声明了这些变量类型。我将在后面章节中讨论这些状态变量的用途。 
电源管理的另一个部分涉及电源能力设置的恢复,设备扩展中的devcaps结构用于保存这些设置。 
下面是AddDevice中的初始化语句(着重设备扩展部分的初始化):

NTSTATUS AddDevice(...)
{
  PDEVICE_OBJECT fdo;
  IoCreateDevice(..., sizeof(DEVICE_EXTENSION), ..., &fdo);
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  pdx->DeviceObject = fdo;
  pdx->Pdo = pdo;
  IoInitializeRemoveLock(&pdx->RemoveLock, ...);
  pdx->devstate = STOPPED;
  pdx->devpower = PowerDeviceD0;
  pdx->syspower = PowerSystemWorking;
  IoRegisterDeviceInterface(..., &pdx->ifname);
  pdx->LowerDeviceObject = IoAttachDeviceToDeviceStack(...);
}

...........................................................................................................................
注意侧效   

驱动程序中使用的许多支持“函数”其实是DDK头文件中定义的宏。我们都知道应该避免在宏的参数中使用带有边效的表达式,
原因很明显,宏可以多次使用其参数,见下面代码:  

int a = 2, b = 42, c;
c = min(a++, b);

而应该调用RtlCompareUnicodeString。然
a的值是什么?(c的值又是什么?)            
让我们看看这个似是而非的min宏        

#define min(x,y) (((x)<(y)) ? (x) : (y))        

如果你用a++代替x,你将看到a最后等于4,因为表达式a++执行了两次。而“函数”min将返回3而不是2,因为函数的返回值是在第二次计
算a++之前提取的a值.        
           
通常,你不能知道DDK什么时候使用宏,什么时候使用真正的外部函数。有时候,一个特殊的服务函数在某些平台上是宏而在其它平
台上却是外部函数。此外,Microsoft也可能在将来改变想法。所以,当你写WDM驱动程序时应坚守下面原则:

决不在内核模式服务函数的参数中使用带有侧效的表达式.
.........................................................................................................................

try-finally中的控制流程

VOID RandomFunction(PLONG pcounter)
{
  __try
  {
    ++*pcounter;
    return;
  }
  __finally
  {
    --*pcounter;
  }
}
 

该函数的结果是:pcounter指向的整型值不变,不管控制以何种原因离开被保护体,包括通过return语句或goto语句,终止处理程序都将执行。
开始,被保护体增加计数器值并执行一个return语句,接着清除代码执行并减计数器值,之后该子程序才真正返回。

下面例子可以加深你对try-finally语句的理解:

static LONG counter = 0;
__try
{
  ++counter;
  BadActor();
}
__finally
{
  --counter;
}
 

这里我们调用了BadActor函数,我假定该函数将导致某种异常,这将触发堆栈回卷。作为回卷“执行和异常堆栈”过程的一部分,
操作系统将调用我们的恢复代码并把counter恢复到以前的值。然后操作系统继续回卷堆栈,所以不论我们在__finally块后有什么代码
都得不到执行。
.............................................................................................................................
Try-Except块
结构化异常处理的另一种使用方式是try-except块:

__try
{
  <guarded body>
}
__except(<filter expression>)
{
  <exception handler>
}
 

try-except块中的被保护代码可能会导致异常。你可能调用了象MmProbeAndLockPages这类的内核模式服务函数,
这些函数使用来自用户模式的指针,而这些指针并没有做过明确的有效性检测。也许是因为其它原因。但不管什么原因,
如果程序在通过被保护代码段时没有发生任何错误,那么控制将转到异常处理代码后面继续执行,
你可以认为这是正常情况。如果在你的代码中或任何你调用的子例程中发生了异常,操作系统将回卷堆栈,
并对__except语句中的过滤表达式求值。结果将是下面三个值中的一个:

EXCEPTION_EXECUTE_HANDLER 数值上等于1,告诉操作系统把控制转移到你的异常处理代码。
 如果控制走到处理程序的右大括号之外(如执行了return语句或goto语句),那么控制将转到紧接着异常处理代码的后面继续执行。
 (我看过了平台SDK中关于异常控制返回点的文档,但那不正确) 
EXCEPTION_CONTINUE_SEARCH 数值上等于0,告诉操作系统你不能处理该异常。
 系统将继续扫描堆栈以寻找其它处理程序。如果没有找到为该异常提供的处理程序,系统立即崩溃。 
EXCEPTION_CONTINUE_EXECUTION 数值上等于-1,告诉操作系统返回到异常发生的地方。关于这个值我稍后再谈。

例如,下面代码演示了如何防止接收非法指针。(见光盘中的SEHTEST例子)

PVOID p = (PVOID) 1;
__try
{
  KdPrint(("About to generate exception\n"));
  ProbeForWrite(p, 4, 4);
  KdPrint(("You shouldn't see this message\n"));
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
  KdPrint(("Exception was caught\n"));
}
KdPrint(("Program kept control after exception\n"));
 

ProbeForWrite测试一个数据区域是否有效。在这个例子中,它将导致一个异常,因为我们提供的指针参数没有以4字节边界对齐。
然后,异常处理程序得到控制。最后,异常处理完成后控制将转到异常处理程序后面的代码。

在上面的例子中,如果你返回EXCEPTION_CONTINUE_SEARCH,操作系统将继续回卷堆栈以寻找适合的异常处理程序。这时,
异常处理程序和跟在它后面的代码都得不到控制,此时或者系统崩溃或者由更高级的处理程序接管控制。

不能在内核模式中返回EXCEPTION_CONTINUE_EXECUTION,因为你没有办法改变导致异常的情况,所以就不能实现重试。

注意,你不能用结构化异常捕获算术异常、页故障,和非法指针引用等等。你必须保证你的代码不产生这样的异常。
............................................................................................................................
如果你需要获得更多的关于异常的信息,有两个函数可以在__except的求值表达式中调用,它们可以提供本次异常的相关信息。实际上,
这两个函数是在Microsoft编译器的内部实现的,所以仅能用于特定时刻:

GetExceptionCode() 返回当前异常的数值代码。该值是一个NTSTATUS值。该函数仅在__except表达式和其后的异常处理代码中有效。 
GetExceptionInformation() 返回EXCEPTION_POINTERS结构的地址,该结构包含异常的所有详细信息,在哪发生、发生时寄存器的内容,
等等。该函数仅在__except表达式中有效。 
由于这两个函数在使用上的限制,你可以以调用某过滤函数的形式使用它们,象下面这样:

LONG EvaluateException(NTSTATUS status, PEXCEPTION_POINTERS xp)
{
  ...
}
...
__except(EvaluateException(GetExceptionCode(), GetExceptionInformation()))
...
 

............................................................................................................................

生成异常
程序中的bug可以导致异常并使系统调用异常处理机制。应用程序开发者应该熟悉Win32 API中的RaiseException函数,
它可以生成任意异常。在WDM驱动程序中,你可以调用表3-1列出的例程。由于下面规则,我不能给你举一个使用这些函数的例子:

    仅当你知道存在一个异常处理代码并知道你真正在做什么时,才可以在非任意线程上下文下生成一个异常。

表3-1. 用于生成异常的服务函数

服务函数
 描述
 
ExRaiseStatus
 用指定状态代码触发异常
 
ExRaiseAccessViolation
 触发STATUS_ACCESS_VIOLATION异常
 
ExRaiseDatatypeMisalignment
 触发STATUS_DATATYPE_MISALIGNMENT异常
 

特别地,不要通过触发异常来告诉你的调用者你在普通执行状态中的信息,你完全可以返回状态代码。应该尽量避免使用异常,
因为堆栈回卷机制非常消耗资源。
............................................................................................................................
__Leave语句 
Microsoft在其C/C++语言中加入了__leave语句,它解决了文中AddDevice例程出现的效率问题。如果在__try块中发出一个普通的return语句,
将触发昂贵的回卷机制。然而,__leave语句可以直接把控制传递到终止处理程序,最后到达终止处理程序后面的语句。
由于它不造成任何回卷动作,所以要比return语句快得多。
.............................................................................................................................
每个用户模式进程都有自己的地址上下文,它把用户模式的虚拟地址映射成一组唯一的物理页帧。这意味着,
当Windows NT调度器把控制从一个进程的当前线程切换到另一个进程的某个线程时,与进程相对应的虚拟地址空间也被更换。
线程切换的一个步骤就是改变处理器当前使用的页表,以便它能引用新线程的进程上下文。
.............................................................................................................................
我们不能简单地使用属于用户模式中的虚拟地址,因为我们没有办法知道它指向的是哪块物理内存。
在编写驱动程序时我们要遵守下面原则:

决不(或几乎从不)直接引用用户模式的内存地址

............................................................................................................................
在虚拟内存系统中,操作系统以固定大小的页帧组织物理内存和交换文件。在WDM驱动程序中,
常量PAGE_SIZE指出页的大小。在某些Windows NT计算机中,一页有4096字节;在另一些计算机中,
一页有8192字节。有一个相关常量PAGE_SHIFT,你可以从下面语句中看出它的值:

PAGE_SIZE == 1 << PAGE_SHIFT
 

下面预处理宏可以简化页大小的使用:

ROUND_TO_PAGES 把指定值舍入为下一个页边界。例如,在4KB页的计算机上,ROUND_TO_PAGES(1)的结果为4096,
ROUND_TO_PAGES(4097)的结果为8192。 
BYTES_TO_PAGES 得出给定的字节量需要多少页来保存。例如,BYTES_TO_PAGES(42)在所有平台上都等于1,
而BYTES_TO_PAGES(5000)在4KB页的平台上为2,在8KB页的平台上为1。 
BYTE_OFFSET 返回虚拟地址的字节偏移部分。例如,在4KB页的计算机上,BYTE_OFFSET(0x12345678)的结果为0x678。 
PAGE_ALIGN 把虚拟地址舍向上一个页边界。例如,在4KB页的计算机上,PAGE_ALIGN(0x12345678)的结果为0x12345000。 
ADDRESS_AND_SIZE_TO_SPAN_PAGES 返回从指定虚拟地址开始的指定字节数所跨过的页数。例如,在4KB的计算机上, 
ADDRESS_AND_SIZE_TO_SPAN_PAGES(0x12345FFF, 2)的结果为2,因为这两个字节跨过了页边界。 
.............................................................................................................................
一页有多大?
在虚拟内存系统中,操作系统以固定大小的页帧组织物理内存和交换文件。
在WDM驱动程序中,常量PAGE_SIZE指出页的大小。在某些Windows NT计算机中,一页有4096字节;在另一些计算机中,
一页有8192字节。有一个相关常量PAGE_SHIFT,你可以从下面语句中看出它的值:

PAGE_SIZE == 1 << PAGE_SHIFT
 

下面预处理宏可以简化页大小的使用:

ROUND_TO_PAGES 把指定值舍入为下一个页边界。例如,在4KB页的计算机上,ROUND_TO_PAGES(1)的结果为4096,
ROUND_TO_PAGES(4097)的结果为8192。 
BYTES_TO_PAGES 得出给定的字节量需要多少页来保存。例如,BYTES_TO_PAGES(42)在所有平台上都等于1,
而BYTES_TO_PAGES(5000)在4KB页的平台上为2,在8KB页的平台上为1。 
BYTE_OFFSET 返回虚拟地址的字节偏移部分。例如,在4KB页的计算机上,BYTE_OFFSET(0x12345678)的结果为0x678。 
PAGE_ALIGN 把虚拟地址舍向上一个页边界。例如,在4KB页的计算机上,PAGE_ALIGN(0x12345678)的结果为0x12345000。 
ADDRESS_AND_SIZE_TO_SPAN_PAGES 返回从指定虚拟地址开始的指定字节数所跨过的页数。例如,在4KB的计算机上, 
ADDRESS_AND_SIZE_TO_SPAN_PAGES(0x12345FFF, 2)的结果为2,因为这两个字节跨过了页边界。 
.............................................................................................................................
但操作系统的某些部分是不能被分页的,这些内存用来支持内存管理器本身。最明显的例子就是,
用于处理页故障的代码和数据结构必须常驻内存。
Windows NT把内核模式地址空间分成分页内存池和非分页内存池。(用户模式地址空间总是分页的) 必须驻留的代码和数据放在非分页池;
不必常驻的代码和数据放在分页池中。Windows NT为决定代码和数据是否需要驻留非分页池提供了一个简单规则。
我将在下一章详细说明这个规则,但在这里先稍提一下:

执行在高于或等于DISPATCH_LEVEL级的代码不可以引发页故障。

...........................................................................................................................
有时,驱动程序的某些部分必须驻留内存而另一些可以被分页,这就需要一种能控制代码和数据是否分页的方法。
通过指导编译器的段分配可以实现这个目的。在运行时,装入器通过检查驱动程序中的段名把段放到你指定的内存池中。
此外在运行时调用内存管理器的例程也能实现这个目的。
.........................................................................................................................
Win32执行文件,包括内核模式驱动程序,在内部都是由一个或多个段组合而成。
段可以包含代码或数据,通常还会有诸如可读性、可写性、共享性、执行性,等等附加属性。
段是指定分页能力的最小单元。当装载一个驱动程序映像时,操作系统把以“page”或“.eda(.edata)”
为段名开头的段放到分页池中,除非HKLM\System\CurrentControlSet\Control\Session Manager\Memory Management
中的DisablePagingExecutive值被设置(在这种情况下,驱动程序占用的内存不被分页)。在Windows 2000中运行Soft-ICE
需要用这种方式禁止内核分页。但这使得把驱动程序代码或数据误放到分页池中所造成的错误特别难以查找。如果你使用这种调试器,
我推荐你最好使用PAGED_CODE宏和驱动程序检查器。 
............................................................................................................................
使编译器把代码放到特定段的传统方法是使用alloc_text编译指示。但不是每种编译器都支持这个编译指示,
判断DDK中是否定义了ALLOC_PRAGMA可以帮助决定能否使用alloc_text编译指示。这个编译指示可以把驱动程序的单独例程放到特定段中:

#ifdef ALLOC_PRAGMA
  #pragma alloc_text(PAGE, AddDevice)
  #pragma alloc_text(PAGE, DispatchPnp)
  ...
#endif
 

上面语句把AddDevice和DispatchPnp函数的代码放到分页池中。

.........................................................................................................................
Microsoft的C/C++编译器在alloc_text的使用上加了两个讨厌的限制:

该编译指示必须跟在函数声明后面而不能在前面。你可以把驱动程序中的所有函数集中到一个头文件中,并在包含该头文件的源文件中,
在#include语句的后面使用alloc_text。 
该编译指示仅能用于有C连接形式的函数。即,它不能用于类成员函数或C++源文件中未用extern "C"声明的函数。 
控制数据变量的布置需要使用另外一个编译指示:

#ifdef ALLOC_DATA_PRAGMA
  #pragma data_seg("PAGE")
#endif
 

data_seg编译指示使所有在其后声明的静态数据变量进入分页池。这个编译指示与alloc_text完全不同。
一个分页段可以从#pragma data_seg("PAGE")出现的地方开始到#pragma data_seg()出现的地方结束。而Alloc_text仅应用于单个函数。

在把数据放到分页段前应该仔细考虑,因为这有可能把事情搞得更坏。最小的页单位大小为PAGE_SIZE长。
仅把一点字节放到分页段可能很蠢,因为整个页的内存都会被占用。另外,一个脏页(从磁盘读取后被改变过)在其物理页帧能被重用之前需
要写回硬盘。
.........................................................................................................................
关于段布置

我发现用code_seg编译指示安排代码段更方便,
它与data_seg的用法一样,但仅用于代码。你可以象下面这样通知Microsoft编译器把函数放到分页池:

#pragma code_seg("PAGE")
NTSTATUS AddDevice(...){...}
NTSTATUS DispatchPnp(...){...}
 

AddDevice和DispatchPnp函数将进入分页池。
为了检测编译器是否是Microsoft的编译器,可以测试预定义宏_MSC_VER是否存在。

加入下面语句,可以恢复到默认的代码段设置安排:

#pragma code_seg()
 

类似的,加入下面语句,可以使数据放入通常使用的非分页数据段:

#pragma data_seg()
 

顺便提一下,如果某些代码在驱动程序完成初始化后不再需要,可以直接把它插入到INIT段。例如:

#pragma alloc_text(INIT, DriverEntry)
 

该语句强制DriverEntry函数进入INIT段。当函数返回后,系统将释放掉它占用的内存。
这个小的节省并不十分重要,因为WDM的DriverEntry函数没有多少工作要做。以前的Windows NT驱动程序有一个大的DriverEntry函数,
用于创建设备对象,定位资源,配置设备,等等。所以对于老式驱动程序,这个特征可以节省一些内存。

...........................................................................................................................
服务函数 描述 
MmLockPagableCodeSection 锁定含有给定地址的代码段 
MmLockPagableDataSection 锁定含有给定地址的数据段 
MmLockPagableSectionByHandle 用MmLockPagableCodeSection返回的句柄锁定代码段(仅用于Windows 2000) 
MmPageEntireDriver 解锁所有属于某驱动程序的页 
MmResetDriverPaging 恢复整个驱动程序的编译时分页属性 
MmUnlockPagableImageSection 为一个锁定代码段或数据段解锁 
...........................................................................................................................
下面我要描述一种用这些函数实现代码分页的方法,其它方法可参考DDK文档。首先,把驱动程序的某些例程放到单独命名的代码段,
例如:

#pragma alloc_text(PAGEIDLE, DispatchRead)
#pragma alloc_text(PAGEIDLE, DispatchWrite)
...
 

即,定义一个以“PAGE”开头加任意四个字母做后缀为段名的段。
然后用alloc_text编译指示把一些例程放到该段。你可以有任意多个专用分页段,但那会带来许多麻烦的维护问题。

在初始化期间(即,在DriverEntry),按下面方式锁定分页段:

PVOID hPageIdleSection;
NTSTATUS DriverEntry(...)
{
  hPageIdleSection = MmLockPagableCodeSection((PVOID) DispatchRead);
}
 

调用MmLockPagableCodeSection时,你可以指定任何一个要锁定段中的地址。
在DriverEntry中调用该函数的真正目的是为了获得其返回的句柄,该句柄被保存到全局变量hPageIdleSection中。
在后面,当这个段不再需要存在于内存中时会用到该句柄:

MmUnlockPagableImageSection(hPageIdleSection);
 

该调用将解锁包含有PAGEIDLE段的页并允许它们按需要进出内存。之后,如果你需要它出现在内存中时,可以调用:

MmLockPagableSectionByHandle(hPageIdleSection);
 

该调用完成后,PAGEIDLE段将再次进入非分页内存(可以是与上一次不同的物理内存)。
注意该函数仅在windows 2000中有效,并且必须使用ntddk.h文件代替wdm.h文件。对于其它系统,
只能使用MmLockPagableCodeSection函数。

把数据对象放入分页段与上面类似:

PVOID hPageDataSection;

#pragma data_seg("PAGE")
ULONG ulSomething;
#pragma data_seg()

hPageDataSection = MmLockPagableDataSection((PVOID) &ulSomething);

MmUnlockPagableImageSection(hPageDataSection);

MmLockPagableSectionByHandle(hPageDataSection);
 

这些内存管理器服务函数后面的主要思想是:你一开始先锁定包含一个或多个页的段,
并获得其句柄,而后面的调用将用到该句柄。调用MmUnlockPagableImageSection并传递同样的句柄你还可以解锁该段占用的页,
再次调用MmLockPagableSectionByHandle又可以锁定该段。

如果你确信全部驱动程序都不必驻留,可以调用MmPageEntireDriver把驱动程序的所有段都变为分页式。
相反,调用MmResetDriverPaging将把整个驱动程序恢复到编译时的分页属性布局。
调用这些函数仅需要一个存在于驱动程序中的某个地址。例如:

MmPageEntireDriver((PVOID) DriverEntry);
...
MmResetDriverPaging((PVOID) DriverEntry);
 

如果设备使用中断,那么你在练习使用这些内存管理器例程时需要格外小心。
有时会发生假中断,这将导致系统调用不存在的ISR,这种随机系统崩溃的原因特别难于发现。DDK推荐的规则是:
不要把ISR和与其相关的DPC代码所占用的内存置成分页式。
........................................................................................................................
内核模式中的基本堆分配函数是ExAllocatePool。调用方式如下:

PVOID p = ExAllocatePool(type, nbytes);
 
 ExAllocatePool的内存池类型参数

内存池类型 描述 
NonPagedPool 从非分页内存池中分配内存 
PagedPool 从分页内存池中分配内存 
NonPagedPoolMustSucceed 从非分页内存池中分配内存,如果不能分配则产生bugcheck 
NonPagedPoolCacheAligned 从非分页内存池中分配内存,并确保内存与CPU cache对齐 
NonPagedPoolCacheAlignedMustS 与NonPagedPoolCacheAligned类似,但如果不能分配则产生bugcheck 
PagedPoolCacheAligned 从分页内存池中分配内存,并确保内存与CPU cache对齐

释放内存块
调用ExFreePool可以释放由ExAllocatePool分配的内存块:

ExFreePool((PVOID) p);
 
........................................................................................................................
ExAllocatePoolWithTag
调用ExAllocatePool是从内核模式堆中分配内存的标准方式。另一个函数ExAllocatePoolWithTag,
与ExAllocatePool稍有不同,它提供了一个有用的额外特征。当使用ExAllocatePoolWithTag时,
系统在你要求的内存外又额外地多分配了4个字节的标签。这个标签占用了开始的4个字节,位于返回指针所指向地址的前面。
调试时,如果你查看分配的内存块会看到这个标签,它帮助你识别有问题的内存块。例如:

PVOID p = ExAllocatePoolWithTag(PagedPool, 42, 'KNUJ');
 

在这里,我使用了一个32位整数常量作为标签值。在小结尾的计算机如x86上,组成这个标签的4个字节的顺序与正常拼写相反。

WDM.H中声明的内存分配函数受一个预处理宏POOL_TAGGING控制。WDM.H(NTDDK.H中也是)中无条件地定义了POOL_TAGGING,结果,
无标签的函数实际上是宏,它真正执行的是有标签函数并加入标签‘ mdW’(指明为WDM的内存块)。
如果在未来版本的DDK中没有定义POOL_TAGGING,那么带标签函数将成为无标签函数的宏。Microsoft现在还没打算改变POOL_TAGGING的设置。

由于POOL_TAGGING宏的存在,当你在程序中调用ExAllocatePool时,最终被调用的将是ExAllocatePoolWithTag。如果你关闭了该宏,
自己去调用ExAllocatePool,但ExAllocatePool内部仍旧调用ExAllocatePoolWithTag并带一个‘enoN’(即None)的标签。
因此你无法避免产生内存标签。所以你应该明确地调用ExAllocatePoolWithTag并加上一个你认为有意义的标签。实际上,
Microsoft强烈鼓励你这样做。
..........................................................................................................................
ExAllocatePool的其它形式
尽管ExAllocatePoolWithTag函数是分配堆内存时应该使用的函数,但在某些特殊场合你也可以使用该函数的另外两种形式:

ExAllocatePoolWithQuota 分配一块内存并充入当前线程的调度配额中,该函数仅用于顶层驱动程序,如文件系统驱动程序或其它运行在
非任意线程上下文中的驱动程序。 
ExAllocatePoolWithQuotaTag 同上,但加入一个标签。 
.........................................................................................................................
通常,不管你是用单链表还是用双链表组织数据,都需要把一个子结构(用于连接链表的连接元素)——
或者是LIST_ENTRY,或者是SINGLE_LIST_ENTRY——嵌入到你自己的数据结构中。另外你还需要在某处保存一个链表头,
它与连接元素有相同的结构。下面是一个例子:

typedef struct _TWOWAY 
{
  ...
  LIST_ENTRY linkfield;
  ...
} TWOWAY, *PTWOWAY;

LIST_ENTRY DoubleHead;

typedef struct _ONEWAY
{
  ...
  SINGLE_LIST_ENTRY linkfield;
  ...
} ONEWAY, *PONEWAY;

SINGLE_LIST_ENTRY SingleHead;


当调用任何一个链表管理函数时,应该总是使用连接域或链表头——决不直接使用包含它的数据结构本身。
所以,假设你得到了一个TWOWAY结构的指针(pdElement),为了把这个结构加入到链表中,应该象下面这样引用嵌入的连接域:

InsertTailList(&DoubleHead, &pdElement->linkfield);

类似地,当你要从链表中提取一个元素时,真正使用的地址是嵌入连接域的地址。为了得到外层数据结构的地址,
可以使用CONTAINING_RECORD宏。
.........................................................................................................................
我们应该了解RemoveHeadList和RemoveTailList的实现细节,这样可以避免一些错误。例如,观察下面语句:

if (<some-expr>)
  pdLink = RemoveHeadList(&DoubleHead);
 

很明显,上面语句的意图是有条件地从链表中删除第一个元素。但是,当调试这段代码时,你会发现第一个链表元素总是神秘地消失,
pdLink会在if表达式为TRUE时被更改,但是RemoveHeadList看起来好象在if表达式为FALSE时也被调用。

天哪!怎么回事?实际上RemoveHeadList是一个宏,在预编译后被扩展成多个语句。下面是编译器真正看到的程序:

if (<some-expr>)
  pdLink = (&DoubleHead)->Flink;
{{
  PLIST_ENTRY _EX_Blink;
  PLIST_ENTRY _EX_Flink;
  _EX_Flink = ((&DoubleHead)->Flink)->Flink;
  _EX_Blink = ((&DoubleHead)->Flink)->Blink;
  _EX_Blink->Flink = _EX_Flink;
  _EX_Flink->Blink = _EX_Blink;
}}
 

啊!现在链表元素神秘消失的原因终于变得明了了。if语句的TRUE分支仅有pdLink = (&DoubleHead)->Flink语句,
它把一个指针放到第一个元素中。而删除链表元素的语句块却跑到了if语句外面,所以这些语句总是被执行。
RemoveHeadList和RemoveTailList都被翻译成一个表达式加上一个复合语句的形式,因此你不能在单表达式或单语句的地方使用它们。
:-(

其它链表操作函数(宏)没有这个问题。
............................................................................................................................
单链表
单链表以一个方向连接元素,如图3-10。Windows NT用单链表实现下推堆栈,表3-5列出了下推堆栈的服务函数。与双链表相同,这些“函数”
在wdm.h中以宏实现。PushEntryList和PopEntryList也生成多条语句,所以你只能把它们用在等号右边。

表3-5. 单链表服务函数

服务函数或宏 描述 
PushEntryList 向链表顶加入元素 
PopEntryList 删除最上面的元素 
.............................................................................................................................
 lookaside链表的服务函数

服务函数 描述 
ExInitializeNPagedLookasideList
ExInitializePagedLookasideList 初始化lookaside链表 
ExAllocateFromNPagedLookasideList
ExAllocateFromPagedLookasideList 分配一个固定大小的内存块 
ExFreeToNPagedLookasideList
ExFreeToPagedLookasideList 将一个内存块释放回lookaside链表 
ExDeleteNPagedLookasideList
ExDeletePagedLookasideList 删除lookaside链表

为lookaside链表对象保留完存储后,应调用相应的初始化函数:

PPAGED_LOOKASIDE_LIST pagedlist;
PNPAGED_LOOKASIDE_LIST nonpagedlist;

ExInitializePagedLookasideList(pagedlist, Allocate, Free, 0, blocksize, tag, 0);
ExInitializeNPagedLookasideList(nonpagedlist, Allocate, Free, 0, blocksize, tag, 0);

从链表上分配一个内存块,调用AllocateFrom函数:

PVOID p = ExAllocateFromPagedLookasideList(pagedlist);
PVOID q = ExAllocateFromNPagedLookasideList(nonpagedlist);
 

向链表返回一个内存块,调用FreeTo函数:

ExFreeToPagedLookasideList(pagedlist, p);
ExFreeToNPagedLookasideList(nonpagedlist, q);
 

删除链表,调用Delete函数:

ExDeletePagedLookasidelist(pagedlist);
ExDeleteNPagedLookasideList(nonpagedlist);
 

经常犯的错误是忘记删除lookaside链表。应该在lookaside链表的有效范围内删除它。例如,
如果在AddDevice函数中创建了lookaside链表,应该把链表对象放到设备对象中并在调用IoDeleteDevice前删除它。
如果在DriverEntry函数中创建了lookaside链表,应该把链表对象放到全局变量中,
并在DriverUnload例程返回前删除它(在DriverUnload例程的尾部)。
..........................................................................................................................

打开注册表键
在读注册表的某个值之前,你需要先打开包含该值的键。用ZwOpenKey函数可以打开一个已存在的键。ZwCreateKey函数既可以打开已
存在键又可以创建新键。这两个函数都需要事先用键名以及某些其它信息初始化一个OBJECT_ATTRIBUTES结构。OBJECT_ATTRIBUTES结
构的声明如下:

typedef struct _OBJECT_ATTRIBUTES {
  ULONG Length;
  HANDLE RootDirectory;
  PUNICODE_STRING ObjectName;
  ULONG Attributes;
  PVOID SecurityDescriptor;
  PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES;
 

除了手工初始化这个结构外,你还可以调用InitializeObjectAttributes宏来初始化它。

例如,假设我们想要打开驱动程序的服务键。它的键名我们可以从I/O管理器传递给DriverEntry函数的一个参数中得到。所以,
我们的代码如下:

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
  ...
  OBJECT_ATTRIBUTES oa;
  InitializeObjectAttributes(&oa, RegistryPath, 0, NULL, NULL);   <--1
  HANDLE hkey;
  status = ZwOpenKey(&hkey, KEY_READ, &oa);     <--2
  if (NT_SUCCESS(status))
  {
    ...
    ZwClose(hkey);        <--3
  }
  ...
}
............................................................................................................................
接口键是HKLM\System\CurrentControlSet\Control\DeviceClasses下的一个子键,可以常驻注册表。
我们可以在这里放入需要与用户模式程序共享的参数信息,用户模式程序可以调用SetupDiOpenDeviceInterfaceRegKey函数访问该键。
..........................................................................................................................
删除子键或键值
为了删除已打开键中的键值,可以使用RtlDeleteRegistryValue函数:

RtlDeleteRegistryValue(RTL_REGISTRY_HANDLE, (PCWSTR) hkey, L"TheAnswer");
 

RtlDeleteRegistryValue是一个通用的服务函数,它的第一个参数可以指向注册表中的几个特殊位置。当使用RTL_REGISTRY_HANDLE时,
表示你要删除已打开键中的键值。第二参数指出键。第三个参数是一个空结尾的Unicode串,它是被删除键值的名称。在这里,
你不必为描述该串创建一个UNICODE_STRING结构。

如果你对某注册表键有DELETE权限(用KEY_ALL_ACCESS打开),可以调用ZwDeleteKey删除该键:

ZwDeleteKey(hkey);
 

被删除键要等到其所有句柄都关闭后才真正消失,所有后来访问或打开该键的请求都将失败,并返回STATUS_KEY_DELETED错误代码。
键删除后,仍需要调用ZwClose函数关闭其句柄。(DDK文档中关于ZwDeleteKey函数的内容指出那个句柄将变为无效,但实际不是这样,
你仍要调用ZwClose关闭它)
.............................................................................................................................
枚举子键或键值
枚举键中的元素(子键和键值)是一个较复杂的操作。为此,需要先调用ZwQueryKey以获得该键子键和键值的一些信息,
如个数、最长名的长度,等等。ZwQueryKey有一个参数可以让你指出需要该键的哪种类型的信息。有三种类型:基本信息、
节点信息,和全部信息。为了准备枚举操作,你最好指出需要全部信息:

typedef struct _KEY_FULL_INFORMATION {
    LARGE_INTEGER LastWriteTime;
    ULONG   TitleIndex;
    ULONG   ClassOffset;
    ULONG   ClassLength;
    ULONG   SubKeys;
    ULONG   MaxNameLen;
    ULONG   MaxClassLen;
    ULONG   Values;
    ULONG   MaxValueNameLen;
    ULONG   MaxValueDataLen;
    WCHAR   Class[1];
} KEY_FULL_INFORMATION, *PKEY_FULL_INFORMATION;
 

该结构实际上是可变长的,因为Class[0]只是类名的第一个字符。通常,第一次调用获得要分配缓冲区的大小,
第二次调用获得实际的数据,如下:

ULONG size;
ZwQueryKey(hkey, KeyFullInformation, NULL, 0, &size);
PKEY_FULL_INFORMATION fip = (PKEY_FULL_INFORMATION) ExAllocatePool(PagedPool, size);
ZwQueryKey(hkey, 0, KeyFullInformation, bip, size, &size);
 

用subkeys值做计数器,循环调用ZwEnumerateKey:

for (ULONG i = 0; i < fip->SubKeys; ++i)
{
  ZwEnumerateKey(hkey, i, KeyBasicInformation, NULL, 0, &size);
  PKEY_BASIC_INFORMATION bip = (PKEY_BASIC_INFORMATION) ExAllocatePool(PagedPool, size);
  ZwEnumerateKey(hkey, i, KeyBasicInformation, bip, size, &size);
  <do something with bip->Name>
  ExFreePool(bip);
}
 

每个子键的关键信息就是它的名字,存在于KEY_BASIC_INFORMATION结构中:

typedef struct _KEY_BASIC_INFORMATION {
    LARGE_INTEGER LastWriteTime;
    ULONG   Type;
    ULONG   NameLength;
    WCHAR   Name[1];
} KEY_BASIC_INFORMATION, *PKEY_BASIC_INFORMATION;
 

这个名字并不是空结尾的字符串,因此你必须使用NameLength成员确定其长度,不要忘了长度的单位是字节。
这个名字并不是完整的注册表路径,它就是这个子键的名称。这样更好,因为只要有子键名和其父键的句柄我们就可以打开这个子键了。

为了枚举键中的键值,可使用下面方法:

ULONG maxlen = fip->MaxValueNameLen + sizeof(KEY_VALUE_BASIC_INFORMATION);
PKEY_VALUE_BASIC_INFORMATION vip = (PKEY_VALUE_BASIC_INFORMATION) ExAllocatePool(PagedPool, maxlen);
for (ULONG i = 0; i < fip->Values; ++i)
{
  ZwEnumerateValueKey(hkey, i, KeyValueBasicInformation, vip, maxlen, &size);
  <do something with vip->Name>
}
ExFreePool(vip);
 

基于已获得的KEY_FULL_INFORMATION结构中的MaxValueNameLen成员,你可以为最大可能的KEY_VALUE_BASIC_INFORMATION结构分配空间。
在循环中,你可以处理该键值的名称,这个名称存在于KEY_VALUE_BASIC_INFORMATION结构中:

typedef struct _KEY_VALUE_BASIC_INFORMATION {
    ULONG   TitleIndex;
    ULONG   Type;
    ULONG   NameLength;
    WCHAR   Name[1]; 
} KEY_VALUE_BASIC_INFORMATION, *PKEY_VALUE_BASIC_INFORMATION;
 

再一次,我们有了键值名和其父键的句柄,因此可以直接获取该键值。
............................................................................................................................
DDK文档中明确指出支持例程的IRQL限定。例如,KeWaitForSingleObject例程有两个限定:

调用者必须运行在低于或等于DISPATCH_LEVEL级上。 
如果调用中指定了非0的超时,那么调用者必须严格地运行在低于DISPATCH_LEVEL的IRQL上。 
上面这两行想要说明的是:如果KeWaitForSingleObject真的被阻塞了指定长的时间(你指定的非0超时),
那么你必定运行在低于DISPATCH_LEVEL的IRQL上,因为只有在这样的IRQL上线程阻塞才是允许的。
如果你所做的一切就是为了检测事件是否进入信号态,则可以执行在DISPATCH_LEVEL级上。
但你不能在ISR或其它运行在高于DISPATCH_LEVEL级上的例程中调用KeWaitForSingleObject例程。
............................................................................................................................
IRQL的明确控制
如果必要,你还可以在当前处理器上临时提升IRQL,然后再降回到原来的IRQL,使用KeRaiseIrql和KeLowerIrql函数。
下面代码运行在PASSIVE_LEVEL级上:

KIRQL oldirql;        <--1
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);    <--2
KeRaiseIrql(DISPATCH_LEVEL, &oldirql);     <--3
...
KeLowerIrql(oldirql);       <--4
 

KIRQL定义了用于保存IRQL值的数据类型。我们需要一个变量来保存当前IRQL。 
这个ASSERT断定了调用KeRaiseIrql的必要条件:新IRQL必须大于或等于当前IRQL。如果这个关系不成立,
KeRaiseIrql将导致bug check。(即用死亡蓝屏报告一个致命错误) 
KeRaiseIrql把当前的IRQL提升到第一个参数指定的IRQL级上。它同时还把当前的IRQL值保存到第二个参数指定的变量中。
在这个例子中,我们把IRQL提升到DISPATCH_LEVEL级,并把原来的IRQL级保存到oldirql变量中。 
执行完任何需要在提升的IRQL上执行的代码后,我们调用KeLowerIrql把IRQL降低到调用KeRaiseIrql时的级别。 
DDK文档中提到,你必须用与你最近的KeRaiseIrql调用所返回的值调用KeLowerIrql。这在大的方面是对的,
因为你提升了IRQL就必须再降低它。然而,由于你调用的代码或者调用你的代码所做的各种假设会使后面的决定变得不正确。所以,文档中的这句话从严格意义上讲是不正确的。应用到KeLowerIrql函数的唯一的规则就是新IRQL必须低于或等于当前IRQL。

当系统调用你的驱动程序例程时,你降低了IRQL(系统调用你的例程时使用的IRQL,或你的例程希望执行的IRQL),
这是一个错误,而且是严重错误,尽管你在例程返回前又提升了IRQL。这种打破同步的结果是,某些活动可以抢先你的例程,
并能访问你的调用者认为不能被共享的数据对象。

有一个函数专用于把IRQL提升到DISPATCH_LEVEL级:

KIRQL oldirql = KeRaiseIrqlToDpcLevel();
...
KeLowerIrql(oldirql)
 

注意:该函数仅在NTDDK.H中声明,WDM.H中并没有声明该函数,因此WDM驱动程序不应该使用该函数。

...........................................................................................................................
自旋锁

--------------------------------------------------------------------------------

IRQL概念仅能解决单CPU上的同步问题,在多处理器平台上,它不能保证你的代码不被运行在其它处理器上的代码所干扰。
一个称为自旋锁(spin lock)的原始对象可以解决这个问题。为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,
该操作测试并设置(test-and-set)某个内存变量,由于它是原子操作,所以在该操作完成之前其它CPU不可能访问这个内存变量。
如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行。如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个
“测试并设置(test-and-set)”操作,即开始“自旋”。最后,锁的所有者通过重置该变量释放这个自旋锁,于是,
某个等待的test-and-set操作向其调用者报告锁已释放。

关于自旋锁有两个明显的事实。第一,如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,
则该CPU将死锁(deadlock)。自旋锁没有与其关联的“使用计数器”或“所有者标识”;锁或者被占用或者空闲。
如果你在锁被占用时获取它,你将等待到该锁被释放。如果碰巧你的CPU已经拥有了该锁,那么用于释放锁的代码将得不到运行,
因为你使CPU永远处于“测试并设置”某个内存变量的自旋状态。

关于自旋锁的另一个事实是,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。所以,为了避免影响性能,
你应该在拥有自旋锁时做尽量少的操作,因为此时某个CPU可能正在等待这个自旋锁。

关于自旋锁还存在着一个不太明显但很重要的事实:你仅能在低于或等于DISPATCH_LEVEL级上请求自旋锁,在你拥有自旋锁期间,
内核将把你的代码提升到DISPATCH_LEVEL级上运行。在内部,内核能在高于DISPATCH_LEVEL的级上获取自旋锁,但你和我都做不到这一点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值