虚拟内存的概念与意义
在上一章中,我们已经在 Loader 程序中成功开启了分页机制,初步接触了虚拟内存的概念。本章将深入探讨虚拟内存的工作原理,理解它是如何让每个进程都拥有独立的地址空间,以及操作系统如何高效地管理物理内存。
为什么需要虚拟内存
在早期的计算机系统中,程序直接使用物理内存地址。这种做法存在诸多问题:首先是地址冲突问题,当多个程序同时运行时,它们可能使用相同的内存地址,导致数据互相覆盖;其次是内存不足问题,程序的内存需求可能超过物理内存容量;再者是安全问题,恶意程序可以读写任意物理内存,窃取或破坏其他进程的数据。
虚拟内存技术的出现完美解决了这些问题。它为每个进程提供了一个独立的、连续的虚拟地址空间,使得每个进程都以为自己独占了整个内存。在 32 位系统中,每个进程拥有 4GB 的虚拟地址空间;在 64 位系统中,这个数字更是达到了惊人的 256TB(实际实现中通常为 48 位或 57 位地址)。
虚拟内存的核心思想是将程序的虚拟地址与物理内存地址解耦。程序使用虚拟地址进行访存操作,而 CPU 通过内存管理单元(MMU)将虚拟地址翻译成物理地址。这种翻译过程是透明的,程序无需关心实际的物理内存布局。
虚拟内存的优势
虚拟内存带来了三个主要优势:
第一是进程隔离。每个进程都有独立的虚拟地址空间,一个进程无法直接访问另一个进程的内存,这从根本上解决了进程间的内存安全问题。即使一个进程崩溃,也不会影响其他进程的运行。
第二是内存超额分配。操作系统可以将不常用的内存页面换出到磁盘,从而让运行的程序总内存需求超过实际物理内存容量。当程序访问被换出的页面时,会触发缺页异常,操作系统再将该页面从磁盘加载回内存。
第三是内存共享。多个进程可以通过将各自的虚拟页面映射到同一个物理页面来实现内存共享。这在实现共享库、进程间通信等场景中非常有用,可以显著节省物理内存。
虚拟地址空间布局(x86-64)
32 位地址空间布局
在 32 位系统中,虚拟地址空间为 4GB。Linux 系统通常将高 1GB 划分给内核空间,低 3GB 划分给用户空间:
高地址┌─────────────────┐ 0xFFFFFFFF│ ││ 内核空间 │ 1GB (0xC0000000 - 0xFFFFFFFF)│ │├─────────────────┤ 0xC0000000│ ││ 用户空间 │ 3GB (0x00000000 - 0xBFFFFFFF)│ │└─────────────────┘ 0x00000000低地址用户空间的布局从低地址到高地址依次为:
- 代码段(Text):存放程序的可执行代码,只读
- 数据段(Data):存放已初始化的全局变量和静态变量,可读写
- BSS 段:存放未初始化的全局变量和静态变量,可读写
- 堆(Heap):动态分配的内存,从低地址向高地址增长
- 内存映射区域(mmap):用于内存映射文件、共享内存等
- 栈(Stack):存放局部变量、函数参数等,从高地址向低地址增长
用户空间布局:┌─────────────────┐ 0xBFFFFFFF│ 栈 │ ↓ 向下增长├─────────────────┤│ ││ (未使用) ││ │├─────────────────┤│ 内存映射区 │├─────────────────┤│ 堆 │ ↑ 向上增长├─────────────────┤│ BSS 段 │├─────────────────┤│ 数据段 │├─────────────────┤│ 代码段 │└─────────────────┘ 0x08048000 (程序起始地址)64 位地址空间布局
在 x86-64 架构中,虚拟地址理论上有 64 位,但实际实现中 CPU 只支持 48 位(早期)或 57 位(较新的 CPU,通过 LA57 特性启用)虚拟地址。这是因为 48 位地址已经可以寻址 256TB 的空间,完全满足当前的需求,同时可以简化 CPU 的设计。
x86-64 使用一种独特的「规范地址」设计:有效地址的第 47 位到第 63 位必须相同(要么全为 0,要么全为 1)。这导致地址空间被分为两部分:
高地址┌─────────────────────────────┐ 0xFFFFFFFFFFFFFFFF│ ││ 内核空间 (高 128TB) │ 0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF│ │├─────────────────────────────┤ 0x00007FFFFFFFFFFF│ ││ 用户空间 (低 128TB) │ 0x0000000000000000 - 0x00007FFFFFFFFFFF│ │└─────────────────────────────┘ 0x0000000000000000低地址
中间的空洞区域(非规范地址):0x0000800000000000 - 0xFFFF7FFFFFFFFFFF访问这些地址会触发通用保护异常(#GP)Linux 在 x86-64 上的具体布局如下:
用户空间:┌─────────────────────────────┐ 0x00007FFFFFFFFFFF│ 栈 │├─────────────────────────────┤│ (共享库、vvar、vdso) │├─────────────────────────────┤│ 堆 │├─────────────────────────────┤│ BSS 段 │├─────────────────────────────┤│ 数据段 │├─────────────────────────────┤│ 代码段 │├─────────────────────────────┤│ (不可访问) │└─────────────────────────────┘ 0x0000000000000000
内核空间:┌─────────────────────────────┐ 0xFFFFFFFFFFFFFFFF│ 直接映射区 (64TB) │ 物理内存的直接映射├─────────────────────────────┤│ vmalloc/ioremap 区 │├─────────────────────────────┤│ vmemmap 区 │ struct page 数组├─────────────────────────────┤│ KASAN 影子内存 │ 内核地址消毒器├─────────────────────────────┤│ 内核代码段/数据段 │└─────────────────────────────┘ 0xFFFF800000000000MMU 地址转换流程
基本概念
在 认识内存管理单元(MMU) 中,我们了解了 MMU 的基本工作原理。这里将深入分析地址转换的具体流程。
当 CPU 执行内存访问指令时,会生成一个虚拟地址(Virtual Address,VA)。MMU 负责将这个虚拟地址转换成物理地址(Physical Address,PA)。转换过程需要使用页表(Page Table),页表是一种数据结构,记录了虚拟页到物理页框的映射关系。
现代操作系统普遍采用分页机制来管理虚拟内存。分页的基本单位是页(Page),通常大小为 4KB。物理内存被划分为相同大小的页框(Page Frame),每个页框可以存储一个页的内容。
地址转换步骤
MMU 进行地址转换的基本步骤如下:
虚拟地址 (VA) 物理地址 (PA)┌──────────────────────┐ ┌──────────────────────┐│ VPN │ Offset │ ─────→ │ PFN │ Offset │└──────────────────────┘ └──────────────────────┘ │ ▲ │ │ ▼ │┌──────────────────────┐ ││ 页表查询 │ ──────────────────┘└──────────────────────┘以 32 位系统、4KB 页面、二级页表为例:
- CPU 生成虚拟地址 VA
- MMU 将 VA 分解为三部分:
- 页目录索引(Directory Index,10 位)
- 页表索引(Table Index,10 位)
- 页内偏移(Page Offset,12 位)
- 从 CR3 寄存器获取页目录的物理基址
- 用页目录索引定位页目录项(PDE),获取页表物理基址
- 用页表索引定位页表项(PTE),获取物理页框号(PFN)
- 将 PFN 与页内偏移组合,得到最终物理地址
32 位虚拟地址结构: 31 22 21 12 11 0┌────────────┬────────────┬────────────────────┐│ 页目录索引 │ 页表索引 │ 页内偏移 ││ (10 bit) │ (10 bit) │ (12 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 位地址空间,这个数字更是天文数字。多级页表通过「按需分配」的方式解决了这个问题:只有实际使用的地址范围才会分配页表,大大节省了内存。
32 位二级页表
32 位系统通常采用二级页表结构:
页目录(Page Directory)├── 1024 个页目录项(PDE)│ 每个 PDE 指向一个页表│ 每个 PDE 管理 4MB 虚拟地址空间│└── 页表(Page Table) ├── 1024 个页表项(PTE) │ 每个 PTE 指向一个物理页框 │ 每个 PTE 管理 4KB 虚拟地址空间 │ └── 总计:1024 × 1024 × 4KB = 4GB 虚拟地址空间页目录项(PDE)和页表项(PTE)的结构:
页目录项(4 字节):┌─────────────────────────────────────────────────────────────┐│ 保留供软件使用 │├─────────────────────────────────────────────────────────────┤│ G│PS│D│A│PCD│PWT│U/S│R/W│P│ 页表基址 [31:12] │└─────────────────────────────────────────────────────────────┘ 7 6 5 4 3 2 1 0
页表项(4 字节):┌─────────────────────────────────────────────────────────────┐│ 保留供软件使用 │├─────────────────────────────────────────────────────────────┤│ G│PAT│D│A│PCD│PWT│U/S│R/W│P│ 页框基址 [31:12] │└─────────────────────────────────────────────────────────────┘ 7 6 5 4 3 2 1 0
关键标志位说明:- 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 39 38 30 29 21 20 12 11 0┌────────────┬────────────┬────────────┬────────────┬───────────┐│ PML4 │ PDPT │ PD │ PT │ Offset ││ 索引 │ 索引 │ 索引 │ 索引 │ ││ (9 bit) │ (9 bit) │ (9 bit) │ (9 bit) │ (12 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 物理内存
使用大页的优势:
- 减少页表层级,降低内存访问开销
- 减少页表占用的内存
- 提高 TLB 覆盖范围,减少 TLB 缺失
; 开启 2MB 大页的页目录项设置示例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缺页异常与页面置换
缺页异常
当程序访问的虚拟地址对应的页表项不存在(P=0)时,CPU 会触发缺页异常(Page Fault,中断号 14)。操作系统需要在异常处理程序中完成以下工作:
- 保存异常信息(CR2 寄存器存放导致缺页的虚拟地址)
- 检查虚拟地址是否合法
- 分配新的物理页框
- 如果需要,从磁盘加载页面内容
- 更新页表项
- 返回重新执行导致缺页的指令
缺页异常的错误码格式:
错误码(Error Code):┌───┬───┬───┬───┬───┬───────┐│ - │ - │ - │ I │ R │ P │ W │ U │└───┴───┴───┴───┴───┴───────┘ │ │ │ │ │ └─ P (0=页面不存在,1=保护违例) │ └───── W/R (0=读操作,1=写操作) └───────── U/S (0=内核态,1=用户态)缺页异常处理流程:
// 简化的缺页异常处理程序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 的实用近似实现,使用一个循环链表和一个「指针」,每个页面有一个访问位:
- 指针扫描页面
- 如果访问位为 0,选择该页面换出
- 如果访问位为 1,将访问位清零,继续扫描
时钟算法示意:
┌──────┐ ╱ P=0 │ │ ────── │ ╲ P=1 │ ╲──────╱ ↓ 指针
P=0 的页面会被选中换出P=1 的页面会被清零并跳过TLB 加速
TLB 的作用
多级页表的地址转换需要多次内存访问,严重影响性能。例如,四级页表需要 4 次页表访问才能完成一次地址转换。为了加速这个过程,CPU 引入了转换后备缓冲区(Translation Lookaside Buffer,TLB)。
TLB 是一个高速缓存,存储最近使用的虚拟页到物理页框的映射。TLB 位于 CPU 内部,访问速度接近寄存器。当进行地址转换时,CPU 首先查询 TLB,如果命中(TLB Hit)则直接获得物理地址,无需访问内存中的页表。
地址转换流程(带 TLB):
虚拟地址 ──→ TLB 查询 ──→ 命中? ──→ 物理地址 │ ↓ 未命中 ┌─────────┐ │ 页表遍历│ └─────────┘ │ ↓ 更新 TLB │ ↓ 物理地址TLB 结构
TLB 采用相联存储器(Associative Memory)实现,支持快速并行查找。典型的 TLB 结构:
TLB 条目结构:┌─────────────────────────────────────────────────────────────┐│ VPN │ ASID │ PFN │ D │ A │ R/W │ U/S │ V │├─────────────────────────────────────────────────────────────┤│虚拟页号│地址空间│物理帧号│脏│访问│权限 │有效││ │标识符 │ │ │ │ │ │└─────────────────────────────────────────────────────────────┘
VPN (Virtual Page Number): 虚拟页号ASID (Address Space ID): 用于区分不同进程的地址空间PFN (Physical Frame Number): 物理帧号D (Dirty): 脏位A (Accessed): 访问位R/W: 读写权限U/S: 用户/超级用户权限V (Valid): 有效位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, cr3mov cr3, eax
; 刷新全局页(需要先清除 CR4.PGE)mov eax, cr4and eax, ~0x80 ; 清除 PGE 位mov cr4, eaxor eax, 0x80 ; 恢复 PGE 位mov cr4, eaxTLB 一致性
在多核系统中,当一个 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); }}小结
虚拟内存是现代操作系统的基石,它通过 MMU 实现虚拟地址到物理地址的转换,为每个进程提供独立的地址空间。多级页表结构有效减少了页表占用的内存,TLB 加速大幅提升了地址转换性能,而缺页异常处理和页面置换机制则让系统能够高效地管理有限的物理内存。
在下一章中,将正式加载内核,了解 ELF 可执行文件格式,以及如何从汇编引导程序跳转到 C 语言内核入口,开始真正的操作系统内核开发。
参考
Intel 64 and IA-32 Architectures Software Developer’s Manual
Understanding the Linux Virtual Memory Manager
OSDev Wiki - Paging
Linux 内核文档 - Memory Management
详解 Linux 中的虚拟内存
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






