纯干货,linux内存管理——内存管理架构(建议收藏)

发布于:2022-11-09 ⋅ 阅读:(6) ⋅ 点赞:(0) ⋅ 评论:(0)

一、内存管理架构

内存管理子系统架构可以分为:用户空间、内核空间及硬件部分3个层面,具体结构如下所示:

1、用户空间:应用程序使用malloc()申请内存资源/free()释放内存资源。

2、内核空间:内核总是驻留在内存中,是操作系统的一部分。内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数。

3、硬件:处理器包含一个内存管理单元(Memory Management Uint,MMU)的部件,负责把虚拟地址转换为物理地址。

二、虚拟地址空间布局架构

上面的用户空间和内核空间所指的都是虚拟地址,物理地址没有用户和内核之分。每个项目的物理地址对于进程不可见,谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的,里面的内存都是从 0 开始编号。

所有进程共享内核虚拟地址空间,每个进程有独立的用户虚拟地址空间,同一个线程组的用户线程共享用户虚拟地址空间,内核线程没有用户虚拟地址空间。

在程序里面,指令写入的地址是虚拟地址。例如,位置为 10M 的内存区域,操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

32位处理器使用32位虚拟地址,而64位处理器却不是使用64位虚拟地址。因为目前应用程序没有那么大的内存需求,所以ARM64和X86_64处理器不支持完全的64位虚拟地址,而是使用了48位。我们计算一下,如果是 32 位,有 2^32 = 4G 的内存空间都是我的,不管内存是不是真的有 4G。如果是 64 位,在 ARM64和X86_64 下面,其实只使用了 48 位,那也挺恐怖的。48 位地址长度也就是对应了 256TB 的地址空间。我都没怎么见过 256T 的硬盘,别说是内存了。

这么大的虚拟空间一切二,一部分用来放内核的东西,称为内核空间,一部分用来放进程的东西,称为用户空间。

用户空间其实包含以下几个区域,我们最低位开始排起:

1.代码段,数据段,未初始化的数据段(bss)

2.存放动态生成数据的堆,堆是往高地址增长的

3.动态库的代码段,数据段和未初始化的数据段(bss)

4.存放局部变量和实现函数调用的栈

5.把文件映射到虚拟地址空间的内存映射区

6.存放在栈底的环境变量和参数字符串

内核资料直通车: Linux内核源码技术学习路线+视频教程代码资料https://link.zhihu.com/?target=https%3A//docs.qq.com/doc/DUGZVQk1qWVBHTEl3

学习直通车:Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈-学习视频教程-腾讯课堂是不是学完操作系统原理后觉得纸上谈兵不过瘾?是不是面对浩若烟海的Linux内核源代码迷失在代码的海洋里不知所措?这门课可以带您用理论结合实践的方法一步一步抓住Linux内核最核心的部分代码,理解Linux操作系统运行的基本过程及涉及的核心机制。https://ke.qq.com/course/4032547?flowToken=1044374

三、物理内存体系架构

目前多处理器系统有两种体系结构:

1)一致内存访问(Uniform Memory Access,UMA),所有处理器访问内存花费的时间是相同。

这种结构的CPU 是通过一条通用总线连接到北桥,北桥中的内存控制器链接着内存。这种设计中,瓶颈马上出现了。第一个瓶颈与设备对RAM 的访问有关。早期,所有设备之间的通信都需要经过 CPU,结果严重影响了整个系统的性能。为了解决这个问题,有些设备加入了直接内存访问(DMA)的能力。DMA 允许设备在北桥的帮助下,无需 CPU 的干涉,直接读写 RAM。到了今天,所有高性能的设备都可以使用 DMA。虽然 DMA 大大降低了 CPU 的负担,却占用了北桥的带宽,与 CPU 形成了争用。所以现在很少使用了。

2)非一致内存访问(Non-Unit Memory Access,NUMA):指内存被划分成多个内存节点的多处理器系统,访问一个内存节点花费的时间取决于处理器和内存节点的距离。

采用这样的架构,系统里有几个处理器,就可以有几个内存库。系统仍然要让所有内存能被所有处理器所访问,导致内存不再是统一的资源。处理器能以正常的速度访问本地内存(连接到该处理器的内存)。但它访问其它处理器的内存时,却需要使用处理器之间的互联通道。

四、内存结构

