[3]windows内核情景分析--内存管理
32位系统中有4GB的虚拟地址空间
每个进程有一个地址空间,共4GB,(具体分为低2GB的用户地址空间+高2GB的内核地址空间)
各个进程的用户地址空间不同,属于各进程专有,内核地址空间部分则几乎完全相同
虚拟地址如0x11111111, 看似这8个数字是一个整体,其实是由三部分组成的,是一个三维地址,将这个32位的值拆开,高10位表示二级页表号,中间10位表示二级页表中的页号,最后12位表示页内偏移(2^12=4kb),因此,一个虚拟地址实际上是一个三维地址,指明了本虚拟地址在哪个二级页表,又在哪个页以及页内偏移是多少 这三样信息!
【虚拟地址 = 二级页表号.页号.页内偏移】:口诀【页表、页号、页偏移】
Cpu访问物理内存的原理介绍:
如高级语言
DWORD g_var; //假设这个全局变量被编译器编译为0x00000004
g_var=100;
那么这条赋值语句编译后对应的汇编语句为:mov DWORD PTR[0x00000004],100
这里0x00000004就是一个虚拟地址,简称VA,那么这条mov 指令究竟是如何寻址的呢?
寻址过程为:CPU中的虚拟地址转换器也即MMU,将虚拟地址0x00000004转换为物理地址
具体转换过程为:
根据CR3寄存器中记录的当前进程页表的物理地址,找到总页表也即页目录,再根据虚拟地址中的页表号,以页表号为索引,找到总页表中对应的PDE,再根据PDE,找到对应的二级页表,再以虚拟地址中的页号部分为索引,找到二级页表中的对应PTE,再根据这个PTE记录的映射关系,找到这个虚拟页面对应的物理页面,最后加上虚拟地址中的页内偏移部分,加上这个偏移值,就得出最后的物理地址。具体用下面的函数可以形象表达寻址转换过程:
mov DWORD PTR[0x00000004],100 //这条指令的内部原理(没考虑二级缓冲情况)
{
va=0x00000004;//页表号=0,页号=0,页内偏移=4
总页表=CR3; //本进程的总页表的物理地址固定保存在cr3寄存器中
PDE=总页表[va.页表号]; //PDE为对应的二级页表描述符
二级页表=PDE.PageAddr; //得出本二级页表的地址
PTE=二级页表[va.页号]; //得出到该虚拟地址所在页面的PTE映射描述符
If(PTE空白) //PTE为空表示该虚拟页面尚未建立映射
触发0x0e号页面访问异常(具体为缺页异常)
Else
If(PTE.bPresent==false) //PTE的这个字段表示该虚拟页面当前是否映射到了物理内存
触发0x0e号页面访问异常(具体为缺页异常)
Else
If(CR0.wp==1 && PTE.Writable==false) //已开启页面写保护功能,就检查这个页面是否可写
触发0x0e号页面访问异常(具体为页面访问保护越权异常)
Else
物理地址pa =cs.base + PTE.PageAddr + va.页内偏移 //得出对应的物理地址
将得到的pa放到地址总线上,100放在数据总线上,经由FSB->北桥->内存总线->内存条 写入内存
}
PTE是二级页表中的表项,记录了对应虚拟页面的映射情况,这个PTE实际上可以看做一个描述符。
上面的过程比较简单,由于每次访问内存都要先访问一次PTE获取该虚拟页面对应的物理页面,再访问物理页面读得对应的数据,因此实际上访问了两次物理内存,如果类似于每条这样的Mov指令都要访问物理内存两次,才能获得数据,效率就很低。因此,cpu芯片中专门开辟了一个二级缓冲,用来保存那些频繁访问的PTE,这样,cpu每次去查物理页面时,就先尝试在二级缓冲中查找对应的PTE,如果找不到,再才去访问内存中的PTE。这样,效率就比较高,实际上绝大数情况就可以在二级缓冲中一次性找到对应的PTE。
另外有一个问题需要说明下:va---->pa的转换过程实际上是va->la->pa,实际上PTE.PageAddr表示的是相对于cs段的偏移,加上cs段的base基址,就得到了该页面的la线性地址。
(线性地址=段.基地址 + 段内偏移),但是由于Windows采取了Flat也即所谓的平坦分段机制,使得每个段的基地址都在0x00000000处,长度为4GB,也即相当于Windows没有采取分段机制。前面讲过,cs是GDT表中的索引,指向GDT表中的cs段描述符,由于Windows不分段,因此GDT中每个段描述符的基址=0,长度=4GB,是固定的!这样一来,由于不分段,线性地址就刚好是物理地址,所以本来是由虚拟地址->线性地址->物理地址的转换就可以直接看做虚拟地址->物理地址。
(注:在做SSDT hook、IDT hook时,由于SSDT与IDT这两张表各自所在的页面都是只读的,也即他们的PTE中标志位标示了该页面不可写。因此,一修改SSDT、IDT就会报异常,一个简单的处理方法是是关闭CRO中的wp即写保护位,这样就可以修改了)
前文说了,每个进程有两个地址空间,一个用户地址空间,一个内核地址空间,该地址空间的内核结构体定义为:
Struct MADDRESS_SPACE //地址空间描述符
{
MEMORY_AREA* MemoryRoot;//本地址空间的已分配区段表(一个AVL树的根)
VOID* LowestAddress;//本地址空间的最低地址(用户空间是0,内核空间是0x80000000)
EPROCESS* Process;//本地址空间的所属进程
/*一个表,表中每个元素记录了本地址空间中各个二级页表中的PTE个数,一旦某个二级页表中的PTE个数减到了0,就自动释放该二级页面表本身,体现为稀疏数组特征*/
USHORT* PageTableRefCountTable;
ULONG PageTableRefCountTableSize;//上面那个表的大小
}
地址空间中所有已分配的区段都记录在一张表中,这个表不是简单的数组,而是一个AVL树,用来提高查找效率。每个区段的基址都对齐64KB或4KB(指64KB整倍数),各个区段之间可以有空隙,
区段的分布是很零散的!各个区段之间,夹杂的空隙就是尚未分配的虚拟内存。
注:所谓已分配区段,是指已经过VirtualAlloc预订(reserve)或提交(commit)后的虚拟内存
区段的描述符如下:
Struct MEMORY_AREA //区段描述符
{
Void* StartingAddress; //开始地址,普通区段对齐64KB,其它类型区段对齐4KB
Void* EndAddress;//结尾地址,EndAddress – StartingAddress就是该区段的大小
MEMORY_AREA* Parent;//AVL树中的父节点
MEMORY_AREA* LeftChild;//左边的子节点
MEMORY_AREA* RightChild;//右边的子节点
//常见的区段类型有:普通型区段、视图型区段、缓冲型区段(后面文件系统中会讲到)等
ULONG type;//本区段的类型
ULONG protect;//本区段的保护权限,可读、可写、可执行的组合
ULONG flags;//当初分配本区段时的分配标志
BOOLEAN DeleteInProgress;//本区段是否标记为了‘已删除’
ULONG PageOpCount;
Union
{
Struct //这个Struct专用于视图型区段
{
//凡是含有ROS字样的函数与结构体都表示是ReactOS与Windows中不同的实现细节
ROS_SECTION_OBJECT* section;
ULONG ViewOffest;//指本视图型区段在所在Segment内部的偏移
MM_SECTION_SEGMENT* Segment;//所属Segment
BOOLEAN WriteCopyView;//本视图区段是不是一个写复制区段
}SectionData;
LIST_ENTRY RegionListHead;//本区段内部的所有Region区块,放在一个链表中
}Data;
}//end
浅谈区段类型:
MEMORY_AREA_VIRTUAL_MEMORY://普通型区段,由VirtuAlloc应用层用户分配的区段都是普通区段
MEMORY_AREA_SECTION_VIEW://视图型区段,用于文件映射、共享内存
MEMORY_AREA_CACHE_SEGMENT://用于文件缓冲的区段(一个簇大小)
MEMORY_AREA_PAGED_POOL://内核分页池中的区段
MEMORY_AREA_KERNEL_STACK://用于内核栈中的区段
MEMORY_AREA_PEB_OR_TEB://用于PEB、TEB的区段
MEMORY_AREA_MDL_MAPPING://内核中专用于建立MDL映射的区段
MEMORY_AREA_CONTINUOUS_MEMORY://对应的物理页面也连续的区段
MEMORY_AREA_IO_MAPPING://内核空间中用于映射外设内存(如显存)的区段
MEMORY_AREA_SHARED_DATA://内核空间中用于与用户空间共享的区段
Struct MM_REGION //区块描述符
{
ULONG type;//指本区块的分配类型(预定型分配、提交型分配),又叫映射状态(已映射、尚未映射)
ULONG protect;//本区块的访问保护权限,可读、可写、可执行的组合
ULONG length;//区块长度,对齐页面大小(4KB)
LIST_ENTRY RegionListEntry;//用来挂入所在区段的区块链表
}
内存以区段为分配单位,一个区段内部,又按分配类型、保护属性划分区块。一个区块包含一到多个内存页面,分配类型相同并且保护权限相同的区域组成一个个的区块,因此,称为“同属性区块”。一个区段内部,相邻区块之间的属性肯定是不相同的(分配类型或保护权限不同),若两个相邻区块的属性相同了,会自动合并成一个新的区块。
进程,地址空间,区段,区块,页面的逻辑层次关系
一个虚拟页面实际上有五级限定:
【进程.地址空间.区段.区块.虚拟页面】
意为:哪个进程的哪个地址空间中的哪个区段中的哪个区块中的哪个虚拟页面
MEMORY_AREA* MmLocateMemoryAreaByAddress(MADDRESS_SPACE* as, void* addr);
这个内核函数用于在指定地址空间中查找指定地址所属的已分配区段,如果返回NULL,表示该地址尚不处于任何已分配区段中,也即表示该地址尚未分配。
Void*
MmFindGap(MADDRESS_SPACE* as, ULONG len, ULONG AlignGranularity, BOOL TopDown);
这个函数在指定地址空间中 查找一块符合len长度的空闲(也即未分配)区域,返回找到的空闲区的地址,AlignGranularity表示该空白区必须的对齐粒度,TopDown表示是否从高地址端向低地址端搜索
MEMORY_AREA*
MmLocateMemoryAreaByRegion(MADDRESS_SPACE* as, void* addr, ULONG len);
这个函数从指定地址空间的低地址端向高地址段搜索,返回第一个与给点区间(addr,len)有交集的已分配区段
NTSTATUS
MmCreateMemoryArea(MADDRESS_SPACE* as, type, void** BaseAddr, Len, protect, bFixedAddr, AllocFlags, MEMORY_AREA** Result)
{
Len=Align(Len,4kb);//区段长度都要对齐4kb
UINT BaseAlign;//区段的基址对齐粒度
If(type==普通区段)
BaseAlign=64KB;
Else
BaseAlign =4KB;
If(*BaseAddr ==NULL && !bFixedAddr)//if 用户不要求从固定地址处开始分配
{
*BaseAddr=MmFindGap(as,Len, BaseAlign, AllocFlags要求TopDown?);
}
Else//else只要用户给定了基址,就必须从那儿开始分配
{
*BaseAddr=Align(*BaseAddr, BaseAlign);
If(要分配的区域没有完全落在指定地址空间内部)
Return fail;
If(MmLocateMemoryAreaByRegion(as,*BaseAddr,Len)!=0)//if 这段范围已分配过
Return fail;
}
//找到了一个空闲区域后/指定的地址满足分配要求,就把这块区域分配出去
Memory_Area* Area=ExAllocatePool(NonPagePool, sizeof(*Area),tag);
ZeroMemory(Area);
Area.type=type;//本区段的初始分配类型(初始时,一个区段内部就一个区块)
Area.StartAddr=*BaseAddr;
Area.EndAddr=*BaseAddr+Len;
Area.protect=protect;//本区段的初始保护属性
Area.flags=Allocflags;
MmInsertMemoryArea(as,Area);//分配后插入地址空间中的已分配区段表中(AVL树)
*Result=Area;
Return succ;
}
上面这个函数用来从指定地址或者让系统自动寻找一块空闲的区域,分配一块指定长度、类型的区段。所谓分配,包含reserve型分配(即预定型分配),和commit型分配(即提交型分配)
预定:只占用分配一块区段,不建立映射
提交:分配一块区段并建立映射(映射到磁盘页文件/物理内存页面/普通文件)
MM_REGION*
MmFindRegion(void* AreaBaseAddr, LIST_ENTRY* RegionListHead, void* TgtAddr,
Void** RegionBaseAddr);
这个函数从指定区段的区块链表中,查找给定目标地址TgtAddr落在哪一个区块内
第一个参数表示区段的基址。函数返回找到的区段并顺便将该区段的基址也存入最后一个参数中返回给调用者
MM_REGION*
MmSplitRegion(MM_REGION* rgn, BaseAddr, StartAddr,Len, NewType,NewProtect,AlterFunc);
这个函数将指定区块内部的指定区域(StartAddr,Len)修改为新的分配类型、保护属性,使原区块分裂,一分为三(特殊情况一分为二),然后调用AlterFunc跟着修改二级页表中,新区块的那些PTE,最后再跟着修改物理页面分配情况。函数返回新分出来的那个中间区块。这是一个内部辅助函数。
NTSTATUS
MmAlterRegion(AreaBaseAddr, RegionListHead, TgtAddr,Len, NewType,NewProtect, AlterFunc);
这个函数是个通用函数,用来修改指定区段内部的指定区域的分配类型、保护属性,然后
调用调用AlterFunc跟着修改二级页表中,目标区域对应的那些PTE,最后再跟着修改物理
页面的分配情况。
物理内存讲述:
内核中有一个全局的物理页面数组,和7个物理页面链表。分别是:
PHYSICAL_PAGE MmPageArray[];//物理内存有多大,该数组就有多大
LIST_ENTRY FreeZeroedPageListHead;//空闲物理页面链表(且物理页面已清0)
LIST_ENTRY FreeUnzeroedPageListHead;//空闲物理页面链表(但物理页面尚未清0)
LIST_ENTRY UsedPageListHeads[4];//细分为4大消费用途的忙碌物理页面链表,各链表中按LRU顺序
LIST_ENTRY BiosPageListHead;//用于Bios的物理页面链表
物理页面数组是一个物理页面描述符数组,每个元素描述对应的物理页面(数组索引号即
物理页号,又叫pfn),每个描述符是一个PHYSICAL_PAGE结构体
Struct PHYSICAL_PAGE //物理页面描述
{
Type ;//该物理页面的空闲占用状态(1表示空闲,2表示已占用,3表示分给了BIOS)
Consumer;//该物理页面的消费用途(用户/内核分页池/内核非分页池/文件缓冲 四种)
Zero;//标志本页面是否已清0
ListEntry;//用来挂入那7个链表之一
ReferenceCount;//引用计数,一旦减到0,页面就变为空闲状态,进入空闲链表
SWAPENTRY SavedSwapEntry;//对应的来源页文件,用于置换,一般为空
LockCount;//本物理页面的锁定计数(物理页面可锁定在内存中,不许置换到外存)
MapCount;//同一个物理页面可以映射到N个进程的N个虚拟页面
MM_RMAP_ENTRY* RmapListHead;//本物理页面映射给的那些虚拟页面,组成的链表
}
一个物理页面的典型状态转换过程为:
起初处于空闲并清0的状态,然后应内存分配要求分配给4个消费者之一,同时,将该物理
页面记录到对应消费者的UsedPageListHead链表中,最后用户用完后主动释放,或者因为物
理内存紧张,被迫释放换到外存,而重新进入空闲状态,但此时尚未清0,将进入
FreeUnzeroedPageList链表。然后,内核中有一个守护线程会定时、周期扫描这个空闲链表,
将物理内存清0,转入FreeZeroedPageList链表,等候下次被分配。如此周而复返…
PFN_NUMBER
MmAllocPage(ULONG ConsumerType)
{
PFN_NUMBER Pfn;//物理页号
PPHYSICAL_PAGE PageDescriptor;
BOOLEAN NeedClear = FALSE;//是否需要清零
if (FreeZeroedPageList链表 为空)
{
if (FreeUnzeroedPageList 为空)
return 0;//如果两个空闲链表都为空就失败
PageDescriptor = MiRemoveHeadList(&MmFreePageListHead);
NeedClear = TRUE;
}
else
PageDescriptor = MiRemoveHeadList(&MmZeroedPageListHead);
//从空闲链表中摘下来一个空闲页面后,初始化
MmAvailablePages--;//总的可用物理页数--
PageDescriptor->ReferenceCount = 1;//刚分配的物理页面的引用计数为1
PageDescriptor->LockCount=0;//表示可被置换到外存
PageDescriptor->MapCount=0;//表示刚分配的物理页面尚未映射到任何虚拟页面
//记录到分配链表中
InserTailList(&UsedPageListHeads[ConsumerType], PageDescriptor);
if (NeedClear)
MiZeroPage(PfnOffset);//清0
Pfn = PageDescriptor-MmPageArray;//pfn=数组的索引,就是物理页号
return Pfn;
}
这段函数为指定消费者分配一个物理页面,并第一时间将物理页面清0.然后返回分得的物理页号
NTSTATUS
MmRequestPageMemoryConsumer(consumer, PFN* pfn)
{
//先检查物理页面配额,超出配额,就自我修剪
If(本消费者的分得的物理页面数量 = 本消费者的最大配额)
{
//换出那个消费者的某个物理页面到外存,腾出一个物理页面出来
Call 对应消费者的自我页面修剪函数
}
If(当前系统总的空闲页面总量 < 储备阀值)
{
If(consumer==非分页池消费者)
{
*pfn = MmAllocPage(consumer);
//分完后唤醒系统中的平衡线程去平衡物理页面,填补空白
KeSetEvent(&MiBalancerEvent);
Return succ;
}
Else
{
*pfn = 请求平衡线程赶紧从其他消费者手中退出一个物理页面;
Return succ;
}
}
Else
*pfn = MmAllocPage(consumer);
}
这个函数,先检查配额,再检查空闲页面阀值,做好准备工作后,再才分配物理页面
NTSTATUS MmReleasePageMemory(consumer, pfn)
{
Consumer.UsedPageCount--;//递减本消费者持有的页面计数;
pfn.ReferenceCount--;//递减本页面的引用计数
If(pfn.ReferenceCount==0)
{
If(有其他分配请求正在等待退让物理页面)
将这个pfn分给那个Pending中的分配请求
Else
将这个页面挂入系统 FreeUnzeroedPageList 链表;
}
}
这个函数释放指定消费者占用的指定物理页面,实际上是递减引用计数,引用计数减到0后就挂入系统空闲链表
虚拟页面与物理页面之间的映射:
一个物理页面可以映射到N个进程的N个虚拟页面中,但一个虚拟页面同一时刻只能映射到一个物理页面。可以这么理解:“一个物理页面当前可能被N个虚拟页面映射着”,“本虚拟页面当前映射着一个物理页面”。
每个虚拟页面又分四种映射状态:
1、 映射着某个物理页面(已分配且已映射)
2、 映射着某个磁盘页文件中的某个页面(已分配且已映射)
3、 没映射到任何物理存储介质(对应的PTE=0),但是可能被预定了(已分配,但尚未映射)
4、 裸奔(尚未分配,以上情况都不满足)
一个进程的用户地址空间高达2GB,分成很多虚拟页面,如果时时刻刻都让这些虚拟页面映射着物理内存,那么物理内存恐怕很快就分完了。所以,同一时刻,只有最频繁访问的那些虚拟页面映射着物理页面(最频繁访问的那些虚拟页面就构成了一个进程的工作集),工作集中的所有虚拟页面都映射着物理页面,一旦访问工作集外面的虚拟页面,势必引发缺页异常,系统的缺页异常处理函数会自动帮我们处理这种异常(自动分配一个物理页面,将那个引发缺页异常的虚拟页面映射着的外存页面 以分页读irp的形式读入到 新分配的物理页面中,然后修改那个虚拟页面的映射关系,指向那个新分配的物理页面),这就是系统的缺页异常处理函数的工作原理,应用程序毫不知情。
漫谈页目录、二级页表:
前面讲到每个虚拟地址看似是一个整形值,实际上由三部分组成:页表号.页号.页内偏移,为什么不是直接的页号.页内偏移呢,直接采用一个简单的一维数组,记录所有虚拟页面的这样多直观!原因是:一个进程的虚拟地址空间太大,如果为每个虚拟页面都分配一个PTE,那么将占用大量内存,不信我们算一下:
一个进程中总共有4GB/4KB=2^20个虚拟页面,也即1MB个虚拟页面,如果直接采用一维数组,描述这个1MB页面的映射情况,那么整个数组大小=1MB*sizeof(PTE)=4MB,这样,光页表部分就占据了4MB的内存。注意页表部分本身占用的内存是非分页内存,也即真金白银地占据着4MB物理内存,这4MB在现在的机器看来,并不算多,但在早期只有256MB物理内存的老爷机上(最多只能同时支持256MB/4MB个=64个进程),已经算够多了!
相反,如果采用页目录+二级页表的方式就会节省很多内存!
一个二级页表本身有一个页面大小,可以容纳4KB/sizeof(PTE)=1024 个PTE,换句话说,一个二级页表可以描述1024个页面的映射情况(换算成字节数,一个二级页面能描述1024*4kb的地址空间),一个进程总共有4GB地址空间,那么整个地址空间就有4GB/(1024*4kb)=1024个二级页表,那些暂时未映射的一大片虚拟地址,一般是高端的地址,就对应这1024个二级页表中靠后的那些二级页表,就可以暂时不用为他们分配物理内存了, 只有在确实要访问那些虚拟页面时,才分配他们对应的二级页表,这样按需分配,就节省了物理内存。
另外,32位系统中每个进程有1024个二级页表外加一个页目录。咋一看,似乎系统中有1025个页表维持着映射关系,其实不然,因为页目录本身是一个特殊的二级页表,也是那1024个二级页表中的一个。概念上,我们把第一个二级页表理解为页目录。这样,系统中实际上共有1024个二级页表(包括页目录本身在内,但要注意页目录并不在二级页表区的中的第一个位置,而是在中间的某个位置,后面我会推算页目录本身的虚拟地址在什么地方)。明白了这个道理,就可以由任意一个虚拟地址推算出他所在的二级页表在页目录中的索引位置。
#define ADDR_TO_PDE_OFFSET(addr) ( v/(1024*4kb) )
#define ADDR_TO_PAGE_TABLE(addr) ADDR_TO_PDE_OFFSET(addr)
这样,每个进程的页表不再是个简单的数组,而变成了一个稀疏数组。
页目录中的每个PDE描述了每个二级页表本身的物理地址。如果PDE=0,就表示那个二级页表尚未分配,体现为‘稀疏数组’特征。实际上,一个进程很少使用到整个4GB地址空间,因此,页目录中的绝大多数PDE都是空的,实际的二级页面个数往往很少。
每个虚拟页面的映射描述符(即PTE)的位置是固定的,根据虚拟页号可以自然算出那个虚拟页面的映射描述符位置,找到映射描述符的位置后,就可以获得该虚拟页面的当前映射情况(是否已映射,若已映射,是映射到了物理内存还是页文件,又具体映射到了哪个具体的物理页面,这些信息都一一获悉),因此PTE映射描述符是页表的核心,现在看一下PTE它的结构。
PTE的结构,PTE是二级页表中的表项,用来描述一个虚拟页面的映射情况以及其他信息
注意PTE本身长度为4B,但我们可以把它当做一个描述符结构体,并不妨碍理解
Struct PTE
{
Union
{
Struct
{
Bool bPresent;//重点字段,表示该虚拟页面是否映射到了物理内存
Bool bWritable;//表示这个虚拟页面是否可写
Bool bUser;//表示是否是用户地址空间中的虚拟页面
Bool bReaded;//表示本虚拟页面自从上次置换到内存后是否曾被读过
Bool bDirty;//表示本虚拟页面自从上次置换到内存后是否曾被写过
Bool bGlobal;//表示本PTE表项是全局页面的映射描述符,切换进程时不用刷新本PTE
UINT pfn;//关键字段,表示本虚拟页面对应的物理页号
}Mem;
Struct
{
文件中的页面号;
页文件号;//系统中可以支持多个Pagefile.sys页文件
}File;
}
}
这样,这个PTE如果映射到了内存,就解释为Mem结构体,如果映射到了页文件,就解释为File结构体。
NTSTATUS
MmCreateVirtualMapping(process, FirstVirtualPageAddr, VirtualPageCount,
PfnArray, PfnCount, PteFlags)
{
If(VirtualPageCount != PfnCount )
Return fail;
DWORD NewPTE=ConstructPte(PteFlags);//拷贝PTE中的那些Bool标志位
Void* CurPageAddr = FirstVirtualPageAddr;//当前虚拟页面的地址
PTE* Pt;//当前虚拟页面的PTE在二级页表中对应的位置
For(int i=0; i< VirtualPageCount;i++)//遍历每个要创建映射的虚拟页面
{
//这个函数下文有解析
Pt = MmGetPageTableForProcess(process, CurPageAddr);//找到这个页面的pte位置
OldPte = *Pt;//记录这个虚拟页面原来的PTE
If(OldPte映射到了页文件)
return fail;
If(OldPte映射到了物理内存)
撤销原来的映射;
NewPTE.pfn = PfnArray[i];//关键,将这个虚拟页面映射到指定的物理页面
*pt = NewPTE;//修改原PTE表项
//递增对应二级页表中的PTE个数,这个函数其实是创建PTE,不是修改PTE
Process.地址空间.PageTableRefCountTable[ ADDR_TO_PAGE_TABLE(CurPageAddr) ]++;
If(OldPte映射到了某物理内存页面)
MiFlushTlb(pt, CurPageAddr);//同步更新cpu二级缓冲中的PTE
CurPageAddr+=4KB;//下一个虚拟页面
}
}
如上,这个函数用来为指定的一段连续虚拟页面,批量创建PTE,建立到各个物理页面的映射。注意虚拟页面一定是连续的,物理页面数组中的物理页面是可以零散分布的。
MmDeleteVirtualMapping(process, PageAddr, bFreePhysicalPage, BOOL* bDirty, PFN* pfn)
{
PTE* pt= MmGetPageTableForProcess(process, CurPageAddr);//找到这个页面的pte位置
PTE OldPte=*pt;
*pt=0;//全0就表示删除PTE
/*注意,一个物理页面可能还可能被其他虚拟页面映射着,应该等到该物理页面的MapCount减到0时才释放这个物理页面*/
If(bFreePhysicalPage)
MmReleasePageMemoryConsumer(pfn);
//递减对应二级页表中的PTE个数
Process.地址空间.PageTableRefCountTable[ ADDR_TO_PAGE_TABLE(CurPageAddr) ] --;
If(Process.地址空间.PageTableRefCountTable[ ADDR_TO_PAGE_TABLE(CurPageAddr) ] = 0)
MmReleasePageTable(process,PageAddr);//释放对应的整个二级页表,体现稀疏数组特征
*bDirty=OldPte.bDirty;
*pfn=OldPte.pfn;//返回原来映射的物理页面号
}
Windows中,不管是应用程序还是内核程序,都不能直接访问物理内存,如Mov eax,DWORD PTR[物理地址],
是不允许的,不支持的。所有非IO指令都只能访问虚拟内存地址,如Mov eax, DWORD PTR[虚拟地址]形式,但是,有时候,我们明明已经知道了某个东西固定在物理内存条某处,假如系统时间的值永远固定存放在物理内存条的物理地址0x80000000处,我们已经知道了物理地址,如何访问获得系统时间值呢?这是个问题!Windows为了解决这样的直接访问物理内存操作提供了手段!其中之一便是:“为物理页面建立临时映射”,也即可以将某个物理页面映射到系统地址空间中的那段专用于临时页面映射的保留区域。
具体的:系统地址空间中专用于临时映射的那段保留区的起始虚拟地址为:
#define HYPERSPACE 0xC0400000
保留区的大小为:1024个虚拟页面,也即1024*4KB=4MB大小
下面这个函数用来将指定物理页面 临时 映射到保留区中的某个虚拟页面,返回得到的虚拟页面地址
Void* MmCreateHyperspaceMapping(pfn)
{
PTE* Pte=临时映射保留区的映射描述符们所在的二级页表;//也即第一个临时页面的映射描述符
Pte+=pfn%1024;//从这个虚拟页面的映射描述符开始,向后搜索第一个尚未映射的虚拟页面
For(i=pfn%1024; i<1024; i++,Pte++)//先遍历后面的那些PTE
{
If(*pte == 空白)
{
*pte.pfn=pfn;
Break;
}
}
If(i==1024)//如果后面部分未找到一个空闲PTE,又从前面部分开始查找
{
PTE* Pte=临时映射保留区的映射描述符们所在的二级页表;//回到开头
For(i=0; i<pfn%1024;i++,Pte++)
{
If(*pte == 空白)
{
*pte.pfn=pfn;
Break;
}
}//end for
}//end if(i==1024)
//上面是一个简单的闭式hash表的查找过程,找到一个尚未映射的临时保留虚拟页面后,就返回
Return HYPERSPACE + i*4kb;
}
既然叫临时映射,那用完后,就得撤销映射
MmCreateHyperspaceMapping(pfn);//这个函数就是用来删除以前建立的临时映射,省略
要想查询一个虚拟页面的映射情况(有没有映射,有的话,又映射到了什么地方 这些信息),唯一的办法就是要找到这个虚拟页面的PTE映射描述符,那么如何查找呢?
#define PAGETABLE_MAP 0xC0000000
如前文所述,每个进程的页表区都真枪实弹的占据着对应的物理内存,系统为了方便,把每个进程的页表区都事先固定映射到了虚拟地址0xC0000000处,长度为1024个页表 * 每个页表本身的大小(即4KB)=4MB。因此,各个进程的页表区也都是被系统映射到了同一段虚拟空间(0xC0000000---0xC0000000+4MB)处。
这段区域用来映射二级页表们
整个4GB空间的布局:
用户空间 系统空间开头 二级页表 二级页表 … 页目录 … 二级页表 二级页表 系统空间结尾
#define PAGEDIR_MAP (PAGETABLE_MAP + PAGETABLE_MAP/1024)
PAGEDIR_MAP表示页目录本身所在的虚拟页面的地址,这是怎么得来的呢?是经过下面这样推算出来的
PAGEDIR_MAP= PAGETABLE_MAP + idx*每个二级页面的大小
= PAGETABLE_MAP + idx*4kb
= PAGETABLE_MAP + (PAGETABLE_MAP偏移/每个二级页表描述的长度范围) * 4kb
= PAGETABLE_MAP + (PAGETABLE_MAP/(1024*4kb)) * 4kb
= PAGETABLE_MAP + PAGETABLE_MAP/1024
因此,只要知道了页表区中第一个二级页面的虚拟地址,就可以推算出页目录本身的虚拟地址
进一步:
#define ADDR_TO_PDE(PageAddr) PAGEDIR_MAP + PageAddr/(1024*1024) //直接推算PDE的地址
#define ADDR_TO_PTE(PageAddr) PAGETABLE_MAP + PageAddr/1024 //直接推算PTE的地址
这两个宏我就不想多说了
下面这个函数用来找到指定进程中的指定虚拟页面的映射描述符位置:
PTE* MmGetPageTableForProcess(process, PageAddr)
{
ULONG PDE_IDX = ADDR_TO_PDE_OFFSET(PageAddr);//计算该虚拟页面的映射描述符在哪个二级页表中
PDE* PageDir;//页目录的虚拟地址
If(process!=当前进程 && PageAddr<2GB)//if PageAddr是其他进程的用户空间中的某个虚拟页面
{
PFN pfn=process.pcb.DirectoryTableBase;//获得那个进程的页目录所在的物理页面号
PageDir=MmCreateHyperspaceMapping(pfn);//临时映射那个物理页面,以便访问它的页表
If(PageDir[PDE_IDX]==空白)
Return NULL;//若整个二级页面尚未分配,返回NULL
Pfn= PageDir[PDE_IDX].pfn;//获得二级页表所在的物理页号
MmDeleteHyperspaceMapping(PageDir);//不用在访问页目录了,撤销临时映射
PTE* pte= MmCreateHyperspaceMapping(Pfn);//再临时映射二级页表本身,以便访问它
Return pte+ADDR_TO_PTE_OFFSET(PageAddr);//OK,返回那个页面的映射描述符
}
Else//反之,若那个虚拟页面就在本进程的用户地址空间或公共的系统地址空间中,就直接推算页目录的虚拟地址,免得为其创建临时映射,以提高效率
{
PageDir=ADDR_TO_PDE(PageAddr);//直接推算的页目录的虚拟地址
If(PageDir[PDE_IDX]==空白)
Return NULL;//若整个二级页面尚未分配,返回NULL
Return ADDR_TO_PTE(PageAddr);//直接推算得到这个虚拟页面的映射描述符地址
}
}
前面说过,各个进程的用户地址空间是私有的,各不相同的,内核地址空间部分则几乎完全相同,为什么是几乎呢,而不是全部呢?那就是因为内核地址空间中,每个进程的二级页表区和临时映射区,没有映射到相同的物理页面。
MmUpdatePageDir(process, KernePagelAddr,PageCount)
每当内核地址空间中的某组页面的映射发生变化,系统就会调用这个函数将内核地址空间中从KernePagelAddr开始的一组内核虚拟页面,从系统的公共内核页表中同步复制这些页面的PTE到各个进程的对应页表中,这样,就使得每个进程的内核页面映射都相同,落到同一个物理页面或者文件页面中。
但是,系统绝不会同步修改各个进程的二级页表区和临时映射区中那些虚拟页面的映射描述符,因为那部分虚拟页面由每个进程自己单独维护映射,各不相同。
也即每个进程的内核页表部分都copy自系统,用户页表部分各不相同。
综上:【各个进程的用户地址空间各不相同,内核地址空间相同,但页表区、临时映射区除外】
下面看一下普通的内存分配流程:
Void* Kernel32.VirtualAlloc(void* BaseAddr, Len, AllocType, protect)
{
Void* addr=BaseAddr;
NTDLL.NtVirtualAlloc(&addr, Len, AllocType, protect)
{
Mov eax,服务号
Lea edx,[esp+4] //记录用户区参数地址
Sysenter
--------------------用户模式与内核模式分界线-----------------------
KiFastCallEntry()
{
…
NtAllocateVirtualMemory(hCurProcess,&BaseAddr, &Len, AllocType, protect);
….
Sysexit
}
Return status;
}
Return addr;
}
如上,应用层的这个API调用内核服务函数,从指定进程的用户空间中分配一块指定特征的区段,最后返回区段的地址。
内核中的服务函数如下:
NTSTATUS
NtAllocateVirtualMemory(hProcess, void** BaseAddr, int* Len, AllocType, protect)
{
If(参数不合法)//一般SST中的内核服务函数入口处都会对用户空间传下来的参数进行合法性检查
Return fail;
*BaseAddr=Align(*BaseAddr,64kb);
*Len=Align(*Len,4kb);
EPROCESS* process;//该进程对象的内核结构
ObReferenceObjectByHandle(hProcess,PROCESS_VM_OPERATION,UserMode,&process,…)//获得对象
Type=(AllocType & MEM_COMMIT)?MEM_COMMIT:MEM_RESERVE;//提交型分配或预定型分配
MADDRESS_SPACE* As = process->VadRoot;//VadRoot表示该进程的用户地址空间
If(*BaseAddr!=NULL)//if 用户给定了分配的起始地址,必须从那儿分配
{
MEMORY_AREA* Area=MmLocateMemoryAreaByAddress(As,*BaseAddr);
If(Area!=NULL)//如果该地址落在事先已经分配的某个区段中
{
AreaLen=Area->EndAddress – Area->StartingAddress;
//如果用户要求分配的这块区域完全落在那个已分配区段中,就修改分配类型、保护属性
//然后调用AlterFunc执行合并、拆分、修改页面映射等相关工作
If(AreaLen >= *Len)
{
MmAlterRegion(As,Area->StratingAddr, Area->区块链表, *BaseAddr,*Len,Type,protect
AlterFunc=MmModifyAttributes);
Return succ;
}
Else
Return fail;
}//end If(Area!=NULL)
}//end if(*BaseAddr!=NULL)
//若用户没指定地址,或者即使指定了地址,但那个地址尚未落入任何已分配区段中,就分配区段
MmCreateMemoryArea(As,普通型区段,BaseAddr,Len,protect,…);
MmInitializeRegion(Area);//每个区段初始分配时,内部就初始化为:包含一个区块。
Return succ;
}//end func
注意,上面函数分配的区段尚未建立映射,既没有映射到物理内存,也没有映射到页文件,但是,该区段已经分配,会被记录到地址空间的已分配区段表中(AVL树).由于尚未映射,此时该区段中各个页面的PTE映射描述符是空的,cpu一访问这个页面就会引发缺页异常
页面访问异常:
当cpu访问一个虚拟页面时,如果:
1、 该虚拟页面尚未映射到物理页面,触发典型的0x0e号缺页异常
2、 该虚拟页面映射着了某个物理页面,但是读写访问权限不匹配,触发0x0e越权异常
不管是缺页异常还是越权异常,都叫页面访问异常。一旦发生异常,cpu自动从当前cpu的IDT[异常号]位置找到对应的异常处理函数(简称epr),epr最终将调用MmAccessFault函数处理该异常
注意发生异常时,cpu还会自动把具体的异常原因号(非异常号)压入内核栈帧中,然后跳转到对应的epr,该epr是_KiTrap14函数,该epr在内部构造好异常Trap帧后(也即保存寄存器现场),Jmp到KiTrap0EHandler异常处理函数,这个函数从CR2寄存器读取发生异常的内存单元地址,然后调用下面的函数
{
…
//异常码的最低位表示是因缺页引起的异常还是写保护引起的异常
Status = MmAccessFault(TrapFrame->ErrCode & 1, Cr2,TrapFrame->SegCs & MODE_MASK, TrapFrame);
…
}
NTSTATUS MmAccessFault(bool bProtect, MemoryAddr, Mode, void* TrapInfo)
{
If(bProtect)
Return MmpAccessFault(Mode, MemoryAddr, TrapInfo?TRUE:FALSE);
Else
Return MmNotPresentFault(Mode, MemoryAddr, TrapInfo?TRUE:FALSE);
}
bProtect表示是越权引起的异常还是缺页引起的异常,MemoryAddr表示访问的内存单元地址,Mode表示该指令位于哪个模式空间
看缺页异常是怎么处理的:
NTSTATUS
MmNotPresentFault(Mode,Address)
{
MADDRESS_SPACE AddressSpace;
If(Mode==KernelMode)
AddressSpace =MmGetKernelAddressSpace();
Else
AddressSpace =当前进程的用户地址空间;
do
{
MemoryArea = MmLocateMemoryAreaByAddress(AddressSpace, Address);
//如果一个页面尚未映射,那么它的PTE==0,这种情况引发缺页异常,如果该地址落在了一个已经分配的区段中,那么MemoryArea不会为NULL,否则MemoryArea为NULL。如果既未建立映射,也未分配,就不会进行缺页置换处理,而是直接返回失败,抛出Win32异常,通知上层应用程序去处理 。相反,如果已经分配过了,并且分配类型是commit,就由系统自己进行缺页处理,调入页面。
if (MemoryArea == NULL || MemoryArea->DeleteInProgress)
return (STATUS_ACCESS_VIOLATION);
switch (MemoryArea->Type)
{
case MEMORY_AREA_PAGED_POOL://分页池中的区段
Status = MmCommitPagedPoolAddress(Address);
break;
case MEMORY_AREA_SECTION_VIEW://视图型区段
Status = MmNotPresentFaultSectionView(AddressSpace,MemoryArea,Address);
break;
case MEMORY_AREA_VIRTUAL_MEMORY://普通型区段
Status = MmNotPresentFaultVirtualMemory(AddressSpace,MemoryArea,Address);
break;
}
}while (Status == STATUS_MM_RESTART_OPERATION);
}
如上,只有这几种区段中的页面才有可能被置换到外存去,各种类型的区段的缺页处理都不同,我们看典型的普通型区段的缺页处理:
NTSTATUS
MmNotPresentFaultVirtualMemory(AddressSpace,MemoryArea,Address)
{
NTSTATUS win32ExcepCode;//由cpu异常码转换后的win32异常码
Region=MmFindRegion(MemoryArea->StratinngAddress, MemoryArea->区块链表,Address);
If(Region->Type==MEM_RESERVE || Region->Protect == PAGE_NO_ACCESS)
{
win32ExcepCode==STATUS_ACCESS_VIOLATION;
return win32ExcepCode;
}
If(当前正有其他线程在处理这个页面异常,正在做置换工作)
等待那个线程处理完缺页异常,return succ;
MmRequestPageMemoryConsumer(USER,&pfn);//分配一个空闲物理页面
If(MmIsPageSwapEntry(Address))//if 这个虚拟地址所在的虚拟页面映射到了外存
{
MmDeletePageFileMapping(Address,&SwapEntry);//返回原映射的那个外存页面,然后删除原映射
MmReadFromSwapPage();//将外存页面读入新分配的物理页面中
Pfn.SavedSwapEntry=SwapEntry;//记录本物理页面,当初是从这个页文件调入的
}
//创建映射,将该虚拟页面改映射到新分配的物理页面
MmCreateVirtualMapping(AddressSpace->process, Address, Region->Protect, &pfn数组,1个元素)
//将这个虚拟页面插入那个物理页面的映射链表中(多个虚拟页面可映射到同一物理页面)
MmInsertRmap(pfn, AddressSpace->process,Align(Address,4kb));
Return succ;
}
NTSTATUS MmReadFromSwapEntry(SwapEntry,pfn)
{
MDL mdl;
…
MmBuildMdlFromPages(mdl,pfn);//将物理页面pfn映射到系统的mdl映射区中
FileNo=SwapEntry.FileNo;
FileOffset=SwapEntry.PageNo * 4kb;
//这个函数内部会构造一个分页读irp发往文件系统,最后发给磁盘驱动,读入页文件中对应的页面
Status=IoPageRead(PagingFileList[FileNo]->FileObject, FileOffset,mdl,…);//读入到物理页面
if (Status == STATUS_PENDING)
{
KeWaitForSingleObject(&Event, Executive, KernelMode, FALSE,
NULL //看到没,Timeout参数=NULL,表示一直等到磁盘页面读入完成
);
Status = Iosb.Status;
}
…
Return status;
}
由于涉及到磁盘I/O,因此,置换过程有点耗时!频繁的缺页异常往往会造成系统性能瓶颈,这就是时间换空间带来的副作用。
另外:由于MmReadFromSwapEntry这个函数会在内部调用KeWaitForSingleObject一直等到页面读入到内存后才返回原处,继续执行。但是KeWaitForSingleObject这个函数,如果是要等待的话,只能运行在DISPATCH_LEVEL irql以下,否则,蓝屏。这就是为什么在DISPATCH_LEVEL及其以上irql时,千万不能访问分页内存。因为分页内存可能在磁盘中,这样,一触发缺页中断,在这个irql尝试去读取磁盘页面时,就会因为KeWaitForSingleObject的问题而崩溃。
【换言之,根源是DISPATCH中断级的代码不能调用KeWaitForSingleObject等待任意对象】
下面引自DDK原话:“Callers of KeWaitForSingleObject must be running at IRQL <= DISPATCH_LEVEL. However, if Timeout = NULL or *Timeout != 0, the caller must be running at IRQL <= APC_LEVEL and in a nonarbitrary thread context.”
看到没,只有在Timeout != NULL && *Timeout==0 的情况下,才可以在DISPATCH_LEVEL等待
每当一个消费者持有的物理页面数量超过自身配额, 消费者会主动自我修剪一部分物理页面,置换到外存。
每当系统总体空闲物理内存紧张时(即小于最低空闲页数阀值也即64个页面时),内核中的那个平衡线程也会强制修剪某些物理页面,置换到外存,以腾出一个物理页面出来。注意并不是物理内存完全耗尽后才开始发生置换操作,而是物理内存快要用完(小于64个页面)时,系统就开始着手置换操作了。
下面是置换函数原理:
NTSTATUS
MmPageOutVirtualMemory(MADDRESS_SPACE* as, MEMORY_AREA* Area, PageAddr)
{
PTE* pt= MmGetPageTableForProcess(process, CurPageAddr);//找到这个页面的pte位置
PTE pte=*pt;
PFN pfn=pte.pfn;
SavedSwapEntry = pfn.SavedSwapEntry;
If(pte.bDirty == false)//如果该页面未脏,那好办
{
MmDeleteVirtualMapping(as.process, PageAddr, …);//删除该虚拟页面对应的原PTE
If(SavedSwapEntry != 0 )//if 该物理页面是从页文件调入的,就直接使用那个页文件
{
//将该虚拟页面对应的PTE重定向映射到原先的页文件中的那个页面
MmCreatePageFileMapping(as.process, PageAddr, SavedSwapEntry);
Pfn.SavedSwapEntry = 0;
}
MmReleasePageMemoryConsumer(USER, pfn);//既然换到外存了,那就释放物理页面变成空闲状态
Return succ;
}
Else//如果已经脏了,工作有点多
{
If(SavedSwapEntry == 0 )//if 该物理页面是从页文件调入的,就直接使用那个页文件
NewSwapEntry= MmAllocSwapPage();//从磁盘上的页文件中分配一个文件页面
Else
NewSwapEntry= SavedSwapEntry;//沿用原来的页文件页面
MmWriteToSwapPage(pfn ---> NewSwapEntry);//以分页写irp的形式将物理页面内容写入外存页面
MmDeleteVirtualMapping(as.process, PageAddr, …);//删除该虚拟页面对应的原PTE
MmCreatePageFileMapping(as.process, PageAddr, NewSwapEntry);//重定向
Pfn.SavedSwapEntry = 0;
MmReleasePageMemoryConsumer(USER, pfn);//既然换到外存了,那就释放物理页面变成空闲状态
}
}
MiBalancerThread()
{
WaitObjects[0]=&MiBalanceEvent;
WaitObjects[1]=&MiBalancerTimer;
Whilr(true)
{
Status=KeWaitForMultipleObjects(2,WaitObjects,WaitAny,Executive,KernelMode,…);
If(status==STATUS_SUCCESS)//如果收到了内核发来的一个平衡请求
{
While(系统总空闲页数 < 阀值+5)
调用各个消费者的修剪函数;
}
Else//定时醒来
{
For(遍历每个消费者)
{
If(该消费者占有的物理页数是否超过了自己的配额 || 系统空闲物理页数小于了阀值)
调用它的修剪函数;
}
}
}//end while
}
系统中整个分四大消费者:文件缓冲,用户空间,内核分页池,内核非分页池
看下典型的User消费者是如何修剪自己的物理页面的
NTSTATUS
MmTrimUserMemory(ToTrimPageCount, ULONG* ActualTrimPageCount)
{
*ActualTrimPageCount=0;
Pfn=MmGetLRUFirstUserPage();//根据LRU算法找到要换出去的物理页面
While(pfn!=0 && ToTrimPageCount>0)
{
MmPageOutPhysicalAddress(pfn);//换出去
*ActualTrimPageCount++;
Pfn=MmGetLRUNextUserPage(pfn);//获得下一个要换出去的物理页面
}
Return succ;
}
置换算法是LRU,最近以来最少被访问到的物理页面优先换出去。讲述操作系统原理的书籍一般都有介绍,在此不解释。
NTSTATUS MmPageOutPhysicalAddress(pfn)
{
//获得第一个映射到本物理页面的虚拟页面
FirstEntry=MmGetRmapListHeadPage(pfn);
Process=FirstEntry->process;
PageAddr=FirstEntry->address;
If(PageAddr>2GB)
AddressSpace=内核地址空间;
Else
AddressSpace=process->VadRoot;//目标进程的用户空间
MemoryArea = MmLocateMemoryAreaByAddress(AddressSpace, PageAddr);
If(MemoryArea->Type == 视图型区段)//表示if这个物理页面是一个共享页面,被多个进程映射共享
{
遍历pfn.映射链表,一一处理;//特别处理
Return succ;
}
Else if(MemoryArea->Type == 普通型区段)
{
…
MmPageOutVirtualMemory(…);
…
}
}
内存映射文件与共享物理内存:(二者原理相通)
相信编写过应用程序的朋友都知道“内存映射文件”一说。简单地讲,内存映射文件就是把磁盘上的文件当做物理内存使用。这样,要读写文件时,不用再原始地调用ReadFile,WriteFile函数读写文件。可以直接把文件映射到虚拟内存,然后直接读写虚拟内存即可对文件进行读写。
当一个文件映射到虚拟内存后,一读写对应的虚拟内存,势必引发缺页异常,系统的缺页异常处理函数自动处理,把文件页面调入读入物理内存。这样,就间接地对文件进行了IO。
除了普通的纯数据文件可以映射到内存外,exe、dll等可执行文件和磁盘中的页文件也是以内存映射文件的方式进行访问的。
应用层的CreateFileMapping这个API就是专用来创建文件映射用的。
除此之外,两个进程也可以共享物理内存,只要把同一个物理页面映射到这两个进程的地址空间即可,物理内存共享也是靠内存映射文件机制实现的,只不过映射的不是普通磁盘文件,而是页文件。
内核相关结构定义:
Struct ROS_SECTION_OBJECT
{
CSHORT type;//本结构体的类型
CSHORT size;//本结构体的实际长度(结构体后面经常可以衔接其他数据,size包含了那部分的长度)
ULONG protect;//本section的保护权限
ULONGLONG MaxSize;//本section的最大长度
ULONG AllocationAttributes;//包含了本section的文件类型
FILE_OBJECT* FileObject;//创建本section的那个文件对象(文件句柄)
Union
{
MM_SECTION_SEGMENT* Segment;//数据文件section中的唯一segment
MM_IMAGE_SECTION_OBJECT* ImageSegments;//镜像文件中的Segment数组
};
};
如上,普通数据文件section内部就包含一个segment,可执行镜像文件(统称PE文件)section中一般包含多个segment,对应PE文件中的每个“节”,如.TEXT节, .DATA节, .RSRC节
struct MM_IMAGE_SECTION_OBJECT
{
ULONG_PTR ImageBase;
ULONG_PTR StackReserve;
ULONG_PTR StackCommit;
ULONG_PTR EntryPoint;
USHORT Subsystem;
USHORT ImageCharacteristics;
USHORT MinorSubsystemVersion;
USHORT MajorSubsystemVersion;
USHORT Machine;
BOOLEAN Executable;
ULONG NrSegments;// 本ImageSection中的segment个数,也即‘节’个数
ULONG ImageSize;
PMM_SECTION_SEGMENT Segments;//本ImageSection中的segment数组
};
PE文件头的节表区中每个节的格式定义为://参考《Windows PE权威指南》一书
Struct PE_SEGMENT
{
BYTE Name[IMAGE_SIZEOF_SHORT_NAME=8]; //8个字节的节名 如".text" ".rdata" ".data"
DWORD VirtualSize;//该节未对齐前的原始数据大小 DWORD VirtualAddress; //该节的RVA
DWORD SizeOfRawData; //该节的FAS也即文件对齐大小,一般指对齐512B后的大小
DWORD PointerToRawData; //该节的FOA,即文件偏移
DWORD PointerToRelocations; //专用于obj文件
DWORD PointerToLinenumbers; //用于调试
WORD NumberOfRelocations; //专用于obj文件
WORD NumberOfLinenumbers; //用于调试
DWORD Characteristics; //该节的属性(可读、可写、可执行、可共享、可丢弃、可分页等属性)
} IMAGE_SECTION_HEADER;
每个节的Characteristics的特征属性包括下面几个:
IMAGE_SCN_CNT_CODE 该节中包含有代码 如.text
IMAGE_SCN_CNT_INITIALIZED_DATA 该节中包含有已初始化的数据 如.data
IMAGE_SCN_CNT_UNINITIALIZED_DATA 该节中包含有尚未初始化的数据,如.bss .data?
IMAGE_SCN_MEM_DISCARDABLE 该节加载到内存后是可抛弃的,如dll中的.reloc重定位节就是可以抛弃的
IMAGE_SCN_MEM_NOT_CACHED 节中数据不会经过缓冲
IMAGE_SCN_MEM_NOT_PAGED 该节不准交换到页文件中,sys文件中的节(除.page)都不可换出
IMAGE_SCN_MEM_SHARED 这个节可以被多个进程共享,如dll中的共享节。也即表示本节是否允许写复制。(默认允许)
IMAGE_SCN_MEM_EXECUTE 本节可执行
IMAGE_SCN_MEM_READ 本节可读
IMAGE_SCN_MEM_WRITE 本节可写
在内核中,每个节的结构体定义则如下:
Struct MM_SECTION_SEGMENT
{
LONG FileOffset;//foa
ULONG VirtualAddress;//其实是相对虚拟地址偏移rva,不是va
ULONG RawLength;//本节在文件中的原始实际长度
ULONG Length;//本节对齐后的长度(一般指对齐4KB)
ULONG protect;//可读、可写、可执行这些保护属性
ULONG ReferenceCount;
SECTION_PAGE_DIRECTORY PageDir;//本节内部的页表,后文有详说
ULONG Flags;
ULONG Characteristics;
BOOL WriteCopy;//本节是否写复制,pe文件中一般为TRUE,数据文件中一般为FALSE
}
要使用内存映射文件,首先需要创建一个公共的“文件section”(section是一种内核对象),以后谁要访问这个文件,映射这个文件section就可以了。
NTSTATUS
NtCreateSection(hFile, HANDLE* hSection, DesiredAccess, ObjectAttribute, MaxSize, protect, AllocAttr,)
{
If(ExGetPreviousMode() == UserMode)
基本参数检查;
ROS_SECTION_OBJEC* SectionObject;//ReactOS中SECTION对象结构体的定义,与Windows中有差别
//为指定文件创建一个section对象
MmCreateSection(hFile, &SectionObject , DesiredAccess, ObjectAttribute, MaxSize, protect, AllocAttr);
ObInsertObject(SectionObject, …, hSection);//将对象插入对象目录和句柄表,返回它的句柄在hSection中
}
NTSTATUS
MmCreateSection(hFile, ROS_SECTION_OBJEC** SectionObject , DesiredAccess, ObjectAttribute, MaxSize, protect, AllocAttr,)
{
If(AllocAttr & SEC_IMAGE)//if 用户给定的hFile是一个可执行镜像文件
Return MmCreateImageSection(hFile,SectionObject, DesiredAccess, ObjectAttribute, MaxSize, … )
If(hFile!=NULL)//创建普通数据文件section
Return MmCreateDataFileSection(hFile,SectionObject, DesiredAccess, ObjectAttribute, MaxSize, … );
Else//用户没给给定文件,说明是要创建一段多进程共享的物理内存
Return MmCreatePageFileSection(hFile,SectionObject, DesiredAccess, ObjectAttribute, MaxSize, … );
}
如上,这个函数可以创建三种文件section,其中若是要创建共享物理内存,就创建页文件section
共享物理内存起初是在页文件中的。
可执行文件的section创建过程比较繁琐,涉及逐个逐个字段解析文件头,虽然繁杂,但是过程简单,
在此不详述,更多内容参考《Windows PE权威指南》
看一下普通数据文件section的创建过程:
NTSTATUS
MmCreateDataFileSection(hFile,SectionObject, DesiredAccess, ObjectAttribute, MaxSize, protect, AllocAttr );
{
FILE_OBJECT* FileObject;
MM_SECTION_SEGMENT * Segment;
ROS_SECTION_OBJECT* Section;
//创建一个section内核对象,返回指针在Section中
ObCreateObject(MmSectionObjectType, ObjectAttribute, sizeof(ROS_SECTION_OBJECT), &Section, …);
*SectionObject = Section;
Section->protect=protect;
Section->AllocateAttribute=AllocAttr;
ObreferenceObjectBuHandle(hFile, IoFileObjecType, &FileObject);//从文件句柄得到对应的文件对象
If(MaxSize==0)
MaxSize=GeFileLen(FileObject);//0就表示采用默认的文件大小,使用整个文件
If(MaxSize> GeFileLen(FileObject))
增长磁盘文件的大小
Segment = ExAllocatePool(NonPagePool, sizeof(MM_SECTION_SEGMENT));
Segment->ReferenceCount=1;
Segment->FileOffset=0;
Segment->protect=protect;
Segment->Flags=MM_DATAFILE_SEGMENT;//标志这是普通数据文件中的一个segment
Segment->WriteCopy=FALSE;//数据文件的segment默认不是写复制的,这点与pe文件不同
Segment->RawLength=MaxSize;
Segment->Length=Align4kb(MaxSize);
Segment->VirtualAddress=0;//指rva=0
Section->segment=segment;//普通数据文件就一个segment,而且整个文件就是一个segment
Section->MaxSize=MaxSize;//记录本section对象的长度
Section->Fileobject=Fileobject;//记录本section是由哪个文件对象创建的
FileObject->SectionObjectPointer->DataSectionObject = segment;
Return succ;
}
创建好了section对象后,就可以让任意进程拿去映射了,不过映射是以视图为单位进行的
【section. segment. 视图. 页面】,这是这四者之间的层级关系,请牢记
NtMapViewOfSection(hSection, ViewOffset, ViewSize, AllocType, protect, hProcess, void** BaseAddr )
{
PreviousMode=ExGetPreviousMode();
If(PreviousMode == UserMode)
参数检查;
ViewOffset=Align4kb(ViewOffset);
ViewSize=Align4kb(ViewSize);
ObReferenceObjectByHandle(hSection---> Section);//获得对应的对象
MmMapViewOfSection(Section, ViewOffset,ViewSize, AllocType, protect, hProcess, void** BaseAddr );
}
MmMapViewOfSection(Section, ViewOffset, ViewSize , AllocType, protect, hProcess, void** BaseAddr )
{
AddressSpace=process->VadRoot;//那个进程的用户地址空间
//若是PE文件的section,则加载映射文件中的每个segment,注意此时的ViewOffset和ViewSize参数不
起作用,将自动把每个完整segment当做一个视图来映射。
If(Section->AllocationAttribute & SEC_IMAGE)
{
ULONG i;
ULONG NrSegments;
ULONG_PTR ImageBase;
ULONG ImageSize;
PMM_IMAGE_SECTION_OBJECT ImageSectionObject;
PMM_SECTION_SEGMENT SectionSegments;
ImageSectionObject = Section->ImageSection;
SectionSegments = ImageSectionObject->Segments;//节数组
NrSegments = ImageSectionObject->NrSegments;//该pe文件中的节数
ImageBase = (ULONG_PTR)*BaseAddress;
if (ImageBase == 0)
ImageBase = ImageSectionObject->ImageBase;
ImageSize = 0;
//下面的循环遍历该pe文件中所有需要加载的节,计算所有节的大小总和
for (i = 0; i < NrSegments; i++)
{
if (!(SectionSegments[i].Characteristics & IMAGE_SCN_TYPE_NOLOAD))//所需要加载这个节
{
ULONG_PTR MaxExtent;
//该节的rva+该节的对齐4KB长度
MaxExtent=SectionSegments[i].VirtualAddress + SectionSegments[i].Length;
ImageSize = max(ImageSize, MaxExtent);
}
}
ImageSectionObject->ImageSize = ImageSize;
//如果该pe文件期望加载的区域中有任何一个地方被占用了,重定位,dll文件一般都会重定位
if (MmLocateMemoryAreaByRegion(AddressSpace, ImageBase,PAGE_ROUND_UP(ImageSize)))
{
if ((*BaseAddress) != NULL)//如果用户的要求是必须加载到预期地址处,返回失败!
return(STATUS_UNSUCCESSFUL);
ImageBase = MmFindGap(AddressSpace, ImageSize, PAGE_SIZE, FALSE);//重定位,找空闲区
}
//一次性加载映射该pe文件中的所有节
for (i = 0; i < NrSegments; i++)
{
//注意pe文件中有的节是不用加载的
if (!(SectionSegments[i].Characteristics & IMAGE_SCN_TYPE_NOLOAD))
{
PVOID SBaseAddress = ((char*)ImageBase + (SectionSegments[i].VirtualAddress);
//把该节整体当做一个view进行映射。由此可见,pe文件中的每个节也是一个视图型区段
MmMapViewOfSegment(AddressSpace,
Section,
&SectionSegments[i],//该视图所在的第一个节
&SBaseAddress,//该节的预期映射地址
SectionSegments[i].Length,//ViewSize=整个节的长度
SectionSegments[i].Protection,
0,//ViewOffset=0
0);
}
}
*BaseAddress = (PVOID)ImageBase;//返回该PE文件实际加载映射的地址
}
Else//普通数据文件和页文件的section,都只有一个segment
{
MmMapViewOfSegment(AddressSpace, section, section->segmen, ViewOffset, ViewSize , AllocType & MEM_TOPDOWN, protect, hProcess, void** BaseAddr);
}
}
MmMapViewOfSegment(AddressSpace, section, segmen, ViewOffset, ViewSize , AllocType, protect, hProcess, void** BaseAddr);
{
MEMORY_AREA* Area;
MmCreateMemoryArea(AddressSpace, 视图型区段, BaseAddr,ViewSize, protect, AllocType, &Area);
//记录本视图区段映射的是哪个section的哪个segment中的哪个位置
Area->Data.SectionData.Section=Section;
Area->Data.SectionData.Segment=segment;
Area->Data.SectionData.ViewOffset=ViewOffset;
Area->Data.SectionData..WriteCopyView=FALSE;//视图型区段默认是不‘写复制’的
初始化Area区段中的区块链表;//初始时,整个区段中就一个区块
}
如上,将文件中的的某个segment中的某部分视图映射到虚拟内存后,视图中的这些虚拟页面的初始时的PTE尚是空白的,Cpu一访问视图区段中的虚拟页面,立马引发缺页异常。系统的缺页异常处理函数此时就会自动将对应的那些文件页面 读入内存中。之前,我们看过了普通型区段的缺页异常处理流程,现在是时候看一下视图型区段的缺页处理流程了
回顾一下:
当发生缺页异常时,将进入缺页异常处理函数,再进入MmAccessFault()函数,再进入MmNotPresentFault函数,这个函数根据发生缺页的虚拟页面所在的区段类型进入现在的“视图区段页面异常处理函数”,即下面的函数,看看是怎么处理这种异常的
NTSTATUS
MmNotPresentFaultSectionView(AddressSpace, MemoryArea, Addr, …)
{
PageAddr=Align4kb(Addr);//缺页内存单元所在的虚拟页面
//计算这个页面在所属segment内的偏移
Offset = PageAddr - MemoryArea->StartingAddress + MemoryArea->Data.SectionData.ViewOffset;
Segment=MemoryArea->Data.SectionData.Segment;//该页面所在segment
If(该页面的PTE 映射到了 页文件)//还记得有一种section是页文件section吧?
按普通页文件异常处理方式处理;//前文已讲,不再赘述
Else
{
//在这个segment内部维护的那个页面映射表中找到这个虚拟页面的映射描述符
Entry = MmGetPageEntrySectionSegment(Segment, Offset);
PFN Pfn;
If(*Entry==0)//空白,表示既未映射到物理页面,也未映射到文件中的页面
{
FileOffset=ConvertTo(Segment, Offset);//将segment内的偏移转换成文件中的偏移
Pfn = 分配一个空闲物理页面;
MiReadPage(MemoryArea, FileOffset, pfn);//读入文件中对应的页面到内存中
*Entry=Pfn;//将分得的物理页面保存到这个映射描述符中,方便其他线程使用
}
Else if( Entry是一个文件页面) //最典型的情况
{
Entry=ConvertToSwapEntry(Entry);//转换格式
Pfn=分配一个空闲物理页面;
MmReadFromSwapEntry(Entry, Pfn);//读入到内存
*Entry=Pfn;//将分得的物理页面保存到这个映射描述符中,方便其他线程使用
}
Else //刚好是一个物理页面
Pfn=*Entry;
MmCreateVirtualMapping(PageAddr <------>Pfn);//建立映射
MmInsertRmap(pfn, CurProcess,PageAddr);//将这个虚拟页面添加到那个物理页面的映射链表中
}
}
每个segment内部也有一个页面映射表,描述了本segment内部各个虚拟页面的映射情况。
表中的每个映射描述符,要么映射到物理页面,要么映射到普通文件页面(注意不是页文件),要么为空,
其工作原理与进程的页表是相同的。为什么多出来一个segment页表呢?根本原因就是普通页表中的PTE,
无法映射到普通数据文件,映射描述符的格式不一样。
最后看一个非常常见的内核函数(供驱动程序用来分配内存的日常函数)
Void* ExAllocatePool(PoolType, NumberOfBytes)
{
Return ExAllocatePoolWithTag(PoolType, NumberOfBytes, 'NONE');
}
Void* ExAllocatePoolWithTag(PoolType, NumberOfByte, tag)
{
if (NumberOfBytes > PageSize-BlockHeadSize)//超出一个页面
{
//大于一个页面大小的分配特殊处理;
Retun MiAllocatePoolPages(PoolType, NumberOfBytes);;
}
For(遍历空闲块表)
{
If(找到了一个合乎大小的空闲块)
{
从空闲块链表中摘下一个合乎大小的块;
前后合并相邻块;//是在一个页面内分隔、合并
Return 找到的块地址;
}
}
//如果已有的空闲链表找不到这样一个大小的块
在池中分配一个新的页面;
在新页面中把前面的部分割出来,后面剩余的部分挂入池中的空闲块表中;
Return 分得的块地址
}
内核中池的分配原理同用户空间中的堆一样,都是先用VirtuAllocate去分配一个页面,然后在这个页面中
寻找空闲块,分给用户。每个池块的块头含有一些附加信息,如这个池块的大小,池类型,该池块的tag标记等信息。用户空间中的malloc,new堆块分配函数,都是调用HeapAlloc API函数从堆管理器维护的N个虚拟页面中分出一些零散的块出来,每个堆块的块头、块尾也含有一些附加信息,如堆块大小,防止堆块溢出的cookie等信息。堆管理器则在底层调用VirtualAlloc API分配,增长虚拟页面,提供底层服务。