第四章 虚拟内存管理
第四章 虚拟内存管理
在 C 语言中定义一个字符数组,打印字符数组的首地址,打印出来的地址0x601038是虚拟地址,而不是能用来访问物理内存的物理地址。
#include <stdio.h>
char str[] = "hello VM!";
int main() {
printf("date address: %p\n", str);
printf("code address: %p\n", main);
return 0;
}

应用进程在运行期间仅使用虚拟地址,无论是数据地址还是指令地址,都是虚拟地址。
两个问题?
- 操作系统为什么不直接让应用进程使用物理地址?
- 应用进程使用虚拟地址,而实际访问物理地址需要通过物理地址,虚拟地址如何转换为物理地址?
问题 1
如果让应用直接操作物理地址,无法保证应用之间内存的隔离性。比如,应用A 和应用 B 用到了相同的物理地址,A 先向该地址写入数据,B 之后覆盖该地址空间,造成 A 应用程序的运行出错。
如果让应用直接操作物理地址,难以保证应用进程可用的空间是连续且统一的,多个进程的内存布局将会十分复杂。
操作系统通过虚拟内存,支持不同应用程序方便且安全的使用内存物理资源。虚拟内存提供连续的且统一的虚拟地址空间,既降低了编程及编译的复杂度,又保证了应用程序仅能访问自己的虚拟地址空间而不能任意访问物理内存。
问题 2
虚拟地址转换物理地址的过程是 CPU 和操作系统协同完成的。CPU 中的内存管理单元负责通过页表将虚拟地址翻译为物理地址,操作系统负责应用程序页表的初始化和构建等。
4.1 CPU 的职责:内存地址翻译
4.1.1 地址翻译
在逻辑上,我们能把物理内存看成一个大数组,其中每个字节都可以通过与之唯一对应的地址进行访问,这个地址就是物理地址。CPU 通过总线发送访问物理地址的请求,物理地址中的控制器负责相应请求,使 CPU 能够从物理内存中读取数据或者向其中写入数据。
CPU 中的内存管理单元(Memory Management Unit, MMU)会将虚拟地址转换称为物理地址,这个过程也叫地址翻译。
此外,现代 CPU 中常包含转置旁路缓存(Translation Lookaside Buffer, TLB)作为加速地址翻译的部件(TLB 是 MMU 内部的硬件单元)。
4.1.2 分页机制
分页机制的基本思想是将应用程序的虚拟地址空间划分为连续的、等长的虚拟页(显著区别于分段机制下不同长度的段),同时物理内存也被划分为连续的、等长的物理页。
虚拟页和物理页的页长固定且相等,因此操作系统能够方便的建立虚拟页到物理页的映射关系表,即页表。
为了时 MMU 硬件能够找到页表,需要将页表的起始地址存放在 CPU 的特殊寄存器,页表基地址寄存器中。
虚拟地址由是由两部分组成:虚拟页号和页内偏移。
在将虚拟地址翻译为物理地址时,MMU 首先解析得到虚拟地址中的虚拟页号,通过虚拟页号去该应用程序的页表(页表起始地址在页表基地址寄存器中)中找到对应条目,然后取出存储的物理页号,最后用物理页号的起始地址加上虚拟地址中的页内偏移量得到最终的物理地址。

在分页机制下,操作系统为不同的应用进程分配不同的页表,实现了把应用进程虚拟地址空间中的任意虚拟页映射到物理内存中的任意物理页。实现了物理资源的离散分配。
当操作系统切换应用程序时,通过切换页表基地址寄存器中存储的页表物理地址即可完成不同进程的虚拟地址空间切换。
分段机制
虚拟地址空间由若干个大小不同的段组成,当 CPU 访问某一个段时,MMU 会通过查询段表得到该段对应的物理内存区域。
虚拟地址由两部分组成:
- 段号:该虚拟地址属于整个虚拟空间的那一段
- 段内地址(段内偏移):相对于该段起始地址的偏移量
段表存储着虚拟空间中的每一个分段的信息,包括:段起始地址(物理内存)和段长。
翻译虚拟地址时,MMU 首先通过段基址寄存器找到段表位置,结合虚拟地址中的段号,可以在段表中获得对应段的信息;取出该段的起始地址,加上虚拟地址的段内偏移量,最终得到真实的物理地址。