由于现在我接触的基本都是使用UMA的结构,所以下面说的都是这种:

内存管理子系统使用节点(node),区域(zone)、页(page)三级结构描述物理内存。节点是基于哪个cpu,一般多少核的cpu就有多少个节点node;zone是每个cpu,也就是每个节点会把内存分为高端内存,低端内存,DMA区域等等的内存区域;页就是物理内存的最小单位了,也是虚拟内存映射到物理内存的最小单位。最后,在NUMA内存架构中, Linux定义了一个 pglist_data 的结构体来管理所有的内存节点.

 【文章福利】小编推荐自己的Linux内核技术交流群: 【977878001】整理一些个人觉得比较好得学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!前100进群领取,额外赠送一份 价值699的内核资料包(含视频教程、电子书、实战项目及代码)

内存节点(node)

在NUMA体系的内存节点是根据处理器和内存的距离划分的,而在具有不连续内存的NUMA系统中,表示比区域的级别更高的内存区域,根据物理地址是否连续划分,每块物理地址连续的内存是一个内存节点。内存节点结构体在linux内核include/linux/mmzone.h文件中,

/*
 * On NUMA machines, each NUMA node would have a pg_data_t to describe
 * it's memory layout. On UMA machines there is a single pglist_data which
 * describes the whole memory.
 *
 * Memory statistics and page replacement data structures are maintained on a
 * per-zone basis.
 */
struct bootmem_data;
typedef struct pglist_data {
	struct zone node_zones[MAX_NR_ZONES];//内存区域数组
	struct zonelist node_zonelists[MAX_ZONELISTS];//备用区域数组

	int nr_zones;//该节点包含的内存区域数量
#ifdef CONFIG_FLAT_NODE_MEM_MAP	/* means !SPARSEMEM */
	struct page *node_mem_map;//指向物理页描述符数组
#ifdef CONFIG_PAGE_EXTENSION
	struct page_ext *node_page_ext;//页的扩展属性
#endif
#endif
#ifndef CONFIG_NO_BOOTMEM
	struct bootmem_data *bdata;//早期内存管理器
......
} pg_data_t;

内存区域(zone)

每一个节点分成一个个区域 zone,放在数组 node_zones 里面。这个数组的大小为 MAX_NR_ZONES。我们来看区域的定义。内存节点被划分为内存区域,内存区域结构体在linux内核include/linux/mmzone.h文件中

