Windows 内存管理
- 作者: 刘杰
- 来源: 技术那些事
- 阅读:211
- 发布: 2025-06-18 10:13
- 最后更新: 2025-06-18 10:13
内存管理
物理内存地址(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 提供了两个函数,帮助程序员在不知道某段内存是否可读写的情况下,试探这段内存的可读写性。这两个函数分别是ProbeForRead 和 ProbeForWrite。
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 的值会有三种可能性。
- EXCEPTION_EXECUTE_HANDLER,该数值为 1。进入到
__except{}进行错误处理,处理完后不会在回到__try {}块中,转而继续执行。 - EXCEPTION_CONTINUE_SEARCH,该数值为 0。不使用
__except {}块中的异常处理,转而向上一层回卷。如果已经是最外层,则向操作系统请求异常处理函数。 - 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 块,还可以某种程度上简化代码。