Windows内核中的内存管理

时间:2024-05-08 09:35:50

内存管理的要点

  1. 内核内存是在虚拟地址空间的高2GB位置,且由所有进程所共享,进程进行切换时改变的只是进程的用户分区的内存
  2. 驱动程序就像一个特殊的DLL,这个DLL被加载到内核的地址空间中,DriverEntry和AddDevice例程在系统的system进程中运行,派遣函数会运行在应用程序的进程上下文中所能访问的地址空间是这个进程的虚拟地址空间利用_EPROCESS结构可以查看该进程的相关信息
  3. 当程序的中断级别在DISPATCH_LEVEL之上时,必须使用非分页内存,否则会造成系统蓝屏,在编译WDK相关例程时,可以使用如下的宏指定某个例程或者某个全局变量是位于分页内存还是运行于非分页内存
#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")

在使用时直接使用#pragma直接加载这些宏即可比如:

#pragma PAGEDCODE
VOID DoSomething()
{
PAGED_CODE()
//函数体
}

其中PAGED_CODE是一个WDK中提供的一个宏,只在debug版本中生效,用于判断当前的中断请求级别,当级别高于DISPATCH_LEVEL(包含这个级别)时会产生一个断言

内核中的堆申请函数

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:这是一个枚举变量,用来表示分配内存的种类,如果为PagedPool表示分配的是分页内存,如果是NonPagedPool表示分配的是非分页内存

NumberOfBytes:分配内存的大小,为了效率最好分配4的倍数

上面这些函数主要分为带有标记和不带标记的两种,其中有Quota的是按配额分配,带有标记的函数可以通过这个标记来判断这块内存最后有没有被分配,标记是一个字符串,但是这个字符串是用单引号引起来的。一般给4个字符,由于IntelCPU采用的是高位优先的存储方式,所以为了阅读方便,一般将这个字符串倒着写

这些函数分配的内存一般使用下面的函数来释放

VOID
ExFreePool(
IN PVOID P
); NTKERNELAPI
VOID
ExFreePoolWithTag(
IN PVOID P,
IN ULONG Tag
);

在驱动中使用链表

WDK给程序员提供了两种基本的链表结构,分别是单向链表和双向链表

双向链表的结构体定义如下:

typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink; //指向下一个节点
struct _LIST_ENTRY *Blink; //指向上一个节点
} LIST_ENTRY, *PLIST_ENTRY;

初始化链表使用宏InitializeListHead,它需要传入一个链表的头节点指针它的定义如下

VOID
InitializeListHead(
IN PLIST_ENTRY ListHead
);

这个宏只是简单的将链表头的Flink和Blink指针指向它本身。

利用宏IsListEmpty可以检查一个链表是否为空,它也是只简单的检查这两个指针是否指向其自身

在定义自己的数据结构的时候需要将这个结构体放到自定义结构体中,比如

typedef struct _MYSTRUCT
{
LIST_ENTRY listEntry;
ULONG i;
ULONG j;
}MYSTRUCT, *PMYSTRUCT

一般插入链表有两种方法,头插法和尾插法,DDK根据这两种方法都给出了具体的函数可供操作:

//头插法,采用头插法只改变链表数据的顺序,链表头仍然是链表中的第一个元素
VOID
InsertHeadList(
IN PLIST_ENTRY ListHead, //链表头指针
IN PLIST_ENTRY Entry //对应节点中的LIST_ENTRY指针
);
//尾插法
VOID
InsertTailList(
IN PLIST_ENTRY ListHead,
IN PLIST_ENTRY Entry
);

删除节点使用的是这样两个函数,同样采用的是从头部开始删除和从尾部开始删除,就是查找链表中节点的方向不同。

//从头部开始删除
PLIST_ENTRY
RemoveHeadList(
IN PLIST_ENTRY ListHead
);
//从尾部开始删除
PLIST_ENTRY
RemoveTailList(
IN PLIST_ENTRY ListHead
);

这两个函数都是传入头节点的指针,返回被删除那个节点的指针,这里有一个问题,我们如何根据返回PLIST_ENTRY结构找到对应的用户定义的数据,如果我们将LIST_ENTRY,这个节点放在自定义结构体的首部的时候,返回的地址就是结构体的地址,如果是放在其他位置,则需要根据结构体的定义来进行转化,对此WDK提供了这样一个宏来帮我们完成这个工作:

PCHAR
CONTAINING_RECORD(
IN PCHAR Address,
IN TYPE Type,
IN PCHAR Field
);

这个宏返回自定义结构体的首地址,传入的是第一个参数是结构体中某个成员的地址,第二个参数是结构体名,第三个参数是我们传入第一个指针的类型在结构体中对应的成员变量值,比如对于上面那个MYSTRUCT结构体可以这样使用

typedef struct _MY_LIST_DATA
{
LIST_ENTRY list;
ULONG i;
}MY_LIST_DATA, *PMY_LIST_DATA; PLIST_ENTRY pListData = RemoveHeadList(&head);//head是链表的头节点
PMYSTRUCT pData = CONTAINING_RECORD(pListData, MYSTRUCT, list);

Lookaside结构