struct zone {
	unsigned long watermark[NR_WMARK];
	unsigned long nr_reserved_highatomic;
	long lowmem_reserve[MAX_NR_ZONES];
#ifdef CONFIG_NUMA
	int node;
#endif
	struct pglist_data	*zone_pgdat;
	struct per_cpu_pageset __percpu *pageset;
#ifndef CONFIG_SPARSEMEM
	unsigned long		*pageblock_flags;
#endif /* CONFIG_SPARSEMEM */
	unsigned long		zone_start_pfn;
	unsigned long		managed_pages;
	unsigned long		spanned_pages;
	unsigned long		present_pages;
	const char		*name;
#ifdef CONFIG_MEMORY_ISOLATION
	unsigned long		nr_isolate_pageblock;
#endif
#ifdef CONFIG_MEMORY_HOTPLUG
	seqlock_t		span_seqlock;
#endif
	int initialized;
	ZONE_PADDING(_pad1_)
	struct free_area	free_area[MAX_ORDER];//内存区域数组,用于伙伴分配器进行页分配
	unsigned long		flags;//内存区域的属性,定义在下面
	spinlock_t		lock;
	ZONE_PADDING(_pad2_)
	unsigned long percpu_drift_mark;
#ifdef CONFIG_COMPACTION
	unsigned int		compact_considered;
	unsigned int		compact_defer_shift;
	int			compact_order_failed;
#endif
#if defined CONFIG_COMPACTION || defined CONFIG_CMA
	bool			compact_blockskip_flush;
#endif
	bool			contiguous;
	ZONE_PADDING(_pad3_)
	atomic_long_t		vm_stat[NR_VM_ZONE_STAT_ITEMS];
	atomic_long_t		vm_numa_stat[NR_VM_NUMA_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;

//struct zone 的 flags 参数
enum zone_type {
#ifdef CONFIG_ZONE_DMA
	ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
	ZONE_DMA32,
#endif
	ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
	ZONE_HIGHMEM,
#endif
	ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
	ZONE_DEVICE,
#endif
	__MAX_NR_ZONES
};

ZONE_DMA 是指可用于作 DMA(Direct Memory Access,直接内存存取)的内存。DMA 是这样一种机制:要把外设的数据读入内存或把内存的数据传送到外设,原来都要通过 CPU 控制完成,但是这会占用 CPU,影响 CPU 处理其他事情,所以有了 DMA 模式。CPU 只需向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,这样就可以解放 CPU。对于 64 位系统,有两个 DMA 区域。除了上面说的 ZONE_DMA,还有 ZONE_DMA32。在这里你大概理解 DMA 的原理就可以,不必纠结,我们后面会讲 DMA 的机制。

ZONE_NORMAL 是直接映射区,就是上一节讲的,从物理内存到虚拟内存的内核区域,通过加上一个常量直接映射。ZONE_HIGHMEM 是高端内存区,就是上一节讲的,对于 32 位系统来说超过 896M 的地方,对于 64 位没必要有的一段区域。

ZONE_MOVABLE 是可移动区域,通过将物理内存划分为可移动分配区域和不可移动分配区域来避免内存碎片。

这里你需要注意一下,我们刚才对于区域的划分,都是针对物理内存的。

内存页(page)

了解了区域 zone,接下来我们就到了组成物理内存的基本单位,页的数据结构 struct page。这是一个特别复杂的结构,里面有很多的 union,union 结构是在 C 语言中被用于同一块内存根据情况保存不同类型数据的一种方式。这里之所以用了 union,是因为一个物理页面使用模式有两种。第一种模式,仅需分配小块内存,Linux 系统采用了一种被称为 slab allocator的技术,下一节会讲。

第二种模式,要用就用一整页。这一整页的内存,或者直接和虚拟地址空间建立映射关系,我们把这种称为匿名页(Anonymous Page)。或者用于关联一个文件,然后再和虚拟地址空间建立映射关系,这样的文件,我们称为内存映射文件(Memory-mapped File)。

每个物理页对应一个page结构体,称为页描述符,内存节点的pglist_data实例的成员node_mem_map指向该内存节点包含的所有物理页的页描述符组成的数组。内存区域结构体在linux内核include/linux/mm_types.h文件中

struct page {
	unsigned long flags;		/* Atomic flags, some possibly
					 * updated asynchronously */
......
} _struct_page_alignment;

其结构大概如下图所示:

五、内存模型

内存模型是其实就是从cpu的角度看,其物理内存的分布情况,在linux kernel中,使用什么的方式来管理这些物理内存。

内存管理子系统支持3种内存模型:

1)平坦内存(Flat Memory):内存的物理地址空间是连续的,没有空洞。

如果从系统中任意一个processor的角度来看,当它访问物理内存的时候,物理地址空间是一个连续的,没有空洞的地址空间,那么这种计算机系统的内存模型就是Flat memory。这种内存模型下,物理内存的管理比较简单,每一个物理页帧都会有一个page数据结构来抽象,因此系统中存在一个struct page的数组(mem_map),每一个数组条目指向一个实际的物理页帧(page frame)。在flat memory的情况下,PFN(page frame number)和mem_map数组index的关系是线性的(有一个固定偏移,如果内存对应的物理地址等于0,那么PFN就是数组index)。因此从PFN到对应的page数据结构是非常容易的,反之亦然,具体可以参考page_to_pfn和pfn_to_page的定义。此外,对于flat memory model,节点(struct pglist_data)只有一个(为了和Discontiguous Memory Model采用同样的机制)。需要强调的是struct page所占用的内存位于直接映射(directly mapped)区间,因此操作系统不需要再为其建立page table。

#define __pfn_to_page(pfn)  (mem_map + ((pfn) - ARCH_PFN_OFFSET)) 

2)不连续内存(Discontiguous Memory):内存的物理地址空间存在空洞,这种模型可以高效地处理空洞。

