内存管理简介
作为一个合格的操作系统,为不同进程之间及用户和内核之间要提供隔离机制。
实现这些基本要求的手段,就是采用基于页面映射的“虚拟内存”机制,或者说提供“页管理机制” 。
硬件上,这是由CPU内部的“存储管理单元”MMU(Memory Manage Unit)支持的。现代的处理器一般都带有MMU。
软件上,则是由系统内核中的内存管理模块来实现的。
内存管理的解释
- 广义的内存管理,指一切与内存有关的管理,包括缓冲区的分配与释放。
- 狭义的内存管理,实际上指页面映射以及与此有关的操作。
采用页式内存管理,程序中所使用的内存地址,即CPU运算单元ALU所发出的都是“虚拟地址”!!!,虚拟地址自然是不能直接访问物理内存,需要MMU将其 “映射” 到某个物理页面上转换成“物理地址”才能访问。如果某个虚拟地址区间没有这样的映射,或者虽有映射但是不允许某些模式的访问,CPU 就不能访问物理内存。看下示意图,它是MMU跟操作系统的配合。
图中的CPU由ALU和MMU共同组成。ALU使用的地址是虚拟地址。而实际用来访问物理内存的地址,则为物理地址。
如果MMU 不存在,或者没有被启用,那么映射关系自然不存在,所以此时ALU的虚拟地址即为真实的物理地址。
然而,如果MMU 存在并且被启用,由ALU所发出的虚拟地址就要由MMU映射(转换)成物理内存所使用的物理地址!!!,这种映射是以页为单位的,即0x1000。同一页面上的数据会被映射到同一个物理页上,MMU 还担任着检查访问权限的工作!!!,可以根据CPU的当前状态和期望请求的访问决定是否允许,比如页目录表和页表低12bit是访问权限的一些属性,例如有U/S位判断哪个特权级可以访问,P位表示该页是否存在,R/W表示是否可写等等。
为什么要有这样的映射呢
- 虽然每个进程的虚拟地址范围是相同的,但是最终映射到的物理地址是不同的,这样就可以满足进程在物理内存中的位置是浮动的(因为你无法保证进程一定安装在某个规定地址,不能也不可能),如果直接采用物理地址,那么一来得不到保护,而且几乎每个程序都要进行重定位操作。
- 另外页映射很好的使得不同进程间进行了隔离,因为程序虽然加载到相同的虚拟地址上,但是因为每个进程页映射的不同,最终映射到的物理页面也不会相同,这就保证了不同的进程间不会有交集。
- 其次,页映射不但保证了不同进程能独立运行互不干扰的问题,也很好的实现了权限的分离。映射的时候MMU会检查所访问页表的权限属性和当前的CPU运行状态。
- 并且还有经济上的考虑,通常,一个CPU的虚拟空间是很大的,每个进程都拥有属于自己的4GB的虚拟空间。但一般PC的物理内存一共才512MB(0.5GB)。,当系统多个进程并发运行的时候,其存储空间是不够的。这个时候就需要对物理内存加以复用,即同一页面在不同的时间片上是可以被用于不同进程的虚存页面上。暂时不会用到的页面就可以暂存到外存上,等到访问的时候再放到相应的物理页面上。
MMU怎么知道映射到哪呢
MMU肯定是要知道映射到哪个页面的,为此需要一个“页面映射表”。实际上就是一个数组,再之前的博客是有讲到的。页面映射表实际上是一个以虚存页面号为下标的数组,数组中的元素称为页面映射表项,这个表项有映射到哪个页面(高20bit),相应的虚拟页面是否存在映射,即属性低12bit。
每个进程都有自己的4GB的虚拟空间,所以每个进程都有自己专属的页面映射表。CPU在执行哪个程序,就要使用哪个进程的映射表。为此,x86专门有个控制寄存器CR3,用来指定DirBase,即该进程页面映射表的物理地址(注意,这里是物理地址)。换句话讲,其实也就是调度不同进程时,就使CR3转向这个进程的页面映射表。MMU正是根据CR3寄存的值从而找到映射到哪个物理页面的。
另外,MMU根据虚存页面号找到相应的表项并映射,因为要多次访问内存,所以如果程序大量的跟内存进行交互,那么效率显然是很低下的。所以会有一个缓存机制!!!。CPU根据需要将当前用到的页面映射表项高速缓存在内部的TLB(Translation Look-aside Buffer 地址转换快速查找缓冲区)中,TLB是CPU内部一块专用的,特殊的高速缓冲。每当需要使用一个页面映射表项的时候,MMU就受限在TLB中进行查询,找到自然就不需要再去访问物理内存了,CPU跟内存之间的访问 和 CPU跟自己内部的访问,其速度自然是大不同的,一般而言,一个进程在运行了一会后,TLB的命中率还是很高的。
MMU总结
总结下在MMU存在的时候且被启用,访问内存的流程如下
- 根据虚拟地址算出该虚拟地址所属的页面号,即假如虚拟地址为0x12345678,要进行分割
- 根据CR3中的页目录基址计算出该页面所在的物理地址
- 根据物理地址先在TLB中进行查询
- 若未命中则需要从内存将其内容装载到TLB中
- 检测该表项的Present位,如果P位为1,还需要进一步检查和对此页面所具有的的访问权限现在状态的CPU是否能达到。如果权限不够则使当前指令会执行失败并产生一次页面异常。
- 如果P位为0,则该虚存页面的映像不在内存中,当前指令的执行因此失败。此时会跑出异常,并进行相应的异常处理并进行相应的处理措施:若整个表项都为0,说明根本没建立映射,这时需要检测虚存页面是否落在已分配使用的区间,如果是就为其分配物理页并建立映射,然后重新执行该指令。否则就是越界访问,会引起更高层次的出错处理,直至终止当前进程的运行。如果不全为0,这说明是被操作系统将映像倒换(swap)到外存,对策是为其分配物理页,从页面倒换文件中读取该映像,并将页面映射表项指向此物理页,然后重新执行这条失败的指令。
当然ALU中的地址其实是段式内存管理运算后得到的虚拟地址,但是Windows几乎不使用段式管理,所以将段选择子指向的描述符的段基址设置为0,只用偏移,这也是我们为什么只关心ESP和EIP的原因,但是现代的CPU可以没有段式内存管理,但是绝不能没有页式存储管理。
内存分配
任何程序,除了它本身的代码所占的空间,运行过程中也需要使用一定的内存空间。实际用于数据存储的内存区域主要有以下三种。
- 全局变量的数据区域,这是在编译的时候就静态分配好的生命周期跟可执行程序共存亡。
- 局部变量也需要内存来存储,不过所有的局部变量都是基于栈的,其生命周期随函数返回而终结
- 通过malloc一类的函数动态分配而来的,其释放是通过free一类的函数或者程序退出的时候结束生命周期的。
内核对用户空间的管理
用户空间是指CPU运行用户态所访问的虚拟地址范围。这个虚拟地址范围属于进程,每个进程都是拥有相同的用户空间虚拟地址范围的。用户空间虚拟地址的范围是从0开始到0x7FFFFFFF,范围为2GB,所以每个进程都拥有2GB的虚拟地址空间,当然不使用的虚拟地址区间自然是不映射的。
每个进程都有自己的用户空间,在EPROCESS中有个指针VadRoot(Virtual Address Descriptor Root,虚拟地址描述符根结点),指向代表着这个用户空间的数据结构,这是一个指向一颗AVL(二叉平衡搜索树)树的跟结点的一个数据结构。
其类型定义为
typedef struct _MADDRESS_SPACE {
PMEMORY_AREA MemoryAreaRoot;//指向该空间的AVL树(即进程空间的使用情况以AVL树进行描述)
PVOID LowerestAddress;//进程空间的虚拟地址最小值
struct _EPROCESS *Process;//该进程的EPROCESS结构
PUSHORT PageTableRefCountTable;
ULONG PageTableRefCountTableSize;
}
每个线程都有个这样的数据结构。当然,自然是会布置在内核中
虽然用户空间的范围有2GB大小,但是实际使用的却只是其中一部分。以一个空间中的每一个已分配使用的区间作为节点,然后把所有这样的结点连成一个队列,需要时从头扫描这个队列,就可以知道哪些区间已经被分配使用。一开始时整个空间当然是完全空闲的,所以没有节点。随着内存的分配和释放,节点可能变得愈来愈多,区间也会越来越零碎。但是只要按地址高低排列,从头扫描,就总能找到一个地址所在的已分配区间,而夹在这些区间之间的空隙则代表着空闲的区间。
但是依靠队列来完成如此分量的内存分配与释放的工作,其效率是不能令人满意的,所以实践中会采用AVL树的算法和数据结构,以提高当节点数量很大时的搜索效率。因此,MemoryAreaRoot指向的并非是一个线性的链表,而是一颗二叉树。这个指针指向的数据结构为MEMORY_AREA,其类型如下:
typedef struct _MEMORY_AREA {
//每一个进程空间中的一个区间的结构
PVOID StartingAddress;
PVOID EndingAddress;
struct _MEMORY_AREA *Parent;//父节点指针
struct _MEMORY_AREA *LeftChild;//左孩子指针
struct _MEMORY_AREA *RightChild;//右孩子指针
ULONG Type; //MEM_COMMIT MEM_RESERVE
ULONG Protect; //主要是读写属性之类的 例如PAGE_READONLY等
ULONG Flags;
BOOLEAN DeleteInProgress;
ULONG PageOpCount;
union {
struct {
//当时文件映射或共享内存时
ROS_SECTION_OBJEXT* Section;
ULONG ViewOffset;
PMM_SECTION_SEGMENT Segment;
BOOLEAN WriteCopyView;
LIST_ENTRY RegionListHead;
}SectionData;
struct {
//大部分情况是这个,指向区间中区块所连接成的双向链表
LIST_ENTRY RegionListHead;
}VirtualMemoryData;
}Data;//该节点存储的数据区域
}MEMORY_AREA, *PMEMORY_DATA;
所以用户空间可用AVL树来观察该用户空间的使用情况,并且根据LeftChild和RightChild可以看出,这是一颗二叉树。
MmLocateMemoryAreaByAddress定位结点
内核函数MmLocateMemoryAreaByAddress(Mm前缀表示内存管理类的函数)从名字不难推断它是根据虚拟地址来定位该虚拟地址所对应的用户空间的MEMORY_AREA结点上。
该函数的实现如下
PMEMORY_AREA STDCALL
MmLocateMemoryAreaByAddress(
PMADDRESS_SPACE AddressSpace,
PVOID Address)
{
PMEMORY_AREA Node = AddressSpace->MemoryAreaRoot;//获取用户空间的AVL树
DPRINT("MmLocateMemoryAreaByAddress(AddressSpace %p, Address %p)\n",
AddressSpace, Address);
MmVerifyMemoryAreas(AddressSpace);//检测该AVL树是否存在问题
while (Node != NULL)//根据二叉搜索树的性质进行和遍历,小到其左子树遍历,大则到其右子树遍历
{
if (Address < Node->StartingAddress)
Node = Node->LeftChild;
else if (Address >= Node->EndingAddress)
Node = Node->RightChild;
else
{
DPRINT("MmLocateMemoryAreaByAddress(%p): %p [%p - %p]\n",
Address, Node, Node->StartingAddress, Node->EndingAddress);
return Node;
}
}
DPRINT("MmLocateMemoryAreaByAddress(%p): 0\n", Address);
return NULL;
}
- 首先获取传进来的AddressSpace->MemoryAreaRoot获取根结点。
- 然后根据MmVerifyMemoryAreas(功能是通过遍历每一个结点,判断这棵AVL树的每个结点的地址是否合法)
- 根据二叉搜索树的特征,比某个结点数值小的在左边,比某个结点数值大的在右边,所以通过每个结点的起始地址和每个结点的末尾地址要比较的地址进行一个比较,若Node->StartingAddress < Address < Node->EndingAddress,说明这个地址在这个结点区间处,就会返回这个结点。
- 若遍历完每个结点都没找到则返回NULL表示未查到
MmVerifyMemoryArea的实现
static VOID MmVerifyMemoryAreas(PMADDRESS_SPACE AddressSpace)
{
PMEMORY_AREA Node;
ASSERT(AddressSpace != NULL);//如果MADDRESS_SPACR不为空则继续
/* Special case for empty tree. */
if (AddressSpace->MemoryAreaRoot == NULL)//根结点要不为空
return;
/* Traverse the tree from left to right. */
for (Node = MmIterateFirstNode(AddressSpace->MemoryAreaRoot);//遍历每个结点
Node != NULL;
Node = MmIterateNextNode(Node))
{
/* FiN: The starting address can be NULL if someone explicitely asks
* for NULL address. */
ASSERT(Node->StartingAddress >= AddressSpace->LowestAddress ||
Node->StartingAddress == NULL);//确保任何一个结点的起始地址要比最低地址要高
ASSERT(Node->EndingAddress >= Node->StartingAddress);//确保任何一个结点的末尾地址比起点地址要高
}
}
MmFindGap寻找Length长度的“间隙”
另外一个经典的操作是在用户地址空间寻找一个给定长度Length的“空隙”,即空闲的地址区间,这是由MmFindGap函数实现的。
MmFindGap(
PMADDRESS_SPACE AddressSpace,//该进程用户空间
ULONG_PTR Length,//寻找的空隙大小
ULONG_PTR Granularity,//粒度位,表明区间起点的对齐要求,注意是起点地址
BOOLEAN TopDown)
{
if (TopDown)//表示寻找的方向是从高端往低端还是低端往高端
return MmFindGapTopDown(AddressSpace, Length, Granularity);//高端往低端
return MmFindGapBottomUp(AddressSpace, Length, Granularity);//低端往高端
}
static PVOID//低端向高端搜索
MmFindGapBottomUp(
PMADDRESS_SPACE AddressSpace,
ULONG_PTR Length,
ULONG_PTR Granularity)
{
//MmSystemRangeStart 为0x7FFFFFFF MAXULONG_PTR为0xFFFFFFFF
PVOID HighestAddress = AddressSpace->LowestAddress < MmSystemRangeStart ?//确定搜索的最高端
(PVOID)((ULONG_PTR)MmSystemRangeStart - 1) : (PVOID)MAXULONG_PTR;
PVOID AlignedAddress;
PMEMORY_AREA Node;
PMEMORY_AREA FirstNode;
PMEMORY_AREA PreviousNode;
MmVerifyMemoryAreas(AddressSpace);//检测该AVL树的合法性
DPRINT("LowestAddress: %p HighestAddress: %p\n",
AddressSpace->LowestAddress, HighestAddress);
/*
#define MM_ROUND_UP(x,s ) ((PVOID)(( (ULONG_PTR)(x)+(s)-1) & ~((ULONG_PTR)(s)-1)))
*/
AlignedAddress = MM_ROUND_UP(AddressSpace->LowestAddress, Granularity);//获得最小地址的对齐地址
/* Special case for empty tree. */
if (AddressSpace->MemoryAreaRoot == NULL)//如果AVL树为空
{
if ((ULONG_PTR)HighestAddress - (ULONG_PTR)AlignedAddress >= Length)//若最高地址-最低符合条件,则返回该对齐地址
{
DPRINT("MmFindGapBottomUp: %p\n", AlignedAddress);
return AlignedAddress;
}
DPRINT("MmFindGapBottomUp: 0\n");
return 0;