mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5432 字
15 分钟
虚拟内存
2024-03-22

虚拟内存解决了什么问题#

上一章在 Loader 中开启了分页机制,但这只是虚拟内存的起点。虚拟地址怎么变成物理地址?为什么每个进程能有自己的地址空间?操作系统如何用有限的物理内存满足所有进程的需求?本章回答这些问题。

早期计算机没有虚拟内存的概念,程序直接使用物理地址运行。这带来三个问题:多个程序同时加载时地址冲突,你用 0x1000,我也用 0x1000,谁也跑不了;物理内存不够时程序就无法运行,即使大部分数据暂时用不到;一个程序的指针越界可以直接改写另一个程序的内存,没有任何硬件层面的防护。

虚拟内存的思路是将程序的地址与物理内存解耦。每个进程拿到一个独立的、连续的虚拟地址空间,都以为自己独占了整个内存。32 位系统中每个进程拥有 4GB 虚拟地址空间;64 位系统中通常为 48 位或 57 位地址。程序使用虚拟地址访存,CPU 通过 MMU 将虚拟地址翻译成物理地址,程序完全不需要知道物理内存的实际布局。

这种解耦带来了三个直接后果。进程隔离:每个进程的虚拟地址空间独立,一个进程无法直接访问另一个进程的内存,即使崩溃也不会波及其他进程,这是用户态程序能安全并发运行的前提。内存超额分配:操作系统可以将不常用的页面换出到磁盘,让所有运行程序的内存需求总和超过实际物理内存容量;当程序访问被换出的页面时触发缺页异常,操作系统再从磁盘加载回来。内存共享:多个进程可以将各自的虚拟页面映射到同一个物理页面,共享库和进程间通信都依赖这个机制,否则每个进程都要加载一份 libc,物理内存会迅速耗尽。

虚拟地址空间布局(x86-64)#

32 位地址空间布局#

在 32 位系统中,虚拟地址空间为 4GB。Linux 系统通常将高 1GB 划分给内核空间,低 3GB 划分给用户空间:

  1. 内核空间 0xC0000000 - 0xFFFFFFFF(1GB)
  2. 用户空间 0x00000000 - 0xBFFFFFFF(3GB)

用户空间的布局从低地址到高地址依次为:

  • 代码段(Text):存放程序的可执行代码,只读
  • 数据段(Data):存放已初始化的全局变量和静态变量,可读写
  • BSS 段:存放未初始化的全局变量和静态变量,可读写
  • 堆(Heap):动态分配的内存,从低地址向高地址增长
  • 内存映射区域(mmap):用于内存映射文件、共享内存等
  • 栈(Stack):存放局部变量、函数参数等,从高地址向低地址增长
  1. 0xBFFFFFFF ↓ 向下增长
  2. (未使用)
  3. 内存映射区
  4. ↑ 向上增长
  5. BSS 段
  6. 数据段
  7. 代码段 0x08048000(程序起始地址)

64 位地址空间布局#

在 x86-64 架构中,虚拟地址理论上有 64 位,但 CPU 并不使用全部 64 位。早期实现只支持 48 位虚拟地址,较新的 CPU 通过 LA57 特性扩展到 57 位。为什么不直接用满 64 位?因为地址线每多一位,页表遍历就多一级,MMU 的硬件复杂度和延迟随之增加。48 位已经可以寻址 256TB,对当前的应用场景绰绰有余,CPU 设计者选择用更少的地址位数换取更简单的硬件实现和更快的地址转换速度。