如果cpu在访问物理内存的时候,其地址空间有一些空洞,是不连续的,那么这种计算机系统的内存模型就是Discontiguous memory。一般而言,NUMA架构的计算机系统的memory model都是选择Discontiguous Memory,不过,这两个概念其实是不同的。NUMA强调的是memory和processor的位置关系,和内存模型其实是没有关系的,只不过,由于同一node上的memory和processor有更紧密的耦合关系(访问更快),因此需要多个node来管理。Discontiguous memory本质上是flat memory内存模型的扩展,整个物理内存的address space大部分是成片的大块内存,中间会有一些空洞,每一个成片的memory address space属于一个node(如果局限在一个node内部,其内存模型是flat memory)。因此,这种内存模型下,节点数据(struct pglist_data)有多个,宏定义NODE_DATA可以得到指定节点的struct pglist_data。而,每个节点管理的物理内存保存在struct pglist_data 数据结构的node_mem_map成员中(概念类似flat memory中的mem_map)。这时候,从PFN转换到具体的struct page会稍微复杂一点,我们首先要从PFN得到node ID,然后根据这个ID找到对于的pglist_data 数据结构,也就找到了对应的page数组,之后的方法就类似flat memory了。

#define __pfn_to_page(pfn)            \ 
({    unsigned long __pfn = (pfn);        \ 
    unsigned long __nid = arch_pfn_to_nid(__pfn);  \ 
    NODE_DATA(__nid)->node_mem_map + arch_local_page_offset(__pfn, __nid);\ 
})

3)稀疏内存(Space Memory):内存的物理地址空间存在空洞,如果要支持内存热插拔,只能选择稀疏内存模型。

Memory model也是一个演进过程,刚开始的时候,使用flat memory去抽象一个连续的内存地址空间(mem_maps[]),出现NUMA之后,整个不连续的内存空间被分成若干个node,每个node上是连续的内存地址空间,也就是说,原来的单一的一个mem_maps[]变成了若干个mem_maps[]了。一切看起来已经完美了,但是memory hotplug的出现让原来完美的设计变得不完美了,因为即便是一个node中的mem_maps[]也有可能是不连续了。其实,在出现了sparse memory之后,Discontiguous memory内存模型已经不是那么重要了,按理说sparse memory最终可以替代Discontiguous memory的,这个替代过程正在进行中,4.4的内核仍然是有3中内存模型可以选择。

为什么说sparse memory最终可以替代Discontiguous memory呢?实际上在sparse memory内存模型下,连续的地址空间按照SECTION(例如1G)被分成了一段一段的,其中每一section都是hotplug的,因此sparse memory下,内存地址空间可以被切分的更细,支持更离散的Discontiguous memory。此外,在sparse memory没有出现之前,NUMA和Discontiguous memory总是剪不断,理还乱的关系:NUMA并没有规定其内存的连续性,而Discontiguous memory系统也并非一定是NUMA系统,但是这两种配置都是multi node的。有了sparse memory之后,我们终于可以把内存的连续性和NUMA的概念剥离开来:一个NUMA系统可以是flat memory,也可以是sparse memory,而一个sparse memory系统可以是NUMA,也可以是UMA的。对于经典的sparse memory模型,一个section的struct page数组所占用的内存来自directly mapped区域,页表在初始化的时候就建立好了,分配了page frame也就是分配了虚拟地址。但是,对于SPARSEMEM_VMEMMAP而言,虚拟地址一开始就分配好了,是vmemmap开始的一段连续的虚拟地址空间,每一个page都有一个对应的struct page,当然,只有虚拟地址,没有物理地址。因此,当一个section被发现后,可以立刻找到对应的struct page的虚拟地址,当然,还需要分配一个物理的page frame,然后建立页表什么的,因此,对于这种sparse memory,开销会稍微大一些(多了个建立映射的过程)。

#define __pfn_to_page(pfn)    (vmemmap + (pfn))

#define vmemmap            ((struct page *)VMEMMAP_START - \
                 SECTION_ALIGN_DOWN(memstart_addr >> PAGE_SHIFT))

六、虚拟地址和物理地址的转换

cpu读写指令和数据都需要用到内存,而我们程序操作的都是虚拟内存,大家觉得,为什么系统设计者要引入虚拟地址呢?

设想一下,如果一台计算机的内存中只运行一个程序 A,因为程序 A 的地址在链接时就可以确定,例如从内存地址 0x8000 开始,每次运行程序 A 都装入内存 0x8000 地址处开始运行,没有其它程序干扰。现在改变一下,内存中又放一道程序 B,程序 A 和程序 B 各自运行一秒钟,如此循环,直到其中之一结束。这个新场景下就会产生一些问题,当然这里我们只关心内存相关的这几个核心问题。