频繁的申请和释放内存将造成内存空洞,即出现大量小块的不连续的内存片段,这个时候即使内存仍有剩余,但是我们也申请不了内存,一般在操作系统空闲的时候会进行内存整理,将空洞内存进行合并,如果驱动需要频繁的从内存中申请释放相同大小的内存块,DDK提供了Lookaside内存容器,在初始时它先向系统申请了一块比较大的内存,以后程序每次申请内存的时候不是直接在Windows堆中进行分配,而是在这个容器中,Lookaside结构会智能的避免产生内存空洞,如果申请的内存过多,lookaside结构中的内存不够时,他会自动向操作系统申请更多的内存,如果lookaside内部有大量未使用的内存时,他会自动释放一部分,总之它是一个智能的自动调整内存大小的一个容器。一般应用于以下几个方面:

1. 程序每次申请固定大小的内存

2. 申请和回收的操作十分频繁

使用时首先初始化Lookaside对象,调用函数

VOID
ExInitializeNPagedLookasideList(
IN PNPAGED_LOOKASIDE_LIST Lookaside,
IN PALLOCATE_FUNCTION Allocate OPTIONAL,
IN PFREE_FUNCTION Free OPTIONAL,
IN ULONG Flags,
IN SIZE_T Size,
IN ULONG Tag,
IN USHORT Depth
);

或者

VOID
ExInitializePagedLookasideList(
IN PPAGED_LOOKASIDE_LIST Lookaside,
IN PALLOCATE_FUNCTION Allocate OPTIONAL,
IN PFREE_FUNCTION Free OPTIONAL,
IN ULONG Flags,
IN SIZE_T Size,
IN ULONG Tag,
IN USHORT Depth
);

这两个函数一个是操作的是非分页内存,一个是分页内存。

Lookaside:这个参数是一个NPAGED_LOOKASIDE_LIST的指针,在初始化前需要创建这样一个结构体的变量,但是不用填写其中的数据。

Allocate:这个参数是一个分配内存的回调函数,一般这个值填NULL

Free:这是一个释放的函数,一般也填NULL

这两个函数有点类似于C++中的构造与析构函数,如果我们对申请的内存没有特殊的初始化的操作,一般这个两个都给NULL

Flags:这是一个保留字节,必须为NULL

Size:指明明我们每次在lookaside容器中申请的内存块的大小

每次申请的内存块的标志,这个标志与上面的WithTag函Tag:数申请内存时填写的标志相同

Depth:系统保留,必须填0

创建容器之后,可以用下面两个函数来分配内存

PVOID
ExAllocateFromNPagedLookasideList(
IN PNPAGED_LOOKASIDE_LIST Lookaside
); PVOID
ExAllocateFromPagedLookasideList(
IN PPAGED_LOOKASIDE_LIST Lookaside
);

用下面两个函数来释放内存

VOID
ExFreeToNPagedLookasideList(
IN PNPAGED_LOOKASIDE_LIST Lookaside,
IN PVOID Entry
); VOID
ExFreeToPagedLookasideList(
IN PPAGED_LOOKASIDE_LIST Lookaside,
IN PVOID Entry
);

最后可以使用下面两个函数来释放Lookaside对象

VOID
ExDeleteNPagedLookasideList(
IN PNPAGED_LOOKASIDE_LIST Lookaside
); VOID
ExDeletePagedLookasideList(
IN PPAGED_LOOKASIDE_LIST Lookaside
);

其他内存函数

  1. 内存拷贝函数
VOID
RtlCopyMemory(
IN VOID UNALIGNED *Destination,
IN CONST VOID UNALIGNED *Source,
IN SIZE_T Length
);

需要注意的是这个函数没有考虑到内存重叠的情况,假如内存发生重叠例如这样:

Windows内核中的内存管理

这个时候AC内存块和BD内存块有部分重叠,如果将AC拷贝到BD那么会改变AC的值,这样在拷贝到BD中的值也会发生变化,有可能造成错误,为了保证重叠也可以正常拷贝,可以使用函数

void MoveMemory(
__in PVOID Destination,
__in const VOID* Source,
__in SIZE_T Length
);

填充内存一般使用函数

void FillMemory(
[out] PVOID Destination,
[in] SIZE_T Length,
[in] BYTE Fill
);

另外DDK另外提供了一个将内存清零的函数

VOID
RtlZeroMemory(
IN VOID UNALIGNED *Destination,
IN SIZE_T Length
);

内存比较函数

ULONG
RtlEqualMemory(
CONST VOID *Source1,
CONST VOID *Source2,
SIZE_T Length
);

这个函数返回的是两块内存中相同的字节数,如果要比较两块内存是否完全相同,可以将返回值与Length相比较,如果相等则说明两块内存相同,否则不相同,另外为了实现这个功能DDK提供了一个与该函数同名的宏来判断,具体在编写代码时可以根据情况判断调用的是函数还是宏。

在内核中,对于内存的读写要相当的谨慎,稍不注意就可能产生一个新漏洞或者造成系统的蓝屏崩溃,有时在读写内存前需要判断该内存是否合法可供读写,DDK提供了两个函数来判断内存是否可读可写

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
);

这两个函数在内存不可读写的时候引发一个异常,需要用结构化异常进行处理,这里使用结构化异常的方式与在应用层的使用方式相同

其他数据结构

typedef union _LARGE_INTEGER {
struct {
DWORD LowPart;
LONG HighPart;
};
struct {
DWORD LowPart;
LONG HighPart;
} u;
LONGLONG QuadPart;
} LARGE_INTEGER, *PLARGE_INTEGER;

这个结构用来表示64位二进制的整形数据,它是一个共用体,占内存大小是64位8个字节,从定义上来看可以看做一个LONGLONG型数据,也可以看做两个4字节的数据。

相关文章