x86-64 使用一种「规范地址」设计来利用未实现的地址位:有效地址的第 47 位到第 63 位必须相同(要么全为 0,要么全为 1)。这不是随意的规定,硬件在地址转换前会检查这个条件,违反则触发通用保护异常(#GP)。这个设计将地址空间自然地劈成两半:

  1. 内核空间(高 128TB)0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF
  2. 用户空间(低 128TB)0x0000000000000000 - 0x00007FFFFFFFFFFF

中间的空洞区域(非规范地址):0x0000800000000000 - 0xFFFF7FFFFFFFFFFF,访问这些地址会触发通用保护异常(#GP)。

Linux 在 x86-64 上的具体布局如下:

用户空间(从高地址到低地址):

  1. 共享库、vvar、vdso
  2. BSS 段
  3. 数据段
  4. 代码段
  5. (不可访问)0x0000000000000000

内核空间(从高地址到低地址):

  1. 直接映射区(64TB)物理内存的直接映射
  2. vmalloc/ioremap 区
  3. vmemmap 区 struct page 数组
  4. KASAN 影子内存 内核地址消毒器
  5. 内核代码段/数据段 0xFFFF800000000000

MMU 地址转换流程#

MMU 的核心职责#

MMU(Memory Management Unit)是 CPU 中负责虚拟地址到物理地址转换的硬件单元。它的核心职责包括:

功能说明
地址转换将 CPU 发出的虚拟地址翻译为物理内存地址
访问权限检查根据页表中的权限位判断当前操作是否合法(读/写/执行)
内存隔离为不同进程维护独立的地址空间,防止互相干扰
缺页异常触发当访问的虚拟页未映射到物理页时,触发缺页异常交由操作系统处理
Cache 控制通过页表属性位控制页面缓存策略(Write-Back / Write-Through)

在现代操作系统中,每个进程都有自己独立的虚拟地址空间。以 64 位 Linux 系统为例,用户空间通常占 128 TB(0x0000000000000000 ~ 0x00007fffffffffff),内核空间也占 128 TB。这些虚拟地址到物理内存的映射关系由页表描述,而 MMU 就是查页表、完成地址转换的硬件执行者。

地址转换步骤#

MMU 进行地址转换的基本步骤如下:

flowchart LR VA["虚拟地址 (VA)<br>VPN | Offset"] --> PT["页表查询"] PT --> PA["物理地址 (PA)<br>PFN | Offset"]

以 32 位系统、4KB 页面、二级页表为例:

1
CPU 生成虚拟地址 VA
2
MMU 将 VA 分解为三部分:
  • 页目录索引(Directory Index,10 位)

  • 页表索引(Table Index,10 位)

  • 页内偏移(Page Offset,12 位)

3
从 CR3 寄存器获取页目录的物理基址
4
用页目录索引定位页目录项(PDE),获取页表物理基址
5
用页表索引定位页表项(PTE),获取物理页框号(PFN)
6
将 PFN 与页内偏移组合,得到最终物理地址

32 位虚拟地址结构:

位范围31 - 2221 - 1211 - 0
字段页目录索引页表索引页内偏移
宽度10 bit10 bit12 bit

转换过程示例#

假设虚拟地址为 0x12345678,我们来分析转换过程:

虚拟地址: 0x12345678 = 0001 0010 0011 0100 0101 0110 0111 1000
分解:
- 页目录索引: 0001 0010 00 = 0x48 (72)
- 页表索引: 11 0100 0101 = 0x345 (837)
- 页内偏移: 0110 0111 1000 = 0x678 (1656)
假设:
- CR3 指向页目录基址: 0x100000
- PDE[0x48] 中的页表基址: 0x200000
- PTE[0x345] 中的物理页框号: 0xABC
则物理地址 = (0xABC << 12) | 0x678 = 0xABC678

用代码表示这个转换过程:

// 32 位系统的地址转换
#define PAGE_SIZE 4096
#define PAGE_MASK 0xFFFFF000
#define OFFSET_MASK 0xFFF
uint32_t translate_address(uint32_t va, uint32_t cr3) {
uint32_t dir_index = (va >> 22) & 0x3FF; // 页目录索引
uint32_t table_index = (va >> 12) & 0x3FF; // 页表索引
uint32_t offset = va & OFFSET_MASK; // 页内偏移
// 获取页目录项
uint32_t* page_dir = (uint32_t*)cr3;
uint32_t pde = page_dir[dir_index];
// 检查页目录项是否存在
if (!(pde & 0x1)) {
trigger_page_fault(va);
return 0;
}
// 获取页表基址
uint32_t page_table_addr = pde & PAGE_MASK;
uint32_t* page_table = (uint32_t*)page_table_addr;
uint32_t pte = page_table[table_index];
// 检查页表项是否存在
if (!(pte & 0x1)) {
trigger_page_fault(va);
return 0;
}
// 获取物理页框号
uint32_t page_frame = pte & PAGE_MASK;
// 组合成物理地址
return page_frame | offset;
}

多级页表结构#

为什么需要多级页表#

如果使用单级页表,对于 32 位地址空间,每个进程需要 2^20 = 1048576 个页表项,每项 4 字节,总共需要 4MB 的连续内存来存储页表。对于 64 位地址空间,这个数字更是天文数字。多级页表通过「按需分配」的方式解决了这个问题:只有实际使用的地址范围才会分配页表,大大节省了内存。

以 64 位 Linux 系统为例,假设进程只使用了 1 GB 的虚拟内存:

方案页表大小
单级页表2^52 × 8 ≈ 32 PB(理论值,不可能实现)
四级页表1 个 PML4 + 1 个 PDPT + 1 个 PD + 512 个 PT ≈ 2 MB

多级页表把内存开销从天文数字降到了可接受的范围。

32 位二级页表#

32 位系统通常采用二级页表结构:

  • 页目录(Page Directory):1024 个页目录项(PDE),每个 PDE 指向一个页表,每个 PDE 管理 4MB 虚拟地址空间
    • 页表(Page Table):1024 个页表项(PTE),每个 PTE 指向一个物理页框,每个 PTE 管理 4KB 虚拟地址空间
      • 总计:1024 × 1024 × 4KB = 4GB 虚拟地址空间

页目录项(PDE)和页表项(PTE)的结构:

页目录项(4 字节):

0123456789-1112-31
字段PR/WU/SPWTPCDA保留PSG可用页表基址

页表项(4 字节):

0123456789-1112-31
字段PR/WU/SPWTPCDADPATG可用页框基址

位 0-7 两者的差异在于第 6 位和第 7 位:4KB 页目录项的第 6 位保留、第 7 位是 PS(页大小,4KB PDE 应为 0),页表项的第 6 位是 D(脏位)、第 7 位是 PAT。位 8 的 G(全局页)两者都有,位 9-11 供操作系统使用,高位 12-31 是基址。这与本系列代码 bootmacros.incPTE_GLOBAL = 1<<8 的定义一致。

关键标志位说明:

  • P (Present): 页/页表是否存在于内存中
  • R/W: 读/写权限(0=只读,1=可读写)
  • U/S: 用户/超级用户权限(0=内核,1=用户)
  • PWT: 写穿透缓存策略
  • PCD: 禁用缓存
  • A (Accessed): 是否被访问过(由 CPU 设置)
  • D (Dirty): 是否被写过(仅 PTE 有效)
  • PS (Page Size): 页大小(PDE 中,1=4MB 大页)
  • G (Global): 全局页(切换 CR3 时不清除 TLB)

64 位四级页表#

x86-64 使用四级页表结构(有些系统使用五级):

虚拟地址结构(48 位有效地址):

位范围47 - 3938 - 3029 - 2120 - 1211 - 0
字段PML4 索引PDPT 索引PD 索引PT 索引Offset
宽度9 bit9 bit9 bit9 bit12 bit

四级页表层次:

  • PML4(Page Map Level 4):512 个 PML4E
    • PDPT(Page Directory Pointer Table):512 个 PDPTE
      • PD(Page Directory):512 个 PDE
        • PT(Page Table):512 个 PTE
          • 物理页框(4KB)

每一级索引占 9 位,可索引 512 个表项。最终可寻址:512 × 512 × 512 × 512 × 4KB = 256TB

四级页表的转换过程:

// 64 位系统的四级页表地址转换
#define PAGE_SIZE 4096
#define PAGE_MASK 0xFFFFFFFFFF000
#define OFFSET_MASK 0xFFF
struct page_entry {
uint64_t present : 1; // 第 0 位
uint64_t writable : 1; // 第 1 位
uint64_t user : 1; // 第 2 位
uint64_t pwt : 1; // 第 3 位
uint64_t pcd : 1; // 第 4 位
uint64_t accessed : 1; // 第 5 位
uint64_t dirty : 1; // 第 6 位
uint64_t ps : 1; // 第 7 位(大页标志)
uint64_t g : 1; // 第 8 位
uint64_t os_use : 3; // 第 9-11 位(操作系统可用)
uint64_t frame : 40; // 第 12-51 位(物理帧号)
uint64_t reserved : 11; // 第 52-62 位
uint64_t nx : 1; // 第 63 位(禁止执行)
} __attribute__((packed));
uint64_t translate_address_64(uint64_t va, uint64_t cr3) {
uint64_t pml4_index = (va >> 39) & 0x1FF;
uint64_t pdpt_index = (va >> 30) & 0x1FF;
uint64_t pd_index = (va >> 21) & 0x1FF;
uint64_t pt_index = (va >> 12) & 0x1FF;
uint64_t offset = va & OFFSET_MASK;
// 第一级:PML4
struct page_entry* pml4 = (struct page_entry*)cr3;
if (!pml4[pml4_index].present) {
trigger_page_fault(va);
return 0;
}
// 第二级:PDPT
struct page_entry* pdpt = (struct page_entry*)(pml4[pml4_index].frame << 12);
if (!pdpt[pdpt_index].present) {
trigger_page_fault(va);
return 0;
}
// 检查是否为 1GB 大页
if (pdpt[pdpt_index].ps) {
return (pdpt[pdpt_index].frame << 30) | (va & 0x3FFFFFFF);
}
// 第三级:PD
struct page_entry* pd = (struct page_entry*)(pdpt[pdpt_index].frame << 12);
if (!pd[pd_index].present) {
trigger_page_fault(va);
return 0;
}
// 检查是否为 2MB 大页
if (pd[pd_index].ps) {
return (pd[pd_index].frame << 21) | (va & 0x1FFFFF);
}
// 第四级:PT
struct page_entry* pt = (struct page_entry*)(pd[pd_index].frame << 12);
if (!pt[pt_index].present) {
trigger_page_fault(va);
return 0;
}
// 返回最终物理地址
return (pt[pt_index].frame << 12) | offset;
}

大页机制#

除了标准的 4KB 页面,x86 架构还支持大页(Huge Page):

  • 2MB 大页:在 PD 级设置 PS=1,跳过 PT 级,直接映射 2MB 物理内存
  • 1GB 大页:在 PDPT 级设置 PS=1,跳过 PD 和 PT 级,直接映射 1GB 物理内存

使用大页的优势:

  1. 减少页表层级,降低内存访问开销
  2. 减少页表占用的内存
  3. 提高 TLB 覆盖范围,减少 TLB 缺失
; 概念示例:开启 2MB 大页的页目录项设置
; 注意:本系列代码实际使用 4KB 标准页(PAGE_SIZE equ 0x1000),
; 此处仅为演示 2MB 大页 PDE 的设置原理,并非项目真实代码。
setup_2mb_page:
mov eax, 0x83 ; P=1, R/W=1, PS=1 (0x80 = 大页标志)
mov ebx, 0x000000 ; 物理基址
mov ecx, 512 ; 映射 512 个 2MB 页 = 1GB
.fill_pd:
mov [pd_base + ecx*8 - 8], eax
mov dword [pd_base + ecx*8 - 4], 0
add eax, 0x200000 ; 下一个 2MB
loop .fill_pd
ret

TLB 覆盖范围与页面大小#

TLB 的覆盖范围是指 TLB 中所有条目能够映射的物理内存总量:

TLB 覆盖范围 = TLB 条目数 × 页面大小

以 L2 STLB 1536 条目、4 KB 页面为例,覆盖范围 = 1536 × 4 KB ≈ 6 MB。这意味着如果一个进程的工作集超过 6 MB,就会频繁发生 TLB Miss。

页面大小TLB 覆盖范围(1536 条目)适用场景
4 KB6 MB通用应用
2 MB3 GB数据库、虚拟化
1 GB1.5 TB大规模内存数据库

更大的页面可以让 TLB 覆盖更多内存,减少 TLB Miss,但大页并非没有代价:

  • 内存浪费:大页的分配粒度更大,即使只使用其中一小部分,也要占用整页。2 MB 大页哪怕只用了 1 KB,也要分配 2 MB
  • 碎片化:随着系统运行,物理内存会碎片化,很难找到连续的 2 MB 或 1 GB 物理页帧
  • 管理复杂度:操作系统需要额外的数据结构来管理不同大小的页面

缺页异常与页面置换#

缺页异常#

当程序访问的虚拟地址对应的页表项不存在(P=0)时,CPU 会触发缺页异常(Page Fault,中断号 14)。操作系统需要在异常处理程序中完成以下工作:

1
保存异常信息(CR2 寄存器存放导致缺页的虚拟地址)
2
检查虚拟地址是否合法
3
分配新的物理页框
4
如果需要,从磁盘加载页面内容
5
更新页表项
6
返回重新执行导致缺页的指令

缺页异常的错误码格式:

错误码(Error Code):

01234
字段PW/RU/SRSVDI/D
含义0=页面不存在,1=保护违例0=读操作,1=写操作0=内核态,1=用户态1=保留位违例1=取指触发(指令访问)

位 0 到位 2 是最常用的三位。P=0 表示页面根本不存在(需要分配或换入),P=1 且 W/R=1 表示写了一个只读页(可能是 COW 或保护违例)。位 3 的 RSVD 置位表示页表保留位被错误地设成了 1。位 4 的 I/D 置位表示缺页由取指触发,配合 SMEP 等机制可以检测从用户态数据页执行代码的攻击。高位保留。

缺页异常处理流程:

// 简化的缺页异常处理程序
void page_fault_handler(uint64_t error_code) {
uint64_t fault_addr;
asm volatile("mov %%cr2, %0" : "=r"(fault_addr));
// 获取当前进程的页表
process_t* proc = get_current_process();
// 检查地址是否在合法范围内
if (!is_valid_address(proc, fault_addr)) {
kill_process(proc, SIGSEGV);
return;
}
// 检查是否为写操作但页面只读(写时复制)
if ((error_code & 0x03) == 0x03) { // P=1, W=1
handle_cow(proc, fault_addr);
return;
}
// 分配新的物理页
uint64_t frame = alloc_physical_page();
if (frame == 0) {
// 物理内存不足,需要换出页面
frame = swap_out_page();
}
// 清零页面
memset((void*)frame, 0, PAGE_SIZE);
// 更新页表
map_page(proc->page_table, fault_addr, frame,
PTE_PRESENT | PTE_WRITABLE | PTE_USER);
// 刷新 TLB
invlpg(fault_addr);
}

写时复制(Copy-on-Write)#

写时复制是一种重要的优化技术。当进程调用 fork() 创建子进程时,并不立即复制父进程的所有内存页面,而是将父子进程的页面都标记为只读,并指向相同的物理页框。当任一进程尝试写入时,触发缺页异常,操作系统才真正复制该页面。

// fork 时的页表处理
void fork_copy_pages(process_t* parent, process_t* child) {
for (each page in parent->page_table) {
if (page->present && !page->cow) {
// 标记为写时复制
page->writable = 0;
page->cow = 1;
// 子进程指向同一物理页
child->page_table[vpn] = page;
// 增加引用计数
frame_ref_count[page->frame]++;
}
}
}
// 写时复制的缺页处理
void handle_cow(process_t* proc, uint64_t addr) {
pte_t* pte = get_pte(proc->page_table, addr);
if (pte->cow && pte->ref_count > 1) {
// 分配新页面
uint64_t new_frame = alloc_physical_page();
// 复制原页面内容
memcpy((void*)new_frame, (void*)(pte->frame << 12), PAGE_SIZE);
// 更新引用计数
frame_ref_count[pte->frame]--;
// 更新页表项
pte->frame = new_frame >> 12;
pte->writable = 1;
pte->cow = 0;
} else {
// 只有一个引用,直接改为可写
pte->writable = 1;
pte->cow = 0;
}
}

页面置换算法#

当物理内存不足时,操作系统需要将一些页面换出到磁盘,腾出空间给新的页面。选择哪个页面换出是一个关键问题,常见的算法有:

最优置换(OPT)#

换出将来最长时间不会被访问的页面。这是理论最优算法,但无法实现(因为无法预知未来的访问模式)。

先进先出(FIFO)#

换出最早进入内存的页面。实现简单但效果不佳,可能出现 Belady 异常(分配的页框增多但缺页率反而上升)。

最近最少使用(LRU)#

换出最近最长时间没有被访问的页面。这是最常用的算法,但精确实现开销较大。

// 简化的 LRU 实现(使用访问位)
#define MAX_FRAMES 1024
typedef struct {
uint64_t frame;
uint64_t vpn; // 虚拟页号
int referenced; // 引用位
int clock; // 时钟值
} frame_info_t;
frame_info_t frame_table[MAX_FRAMES];
// 时钟算法(LRU 的近似实现)
uint64_t select_victim_frame() {
static int hand = 0;
while (1) {
if (frame_table[hand].referenced == 0) {
// 找到牺牲页面
uint64_t victim = frame_table[hand].frame;
hand = (hand + 1) % MAX_FRAMES;
return victim;
} else {
// 给第二次机会
frame_table[hand].referenced = 0;
hand = (hand + 1) % MAX_FRAMES;
}
}
}

时钟算法(Clock)#

时钟算法是 LRU 的实用近似实现,使用一个循环链表和一个「指针」,每个页面有一个访问位:

  1. 指针扫描页面
  2. 如果访问位为 0,选择该页面换出
  3. 如果访问位为 1,将访问位清零,继续扫描
flowchart LR p0["页面 (P=0)"] --> p1["页面 (P=1)"] p1 --> p2["页面 (P=0)"] p2 --> p0 p0 ---|"指针"| scan["扫描方向"]

P=0 的页面会被选中换出,P=1 的页面会被清零并跳过。

TLB 加速#

TLB 的作用#

多级页表的地址转换需要多次内存访问,严重影响性能。例如,四级页表需要 4 次页表访问才能完成一次地址转换。为了加速这个过程,CPU 引入了转换后备缓冲区(Translation Lookaside Buffer,TLB)。

TLB 是一个高速缓存,存储最近使用的虚拟页到物理页框的映射。TLB 位于 CPU 内部,访问速度接近寄存器。当进行地址转换时,CPU 首先查询 TLB,如果命中(TLB Hit)则直接获得物理地址,无需访问内存中的页表。

flowchart TD VA[虚拟地址] --> TLB[TLB 查询] TLB -->|命中| PA1[物理地址] TLB -->|未命中| PT[页表遍历] PT --> UPD[更新 TLB] UPD --> PA2[物理地址]

TLB 结构#

TLB 采用相联存储器(Associative Memory)实现,支持快速并行查找。典型的 TLB 结构:

TLB 条目结构:

字段VPNASIDPFNDAR/WU/SV
含义虚拟页号地址空间标识符物理帧号脏位访问位读写权限用户/超级用户权限有效位

TLB 的分层结构#

现代 CPU 通常将 TLB 分为两层:

层级名称大小(典型值)覆盖范围延迟
L1 TLB一级 TLB64-128 条目指令/数据分离1-2 周期
L2 TLB二级 TLB512-1536 条目指令数据共享5-10 周期

以 Intel Core i7 处理器为例:

  • L1 ITLB:64 条目(4 KB 页),32 条目(大页)
  • L1 DTLB:64 条目(4 KB 页),32 条目(大页)
  • L2 STLB(共享):1536 条目(4 KB 页)

四级页表每次地址转换需要 4 次内存访问。假设内存访问延迟为 100 ns,一次地址转换就需要 400 ns,加上最终的数据访问共 500 ns。而 CPU 的时钟周期可能只有 0.3 ns(3 GHz),这意味着一次内存访问就要等待上千个时钟周期。TLB 正是为了避免这笔开销而存在的。

TLB 缺失处理#

TLB 缺失有两种处理方式:

硬件处理#

由 CPU 的 MMU 自动遍历页表,填充 TLB。这种方式称为硬件页表遍历(Hardware Page Table Walk),x86 架构采用这种方式。

软件处理#

TLB 缺失触发异常,由操作系统软件遍历页表并填充 TLB。MIPS、ARM 等架构采用这种方式。

; x86 的 TLB 管理
; 刷新单个虚拟地址的 TLB 条目
; INVLPG 指令使指定地址的 TLB 条目失效
invlpg [rax]
; 完整刷新 TLB(通过重写 CR3)
mov eax, cr3
mov cr3, eax
; 刷新全局页(需要先清除 CR4.PGE)
mov eax, cr4
and eax, ~0x80 ; 清除 PGE 位
mov cr4, eax
or eax, 0x80 ; 恢复 PGE 位
mov cr4, eax

TLB 刷新与上下文切换#

当发生进程切换时,新进程的虚拟地址空间与旧进程完全不同,因此 TLB 中的旧映射不再有效。处理方式有三种:

  1. 全部刷新:最简单的方式,切换时清空所有 TLB 条目。代价是切换后会有大量 TLB Miss。
  2. ASID 标记(Address Space ID):为每个进程分配一个 ASID,TLB 条目中附带 ASID 标记。查找时只匹配当前 ASID 的条目,避免刷新。ARM 和 MIPS 架构使用这种方式。
  3. PCID 机制(Process-Context Identifier):x86 从 Nehalem 架构开始支持类似 ASID 的机制,称为 PCID。Linux 从 2.6.36 开始支持 PCID。

TLB 一致性#

在多核系统中,当一个 CPU 修改了页表(如修改页面权限、换出页面),其他 CPU 的 TLB 可能还缓存着旧的映射,这会导致 TLB 一致性问题。操作系统需要通过处理器间中断(IPI)来通知其他 CPU 刷新 TLB。

// 多核 TLB 刷新
void flush_tlb_others(uint64_t addr) {
// 向其他 CPU 发送 IPI
for (int i = 0; i < num_cpus; i++) {
if (i != current_cpu) {
send_ipi(i, IPI_TLB_FLUSH, addr);
}
}
// 刷新本地 TLB
invlpg(addr);
}
// TLB 刷新的 IPI 处理函数
void handle_tlb_flush_ipi(uint64_t addr) {
if (addr == FLUSH_ALL) {
// 刷新整个 TLB
write_cr3(read_cr3());
} else {
// 刷新指定地址
invlpg(addr);
}
}

延伸阅读:Linux 的实现与生产应用#

本章讲的是虚拟内存的通用机制,本系列后续章节会在自研 OS 中实现物理内存管理器和虚拟内存管理器。如果对 Linux 生产级实现感兴趣,以下方向值得深入。

Linux 内存管理源码#

Linux 内核的内存管理代码位于 mm/ 目录下,关键文件包括:

Linux 的五级页表类型 pgd_tp4d_tpud_tpmd_tpte_t 定义在 include/linux/pgtable.h 中,进程的虚拟地址空间由 struct mm_structstruct vm_area_struct 描述(include/linux/mm_types.h)。这些数据结构比本章讲的二级页表复杂得多,但核心思路一致:多级页表按需分配,TLB 加速转换,缺页异常驱动按需换页。

大页在生产环境的取舍#

大页能减少 TLB Miss,对大内存工作负载(数据库、虚拟化)有实际收益,但并非总是正收益:

  • THP(透明大页) 的后台合并操作可能导致延迟抖动,Oracle、MongoDB、Redis 官方文档都建议在生产环境关闭 THP
  • 静态大页(HugeTLB) 需要预分配,分配粒度大(2 MB 起步),碎片化后难以获取连续物理页
  • Redis 的 fork + COW 持久化机制与 THP 冲突:fork 后复制 2 MB 大页的页表比 4 KB 小页慢得多

这些取舍在自研 OS 中暂时不会遇到,但理解它们有助于在将来设计生产级内存管理时少走弯路。

下一章将正式加载内核,解析 ELF 可执行文件格式,从汇编引导程序跳转到 C 语言内核入口。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

虚拟内存
https://blog.souloss.cn/posts/os/virtual-memory-deep-dive/
作者
Souloss
发布于
2024-03-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时