谁来保证程序 A 跟程序 B 没有内存地址的冲突?换句话说,就是程序 A、B 各自放在什么内存地址,这个问题是由 A、B 程序协商,还是由操作系统决定。

怎样保证程序 A 跟程序 B 不会互相读写各自的内存空间?这个问题相对简单,用保护模式就能解决。

如何解决内存容量问题?程序 A 和程序 B,在不断开发迭代中程序代码占用的空间会越来越大,导致内存装不下。

还要考虑一个扩展后的复杂情况,如果不只程序 A、B,还可能有程序 C、D、E、F、G……它们分别由不同的公司开发,而每台计算机的内存容量不同。这时候,又对我们的内存方案有怎样的影响呢?

想完美地解决以上最核心的 4 个问题,一个较好的方案是:让所有的程序都各自享有一个从 0 开始到最大地址的空间,这个地址空间是独立的,是该程序私有的,其它程序既看不到,也不能访问该地址空间,这个地址空间和其它程序无关,和具体的计算机也无关。事实上,计算机科学家们早就这么做了,这个方案就是虚拟地址。

虚拟地址

正如其名,这个地址是虚拟的,自然而然地和具体环境进行了解耦,这个环境包括系统软件环境和硬件环境。

事实上,所有的应用程序开始的部分都是这样的。这正是因为每个应用程序的虚拟地址空间都是相同且独立的。那么这个地址是由谁产生的呢?答案是链接器,其实我们开发软件经过编译步骤后,就需要链接成可执行文件才可以运行,而链接器的主要工作就是把多个代码模块组装在一起,并解决模块之间的引用,即处理程序代码间的地址引用,形成程序运行的静态内存空间视图。只不过这个地址是虚拟而统一的,而根据操作系统的不同,这个虚拟地址空间的定义也许不同,应用软件开发人员无需关心,由开发工具链给自动处理了。由于这虚拟地址是独立且统一的,所以各个公司开发的各个应用完全不用担心自己的内存空间被占用和改写。

物理地址

虽然虚拟地址解决了很多问题,但是虚拟地址只是逻辑上存在的地址,无法作用于硬件电路的,程序装进内存中想要执行,就需要和内存打交道,从内存中取得指令和数据。而内存只认一种地址,那就是物理地址。

什么是物理地址呢?物理地址在逻辑上也是一个数据,只不过这个数据会被地址译码器等电子器件变成电子信号,放在地址总线上,地址总线电子信号的各种组合就可以选择到内存的储存单元了。

但是地址总线上的信号(即物理地址),也可以选择到别的设备中的储存单元,如显卡中的显存、I/O 设备中的寄存器、网卡上的网络帧缓存器。不过如果不做特别说明,我们说的物理地址就是指选择内存单元的地址。

虚拟地址到物理地址的转换

明白了虚拟地址和物理地址之后,我们发现虚拟地址必须转换成物理地址,这样程序才能正常执行。要转换就必须要转换机构,它相当于一个函数:p=f(v),输入虚拟地址 v,输出物理地址 p。

那么要怎么实现这个函数呢?用软件方式实现太低效,用硬件实现没有灵活性,最终就用了软硬件结合的方式实现,它就是 MMU(内存管理单元)。MMU 可以接受软件给出的地址对应关系数据,进行地址转换。

MMU一个工具,我们通过mmu去读取地址关系转化表,再根据虚拟地址空间地址找到物理地址所在区域,可以看图:

下面我们不妨想一想地址关系转换表的实现. 如果在地址关系转换表中,这样来存放:一个虚拟地址对应一个物理地址。那么问题来了,32 位地址空间下,4GB 虚拟地址的地址关系转换表就会把整个 32 位物理地址空间用完,这显然不行。

系统设计者最后采用一个这样的方案,即把虚拟地址空间和物理地址空间都分成同等大小的块,也称为页,按照虚拟页和物理页进行转换。根据软件配置不同,这个页的大小可以设置为 4KB、2MB、4MB、1GB,这样就进入了现代内存管理模式——分页模型。于是mmu的功能就是这样的了:

结合图片可以看出,一个虚拟页可以对应到一个物理页,由于页大小一经配置就是固定的,所以在地址关系转换表中,只要存放虚拟页地址对应的物理页地址就行了。