段表中的段长用于检查虚拟地址是否超出合法范围。
在分段机制下,物理内存也同样被分为不同的段。相邻的段对应的物理内存可以连续也可以不连续,因此操作系统能够实现物理内存的离散分配。但是这种分配方式容易造成外部碎片,即在段和段之间留下碎片空间。
Intel 公司在 8086 处理器上开始引入分段机制,在 80826 处理器上使用分段机制来支持虚拟内存,在 x86-64 架构之后,基于分页机制的虚拟内存已经成为主流。
4.1.3 多级页表
页表是分页机制中的关键数据结构,那么页表有多大呢?
假设页面的大小是 4 KB,页表项占用的空间是 8 B,虚拟地址空间是 48 位。
每一个物理地址指向的物理内存空间大小为 1 B,因此,48 位的虚拟地址空间能够指向的最大内存为\(2^{48}\)B=\(2^{38}\)KB。每个页是 4 KB。可以分为\(2^{38}/4=2^{36}\)个页,也就对应着一个页表项,一个页表项占的大小为 8 B页表占的空间大小为\(2^{36}*2^{3}=2^{39}\)B。也就是 512 GB。页表占据的物理空间是极大的,且在占据的物理空间还必须连续。
为什么单级页表中的每一项都需要存在?
单级页表可以看成以虚拟地址的虚拟页号作为索引的数组,翻译虚拟地址时,要根据虚拟页号找到对应的数组项,因此必须连续。(数组空间本来就是连续的,如果不连续的话,可能会存在映射不到的情况)
为了压缩页表的大小,就引入了多级结构页表,多级结构页表如图所示。

在多级结构的页表中,一个虚拟地址中依然包括虚拟页号和页内偏移量,虚拟页号被进一步分成k个部分,每个部分表示的是虚拟地址在当前部分页表中的索引。
MMU 硬件在 EL1 特权级中提供了两个页表基地址寄存器,分别是 TTBR0_EL1 和 TTBR1_EL1。当虚拟地址 63 ~ 48 位全为 0 时(应用程序虚拟地址空间),MMU 硬件基于 TTBR0_EL1 寄存器存储的页表进行地址翻译。当虚拟地址 63 ~ 48 位全为 1 时(操作系统虚拟地址空间),MMU 硬件基于 TTBR1_EL1 寄存器存储的页表进行地址翻译。
地址翻译过程
虚拟地址低48位参与地址翻译,页表级数为4级,虚拟页大小为4 KB。因为页的大小是 4KB=2^12,因此虚拟地址的低12位可以表示页内偏移量。整个页表的起始地址存储在页表基地址寄存器中,在Linux中是,TTBR0_EL1。每个页表项占用 8 个字节,用于存储物理地址和相应的访问权限,所以一个页表页包含 4KB/8=512 个页表项。512 = 2^9,所以虚拟地址中对应于每一级页表的索引都是9位。
具体划分:
- 第63至48位:全为0或者全为1(硬件要求),应用程序使用的虚拟地址全为 0
- 第47位至39位:9位作为该虚拟地址在第0级页表中的索引值
- 第38位至30位:9位作为该虚拟地址在第1级页表中的索引值
- 第29位至21位:9位作为该虚拟地址在第2级页表中的索引值
- 第20位至12位:9位作为该虚拟地址在第3级页表中的索引值
- 第11至0位:表示页内偏移量
当MMU翻译虚拟地址的时候,首先根据页表基地址寄存器中的物理地址找到第0级页表页,然后将虚拟地址的虚拟页号0(第47至39位)作为页表项索引读取第0级页表页中的相应页表项;该页表项中存储着下—级(第1级)页表页的物理地址。MMU按照类似的方式将虚拟地址的虚拟页号1(第38至30位)作为页表项索引继续读取第1级页表页中的相应页表项;往下类推,MMU将在第3级页表页中的页表项里面找到该虚拟地址对应的物理页号,再结合虚拟地址中的页内偏移量即可获得最终的物理地址。
这样的 4 级页表结构允许内存存在“空洞”,操作系统可以在虚拟地址被应用进程使用之后再分配并填写相应的页表项。
多级页表一定能够减少页表占用的空间吗?
多级页表能减少页表占用的空间,是建立在“应用进程使用的虚拟地址远小于总的虚拟地址空间”这个假设下,如果假设不成立,那么多级页表有可能会比单级页表占用更多的内存。
4.1.4 页表项与大页
一般来说,多级页表中并非只有最后一级的页表能够指向物理页,中间级的页表项也能够直接指向物理页,而不是指向下一级页表页。当中间级的页表项指向物理页时,其指向的是大页。
页表项除了存储物理页号之外,还会存储一些属性位,允许操作系统设置诸如读写执行等权限。若实际访问所需权限和页表项设置的权限不一致,则 MMU 会在地址翻译过程中触发访问异常。
页表项介绍
每个页表项栈用 8 字节,其中既包括物理页号(PFN),也包括描述页面属性的位。
3 级页表项(最后一级页表项)中的 PFN 表示指向的 4 KB 物理页,这样的页表项成为页描述符(Page Descriptor)。0 级、1 级、2 级页表项中的 PFN 指向下一级页表页,这样的页表项称为表描述符(Table Descriptor)。0 级、1 级、2 级页表项还可以作为块描述符(Block Descriptor)直接指向 512 GB、1 GB、2 MB 的物理页(大页)。
若 0 级、1 级、2 级页表项第一位为 1,则表示该页表项是表描述符,为 0 表示该页表项是块描述符。
页描述符和块描述符的属性位相同,页表项中的属性位,只记录一个,其他的查找相关资料
V(Valid):有效位,用于标识该页表项是否有效。若 V 位为 0,则当 MMU 查询高该页表项时会触发一种异常——缺页异常(Page Fault),表示 MMU 在页表中未找到地址翻译所需要的映射。若 V 位为 1,则 MMU 能完成地址翻译。
此外,页表项中还引入了 DMB 为,表示 Dirty State,即相应的物理页是否被写过。
4.1.5 TLB:页表的缓存
多级页表虽然能减少页表所占用的空间,但是却多了更多的内存访问时间(时间换空间)。为了减少地址翻译时访问内存的次数,MMU 引入了转址旁路缓存(TLB)。
TLB 缓存了虚拟页号到物理页号的映射关系;可以将 TLB 简化为一个哈希表,key 是虚拟页号,value 是物理页号。
TLB 硬件采用分层的架构,如图所示

