在驱动程序编写中,分配和管理内存不能使用熟知的Win32API函数,取而代之的是DDK提供的高效的内核函数。程序员必须小心地使用这些内存相关的内核函数。因为在内核模式下,操作系统不会检查内存使用的合法性稍有不慎就可能导致操作系统的崩溃。另外,C语言和C++中大多数关于内存操作的运行时函数,大多在内核模式下是无法使用的。
1、内存管理概念
编写windows驱动之前,需要读者进一步理解windows操作系统是如何管理和使用内存的。
1.1、物理内存的概念(Physical Memory Address)
PC上有三条总线,分别是数据总线,地址总线和控制总线。32位的CPU的寻址能力为4GB(2^32)个字节。用户最多可以使用4GB的真实的物理内存。PC中会拥有很多设备,其中很多设备都提供了自己的设备内存。一个设备可以有好几块设备内存映射到物理内存上。
1.2、虚拟内存地址概念(Virtual Memory Address)
虽然可以寻址4GB的内存,而在PC里往往没有如此多的真实物理内存。操作系统和硬件为使用者提供了虚拟内存的概念。Windows的所有程序包括RIng0层和Ring3层的程序可以操作的都是虚拟内存。之所以称为虚拟内存,是因为对它的所有操作,最终会变成一系列对真实物理内存的操作。
虚拟内存转换为物理内存:在CPU中有一个重要的寄存器CR0,它是32位的寄存器,其中的一位PG位是复制告诉系统是否分页的。Windows在启动前会将它的PG位置1,即WIndows允许分页。DDK中有个宏PAGE_SIZE记录着分页大小,一般为4KB。4GB的虚拟内存会被分割成1M(4GB/4KB)个分页单元。
其中,有一部分单元会和物理内存对应起来,即虚拟内存中第N个分页单元对应着物理内存第M个分页单元。这种对应不是一一对应,而是多对一的映射,多个虚拟内存页可以映射同一个物理内存页。还有一部分单元会被映射成磁盘上的文件,并标记为脏的。读取这段虚拟内存的时候,系统会发出一个异常,此时会触发异常的处理函数,异常处理函数会将这个页的磁盘文件读入内存,并标记设置为不脏。当让经常不读写的内存页,可以交换成文件,并将此页设置为脏。还有一部分单元什么也没有对应即空的。
大部分的虚拟内存是没有被映射到物理内存上的。
这样的设计基于以下两个原因:
第一是虚拟的增加了内存的大小。不管PC是否有足够的4GB的物理内存,操作系统总会有4GB的虚拟内存。这就允许使用者申请更多的内存,当物理内存不够时,可以通过将不常用的虚拟内存页交换成文件,等需要的时候再去读取。
第二是使不同进程的虚拟内存互不干扰,为了让系统可以同时运行不同的进程,windows操作系统让每个进程看到的虚拟内存都不同。这个方法就使得不同的进程会有不同的物理内存到虚拟内存的映射。
1.3、用户模式地址和内核模式地址
虚拟地址在0~0X7FFFFFFF范围内的虚拟内存,即低2GB的虚拟内存地址,被称为用户模式地址。而0X80000000~0XFFFFFFF范围内的虚拟内存,即高2GB的虚拟内存,被称为内核模式地址。WIndows规定运行在用户态Ring3层的程序只能访问用户模式地址,而运行在核心态Ring0层的程序,可以访问整个4GB的虚拟内存,即用户模式地址和内核模式地址。Windows的核心代码和Windows的驱动程序加载的位置都是在高2GB的内核地址里,所以一般的应用程序是不能访问到这新核心代码和重要数据的,这大大提高了系统的稳健性。同时,Windows操作系统在进程切换时,保存内核模式地址是完全相同的。也就是说,所有进程的内核地址映射完全一致,进程切换的时候,只改变用户模式地址的映射。
1.4、Windows驱动程序和进程的关系
驱动程序可以看成是一个特殊的DLL文件被应用程序加载到虚拟内存中,只不过加载地址是内核模式地址,而不是用户模式地址。他能访问的只是这个进程的虚拟内存,而不能是其他进程的虚拟内存。需要指出的是,Windows驱动程序里的不同例程运行在不同的进程中。DriverEntry例程和AddDevice例程是运行在系统(System)进程中的。System进程是Windows中非常重要的进程,也是Windows第一个运行的进程。当需要加载的时候,System进程中会有一个线程将驱动程序加载到内核模式地址空间内,并调用DriverEntry例程。
其他一些例程,例如IRP_MJ_READ和IRP_MJ_WRITE的派遣函数会运行与应用程序的“上下文”中。所谓运行在进程的“上下文”,指的是运行于某个进程的环境中,所能访问的虚拟地址是这个进程的虚拟地址。
VOID DisplayItsProcessName()
{
//得到当前进程
PEPROCESS pEProcess = PsGetCurrentProcess();
//得到当前进程名称
PTSTR ProcessName = (PTSTR)((ULONG)pEProcess+0x174);
KdPrint(("%s\n",ProcessName));
}
1.5、分页与非分页内存
前面介绍了虚拟内存页与物理内存页之间的关系,Windows规定有些虚拟内存页面是可以交换到文件中的,这类内存被称为分页内存。而有些虚拟内存页永远不会交换到文件中,这些内存被称为非分页内存。
当程序的中断请求级在DISPATCH_LEVEL之上(包括DISPATCH_LEVEL)时,程序只能使用非分页内存,否则将导致蓝屏死机。
如果将某个函数载入到分页内存中,我们需要在函数的实现中加入以下代码:
#pragma PAGEDCODE
VOID SomeFunction()
{
PAGED_CODE();
//do something
}
PAGED_CODE()是DDK提供的宏,他只在check版本中生效。它会检验这个函数是否运行低于DISOATCH_LEVEL的中断请求级,如果等于或者高于这个中断请求级,将产生一个断言。
如果让函数加载到非分页内存中,需要在内存的实现中加入以下代码:
#pragma LOCKEDCODE
VOID SomeFunction()
{
//do something
}
还有一种特殊情况,就是某个例程需要在初始化的时候载入内存,然后就可以从内存中卸载掉。这种情况只出现在DriverEntry情况下。尤其是NT式的驱动,DriverEntry会很长,占据很大的空间,为了节省内存,需要及时地从内存中卸载掉。代码如下:
#pragma INITCODE
Extern “C” NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject,IN PUNICODE_STRING RegistryPath)
{
//do something
}
1.6、分配内核内存
Windows驱动程序使用的内存资源非常珍贵,分配内存时要尽量节约。和应用程序一样,局部变量是存放在栈空间中的。但栈空间不会像应用程序那么大,所以驱动程序不适合递归或者局部变量是大型结构体。如果需要大型结构体,请在堆中申请。
堆中申请内存的函数有以下几个:
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,则分配内存为分页内存。
NumberOfByte是分配内存的大小,最好是4的倍数
返回值分配的内存地址,一定是内核模式地址,如果返回0表示分配失败
以上四个函数功能类似,函数以WithQuota结尾的代表分配的时候按配额分配。函数以WithTag结尾的函数和ExAllocatePool功能类似,唯一不同的是多了一个Tag参数,系统在要求的内存外有额外地分配了4个字节的标签,在调试的时候,可以找出是否有标有这个标签的内存没有被释放。
将分配的内存,进行回收的函数是ExFreePool和ExFreePoolWithTag:
VOID
ExFreePool(
IN PVOID P
);
NTKERNELAPI
VOID
ExFreePoolWithTag(
IN PVOID P,
IN ULONG Tag
);
2、在驱动中使用链表
在驱动程序开发中,经常使用链表这种数据结构,DDK为用户提供两种链表的数据结构,简化了对链表的操作。
链表中可以记录将整型,浮点,字符型或者程序员自定义的数据结构。链表通过指针将这些数据结构组成一条“链”,链中每个袁术对应着记录的数据。对于单向链表,每个元素中有一个Next指针指向下一个元素。对于双向链表,每个元素有两个指针,指向前驱元素BLINK和后继元素FLINK。本节以双向链表为例:
2.1、链表结构
2.2、链表初始化
初次使用时需要初始化,主要将链表头的FLink和BLink两个指针都指向自己。这意味着链表头所代表的链是空链。使用InitializeListHead宏实现初始化。
2.3、从首部插入链表
对链表的插入有两种方式,一种是在链表的头部插入,一种是在链表的尾部插入。在头部插入链表使用语句InsertHeadList,用法:
InsertHeadList(&head,&mydata->ListEntry);
Head是LIST_ENTRY结构的链表头,mydata是用户定义的数据结构,而他的子域ListEntry是包含其中的LIST_ENTRY数据结构。
2.4、从尾部插入链表
在尾部插入链表使用语句InsertTailList(&head,&mydata->ListEntry);
2.5、从链表删除
a)、从链表头删除RemoveHeadList
b)、从链表尾删除RemoveTailList
//链表的操作
VOID LinkListTest()
{
LIST_ENTRY linkListHead;
//初始化链表
InitializeListHead(&linkListHead);
PMYDATASTRUCT pData;
ULONG i=0;
KdPrint(("Begin insert to link list"));
//在链表中插入10个元素
for(i=0;i<10;i++)
{
//分配分页内存
pData = (PMYDATASTRUCT)ExAllocatePool(PagedPool,sizeof(MYDADASTRUCT));
pData->number = i;
//从头部插入链表
InsertHeadList(&linkListHead,&pData->ListEntry);
}
//从链表中取出,并显示
KdPrint(("Begin remove from link\n"));
while(!IsListEmpty(&linkListHead))
{
//从尾部删除一个元素
PLIST_ENTRY pEntry = RemoveTailList(&linkListHead);
pData = CONTAAINING_RECORD(pEntry,MYDATASTRUCT,ListEntry);
KdPrint(("%d\n",pData->number));
ExFreePool(pData);
}
}
3、Lookaside结构
频繁申请和回收内存,会导致在内存上产生大量的内存“空洞”,从而导致最终无法申请内存。DDK为程序员提供了Lookaside结构来解决这个问题。
3.1、频发申请内存的弊端
频繁地申请内存,会导致一个问题,就是在内存中产生“空洞”,即内存碎片。如果系统中存在大量的内存碎片,即使内存中有大量的可用内存,也会导致申请内存失败。在操作系统空闲时,系统会整理内存碎片,将其合并。
3.2、使用Lookaside
如果驱动程序需要频繁地从内存中申请、回收固定大小的内存,DDK提供了一种机制来解决,即使用Lookaside对象。
可以将Lookaside对象想象成一个内存容器。在初始的时候,它先向Windows申请了一块比较大的内存。以后程序员每次申请内存的时候,不是直接向Windows申请 内存,而是向Lookaside申请内存。Lookaside对象会智能地避免产生内存碎片。如果有Lookaside对象内部的内存不够用时,他会向操作系统申请更多的内存。当Lookaside对象内部有大量的未使用的内存时,他会自动让Windows回收一部分内存,总之,Lookaside是一个自动的内存分配容器。通过对Lookaside对象申请内存,效率要高于直接向windows申请内存。Lookaside一般会在以下情况使用:
a)、程序员每次申请固定大小的内存。
b)、申请和回收的操作十分频繁。
初始化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对象进行初始化。初始化完成后,可以进行申请内存操作:
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
);
这两个函数分别是对非分页内存和分页内存的回收。
VOID
ExDeleteNPagedLookasideList(
IN PNPAGED_LOOKASIDE_LIST Lookaside
);
VOID
ExDeletePagedLookasideList(
IN PPAGED_LOOKASIDE_LIST Lookaside
);
以上两个函数分别是对非分页和分页Lookaside对象的删除
4、运行时函数
一般编译器厂商,在发布其编译器的同时,会将运行时函数一起发布给用户。运行时函数是程序运行的时候必不可少的,它有编译器提供。针对不同的操作系统,运行时函数的实现方法不同,但接口基本保持一致。例如:malloc函数就是典型的运行时函数,所有编译器厂商都必须提供这个函数,它在不同操作系统上的实现方法就不尽相同。
4.1、内存间复制(非重叠)
在驱动程序开发中,经常用到内存的复制。例如,将需要的内容,从缓冲区复制到显卡内存中。DDK为程序提供了以下函数:
VOID
RtlCopyMemory(
IN VOID UNALIGNED *Destination,//表示要复制内存的目的地址
IN CONST VOID UNALIGNED *Source,//表示要复制内存的源地址
IN SIZE_T Length//表示要复制内存的长度,单位为字节
);
4.2、内存间复制(可重叠)
用RtlCopyMemory可以复制内存,但其内部没有考虑内存重叠的情况。RtlCopyMemory函数的内部实现方式是依靠memcpy函数实现的。不能保证重叠部分是否被复制。
为了保证重叠部分也被正确复制,C99规定memmove函数完成这个任务。这个函数对两个内存是否重叠进行了判断,这个判断却牺牲了速度。DDK用宏对memmove进行了封装,名称变为RtlMoveMemory。
VOID
RtlMoveMemory(
IN VOID UNALIGNED *Destination,
IN CONST VOID UNALIGNED *Source,
IN SIZE_T Length
);
4.3、填充内存
驱动程序开发中,还经常用到对某段内存区域用固定字节填充。DDK为程序员提供了函数RtlFillMemory:
VOID
RtlFillMemory(
IN VOID UNALIGNED *Destination,
IN SIZE_T Length,
IN UCHAR Fill
);
在驱动程序开发中,还经常要对某段内存填零,DDK提供了宏RtlZeroMemory和RtlZeroByte。
VOID
RtlZeroMemory(
IN VOID UNALIGNED *Destination,
IN SIZE_T Length
);
VOID
RtlZeroBytes(
PVOID Destination,
SIZE_T Length
);
4.4、内存比较
驱动程序开发中,还会用到比较两块内存是否一致。该函数是RtlCompareMemory:
SIZE_T
RtlCompareMemory(
IN CONST VOID *Source1,
IN CONST VOID *Source2,
IN SIZE_T Length
);
4.5、关于运行时函数使用的注意事项
DDK提供的标准的运行时函数名都是RtlXX形式。其中,大部分是以宏的形式给出。
5、使用C++特性分配内存
在C++语言中分配内存时,可以使用new操作符,回收内存时使用delete操作符。但是在驱动程序开发中,使用new和delete操作符,将得到错误的链接指示。
6、其他
6.1、数据类型
C语言的数据类型和DDK中对应的数据类型
在DDK中又添加了一种64位的无符号长整型整数,范围0~2^64-1,用LONGLONG类型表示,后面加上i64。
使用如下:
LONGLONG val = 100i64;
这种64位整数支持加减乘除等运算。
还有一种64位的表示方法LARGE_INTEGER数据结构。
定义如下:
Typedef union _LARGE_INTEGER{
Struct{
ULONG LowPart;
ULONG HighPart
};
Struct{
ULONG LowPart;
ULONG HighPart
}u;
LONGLONG QuadPart
}
LARGE_INTEGER是个联合体,这种设计非常巧妙。联合体中的三个元素可以认为是LARGE_INTEGER的三个定义。可以认为是由两个部分组分。一个是低32位的整数LowPart,一个是高32位的整数HighPart。
LARGE_INTEGER LargeValue;
LargeValue.LowPart=100;
LargeValue.HighPart = 0;
6.2、返回状态值
DDK大部分函数返回类型是NTSTATUS类型,查看DDK.h文件,可以看到
typedef LONG NTSTATUS;
NTSTATUS的定义和LONG等价。为了函数的形式统一,所有的函数的返回值都是NTSTATUS类型。
6.3、检查内存可用性
在驱动程序开发中,对内存的操作要格外小心。如果某段内存是只读的,而驱动程序试图去写操作,会导致系统的崩溃。同样,当某段内存不可读的情况下,驱动程序试图去读,同样会导致系统的崩溃。
DDK提供了两个函数帮助程序员在不知道某段内存是否可读写的情况下试探内存可读写性。ProbeForRead和ProbeForWrite
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:描述该段内存是多少字节对齐的
6.4、结构化异常处理(try-except块)
结构化异常处理是微软编译器提供的独特处理机制,这种处理方式能在一定程度上出现错误的情况下,免于程序崩溃。为了说明结构化异常,有两个概念需要说明一下:
a)、异常:异常的概念类似于中断的概念,当程序中某种错误触发一个异常,操作系统会寻找处理这个异常的处理函数。如果程序提供错误处理函数,则进入错误处理函数,如果没有提供,则有操作系统的默认错误处理函数处理。在内核模式下,操作系统默认处理错误的办法往往很简单,直接让系统蓝屏,并在蓝屏上简单描述出错信息,之后系统就进入死机状态。所以一般程序员需要自己设置异常处理函数。
b)、回卷:程序执行到某个地方出现异常错误时,系统会寻找出错点是否处于一个try{}块中,并进入try块提供才异常处理程序代码。如果当前try块没有提供异常处理,则会向更外一层的try块,寻找异常处理代码,直到最外层try{}块也没有提供异常处理程序代码,则交个操作系统处理。
这种向更外一层寻找异常处理的机制,被成为回卷。一般处理异常,是通过try-except块来处理的。
6.5、结构化异常处理(try-finally块)
结构化异常处理的另外一种使用方法就是利用try-finally块,强迫函数在退出前执行一段代码。
6.6、使用宏需要注意的地方
宏一般由多行组成,用“\”代表换行。
6.7、断言
在驱动程序开发中,还有一个技巧,就是使用“断言”,在驱动程序使用“断言”,一般是通过使用ASSERT宏。
ASSERT(表达式);
如果表达式返回FALSE,表示断言失败,会引发一个异常。
7、小结
本章围绕着驱动程序中的内存操作进行了介绍,在驱动程序开发中,首先要注意分页内存和非分页内存的使用。同时,还需要区分物理内存地址和虚拟内存地址这两个概念。
在驱动程序开发中,还会经常使用单向链表和双向链表等数据结构,本章对这些数据结构的使用进行了介绍。另外,在驱动程序开发中,内存复制,内存搬移,内存填充和应用程序有所区别,要使用DDK提供专用的内核函数,而不能使用C语言提供的运行时函数。
以上内容参考自张帆 史彩成等编著的《Windows 驱动开发技术详解》第五章