MMU 页表

现在我们开始研究地址关系转换表,其实它有个更加专业的名字——页表。它描述了虚拟地址到物理地址的转换关系,也可以说是虚拟页到物理页的映射关系,所以称为页表。为了增加灵活性和节约物理内存空间(因为页表是放在物理内存中的),所以页表中并不存放虚拟地址和物理地址的对应关系,只存放物理页面的地址,MMU 以虚拟地址为索引去查表返回物理页面地址,而且页表是分级的,总体分为三个部分:一个顶级页目录,多个中级页目录,最后才是页表。.

上图中 CR3 就是 CPU 的一个的寄存器,MMU 就是根据这个寄存器找到页目录的。所以,每个进程都有一个页表基地址,我们每次切换进程都会把当前cpu寄存器的值入栈,这叫环境保护,等cpu再次切换回来的时候出栈,恢复cpu寄存器大值,这叫环境恢复。

七、内存映射原理分析

内存映射即在进程的虚拟地址空间中创建一个映射,分为两种:

(1)文件映射:文件支持的内存映射,把文件的一个区间映射到进程的虚拟地址空间,

数据源是存储设备上的文件。

(2)匿名映射:没有文件支持的内存映射,把物理内存映射到进程的虚拟地址空间,

没有数据源。

创建内存映射时,在进程的用户虚拟地址空间中分配一个虚拟内存区域。内核采用延迟分配物理内存的策略,在进程第一次访问虚拟页的时候,产生缺页异常。如果是文件映射,那么分配物理页,把文件指定区间的数据读到物理页中,然后在页表中把虚拟页映射到物理页。如果是匿名映射,就分配物理页,然后在页表中把虚拟页映射到物理页。

我们的内存空间是分成一段段的,这叫分段机制。分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。段选择子就保存在咱们前面讲过的段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。虚拟地址中的段内偏移量应该位于 0 和段界限之间。如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

其实 Linux 倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页(Paging)。对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫做换入。这样可以扩大可用物理内存的大小,提高物理内存的利用率。这个换入和换出都是以页为单位的。页面的大小一般为 4KB。为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了。

虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。

32 位环境下,虚拟地址空间共 4GB。如果分成 4KB 一个页,那就是 1M 个页。每个页表项需要 4 个字节来存储,那么整个 4GB 空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表,100 个进程就需要 400MB 的内存。对于内核来讲,有点大了 。

页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了。

那怎么办呢?我们可以试着将页表再分页,4G 的空间需要 4M 的页表来存储映射。我们把这 4M 分成 1K(1024)个 4K,每个 4K 又能放在一页里面,这样 1K 个 4K 就是 1K 个页,这 1K 个页也需要一个表进行管理,我们称为页目录表,这个页目录表里面有 1K 项,每项 4 个字节,页目录表大小也是 4K。

页目录有 1K 项,用 10 位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项,也即 4K 的页表项。每个页表项也是 4 个字节,因而一整页的页表项是 1K 个。再用 10 位就可以表示访问页表项的哪一项,页表项中的一项对应的就是一个页,是存放数据的页,这个页的大小是 4K,用 12 位可以定位这个页内的任何一个位置。

这样加起来正好 32 位,也就是用前 10 位定位到页目录表中的一项。将这一项对应的页表取出来共 1k 项,再用中间 10 位定位到页表中的一项,将这一项对应的存放数据的页取出来,再用最后 12 位定位到页中的具体位置访问数据。如下图所示:

你可能会问,如果这样的话,映射 4GB 地址空间就需要 4MB+4KB 的内存,这样不是更大了吗? 当然如果页是满的,当时是更大了,但是,我们往往不会为一个进程分配那么多内存。

比如说,上面图中,我们假设只给这个进程分配了一个数据页。如果只使用页表,也需要完整的 1M 个页表项共 4M 的内存,但是如果使用了页目录,页目录需要 1K 个全部分配,占用内存 4K,但是里面只有一项使用了。到了页表项,只需要分配能够管理那个数据页的页表项页就可以了,也就是说,最多 4K,这样内存就节省多了。

当然对于 64 位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项 PGD(Page Global Directory)、上层页目录项 PUD(Page Upper Directory)、中间页目录项 PMD(Page Middle Directory)和页表项 PTE(Page Table Entry)。