TLB 的体积实际上是极小的,意味能存放的缓存项数量是极其有限的,所以需要有效的加以利用。
当TLB命中时,可以直接找到对应的物理页号,如果TLB未命中时,硬件还是会去内存中查询页表,找到对应的页表项,并将翻译的结果填写到TLB 中,如果TLB 空间不够时,则根据硬件预定的策略替换掉某一项。
如何保证TLB中内容与当前页表的一致性?
考虑一下情况,应用程序 A 和应用程序 B 同样的虚拟地址映射到了不同的物理地址上,TLB 总会缓存其中一个应用程序的虚拟地址和物理地址的映射关系,当另外一个应用程序访问虚拟地址时,直接从 TLB 中拿到物理地址,其实是另一个应用程序的。
导致这个问题的根本原因就是,在切换应用程序时,没有刷新 TLB。
若操作系统在切换应用程序的过程中刷新 TLB,那么应用程序开始执行的时候总是会发生TLB未命中的情况。怎么解决呢?
以AArch64体系结构为例,它提供了地址空间标识 (Address Space IDentifier, ASID)功能,操作系统可以为不同的应用程序分配不同的 ASID 作为应用程序的身份标签,再将这个标签写入应用程序的页表基地址寄存器中的空闲位。同时,TLB 缓存项也会包含 ASID 这个标签,从而使得TLB中属于不同应用程序的缓存项可以被区分开。在切换页表的过程中,操作系统不再需要清空 TLB 缓存项。
ASID 数量
ASID 最多有 16 位,即同时可以有 \(2^{16}=65536\) 个标签。若同时运行的应用进程数量超过 ASID 的最大数量,则操作系统不得不为若干应用进程分配相同的 ASID。
在修改了页表内容之后,操作系统还是需要主动刷新 TLB 以保证 TLB 缓存和页表项内容一致。
AArch64 体系结构提供了多种不同粒度的刷新 TLB 的指令,包括刷新全部 TLB、刷新指定 ASID 的 TLB、刷新指定虚拟地址的 TLB 等。
为什么硬件仅仅采用简单的TLB管理方式,就能够在大多数情况下获得较高的 TLB 命中率?
局部性起了重要作用。应用程序在访问内存时具有时间局部性和空间局部性。即,被访问过一次的内存位置在未来通常会被多次访问;如果一个位置的内存被访问,那么其附近的内存位置通常在未来也会被访问。
4.2 操作系统的职责:管理页表映射
4.2.1 操作系统为自己配置页表
CPU 在上电启动后会默认使用物理地址,这是因为 MMU 的地址翻译功能还未开启,而操作系统负责在初始化过程中启用该功能。
地址翻译功能启用后,CPU 会根据页表对指令执行中涉及的地址进行翻译,即认为这些地址都是虚拟地址,因而操作系统和应用进程在后续运行中都是使用虚拟地址。
操作系统除了需要为每个应用进程设置页表外,也需要为自己配置页表。
操作系统为自己配置的页表具有两个特点:
- 操作系统使用高虚拟地址,应用程序使用地虚拟地址。
- 操作系统一般会一次性将全部物理内存映射到虚拟地址空间中。映射方式:虚拟地址=物理地址+固定偏移,这种方式称为直接映射。
操作系统使用的内存空间也称为内核地址空间。
应用进程使用 0x0000000_00000000 ~ 0x0000FFFF_FFFFFFF 的虚拟地址,这部分地址由页表基地址寄存器 TTBR0_EL1 指向的页表进行翻译;操作系统使用 0xFFFF0000_0000000 ~ 0xFFFFFFFF_FFFFFFFF 的虚拟地址,这部分地址由页表基地址寄存器 TTBR1_EL1 指向的页表进行翻译。
操作系统在初始化过程中,采用固定偏移的地址映射方式为自己配置页表,并且把页表基地址写入 TTBR1_EL1 中。在之后的运行中,TTBR1_EL1 中的值不需要改变,即始终指向操作系统的页表。操作系统会为每个应用进程创建一张独立的页表,在切换应用程序时,操作系统会把下一个应用进程的页表基地址写入 TTBR0_EL1 中。
4.2.2 如何填写进程页表
操作系统会把进程页表作为一个不可或缺的成员变量记录在进程结构体中。进程结构体中包含进程页表基地址成员变量。
struct {
// 上下文
struct context *ctx;
// 页表基地址(物理地址)
u64 pgtbl;
...
};
操作系统配置页表时,具体要实现两个功能:在页表中添加映射和删除映射。
在页表中添加映射代码
// 拿到下一级页表的虚拟地址
u64 get_next_pgtbl_page(u64 *pgtbl_page, u32 index) {
// 页表项中存储的下一级页表的基地址的物理地址
u64 pgtbl_entry;
pgtbl_entry = pgtbl_page[index];
if (pgtbl_entry == 0) {
// 页表项不存在,就要创建页表页了
pgtbl_entry = alloc_pgtbl_page();
pgtbl_page[index] = pgtbl_entry | some_permssion;
}
// 页表中存储的是物理地址,而操作系统在运行时使用虚拟地址
return paddr_to_vaddr(pgtbl_entry);
}
/**
* *p:PCB 进程控制块地址
* u64 va:虚拟地址
* u64 pa:物理地址
*/
void add_mapping(struct process *p, u64 va, u64 pa) {
u64 *pgtbl_page;
u32 index;
// 获取 0 级页表页的起始地址,即页表基地址
pgtbl_page = (u64 *)paddr_to_vaddr(p->pgtbl);
// 获取虚拟地址在 0 级页表页中的页表索引
index = L0_INDEX(va);
// 获取 1 级页表页中的起始地址
pgtbl_page = get_next_pgtbl_page(pgtbl_page, index);
// 获取虚拟地址在 1 级页表页中的页表索引
index = L1_INDEX(va);
// 获取 2 级页表页中的起始地址
pgtbl_page = get_next_pgtbl_page(pgtbl_page, index);
// 获取虚拟地址在 2 级页表页中的页表索引
index = L2_INDEX(va);
// 获取 3 级页表页中的起始地址
pgtbl_page = get_next_pgtbl_page(pgtbl_page, index);
// 获取虚拟地址在 3 级页表页中的页表索引
index = L3_INDEX(va);
// 在 3 级页表页的页表项中填写物理地址 paddr
pgtbl_page[index] = pa | some_permission;
}
页表基地址和页表项中存储的地址都是物理地址,但操作系统在配置页表的过程中读写页表页所使用的均为虚拟地址。因此,操作系统需要具备迅速找到一个物理地址在内核地址空间中对应的虚拟地址,paddr_to_vaddr接口的实现正是使用了直接映射。
在页表中删除映射的代码
// 拿到下一级页表的虚拟地址
u64 find_next_pgtbl_page(u64 *pgtbl_page, u32 index) {
// 页表项中存储的下一级页表的基地址的物理地址
u64 pgtbl_entry;
pgtbl_entry = pgtbl_page[index];
if (pgtbl_entry == 0) {
// 页表项不存在,页表空洞
return 0;
}
// 页表中存储的是物理地址,而操作系统在运行时使用虚拟地址
return paddr_to_vaddr(pgtbl_entry);
}
/**
* *p:PCB 进程控制块地址
* u64 va:虚拟地址
*/
void add_mapping(struct process *p, u64 va) {
u64 *pgtbl_page;
u32 index;
// 获取 0 级页表页的起始地址,即页表基地址
pgtbl_page = (u64 *)paddr_to_vaddr(p->pgtbl);
// 获取虚拟地址在 0 级页表页中的页表索引
index = L0_INDEX(va);
// 获取 1 级页表页中的起始地址
pgtbl_page = find_next_pgtbl_page(pgtbl_page, index);
if (pgtbl_page == 0) return;
// 获取虚拟地址在 1 级页表页中的页表索引
index = L1_INDEX(va);
// 获取 2 级页表页中的起始地址
pgtbl_page = find_next_pgtbl_page(pgtbl_page, index);
// 获取虚拟地址在 2 级页表页中的页表索引
index = L2_INDEX(va);
// 获取 3 级页表页中的起始地址
pgtbl_page = get_next_pgtbl_page(pgtbl_page, index);
if (pgtbl_page == 0) return;
// 获取虚拟地址在 3 级页表页中的页表索引
index = L3_INDEX(va);
// 参数 va 对应的页表项存在,将该页表项清零
pgtbl_page[index] = 0;
// 利用硬件提供的精准刷新虚拟地址相应 TLB 项的指令
flush_tlb(p->pgtbl, va);
}
在具体实现中,操作系统在为进程创建页表时只需要分配 0 级页表项(单个物理页),而无须创建完整的页表。在进程运行的过程中,当操作系统需要在进程页表中添加新的映射时,才会根据需要分配新的内存页作为页表项。
4.2.3 何时填写进程页表:立即映射
在应用程序的声明周期中,其虚拟内存空间的变化主要包括:
- 进程创建时。操作系统将应用的二进制文件和动态代码库加载到物理内存,并在应用进程的页表中添加虚拟地址到物理地址的映射(代码和数据),完成初始的虚拟地址空间布局。
- 进程执行时。应用进程要求改变虚拟地址空间,又可以进一步分为三种常见情况:
- 进程的堆和栈的空间增加或减少
- 进程加载或卸载掉其他代码库
- 进程通过调用 mmap 接口增加新的虚拟内存区域,或通过 munmap 接口删除已有的虚拟内存区域。
- 进程退出时。操作系统删除应用进程的页表,即删除整个进程虚拟地址空间。
操作系统何时在进程页表中为虚拟内存区域添加到物理内存的映射呢?
操作系统的一种策略是立即映射。直接添加虚拟页到物理页的映射。
首先,操作系统 alloc_page 分配空闲的物理页,然后使用物理页,最后通过 add_mapping 在进程页表中添加映射。
在应用进程执行时,它初始拥有的虚拟内存区域可能不够用,因此需要创建新的虚拟内存区域。在立即映射策略下,操作系统立即分配物理页,并通过更新页表项,将新增虚拟内存区域中每个新的虚拟页映射到物理页。
mmap 接口常被应用进程用于创建新的虚拟内存区域。操作系统在接收到应用进程 mmap 的请求后,直接在进程页表中为应用进程添加虚拟页到物理页的映射。
实现代码,操作系统为应用进程中申请的虚拟内存区域中的每个虚拟页一次分配物理页,并且在页表中添加映射。
// addr 虚拟内存区域的起始地址,length 虚拟内存区域的长度
void sys_mmap(u64 addr, u64 length) {
u64 page_num;
u64 pa;
struct process *proc;
// 分配虚拟内存区域的页面数
page_num = get_page_num(length);
// 获取当前进程(即发起系统调用的进程)
proc = get_current_process();
// 为每个虚拟页分配物理页,并在页表中添加映射
while (page_num > 0) {
pa = alloc_page();
add_mapping(proc, addr, pa);
addr += PAGE_SIZE;
--page_num;
}
}
立即映射的方式会带来不少问题,主要是启动时延的增加和内存的浪费:
- 操作系统为游戏进程分配的物理内存大多数在实际游戏运行过程中并不会被用到,既浪费了物理内存资源,也造成游戏启动时间长。
操作系统如何解决立即映射策略所带来的问题呢?
操作系统可以根据应用进程在运行过程中的实际需要进行物理页分配和页表填写,那么就可以避免分配的物理页实际不被用到的情况。操作系统设计了延迟映射的策略,将虚拟内存的分配与物理内存的分配解耦开。
4.2.4 何时填写进程页表:延迟映射
延迟映射的主要思想是:先记录为应用进程分配的虚拟内存区域,但不分配相应的物理内存,也不会在页表中填写映射;当应用进程实际访问某个虚拟页时,由于页表中没有映射,CPU 会触发缺页异常,操作系统在缺页异常的处理函数中为该虚拟页分配物理页,并在页表中添加映射,然后重新运行触发异常的指令。
进程结构体中包含描述进程虚拟地址空间的成员变量 vmspace。vmspace 结构体中除进程页表基地址外,还有一个描述进程虚拟地址空间中各虚拟内存区域的成员变量。每个虚拟内存区域由 vmregion 结构体表示,需要包括该区域的起始地址、结束地址、访问权限。
struct process {
// 上下文
struct context *ctx;
// 虚拟内存
struct vmspace *vmspace;
...
};
struct vmspace {
// 页表基地址
u64 pgtbl;
// 若干虚拟内存区域组成的链表
list vmregions;
};
// 表示一个虚拟内存区域
struct vmregion {
// 起始虚拟地址
u64 start;
// 结束虚拟地址
u64 end;
// 访问权限
u64 perm;
};
操作系统通过若干 vmregion 结构体记录应用进程需要使用的虚拟地址范围。
在应用进程运行过程中,可以通过操作系统提供的接口添加虚拟内存区域,例如,应用进程在运行期间可以通过调用 mmap 接口,现实的要求操作系统添加一段虚拟内存区域。
采用延迟映射策略后,操作系统中 mmap 的实现只需要为进程添加一个 vmregion 结构体即可。示意代码:
void sys_mmap(u64 addr, u64 length, u64 perm,...) {
struct vmregion vmr;
u64 bgtbl;
vmr.start = addr;
vmr.end = addr + length;
vmr.perm = perm;
// 获取当前(发起 mmap 调用)进程的虚拟内存区域链表
vmregions = get_current_process_vmregions();
// 把 vmr 插入 vmregions 链表
add_list(vmregions, vmr);
}
当应用进程在执行期间首次访问某虚拟页会触发缺页异常,操作系统的缺页异常处理函数首先查询触发异常的虚拟地址是否属于某一个虚拟内存区域,并且检出读、写、执行权限是否匹配。通过检查后(合法的缺页异常),为该虚拟页分配物理页并在页表中添加映射。示意代码:
void page_fault_handler(u64 fault_va, ...) {
u64 pa, va;
list vmregions;
struct vmregion vmr;
struct process *proc;
// 获取当前(触发缺页异常)进程的虚拟内存区域链表
vmregions = get_current_process_vmregions();
// 利用 for_each_vmr() 宏遍历链表中的每个虚拟内存区域
for_each_vmr(vmr, vmregions) {
// 检查虚拟地址是否属于已分配的虚拟内存区域
if ((va >= vmr.start) && (va <= vmr.end)) {
// 检查访问权限
if (check_perm() == false) return;
// 分配物理页并在页表中添加映射
pa = alloc_page();
proc = get_current_process();
add_mapping(proc, addr, pa);
}
}
}
Linux 记录虚拟内存区域的结构体——VMA
在 Linux 操作系统中,虚拟内存区域结构体是 vm_area_struct(VMA),其中同样包括其实虚拟地、结束虚拟地址、访问权限等信息。每个应用进程的虚拟地址空间由若干虚拟内存区域构成,Linux 操作系统通过平衡树数据结构把不同的虚拟内存区域组织起来。
延迟映射(按需页面分配)的缺点?
应用进程在访问虚拟页时会触发缺页异常,需要操作系统进行处理,这会增加应用运行性能的开销。为了减少缺页异常对应用性能的影响,
- 操作系统可以采用预先映射的方式减少缺页异常发生的次数,例如在缺页异常处理函数中为连续多个虚拟页添加页表映射(由于应用进程访存具有空间局部性特点,所以操作系统可以预测相邻的虚拟页很可能被访问)
- 应用进程可以主动告知操作系统需要提前填写页表映射,例如进程在调用 mmap 接口时可以设置参数,采用立即映射的方式分配。
操作系统实际上为应用进程制定了一条规则:虚拟地址需要分配后才能使用,没有分配就使用会报错(段错误等)
4.2.5 常见的改变虚拟内存区域的接口
应用进程可以通过 mmap 接口在自己的虚拟地址空间中新增一段虚拟内存区域。与 mmap 接口相对应的是 munmap 接口,可用于删除一段虚拟内存区域,或删除已有虚拟内存区域中的一部分。
munmap 接口删除虚拟内存区域后,还会删除相应的页表中的映射关系,最后还会刷新 TLB。
应用进程加载和删除代码库时,也是通过 mmap 和 munmap 接口。
虚拟内存区域的变化还包括堆空间的增长和栈空间的增长(堆空间和栈空间都是虚拟内存区域)。应用进程在运行时可能需要动态分配对象,例如:malloc 接口分配内存,malloc 返回的内存地址即位于堆中。
由 malloc 分配出来的堆中内存可以被 free 接口释放。
malloc 和 free 接口不需要操作系统参与,只有需要修改堆大小的 brk 或 sbrk 接口才要求操作系统参与。
对于栈空间,操作系统通常在创建应用进程时就新建一定大小的栈(Linux 默认为 8 MB),也是虚拟地址区域,操作系统可以在应用进程运行时按需为栈分配物理页,从而避免由于分配较大的初始栈而造成物理内存浪费。
总结
能改变虚拟内存区域的接口:
- mmap 和 munmap 接口
- malloc 和 free 接口,这两个接口的底层实现是 brk 或者 sbrk 接口
4.2.6 虚拟内存扩展功能
虚拟内存抽象使应用程序能够拥有独立的连续地址空间。除此之外,虚拟你内存抽象还带来了许多有用的功能。
共享内存
共享内存允许同一个物理页在不同的应用程序共享。即,不同的应用程序不同(也可以相同)的虚拟地址映射到相同的物理地址上。
如图所示

