Windows 内存管理

内存管理

物理内存地址(Physical Memory Address)

PCI 上有三条总线,分别是数据总线、地址总线和控制总线。32 位 CPU 的寻址能力为 4GB(2^32)个字节。用户最多可以使用 4GB 的真实物理内存。PC 中会拥有很多的设备,其中很多设备都提供了自己的设备内存。例如,显卡就会提供自己的显存。这部分内存会映射到 PC 的物理内存上,也就是读写这段内存地址,其实会读写设备的内存地址,而不会读写物理内存地址。很多情况下,会广义的认为,这也是物理内存地址。

虚拟内存地址(Virtual Memory Address)

虽然可以寻址 4G 的内存,而在 PC 里往往没有如此多的物理内存。操作系统和硬件(这里是指 CPU 中的内存管理单元 MMU)为使用者提供了虚拟内存的概念。Windows 的所有程序(包括 Ring0 层和 Ring3 层的程序)可以操作的都是虚拟内存。之所以称为虚拟内存,是因为对它的所有操作,最终会变成一系列对真实物理内存的操作。

虚拟内存的设计原因:

  • 虚拟的增加了内存的大小。不管PC是否拥有足够的 4GB 的物理内存,操作系统总会有 4GB 的虚拟内存。这就允许使用者申请更多的内存,当物理内存不够的时候,可以通过将不常用的虚拟内存页交换成文件,等需要的时候再去读取。
  • 是不同的进程的虚拟内存不互相干扰,为了让系统可以运行不同的进程,Windows 让每个进程看到的虚拟内存都不同。这个方法就使得不同的进程会有不同的物理内存到虚拟内存的映射。例如,进程 A 和进程 B 的内存地址 0x40000000 会完全不同。修改 A 进程这个地址,不会影响到 B 进程。因为 A 进程的这个地址可能映射的是一段物理内存地址,而 B 的这个地址映射的是另外一段物理内存地址。

用户模式地址和内核模式地址

虚拟地址 0 ~ 0x7FFFFFFF 范围内的虚拟内存,即低 2GB 的虚拟内存地址,被称为用户模式地址。而 0x80000000 ~ 0xFFFFFFFF 范围内的虚拟内存,即高 2GB 的虚拟内存,被称为内核模式地址。Windows 规定,运行在用户态(Ring3 层)的程序,只能访问用户模式的地址,而运行在核心态(Ring0 层)的程序,可以访问整个 4GB 的虚拟内存,即用户模式地址和内核模式地址。

Windows 驱动程序和进程的关系

驱动程序可以看成是一个特殊的 DLL 文件被应用程序加载到虚拟内存中,只不过加载地址是内核模式地址,而不是用户模式地址。它能访问的只是这个进程的虚拟内存,而不是其他进程的虚拟内存地址。需要指出的是,Windows 驱动程序里不同例程运行在不同的进程中。DriverEntry 例程和 AddDevice 例程是运行在系统进程(System)进程中的。这个进程是 Windows 第一个运行的进程。当需要加载的时候,这个进程中会有一个线程将驱动程序加载到内核模式地址空间内,并调用 DriverEntry 例程。

而其他的一些例程,例如,IRP_MJ_READ 和 IRP_MJ_WRITE 的派遣回调函数,会运行于应用程序的"上下文"中。所谓运行在进程的上下文中,指的是,运行于某个进程的环境中,所能访问的虚拟地址是这个进程的虚拟地址。

如果当前进程是发起IO请求的进程,在驱动程序中打印当前进程的进程名称,会显示出当前进程的名称,说明当前驱动例程是运行在此进程的虚拟地址空间中,如下代码:

c 复制
VOID DisplayItsProcessName()
{
    // 得到当前的进程
    PEPROCESS pEProcess = PsGetCurrentProcess();
    // 得到当前的进程名称
    PTSTR ProcessName = (PTSTR)((ULONG)pEProcess + 0x174);
    KdPrint(("%s\n", ProcessName));
}

分页与非分页内存

Windows 规定有些虚拟内存页面是可以交换到文件中的,这类内存被称为分页内存。而有些虚拟内存页,永远不会交换到文件中,这些内存被称为非分页内存。

当程序的中断请求级在 DISPATCH_LEVEL 之上时(包括 DISPATCH_LEVEL 层),程序只能使用非分页内存,否则将导致蓝屏死机。

