C++后端开发人员,平常的工作主要是围绕着内存管理、进程调度这类核心的底层能力来展开。页表机制乃是支撑这些核心能力的关键底层根基。它如同那默默运转的幕后部件,极少在业务开发当中直接被提及,但是却一直保障着程序内存访问、进程运行的稳定以及安全,是后端服务平稳运行的核心保障。我们日常运用 new 来进行动态内存分配的时候,底层内核都会借助页表机制去筛选空闲的物理内存页,完成内存的分配以及映射,所有的动态内存操作都是基于页表正常开展工作的。
在面临高并发的后端服务状况的时候,频繁的内存进行分配以及释放对于页表的管理有着颇高的要求。倘若页表维护得不够好,那就很容易出现内存碎片化的情形。这样一来就会使得后续的内存分配出现失败的状况,并且还会有内存泄漏的现象发生,严重地影响到服务的稳定性。与此同时在多进程进行调度的时候,每一个进程所具有的独立虚拟地址空间,全依靠页表来实现虚拟地址到物理地址的映射。操作系统进程切换的本质,也包含了页表的切换以及刷新。一旦页表映射出现问题或者切换出现错误,就会致使进程内存出现越界访问的情况,从而篡改其他进程的数据,最终引发程序出现崩溃、系统逻辑出现混乱的状况。这是后端开发所必须掌握的底层核心方面的知识。
一、认识 Linux 页表
面试题写作模版
1.1 什么是页表(Page Table)?
在 Linux 操作系统的内存管理体系当中,页表是极为关键的存在。页表乃是一种数据构造。它便是用于记录虚拟地址和物理地址之间是如何进行映射的映射表。页表是连接虚拟地址与物理地址的关键桥梁,处于核心的位置。
在现今的计算机系统当中,进程所采用的是虚拟地址,而实际的数据是存放在物理内存里面的。页表的主要功能就是构建这两种地址之间的对应关联,使得 CPU 可以根据进程所给出的虚拟地址,精确无误地寻找到数据在物理内存之中的存放所在之处。
举个例子来讲,页表如同是一本地址方面的字典。当你在虚拟地址空间之中去寻找某一个数据的时候,依靠页表这本字典,能够比较快速地寻找到它在物理内存里边的实际所在位置。这样的一种映射方面的机制,既解决了进程对于内存的访问方面的问题,又达成了内存的有效方面的管理以及隔离。每一个进程都拥有自己单独的页表,这使得不同进程的虚拟地址空间相互之间隔离开来,提高了系统的稳定性以及安全性。
页表作为 Linux 虚拟内存体系当中的核心数据结构,承接 MMU 的地址翻译工作,它的核心作用主要体现在以下几个方面:
-
地址翻译:页表具备着一些主要的功能。当程序去访问虚拟地址的时候,中央处理器(CPU)会把虚拟地址划分成为虚拟页号以及页内偏移这两个部分。紧接着以虚拟页号作为索引去查询页表,从而找到相对应的物理页号。最后将物理页号和页内偏移拼接在一起就得到了物理地址。比如说虚拟地址是 0x0804a008,页的大小是 4KB 也就是 2 的 12 次方等于 4096,那么虚拟页号就是将 0x0804a008 右移 12 位得到 0x0804,页内偏移就是将 0x0804a008 和 0xfff 做按位与运算得到 0x008。通过查询页表找到虚拟页号 0x0804 所对应的物理页号,再加上页内偏移 0x008,就获取到了物理地址。
-
内存控制:页表之中的每一个映射项也就是页表项当中,存在着若干个控制位。比如权限位涉及读、写、执行等权限方面的内容;还有存在状态位,其用来表明这个页面是否已经被加载到物理内存之中;另外还有脏位,用来标记页面是否有被修改过的情况。这些控制位可以对内存起到有效的保护以及管理的作用。举例来说把权限位设置成只读的状态,就能够防止程序错误地去写入重要的数据。当存在状态位是 0 的时候,就表示这个页面还没有被加载到物理内存里面,这时候就会引发缺页中断,操作系统就会把对应的页面从磁盘加载到内存当中。
-
隔离与共享:每一个进程都拥有其自身独立的页表(用户空间那一部分)。此情形使得不同进程的虚拟地址相互分隔开来。一个进程不能够随意地去访问另一个进程的内存空间。如此便保障了系统的安全性以及稳定性。打个比方来说,在多进程的服务器环境当中,好几个进程一同运行,每一个进程都具备自己独立的页表,它们的内存相互之间不受干扰。与此同时内核空间页表或者共享内存的页表项能够让好几个进程进行共享,从而达成了物理内存的复用。举个例子而言,好几个进程都需要调用同一个共享库,那么这个共享库所对应的物理内存页面就可以通过共享页表项让好几个进程去访问,这样子就节省了内存资源。
1.2 为什么需要页表?
在页表还没有出现之前,操作系统是运用分段机制来对内存进行管理的。分段机制会把程序依照逻辑功能划分成好多个段,比如说代码段、数据段、栈段等等相关的。每一个段都有着自己的段基址还有段界限,虚拟地址是由段选择子和段内偏移所构成的。打个比方说在一个程序当中,代码段是从物理地址 0x10000 开始的,长度是 0x1000,那这个代码段的段基址就是 0x10000,段界限就是 0x1000。当程序去访问代码段当中的虚拟地址的时候,就会运用段选择子去查找对应的段描述符,从里面获取到段基址,然后再加上段内偏移,从而得到最终的物理地址 。
分段机制存在着十分明显的缺陷。其一物理内存碎片化的情况较为严重。由于每一个段的大小是不一样的,在进程进行创建以及销毁的时候,内存就会出现众多不连续的小空闲块。比如说一个进程占据一段内存,当进程结束释放内存之后,这段内存就会被分割成好几个小块。后续当新的进程需要较大的连续内存空间的时候,这些小块内存就无法满足需求,就算总的空闲内存是足够的,也会导致内存的浪费,进而降低内存的利用率。其二分段机制的映射效率比较低。在进行地址转换的时候,需要去查询段表来获取段基址,同时还需要进行边界检查,以此来保证段内偏移不会越界,这个过程是比较复杂的。而且还无法高效地按需进行加载,当进程访问不在内存当中的段的时候,需要把整个段从磁盘加载到内存,从而增加了系统的开销。
页表采用分页以及映射的机制来解决分段机制所存在的问题。它将虚拟地址空间和物理内存空间都划分成固定大小的页。在进行内存分配的时候,并不需要连续的大块内存,只要有足够多的空闲页就可以了。比如说一个进程需要 100KB 的内存,在分段机制的情况下可能很难找到连续的 100KB 空间,而在分页机制的情况下,系统能够从不同的地方找到 25 个 4KB 的页来分配给该进程,从而减少内存碎片化的这种情况。
在地址转换这一方面,页表的映射效能相对较高。中央处理器(CPU)将虚拟地址划分成虚拟页号以及页内偏移之后,能够直接利用虚拟页号在页表中去查找对应的物理页号,这个过程简便且直接。而且页表支持按照需求进行加载。当进程对一个虚拟页进行访问的时候,如果与之对应的物理页不在内存之中,就会触发缺页中断。操作系统将这个物理页从磁盘加载到内存当中,随后对页表进行更新。这样便能够有效地减少不必要的内存加载情况,从而提高系统的性能 。
单级页表存在着内存占用过大这样的问题。举个例子来说,在 32 位系统当中,页的大小是 4KB,虚拟地址空间是 4GB,那么按照计算的话,就会有 1048576 个页表项(是用 4GB 除以 4KB 得到的)。每一个页表项占据 4 字节,这样单进程的一级页表就会占据 4MB(是 1048576 乘以 4B 得到的)内存。可是实际上很多进程并不会把全部的虚拟地址空间都用上,这就导致很多页表项处于闲置的状态,白白地浪费了内存资源。为了去解决这个问题,现代操作系统就引入了多级页表,接下来会去详细地探讨多级页表的原理以及优势 。
二、Linux 页表的结构详解
面试题写作模版
2.1 单级页表
单级页表乃是页表结构之中最为基本、最为简单的那一种。在单级页表当中,整个虚拟地址空间被映射成为一个连续的数组。此数组的索引便是虚拟页号(VPN)。该数组里边的每一个元素均为页表项(Page Table Entry,PTE)。
在 32 位系统中,虚拟地址通常是 32 位的。假设页大小为 4KB ,因为 4KB = 2^12 字节,所以虚拟地址可以拆分为两部分:高 20 位作为虚拟页号,低 12 位作为页内偏移。例如,一个虚拟地址 0x0804a008,高 20 位 0x0804 就是虚拟页号,低 12 位 0x008 就是页内偏移。
单级页表乃是一个数组,此数组的索引为虚拟页号,数组的元素为页表项(PTE)。每一个页表项具备两部分的信息,其中一部分是物理页号,物理页号用于指明虚拟页所对应的物理页在物理内存里的位置,另一部分是控制位,控制位有着重要的信息,例如权限位,权限位决定对页面的访问权限,是读、还是写或者是执行,存在位表示页面是否被加载到物理内存中,如果存在位是 0,那就意味着页面不在物理内存当中。
当 CPU 需要访问一个虚拟地址时,它会按照以下步骤利用单级页表进行地址转换:
-
拆分虚拟地址:CPU 首先将虚拟地址拆分成虚拟页号以及页内偏移这两个部分。举例而言如同虚拟地址 0x0804a008,就会被拆分成虚拟页号 0x0804 以及页内偏移 0x008 。
-
查询页表:以虚拟页号作为索引,于单级页表数组之中去查找对应的页表项。举例而言依照虚拟页号 0x0804,在页表数组里寻找到相应的页表项。
-
获取物理页号:从所寻找到的页表项之中提取物理页号。就假定那页表项里边存储的物理页号是 0x1234。
-
拼接物理地址:将物理页号和页内偏移进行拼接组合,便可以获取到最终的物理地址。比如说物理地址是 0x1234008,这是由于把 0x1234 向左移动 12 位,之后再加上页内偏移 0x008。
要是于查询页表的时候,发现页表项之中的存在位乃是 0,那么便意味着这个页面当前不在物理内存之中。这时候就会触发缺页中断。操作系统会对于这个缺页中断进行响应,从磁盘将相应页面读取到物理内存里面,随后更新页表,之后再重新去执行引发缺页中断的指令。
为了更直观理解单级页表的结构、地址转换逻辑以及核心缺陷,下面提供一段 C++单级页表的极简代码示例,仅用于原理演示,和 Linux 内核真实源码逻辑一致、简化了冗余细:
#include <iostream>
#include <cstdint>
using namespace std;
// 定义 4KB 页大小 2^12
#define PAGE_SHIFT 12
#define PAGE_SIZE (1 << PAGE_SHIFT)
// 虚拟页号掩码 高 20 位
#define VPN_MASK (0xFFFFF << PAGE_SHIFT)
// 页内偏移掩码 低 12 位
#define OFFSET_MASK (PAGE_SIZE - 1)
// 页表项结构体 真实 PTE 核心字段
typedef struct {
uint32_t phy_page_num; // 物理页号
bool present; // 存在位:是否在物理内存
bool writable; // 读写权限位
} PTE;
// 单级页表:数组形式,索引=虚拟页号
PTE single_level_page_table[1 << 20];
// 虚拟地址转物理地址函数
uint32_t va_to_pa(uint32_t va) {
// 1. 拆分虚拟地址:虚拟页号 + 页内偏移
uint32_t vpn = (va & VPN_MASK) >> PAGE_SHIFT;
uint32_t offset = va & OFFSET_MASK;
// 2. 查询单级页表
PTE pte = single_level_page_table[vpn];
// 3. 页面不在内存,触发缺页中断
if (!pte.present) {
cout << "触发缺页中断,虚拟页号:" << vpn << endl;
return 0;
}
// 4. 拼接物理地址
return (pte.phy_page_num << PAGE_SHIFT) + offset;
}
int main() {
// 初始化一条页表映射:虚拟页 0x0804 -> 物理页 0x1234,有效、可读写
single_level_page_table[0x0804] = {0x1234, true, true};
// 测试虚拟地址转换:0x0804a008
uint32_t virtual_addr = 0x0804a008;
uint32_t physical_addr = va_to_pa(virtual_addr);
cout << "虚拟地址:0x" << hex << virtual_addr << endl;
cout << "物理地址:0x" << hex << physical_addr << endl;
return 0;
}
**从代码看单级页表致命缺陷:**代码中定义的页表数组大小为 1 << 20,也就是 1048576 个页表项,每个 PTE 占用 8 字节,仅这一个页表就会固定占用 8MB 内存。该内存是进程创建时必须一次性分配的,无论进程实际使用多少虚拟内存,页表本身的内存开销都固定存在。
单级页表虽然原理简单,但在面对大虚拟地址空间时,存在一个严重的问题,就是内存占用过大 。以 32 位系统为例,假设页大小为 4KB,虚拟地址空间是 4GB(2^32),那么总共就有 2^20(4GB / 4KB)个虚拟页,也就需要 2^20 个页表项。如果每个页表项占用 4 字节(32 位系统中常见的大小),那么单级页表就需要占用 4MB(2^20 * 4 字节)的内存空间。这对于一些内存资源有限的系统来说,是一个不小的开销。
而在 64 位系统中,这个问题更加严重。假设页大小还是 4KB,64 位系统理论上的虚拟地址空间是 2^64,虚拟页号可能会占用 52 位(具体根据系统实现可能会有所不同),那么单级页表就需要 2^52 个页表项。如果每个页表项占用 8 字节(64 位系统中常见的大小),仅页表就需要占用 2^52 * 8B = 4PB 的内存,这显然是不可行的,无论是从内存资源的实际情况还是成本角度考虑,都无法承受如此巨大的页表开销。所以,单级页表在 64 位系统中基本不会被采用 。
2.2 多级页表(以 Linux 为例)
现代操作系统为了解决单级页表内存占用比较大的这个问题,大多数都采用多级页表的结构。多级页表把虚拟页号拆分成多层索引,每一层索引都对应着一级页表。只有当某一级索引所对应的页表存在映射关系的时候,才去创建下级的页表。这种按照需求来创建子页表的做法,使得页表对于物理内存的占用大幅度地减少,从而提高了内存使用的效率。
Linux 的页表层级:Linux 系统会根据 CPU 的位数以及页大小来采用不同的页表层级。32 位的系统一般使用二级页表。在 64 位系统当中,像常见的 x86 - 64 架构,通常使用四级页表。要是使用大页(2MB、1GB),页表的层级就会发生变化。例如 2MB 大页在 x86 - 64 架构之下,会使用三级页表 。
各级页表详解:以 x86 - 64 的四级页表(PGD→PUD→PMD→PT)为例,来详细说说各级页表的情况 。
-
全局页目录(PGD):每一个进程都具备着一个独立的 PGD。PGD 如同是页表的总目录一般。PGD 是一个数组,在这个数组之中的每一个元素都是页目录项(PDE)。在 x86 - 64 架构当中,PGD 一般有着 512 个项。每一个 PDE 存储着上层页目录(PUD)的物理地址。当 CPU 获取到虚拟地址之后,首先从虚拟地址里提取高 9 位当作 PGD 的索引。例如虚拟地址 0xffff888000001234,高 9 位经过计算之后当作索引在 PGD 里去寻找对应的 PDE,从这个 PDE 就能够得到 PUD 的物理地址 。
-
上层页目录(PUD):PUD 是那么一个数组,它同样也具备 512 个项。每一个项都是上层页目录项也就是 PUE。PUE 所存储的是中间页目录即 PMD 的物理地址。CPU 在找到了从 PGD 当中获取到的 PUD 物理地址之后,就从虚拟地址里边提取接下来的 9 位当作 PUD 的索引。再拿虚拟地址 0xffff888000001234 来说吧,这 9 位索引就在 PUD 里边去查找对应的 PUE,这样一来就能够得到 PMD 的物理地址。
-
中间页目录(PMD):PMD 同样是一个有着 512 个项的数组。每一个项都是中间页目录项(也就是 PME)。PME 所存储的是页表(即 PT)的物理地址。CPU 拿着从 PUD 那里得到的 PMD 物理地址,从虚拟地址当中提取接下来的 9 位当作 PMD 的索引。还是那个虚拟地址,这 9 位索引在 PMD 里面去查找对应的 PME,就能够得到 PT 的物理地址。
-
页表(PT):PT 乃是最后一级页表,它存储确实实在在的物理页号。而且 PT 还是一个有着 512 个项的数组,每一个项就是页表项(PTE)。PTE 存储着物理页号以及一些个控制位。CPU 拿着从 PMD 那里得到的 PT 物理地址,从虚拟地址里边提取最后的 9 位当作 PT 的索引。比如说从对应索引的 PTE 里边提取物理页号,再和虚拟地址的低 12 位页内偏移拼接在一起,就得到最终的物理地址。
当处于这样的大页情形时,例如 2MB 的大页,页内的偏移就变为了 21 位,这是因为 2 的 21 次方是等于 2MB 的。在进行地址转换的时候,就会跳过 PMD 和 PT 这两级页表,直接通过 PGD 和 PUD 就能够找到大页的物理地址。这么做是有好处的,可以减少页表的层级,降低内存访问的次数,提高地址转换的效率,而且还能够减少 TLB(快表)的缺失的概率。像是在数据库这类内存访问频繁的场景当中,使用大页就能够明显地提升性能。同样的 1GB 大页的页内偏移是 30 位,因为 2 的 30 次方等于 1GB,在地址转换的时候跳过的层级就更多,效率提升也就更加明显。
地址转换过程:虚拟地址乃是 0xffff888000001234。在 x86 - 64 四级页表架构之下,地址转换是如此这般进行的。中央处理器先从虚拟地址当中提取高 9 位,举个例子而言是 0x123,拿它当作 PGD 的索引,在 PGD 里寻找到对应的 PDE,再从 PDE 里获取 PUD 的物理地址。然后提取接下来的 9 位,比如说 0x456,用它作为 PUD 的索引,在 PUD 里找到对应的 PUE,获取 PMD 的物理地址。紧接着提取再接下来的 9 位,例如是 0x789,用它作为 PMD 的索引,在 PMD 里找到对应的 PME,获取 PT 的物理地址。最后提取最后的 9 位,比如是 0xabc,用它当作 PT 的索引,在 PT 里找到对应的 PTE,从 PTE 里提取物理页号。假定物理页号是 0x1000,把它和虚拟地址低 12 位页内偏移 0x1234 里的 0x1234 拼接在一块,就得到最终物理地址 0x10001234,这便完成了虚拟地址到物理地址的转换。
2.3 页表相关优化机制
**(1)快表(TLB)------快表(Translation Lookaside Buffer,TLB)是一种高速缓存机制,专门用于加速虚拟地址到物理地址的转换过程 。**在现今的计算机系统当中,页表通常是存放在内存里面的,但是内存的访问速度相对比较缓慢。当中央处理器 CPU 需要去进行地址转换的时候,如果每一次都直接去查询内存之中的页表,就会导致比较大的内存访问方面的延迟情况,从而对系统的性能产生影响。
TLB 乃是页表的缓存,它存储着最近所使用过的虚拟地址以及物理地址的映射关联。当 CPU 接收到虚拟地址的时候,首先会在 TLB 里面去查找对应的物理地址。要是在 TLB 里面找到了相匹配的映射关联,那这就叫做 TLB 命中。这时候 CPU 就能够直接获取到物理地址,而不用再去访问内存当中的页表。这样子就极大地减少了地址转换的时间,进而提高了内存访问的效率。比如说在一个老是访问某些特定内存区域的程序当中,这些区域所对应的虚拟地址和物理地址的映射关联就会被缓存到 TLB 之中。后续访问这些区域的时候就可以通过 TLB 快速地完成地址转换,避免了多次访问内存页表的开销。
倘若在 TLB 里边没有寻找到相对应的映射关联,也就意味着出现了 TLB 未命中的状况,那么 CPU 就必须要去访问内存当中的页表来开展地址的转换。当找到物理地址之后,会把这个虚拟地址到物理地址的映射关联更新到 TLB 里边,以便让后续访问的时候能够迅速地命中。
TLB 一般是运用高速 SRAM 来进行实现的。TLB 它的访问速度和内存相比要快上很多很多。TLB 的容量相对来说比较小,一般也就几十到几百个条目左右。由于它主要就是缓存最为常用的页表项。为了提升 TLB 的命中率,常常会采用替换算法,比如说最近最少使用(LRU)算法。当 TLB 满了需要插入新的映射关系的时候,就会把最近最少使用的映射关系给替换掉。
**(2)大页机制------大页机制是指操作系统除了使用标准的 4KB 小页外,还支持使用更大的页面,常见的大页大小有 2MB、1GB 等 。大页机制的主要作用是减少页表项的数量和内存碎片,从而提高内存管理的效率。**在传统的那种 4KB 小页的机制当中,对于很多占据了比较多内存的程序或者数据结构而言,就会产生出许许多多的页表项。举个例子来说吧,一个占据了 1GB 内存的进程,如果采用 4KB 小页的话,那么就需要有 262144 个页表项(这是因为 1GB 除以 4KB 等于 262144)。而要是使用 2MB 大页的话,那就只需要 512 个页表项就行(1GB 除以 2MB 等于 512),这样子就大大的减少了页表的大小以及管理方面的开销。
与此同时,大页机制可以协助减轻内存碎片的状况。在小页机制的情形之下,内存开展分配以及释放操作的时候很容易产生许多小的空闲内存块。这些小的空闲块有可能没办法契合大内存分配的需求,进而就会导致内存的浪费。而运用大页能够降低这类内存碎片产生的情况,由于大页所分配的内存块比较大,更加能够契合大内存的需求。
大页的机制被应用在一些内存操作比较多、对于内存访问效率有着较高要求的场景之中。比如说在数据库系统当中,数据的存储以及访问一般都需要大量的内存,使用大页能够减少页表查找的次数,从而加快数据访问的速度。在大型的科学计算当中,也常常涉及大量的数据处理以及内存操作,大页的机制能够明显地提升计算的效率。
不过,大页机制具有若干个局限性之处。其一大页内存的分配需要是连续的物理内存块。要是内存资源处于紧张的状况,那么就有可能难以寻找到足够大的连续物理内存来进行大页的分配,这样就会致使大页分配出现失败的情况。其二大页一旦被分配好,它的大小就固定不变。要是程序实际所使用的内存比大页小,那么就会造成内存的浪费现象。比如说一个程序仅仅只需要 1MB 的内存,可是由于采用了大页机制,或许就会分配一个 2MB 的大页,这就浪费了 1MB 的内存空间。
三、Linux 页表如何实现虚拟地址映射
面试题写作模版
在 Linux 操作体系之中,每一个进程都具有独立的虚拟地址空间。此虚拟地址空间被划分成两大主要部分:用户态地址空间以及内核态地址空间。以 32 位体系而言,一般状况下较低的 3GB(0x00000000 - 0xBFFFFFFF)乃是用户态地址空间,较高的 1GB(0xC0000000 - 0xFFFFFFFF)则为内核态地址空间。在 64 位体系里,虚拟地址空间更为宽广,例如在 x86 - 64 架构之下,用户态地址空间通常是较低的 128TB(0 - 0x7FFFFFFFFFFF),内核态地址空间是较高的 128TB(0xFFFF800000000000 - 最高地址)。
进程所具有的用户态地址空间乃是进程所独有的。不同进程的用户态地址空间相互之间是隔开的。一个进程是没有办法直接去访问另一个进程的用户态内存的。这样就保障了进程之间的安全以及独立。进程在用户态运行用户程序代码的时候,存在着不少的限制。比如说不能直接去访问硬件资源,不能随意地去修改内核内存等等。
内核态的地址空间乃是所有进程共同加以使用的。它里面存储着内核的代码以及数据,还有内核所管理的各类资源信息。当进程开展系统调用、出现中断或者异常的时候,就会进入到内核态。在这时就能够去访问内核态的地址空间,去进行一些特权性质的操作,比如说操作硬件设备、管理内存这一类的。
页表当中,用户态地址空间和内核态地址空间的映射方式是不一样的。用户态地址空间的映射属于进程私有的情况。在每一个进程的页表之中,用户态那部分的映射表项记载着该进程用户态虚拟地址和物理地址的对应关联情况。内核态地址空间的映射在所有进程的页表里面是相同的。由于内核代码以及数据被所有进程所共享,如此这般能够节省内存空间,同时也方便内核进行统一的管理。
当进程访问一个虚拟地址时,地址翻译流程如下:
-
地址拆分:CPU 首先接收到进程所发来的虚拟地址,随后按照页表结构去拆分这个虚拟地址。就拿 32 位系统的二级页表来讲,虚拟地址会被拆分成 10 位的页目录索引、10 位的页表索引以及 12 位的页内偏移。在 64 位 x86 - 64 架构的四级页表当中,虚拟地址会被拆分成 9 位的 PGD 索引、9 位的 PUD 索引、9 位的 PMD 索引、9 位的 PT 索引还有 12 位的页内偏移 。
-
页表遍历:在 x86 架构之中,CR3 寄存器存储着页目录表的物理地址。CPU 借助 CR3 寄存器去寻找到一级页表,例如 32 位系统的页目录表或者 64 位系统的页全局目录 PGD 便属于一级页表。然后利用虚拟地址里的页目录索引也就是 PGD 索引,在一级页表当中去查找对应的表项。这个表项指向二级页表或者页上级目录 PUD 的物理地址。随后依照虚拟地址里的页表索引等,依据页表层级的不同或许是 PUD 索引、PMD 索引、PT 索引,在对应的二级页表或者 PUD、PMD、PT 之中继续进行查找。每一级的查找都是通过索引来找到下一级页表的物理地址,一直到最后一级页表也就是页表 PT 之中找到对应的页表项。
-
获取物理页帧号:在那页表项之中,存在着物理页帧号(PFN)以及其他的控制位。当 CPU 寻找到页表项之后,将其中的物理页帧号给提取出来。
-
拼接物理地址:将物理页帧号与虚拟地址当中的页内偏移进行拼接组合,便可以获取到最终的物理地址。紧接着 CPU 借助着页表寻找到了虚拟地址所对应的物理地址,随后就能够去对物理内存开展访问 。
在进行页表查找的时候,要是发现某个页表项的存在位是 0,那就表明对应的页面不在物理内存当中,这时候就会触发缺页异常。缺页异常出现之后,操作系统就会来进行处理。操作系统首先去判断缺页的缘由。要是由于页面是第一次被访问,还没有被加载到内存里,那么操作系统就从磁盘把该页面的数据读取到物理内存里,再去分配一个物理页帧。
要是页面之前被换出到磁盘(比如说因为内存不够,把暂时不用的页面给换出去),那么操作系统把它从磁盘换回到物理内存当中。紧接着操作系统去更新页表,把新分配的物理页帧号填到对应的页表项里面,还把存在位设置成 1,意味着该页面已经在物理内存之中了。做完这些操作之后,CPU 会重新去执行引发缺页异常的指令,这时候因为页表已经更新了,就可以成功找到物理地址来访问内存。
#include <stdio.h>
#include <stdint.h>
// x86-64 四级页表基础参数
#define PAGE_SIZE 4096 // 标准 4KB 页面
#define PAGE_OFFSET 12 // 页内偏移位数
#define INDEX_MASK 0x1FF // 单级页表索引掩码(9 位)
// 四级页表结构体(简化内核页表结构)
typedef struct {
uint64_t pml4; // 页全局目录项
uint64_t pud; // 页上级目录项
uint64_t pmd; // 页中间目录项
uint64_t pte; // 页表项
} PageTable;
// 虚拟地址拆分结构体
typedef struct {
uint16_t pml4_idx;
uint16_t pud_idx;
uint16_t pmd_idx;
uint16_t pt_idx;
uint16_t offset;
} VaddrSplit;
// 拆分 64 位虚拟地址
static void split_vaddr(uint64_t vaddr, VaddrSplit *res) {
res->offset = vaddr & INDEX_MASK;
res->pt_idx = (vaddr >> PAGE_OFFSET) & INDEX_MASK;
res->pmd_idx = (vaddr >> (PAGE_OFFSET + 9)) & INDEX_MASK;
res->pud_idx = (vaddr >> (PAGE_OFFSET + 18)) & INDEX_MASK;
res->pml4_idx = (vaddr >> (PAGE_OFFSET + 27)) & INDEX_MASK;
}
// 虚拟地址转物理地址(完整映射流程)
static int vaddr_to_paddr(uint64_t vaddr, uint64_t *paddr, PageTable *pt) {
VaddrSplit split;
split_vaddr(vaddr, &split);
// 逐级遍历页表,模拟硬件 MMU 寻址
// 1. 查询 PML4,获取 PUD 物理地址
if (!(pt->pml4 & 1)) return -1; // PML4 不存在,触发缺页异常
// 2. 查询 PUD,获取 PMD 物理地址
if (!(pt->pud & 1)) return -1; // PUD 不存在,触发缺页异常
// 3. 查询 PMD,获取 PT 物理地址
if (!(pt->pmd & 1)) return -1; // PMD 不存在,触发缺页异常
// 4. 查询 PTE,获取物理页帧号
if (!(pt->pte & 1)) return -1; // PTE 不存在,触发缺页异常
// 提取物理页帧号,拼接页内偏移,生成最终物理地址
uint64_t pfn = (pt->pte) >> PAGE_OFFSET;
*paddr = (pfn << PAGE_OFFSET) | split.offset;
return 0; // 映射成功
}
int main() {
// 一个已初始化完成的合法页表(各级表项有效、页面在内存中)
PageTable pt = {0x100001, 0x200001, 0x300001, 0x400001};
uint64_t virtual_addr = 0x7F5512345000; // 测试虚拟地址
uint64_t physical_addr = 0;
printf("===== 虚拟地址映射全过程 =====\n");
printf("原始虚拟地址: 0x%012lX\n", virtual_addr);
// 执行地址映射
int ret = vaddr_to_paddr(virtual_addr, &physical_addr, &pt);
if (ret == 0) {
printf("映射成功!对应物理地址: 0x%012lX\n", physical_addr);
} else {
printf("映射失败,触发缺页异常\n");
}
return 0;
}
代码在参数与结构体设计上完全对标 x86‑64 架构 Linux 内核规范,采用 4KB 页面、每级 9 位索引、12 位页内偏移,模拟内核四级页表的真实数据结构,不引入自定义特殊规则;地址拆分逻辑严格遵循硬件规则,将 64 位虚拟地址拆解为 PML4、PUD、PMD、PT 四级索引与页内偏移,还原 MMU 硬件寻址的第一步;同时实现逐级页表校验,检测各级页表项的最低存在位,若该位为 0 则直接返回映射失败,精准模拟页表项无效时触发缺页异常的内核机制;最后从最后一级页表项提取物理页帧号,将其左移 12 位完成页大小对齐,再拼接页内偏移,生成可直接访问的物理地址,完整复刻 CPU 地址翻译的核心逻辑。
**最后总结:**虚拟地址本身不存储数据,必须通过页表逐级翻译,才能定位物理内存数据。
四、在 C++ 后端开发中的应用场景
面试题写作模版
**(1)内存分配与释放------在 C++之中,我们经常会运用 new 以及 delete 来开展内存的分配和释放操作。**举个例子来讲,当我们创建一个对象的时候,就会用到 new 操作符。比如说 MyClass* obj = new MyClass 这样的情况;这时候 new 操作符会在堆上面分配出一块内存,并且还会去调用 MyClass 的构造函数来对这个对象进行初始化。而当要释放对象的时候,就会使用 delete 操作符,比如说 delete obj 这样的情况;这时候它会先去调用 MyClass 的析构函数,之后才会去释放所分配的内存。
页表机制在这个过程当中是十分关键的。当使用 new 操作符来进行内存分配的时候,操作系统会依据页表去给新的对象分配虚拟地址空间,并且还会把虚拟地址映射到物理内存之上。倘若物理内存不够用了,操作系统就会借助页表的换页机制,把一些不经常使用的页面交换到磁盘当中,从而腾出物理内存来给新的对象使用。比如说在内存比较紧张的系统里,存在着多个对象频繁地进行创建和销毁的情况,页表机制能够对物理内存进行合理的管理,使得每一个对象都可以获取到其所需要的内存空间,而且还能够避免内存碎片化的问题发生。
当对 delete 操作来释放内存的时候,页表之中的映射关联就会被予以更新。与之相应的物理内存页就会被标识成为空闲状态,以此来为后续的内存分配做好相关的预备工作。倘若所释放掉的内存页是大页的话,操作系统便会对大页的使用情形开展管理活动,确保大页可以得到高效的利用。
通过 mmap 观察页表的按需映射代码示例如下:
#include <iostream>
#include <sys/mman.h>
#include <unistd.h>
#include <fstream>
#include <string>
void print_maps(const char* label) {
std::cout << "\n=== " << label << " ===" << std::endl;
std::ifstream maps("/proc/self/maps");
std::string line;
while (std::getline(maps, line)) {
if (line.find("heap") != std::string::npos ||
(line.find("rw-p") != std::string::npos &&
line.find("00:00") != std::string::npos)) {
std::cout << line << std::endl;
}
}
}
int main() {
print_maps("分配前");
size_t page_size = sysconf(_SC_PAGESIZE);
void* ptr = mmap(nullptr, page_size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED) {
perror("mmap failed");
return 1;
}
std::cout << "\n 分配的虚拟地址: " << ptr << std::endl;
std::cout << "页大小: " << page_size << " 字节" << std::endl;
print_maps("mmap 分配后(未访问)");
// 第一次访问触发缺页中断,操作系统才建立页表映射
*(int*)ptr = 42;
std::cout << "\n 写入的值: " << *(int*)ptr << std::endl;
print_maps("访问内存后");
munmap(ptr, page_size);
print_maps("释放后");
return 0;
}
调用 mmap 时仅仅会在进程的虚拟地址空间内预留出一块内存区域,此时页表并未建立该虚拟地址到物理页的映射关系。当程序第一次向这片地址执行写操作,CPU 便会触发缺页中断,由操作系统负责分配物理页,并在页表内填充对应的映射项。
我们可以读取/proc/self/maps 文件,观察虚拟地址区域分配前后的状态变化,这也是页表管理进程虚拟地址空间的直观表现。当调用 munmap 释放该虚拟地址区域后,页表里相关的映射项会被清除,对应的物理页也会被系统回收。
**(2)进程与线程管理------在 Linux 系统之中,每一个进程都具备属于自己独立的页表(用户空间那一部分)。这便是实现进程内存隔离的关键之所在。**不同进程的页表将各自的虚拟地址映射到不同的物理内存区域。如此一来一个进程便不能够直接去访问另一个进程的内存空间,也就达成了对系统安全性以及稳定性的保障。就拿多进程的服务器环境来讲吧,存在好几个进程同时处于运行状态,例如 Web 服务器进程、数据库进程这类的。每一个进程都拥有自己独立的页表,它们相互之间的内存是处于隔离状态的,一个进程的内存出现问题并不会对其他进程的正常运行产生影响。
线程乃是进程之中的执行单元。同一个进程之内的所有线程共同享用这个进程的页表。这便致使线程彼此之间能够轻而易举地共享内存数据。由于它们所访问的是相同的虚拟地址空间,借助页表映射到同样的物理内存。举例来说在一个多线程的计算程序当中,多个线程需要一块儿访问以及处理一些共享的数据,比如一个大型的数据数组。因为线程共享进程页表,它们可以直接访问这个共享的数据,无需去进行复杂的进程之间的通信。
线程去共享进程页表是会带来风险的。线程之间共享内存,当多个线程同时去访问并且修改共享数据的时候,就有可能出现数据竞争以及同步方面的问题。举例来说两个线程同时对一个共享变量进行写操作,那就极有可能造成数据不一致的情况发生。要去避免这些问题,就需要采用同步机制,像互斥锁、条件变量相关的,通过这样的方式来确保线程的安全。
//代码示例:进程隔离与线程共享的对比
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <pthread.h>
int shared_var = 0;
void* thread_func(void* arg) {
shared_var = 100;
std::cout << "[子线程] shared_var = " << shared_var
<< ", 虚拟地址 = " << &shared_var << std::endl;
return nullptr;
}
int main() {
std::cout << "=== 进程内存隔离演示 ===" << std::endl;
pid_t pid = fork();
if (pid == 0) {
// 子进程:修改自己的副本
shared_var = 10;
std::cout << "[子进程] shared_var = " << shared_var
<< ", 虚拟地址 = " << &shared_var << std::endl;
return 0;
} else if (pid > 0) {
wait(nullptr);
std::cout << "[父进程] shared_var = " << shared_var
<< ", 虚拟地址 = " << &shared_var << std::endl;
std::cout << "父子进程虚拟地址相同,但物理页不同(写时复制)" << std::endl;
}
std::cout << "\n=== 线程内存共享演示 ===" << std::endl;
shared_var = 0;
std::cout << "[主线程] 修改前 shared_var = " << shared_var << std::endl;
pthread_t tid;
pthread_create(&tid, nullptr, thread_func, nullptr);
pthread_join(tid, nullptr);
std::cout << "[主线程] 修改后 shared_var = " << shared_var << std::endl;
std::cout << "同一进程的线程共享页表,虚拟地址映射到同一物理页" << std::endl;
return 0;
}
fork函数创建子进程之后,父子进程当中 shared_var 的虚拟地址是完完全全一样的,不过各自拥有独立的页表。当子进程去修改这个变量的时候就会触发写时复制的机制,操作系统会给子进程分配新的物理页,还会更新子进程的页表映射,这时候父进程的数据是不会受到影响的。
而同一个进程里面的主线程和子线程是共享整套页表的,子线程把 shared_var 改了之后,主线程就能马上察觉到数据的变化,因为它们的虚拟地址指向同一块物理页。编译这个示例代码得链接 pthread 线程库,编译的命令就是 g++ -o demo code2.cpp -lpthread。
**(3)缓存一致性问题------在多线程的相关环境当中,CPU 缓存和页表相互之间的交互极有可能会引发缓存一致性方面的问题。**当多个线程一同去访问共享内存的时候,每一个线程都有很大的可能性会把共享的数据加载到自身所处 CPU 核心的缓存之中。倘若一个线程对缓存里的数据进行了改动,但是别的线程的缓存里面依旧是陈旧的数据,那么就会产生缓存不一致这样的情形。
现代的 CPU 为了规避缓存一致性方面的问题,常常会采用缓存一致性的协议,例如 MESI 协议。MESI 协议是依靠缓存行的状态来进行管理的,以此来确保每一个缓存当中共享变量的副本处于一致的状态。当某一个 CPU 核心对缓存里的数据进行修改的时候,就会发出信号让其他的 CPU 核心将该变量所对应的缓存行设置成为无效的状态。其他的 CPU 核心要是想要读取这个变量的时候,发现自己缓存里这个变量所对应的缓存行是无效的,那么就会从内存当中重新进行读取,通过这样的方式来保证数据的一致性。
在 C++后端开发之中,我们可以利用若干编程手段来降低缓存一致性问题所带来的影响。举例而言精心设计数据结构,尽可能地减少多线程对于同一内存区域的频繁读写操作。运用线程局部存储,也就是 TLS,使得每个线程都拥有自身独立的数据副本,如此便能够规避因共享数据而引发的缓存一致性问题。
//代码示例:伪共享与缓存一致性性能对比
#include <iostream>
#include <thread>
#include <chrono>
#include <atomic>
// 无对齐:两个变量可能落在同一个 64 字节缓存行
struct NoPadding {
std::atomic<int> a{0};
std::atomic<int> b{0};
};
// 有对齐:两个变量强制分布在不同缓存行
struct WithPadding {
alignas(64) std::atomic<int> a{0};
alignas(64) std::atomic<int> b{0};
};
template<typename T>
long long run_test(T& data) {
auto start = std::chrono::high_resolution_clock::now();
std::thread t1([&]() {
for (int i = 0; i < 10000000; ++i) data.a.fetch_add(1);
});
std::thread t2([&]() {
for (int i = 0; i < 10000000; ++i) data.b.fetch_add(1);
});
t1.join();
t2.join();
auto end = std::chrono::high_resolution_clock::now();
return std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
}
int main() {
NoPadding np;
WithPadding wp;
std::cout << "=== 缓存一致性(伪共享)性能测试 ===" << std::endl;
std::cout << "CPU 缓存行大小通常为 64 字节" << std::endl;
long long time1 = run_test(np);
long long time2 = run_test(wp);
std::cout << "\n 无缓存行对齐(伪共享)耗时: " << time1 << " ms" << std::endl;
std::cout << "有缓存行对齐(无伪共享)耗时: " << time2 << " ms" << std::endl;
std::cout << "性能差异约: " << (double)time1 / time2 << " 倍" << std::endl;
std::cout << "\n 原因:伪共享时两个线程修改同一缓存行的不同变量,"
<< "MESI 协议使缓存行频繁失效,拖累性能。" << std::endl;
return 0;
}
NoPadding 结构体当中的两个 atomic 变量相互靠得很近,极有可能处于同一个 64 字节的缓存行里面。要是两个线程分别去修改结构体里面不同的成员变量,MESI 缓存一致性协议就会使得这个缓存行在好几个 CPU 核心之间不断地失效、进行同步,这样就产生了伪共享的问题。
而 WithPadding 结构体运用 alignas(64) 让每一个成员变量各自占据一条缓存行,那么两个线程的写操作就相互不影响,缓存一致性所带来的性能开销就大大的降低。程序运行起来之后能够很明显地看到,没有做对齐处理的版本所花费的时间比填充对齐之后的版本要高很多很多,性能差距的倍数会受到 CPU 架构、CPU 核心数量的影响。这也表明了搞清楚页表和 CPU 缓存之间的交互关系,对于 C++后端服务做性能优化有着直接的实践方面的价值。
五、解析 Linux 页表 面试题
面试题写作模版
**(1)进程切换时页表如何处理------**当发生进程切换时,操作系统需要对页表进行一系列处理,以确保新进程能够正确访问其虚拟地址空间。具体处理步骤如下:
-
保存旧进程页表相关信息:操作系统会将当前运行进程的页表基地址寄存器当中的值存放到该进程的进程控制块之中。例如在 x86 架构里的 CR3 寄存器,其存储着页目录表的物理地址。同时也许还会存储一些和页表相关的状态信息,比如页表的修改情形相关的。这些信息记载了旧进程的内存映射状态,如此一来下次这个进程恢复执行的时候,就可以精确地还原它的地址空间。
-
选择新进程并获取其页表信息:调度器从就绪的队列当中去挑选一个新的进程来进行运行。随后操作系统从新进程的 PCB(进程控制块)之中去读取它的页表基地址。这个基地址所指向的是新进程的页目录表(在多级页表的结构情况之下),依靠着这个页目录表就可以进一步去找到各级子页表以及相对应的物理页框。
-
加载新进程页表基地址:将新进程的页表基地址载入到页表基地址寄存器之中,像是 CR3 这类寄存器。一旦新的页表基地址被载入到寄存器里面之后,CPU 在后续进行虚拟地址到物理地址转换的时候,便会依照新进程的页表来进行查找。举个例子来讲,在开展内存访问的时候,CPU 会根据新的页表基地址寻找到页目录表,然后依据虚拟地址当中的相关字段逐一去查询子页表,最终获取到物理页框号,从而完成地址转换。
-
更新 TLB(Translation Lookaside Buffer,转换后备缓冲器):TLB 乃是一种高速缓存之物。其被用于加快虚拟地址向物理地址的转换过程。当进程发生切换之后,所采用的是新进程的页表,那么旧进程于 TLB 当中的缓存项便不再起作用。这时候一般存在两种处理的办法。其一就是直接将 TLB 里面的内容全部进行刷新,使得 TLB 所有的缓存项都失效掉,这样一来后续所有的地址转换就都得重新经过页表来进行查询。其二就是采用更为精细的办法,仅仅让和旧进程相关的 TLB 项失效掉,留存着其他还有效的缓存项,如此这般能够减少 TLB 失效所带来的性能方面的损失。不过不管采用哪一种方式,目的都是让 TLB 里面的缓存信息和新进程的页表达成一致,以便新进程运行的时候能够快速地进行地址转换。
-
其他相关处理:进程进行切换的时候,除了很多和页表直接相关的操作之外,还极有可能存在其他与内存管理相关的操作。比如说去更新内存访问权限、去检查新进程内存映射的完整性等等情况。倘若新进程对于某些内存区域有着特殊的访问权限方面的要求,像是只读、可读写、可执行这一类的,操作系统便会依照新进程页表项当中的权限位,来确保 CPU 在访问这些内存区域的时候能够遵循对应的权限规则。
(2)TLB 未命中时会发生什么------当 TLB(Translation Lookaside Buffer,转换后备缓冲器)未命中时,意味着 CPU 在 TLB 中没有找到虚拟地址到物理地址的映射关系,此时需要执行以下处理流程:
-
触发异常:硬件在检测到 TLB 没有命中之后,会自动去触发一个 TLB 缺失异常,也可以叫做 TLB 没有命中异常或者 TLB 重填异常。此异常会使得 CPU 暂停正在执行着的指令,将控制权转移到操作系统的异常处理程序那边去。举例来说在 x86 架构情况之下,当 TLB 没有命中的时候,CPU 会产生一个特定的异常向量,操作系统依靠这个向量来辨认并且处理这个异常。
-
查询页表:操作系统的异常处理程序获取到控制权之后,会依照虚拟地址去查看内存里边的页表。就拿多级页表来讲,会按照虚拟地址当中划分的各级索引字段,从页目录开始,一级一级地往下查找下一级页表,最终找到相对应的物理页框号。这个过程或许得进行多次访问内存,由于各级页表一般是放在不同的内存位置。比如说在四级页表结构当中,得依次去查看页目录、一级页表、二级页表以及三级页表,才可以获取到物理页框号。
-
更新 TLB:通过对页表进行查询从而得到物理页框号之后,操作系统便会把新的虚拟地址到物理地址的映射关系加载到 TLB 里边。如此一来后续在访问相同虚拟地址的时候就可以直接在 TLB 里边命中,进而快速地完成地址转换。要是 TLB 处于满的状态,那么有可能需要采用像最近最少使用 LRU 算法这类替换算法来挑选出一个旧的 TLB 条目然后将其替换掉。
-
重新执行指令:在完成 TLB(转换后援缓冲器)的更新之后,操作系统将被中断的指令进行恢复操作。接着让中央处理器(CPU)重新去执行这一条指令。在这个时候由于 TLB 里面已经存在相应对应的映射关联关系了,指令在执行的时候地址的转换就可以快速地进行,中央处理器(CPU)就能够顺畅地去访问所需要的内存数据。
TLB 要是没有命中的话,对于系统性能的影响是非常大的。查询页表需要多次去访问内存,这和从 TLB 里面获取映射关系所花费的时间相比,要多上很多。在现代计算机系统当中,内存的访问速度可比 CPU 的运算速度慢好多好多,一次内存访问有可能得要几十到几百个 CPU 周期。而当 TLB 命中的时候,地址转换只需要 1 - 2 个 CPU 周期。所以 TLB 没有命中就会让内存访问延迟大大的增加,从而拉低系统整体的性能。特别是当程序老是访问内存而且 TLB 的命中率比较低的时候,性能下降就会更加明显,有可能就变成系统的性能瓶颈。
(3)大页机制是如何工作的,有什么优势------大页机制乃是用于优化内存管理的一项技术,其目的在于提升内存访问的效率以及系统的性能。在传统的内存管理状况之中,页面的大小通常是 4KB。针对部分内存密集型的应用而言,频繁的地址转换以及大量的页表项会产生较高的系统开销。大页机制采用更为大的页面,例如 2MB、1GB 等等,以此来缓解这类问题 。
从单级页表到多级页表的演进,解决了内存占用过大的问题,使得系统能够高效地管理大虚拟地址空间 。快表(TLB)和大页机制等优化措施,进一步提高了内存访问的效率和系统性能 。