写时拷贝
页表项中除了记录物理页号外,还记录了属性位,包括用于标识虚拟页访问权限的位(比如是否可写、是否可执行)等。
写时拷贝正是利用了“是否可写”的权限位来实现的。
写时拷贝如图所示

写时拷贝技术允许应用程序 A 和应用程序 B 以只读的方式(在页表项中设置只读权限)共享一段物理内存。一旦某个应用程序对该内存区域进行修改,就会触发访问权限异常。在异常触发后,CPU 同样会将控制流传递给操作系统预先设置的异常处理函数。在该函数中,操作系统会发现当前异常是由于应用程序写了只读内存,而且相应的内存区域又被标记为写时拷贝。于是,操作系统会将缺页异常对应的物理页重新拷贝一份,并且将新拷贝的物理页以可读可写的方式重新映射给触发异常的应用程序,此后再恢复应用程序的执行。
大页
大页(Huge Page)机制能够有效缓解 TLB 缓存项不够用的问题。
操作系统可利用硬件提供的大页支持,在添加页表映射时以大页进行映射。此外,Linux 还提供了透明大页机制,能够自动地将一个应用程序中连续的 4 KB 内存页合并成 2 MB 的内存页。
使用大页的好处主要有两方面:
- 能够减少 TLB 缓存项的使用,从而有机会提高 TLB 命中率。
- 它可以减少页表级数,从而提升查询页表的效率。
4.3 案例分析:ChCore 虚拟内存管理
这里就不在记录,可以查看《操作系统:原理与实现》书籍。

浙公网安备 33010602011771号