在编译 DDK 提供的例程时候,可以指定某个例程和某个全局变量是载入分页内存,还是非分页内存,需要做如下定义:

c 复制
#define PAGEDCODE code_seg("PAGE")
#define LOCKEDCODE code_seg()
#define INITCODE code_seg("INIT")

#define PAGEDDATA data_seg("PAGE")
#define LOCKEDDATA data_seg()
#define INITDATA data_seg("INIT")

如果将某个函数载入到分页内存中,我们需要再函数的实现中加入如下代码:

c 复制
#pragma PAGEDCODE
VOID SomeFunction()
{
    PAGED_CODE();
    // 相关代码
}

其中,PAGED_CODE() 是 DDK 提供的宏,它只在 check 的版本中生效。它会检验这个函数是否运行低于 DISPATCH_LEVEL 的中断请求级,如果等于或者高于这个中断请求级,将产生一个断言。

如果让函数加载到非分页内存中,需要再函数的实现代码中,加入如下代码:

c 复制
#pragma LOCKEDCODE
VOID SomeFunction()
{
    // 做一些事情
}

还有一种特殊情况,就是某个例程需要再初始化的时候载入内存,然后就可以从内存中卸载掉。这种情况只出现在 DriverEntry 的情况下,尤其是 NT 式的驱动,DriverEntry 会很长,占据很大的空间,为了节省内存,需要及时的从内存中卸载掉。代码如下:

c 复制
#pragma INITCODE
extern "C" NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
    // 做一些事情.
}

分配内核内存

Windows 驱动程序使用的内存资源非常的珍贵,分配内存时候要尽量节约。和应用程序一样,局部变量是存放在栈(Stack)空间的。但栈空间不会像应用程序那么大,所以驱动程序不适合递归调用,或者局部变量是大型结构体。如果需要大型结构体,请在堆中申请。堆中申请内存的函数有以下几个:

c 复制
PVOID
    ExAllocatePool(
    IN POOL_TYPE PoolType,
    IN SIZE_T NumberOfBytes
);
PVOID
    ExAllocatePoolWithTag(
    IN POOL_TYPE PoolType,
    IN SIZE_T NumberOfBytes,
    IN ULONG Tag
);
PVOID
    ExAllocatePoolWithQuota(
	IN POOL_TYPE PoolType,
    IN SIZE_T NumberOfBytes
);
PVOID
    ExAllocatePoolWithQuotaTag(
	IN POOL_TYPE PoolType,
    IN SIZE_T NumberOfBytes,
    IN ULONG Tag
);
  • PoolType 是个枚举变量,如果此值为 NonPagedPool,则分配非分页内存。如果此值为 PagedPool ,则分配内存为分页内存。
  • NumberOfBytes 是分配内存的大小,注意最好是4 的整数倍。
  • 返回值是分配的内存的地址,一定是内核模式地址。如果返回0,则代表分配失败。

驱动中使用链表

运行时函数

内存间的复制

DDK 提供了以下函数:

c 复制
// 内存复制(非重叠)RtlCopyBytes
VOID RtlCopyMemory(
    IN VOID UNALIGNED *Destination,
    IN CONST VOID UNALIGNED *Source,
    IN SIZE_T Length
);
// 内存复制(可重叠)
VOID RtlMoveMemory(
    IN VOID UNALIGNED *Destination,
    IN CONST VOID UNALIGNED *Source,
    IN SIZE_T Length
)

内存填充

c 复制
// 填充内存
VOID RtlFillMemory(
	IN VOID UNALIGNED *Destination,
    IN SIZE_T Length,
    IN CHAR Fill
)
// 填充0(实际调用的 memset)
VOID RtlZeroMemory(
    IN VOID UNALIGNED *Destination,
    IN SIZE_T Length
)

内存比较

c 复制
// 比较内存,返回相等的字节数.
ULONG RtlEqualMemory(
	CONST VOID *Source1,
    CONST VOID *Source2,
    SIZE_T Length
);

其他

数据类型

用 C 语言或者 C++ 语言开发驱动时,字符变量、短整型变量、长整型整数都有自己标准的数据类型。DDK 对这些数据类型做了封装。在驱动程序中,既可以使用 C 语言的类型,也可以使用 DDK 提供的类型定义,下表列出了 C 语言数据类型和 DDK 中对应的类型:

C 语言定义 DDK 中的定义
void VOID
char CHAR
short SHORT
long LONG
wchar_t WCHAR
char* PCHAR
wchar_t* PWCHAR

返回值状态

在执行完内核函数后,应该查看该函数的返回值状态。如果状态值高位为 0,无论其他位置是否设置,该状态码都代表成功。绝对不能用状态码与 0 进行比较来判断是否成功,而应该用 NT_SUCCESS 宏,用法如下:

c 复制
status = Foo...();
if (NT_SUCCESS(status)) {
    // 执行成功的代码.
}

检查内存的可用性

在驱动程序的开发中,对内存的操作要格外小心。如果某段内存是只读的,而驱动程序试图去写,会导致系统的崩溃。同样,当某段内存是不可读的情况下,而驱动程序试图去读,同样会导致系统的崩溃。

DDK 提供了两个函数,帮助程序员在不知道某段内存是否可读写的情况下,试探这段内存的可读写性。这两个函数分别是ProbeForReadProbeForWrite

c 复制
VOID ProbeForRead(
	IN CONST VOID *Address,
    IN SIZE_T Length,
    IN ULONG Alignment
);
VOID ProbeForWrite(
	IN CONST VOID *Address,
    IN SIZE_T Length,
    IN ULONG Alignment
);
  • Address:需要被检查的内存地址
  • Length:需要被检查的内存的长度
  • Alignment:描述该段内存是以多少字节对齐的。

这两个函数不是返回该段内存是否可读写,而是当不可读写的时候,引发一个异常(Exception)。这个异常需要用到微软编译器提供的"结构化异常"处理办法。"结构化异常"机制,会轻松的检测到这种异常,进而做出相应的异常处理。

结构化异常处理(try-except 块)

结构化异常处理,是微软编译器提供的独特处理机制,这种处理方式能在一定程度上再出现错误的情况下,免于程序崩溃。为了说明结构化异常,有两个概念需要说明一下:

(1)异常:异常的概念类似于中断的概念,当程序中某种错误触发一个异常,操作系统会寻找处理这个异常的处理函数。如果程序提供错误处理函数,则进入错误处理函数,如果没有提供错误处理函数,则有操作系统默认的错误处理函数处理。在内核模式下,操作系统默认的处理办法往往很简单,直接让系统蓝屏,并在蓝屏上简单描述出错误出错的信息,之后,系统进入死机状态。这当然不是程序员所希望看到的,程序员需要自己设置异常处理函数。

(2)回卷:程序执行到某个地方出现异常错误时候,系统会寻找错误点是否处于一个 try {} 的块中,并进入 try {} 块提供的异常处理代码。如果当前 try 没有提供异常处理,则会向更外一层的 try 块,寻找异常处理代码。直到最外层 try {} 块也没有提供异常处理代码,则交由操作系统处理。

这种向更外一层寻找异常处理的机制,被称为回卷。一般异常处理是通过 try - except 块来进行处理的。

c 复制
__try {
    
} __except(filter_value) {
    
}

在被 __try {} 包围的块中,如果出现异常,会根据 filter_value 的值,判断是否需要在 __except {} 块中处理。filter_value 的值会有三种可能性。

  1. EXCEPTION_EXECUTE_HANDLER,该数值为 1。进入到__except{}进行错误处理,处理完后不会在回到__try {}块中,转而继续执行。
  2. EXCEPTION_CONTINUE_SEARCH,该数值为 0。不使用 __except {} 块中的异常处理,转而向上一层回卷。如果已经是最外层,则向操作系统请求异常处理函数。
  3. EXCEPTION_CONTINUE_EXECUTION,该数值为 -1。重复先前错误的指令,这个在驱动程序中很少使用。

除了读写内存,try-except 块还可以处理一些异常。DDK 提供了一些函数触发异常,可以根据需要使用:

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

结构化处理异常(try-finally 块)

结构化异常处理还有另外一种使用方法,就是利用 try-finally,强迫函数在退出前执行一段代码。

__try {} 块中无论运行什么代码(即便是运行 return 或者触发异常),在程序退出前都会执行 __finally {} 块中的代码。这样的目的是,在退出前需要运行一些资源的回收工作,而资源回收代码的最佳位置就是放在这个块中。

除此之外,使用 try-finally 块,还可以某种程度上简化代码。