Skip to content
Go back

虚拟内存与物理内存——操作系统最优雅的"骗局

虚拟内存:操作系统最优雅的”骗局”

一句话结论(30s)

虚拟内存是操作系统给每个进程造的「独占全部内存」的幻觉。因为每个进程都拿到独立的 4GB 地址空间、但物理内存有限且几十个进程共享,所以必须靠 MMU + 页表做地址翻译、靠 TLB 加速、靠缺页中断延迟分配——这一整套机制同时实现了进程隔离、内存保护和物理内存的高效利用,是操作系统最优雅的设计。

核心原理(2min)

底层深入(5-10min)

每个进程都活在自己的”平行宇宙”里

启动一个 32 位程序,它看到的地址空间是从 0x000000000xFFFFFFFF 的完整 4GB。再启动一个,它也看到同样的 4GB。但你的电脑可能只有 8GB 物理内存,却同时跑着几十个进程——这怎么可能?

答案是虚拟内存。每个进程拥有独立的虚拟地址空间,由操作系统和 MMU(内存管理单元)协作完成虚拟地址到物理地址的映射。进程以为自己独占 4GB,实际上它的数据可能散落在物理内存的任何角落。

💭 思考穿插:为什么「每个进程一份页表」就能做到隔离?关键在于页表是每进程独立的——进程 A 的页表里根本不存在进程 B 的物理页映射,所以 A 再怎么遍历自己的地址空间,也「翻」不出 B 的内存。隔离靠的不是「谁看不见谁」的玄学,而是「映射表里根本没有你的条目」这个硬约束。这也是为什么进程切换要换页表(cr3 寄存器切到新进程的页表基址)——换了表,整个可见世界就换了。

MMU 的翻译过程

虚拟地址(32位):
  | 页号 (20 bit) | 页内偏移 (12 bit) |
  
MMU 翻译:
  1. 提取页号 → 查页表 → 获取物理页框号
  2. 物理页框号 + 页内偏移 = 物理地址

如果每次访问内存都要查页表(在内存中),每次访存需要两次内存访问——太慢。TLB(Translation Lookaside Buffer)是 MMU 内部的硬件缓存,缓存最近使用的页表项。TLB 命中时,翻译几乎零延迟。

💭 思考穿插:为什么查页表慢、加了 TLB 就快?因为页表存在内存里,查一次翻译等于额外一次内存访问,每次都这样访存延迟直接翻倍。TLB 是放在 MMU 内部的硬件缓存,物理上更近、命中时翻译和取数据几乎同时进行。而「局部性」是 TLB 成立的前提——程序短时间内访问的地址集中在少数几页,所以一个小缓存就能拦住绝大多数翻译请求。

多级页表:页表本身也是”稀疏”的

32 位地址空间,每页 4KB,共 $2^{20} = 1,048,576$ 个页。每个页表项 4B,全量页表需要 4MB。每个进程都分配 4MB 页表太浪费——64 位系统更是天文数字。

多级页表把页表本身也分页——只有真正被使用的地址范围才分配对应的页表页:

Linux 4级页表(64位):
  虚拟地址 = [PGD索引|PUD索引|PMD索引|PTE索引|页内偏移]
  
  只分配进程真正使用的页表页,大部分中间级别的页表项为空 → 节省大量内存

💭 思考穿插:为什么页表要「多级」而不是一张大表?如果是一张扁平大表,4GB 空间 × 每项 4B = 4MB,每个进程都要 4MB,100 个进程就是 400MB,而且大部分地址根本用不到。多级页表把「一整张表」拆成「按需分配的小块」——你只用到地址空间的一小块,就只分配那一小块对应的各级页表页,没用的级别直接留空(pgd/pud/pmd 项为 none)。这是「稀疏数据结构」在 OS 里的经典应用:用「索引层级」换「零浪费」。

缺页中断:延迟分配的魔法

当你 malloc(1GB) 时,操作系统并没有真的分配 1GB 物理内存。它只是在虚拟地址空间”预留”了这段范围,更新了页表标记。只有当你真正访问这些地址时,MMU 发现页表项无效,触发缺页中断(Page Fault),内核才分配一个真正的物理页。

char *buf = malloc(1024 * 1024 * 1024);  // 1GB分配,瞬间返回
// 此时物理内存没有分配任何新页面
buf[0] = 'a';  // 第一次访问 → 缺页中断 → 分配一个4KB物理页
buf[4096] = 'b'; // 访问下一个页面 → 又一次缺页中断

这就是为什么 malloc 返回很快,但第一次使用内存时有额外延迟——懒惰是操作系统最重要的美德。

💭 思考穿插malloc 返回快、首次访问慢,这个「快」到底省了什么?省的是物理页——内核只在页表里「登记」了这段虚拟地址,却没真正分配物理页。真正的物理页分配被推迟到「第一次访问」那一刻,由硬件缺页异常触发。如果这段内存你 malloc 了却从没用过(很多代码都这样),那它就一个物理页都没浪费

下面这段是 Linux 内核处理缺页的真实源码mm/memory.c),从入口到匿名页分配一层层看下去:

/* mm/memory.c —— 缺页入口:handle_mm_fault(节选) */
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
			   unsigned int flags, struct pt_regs *regs)
{
	/* If the fault handler drops the mmap_lock, vma may be freed */
	struct mm_struct *mm = vma->vm_mm;
	vm_fault_t ret;
	bool is_droppable;

	__set_current_state(TASK_RUNNING);

	ret = sanitize_fault_flags(vma, &flags);
	if (ret)
		goto out;

	if (!arch_vma_access_permitted(vma, flags & FAULT_FLAG_WRITE,
					    flags & FAULT_FLAG_INSTRUCTION,
					    flags & FAULT_FLAG_REMOTE)) {
		ret = VM_FAULT_SIGSEGV;
		goto out;
	}

	is_droppable = !!(vma->vm_flags & VM_DROPPABLE);

	/*
	 * Enable the memcg OOM handling for faults triggered in user
	 * space.  Kernel faults are handled more gracefully.
	 */
	if (flags & FAULT_FLAG_USER)
		mem_cgroup_enter_user_fault();

	lru_gen_enter_fault(vma);

	if (unlikely(is_vm_hugetlb_page(vma)))
		ret = hugetlb_fault(vma->vm_mm, vma, address, flags);
	else
		ret = __handle_mm_fault(vma, address, flags);

handle_mm_fault 是各架构底层汇编异常处理最终跳到的内核缺页入口。它先做两层「安检」:sanitize_fault_flags 校验 flag、arch_vma_access_permitted 校验访问权限——不合法直接返回 VM_FAULT_SIGSEGV(就是段错误)。通过后分派:大页走 hugetlb_fault,普通页走 __handle_mm_fault,后者再逐级往下走到 handle_pte_fault

/* mm/memory.c —— 按 PTE 状态分派(节选自 handle_pte_fault) */
	if (!vmf->pte)
		return do_pte_missing(vmf);

	if (!pte_present(vmf->orig_pte))
		return do_swap_page(vmf);

	if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
		return do_numa_page(vmf);

这段是缺页处理的核心分派器,三种条件对应三种完全不同的缺页原因:pte 为空 → 页根本没建立映射,走 do_pte_missing(首次访问的延迟分配);pte 存在但 !pte_present → 页被换出到 swap,走 do_swap_page(从磁盘换回);pte_protnone → NUMA 平衡的保护页,走 do_numa_page。所以「缺页」不是单一事件,而是一族需要分别处理的情况。

/* mm/memory.c —— 空 PTE 的进一步分派 */
static vm_fault_t do_pte_missing(struct vm_fault *vmf)
{
	if (vma_is_anonymous(vmf->vma))
		return do_anonymous_page(vmf);
	else
		return do_fault(vmf);
}
/* mm/memory.c —— 匿名页缺页:延迟分配的真正实现(节选) */
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
	struct vm_area_struct *vma = vmf->vma;
	unsigned long addr = vmf->address;
	struct folio *folio;
	vm_fault_t ret = 0;
	int nr_pages;
	pte_t entry;

	/* File mapping without ->vm_ops ? */
	if (vma->vm_flags & VM_SHARED)
		return VM_FAULT_SIGBUS;

	/*
	 * Use pte_alloc() instead of pte_alloc_map(), so that OOM can
	 * be distinguished from a transient failure of pte_offset_map().
	 */
	if (pte_alloc(vma->vm_mm, vmf->pmd))
		return VM_FAULT_OOM;

	/* Use the zero-page for reads */
	if (!(vmf->flags & FAULT_FLAG_WRITE) &&
			!mm_forbids_zeropage(vma->vm_mm)) {
		entry = pte_mkspecial(pfn_pte(zero_pfn(vmf->address),
						vma->vm_page_prot));
		vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
				vmf->address, &vmf->ptl);
		if (!vmf->pte)
			goto unlock;
		if (vmf_pte_changed(vmf)) {
			update_mmu_tlb(vma, vmf->address, vmf->pte);
			goto unlock;
		}
		ret = check_stable_address_space(vma->vm_mm);
		if (ret)
			goto unlock;
		/* Deliver the page fault to userland, check inside PT lock */
		if (userfaultfd_missing(vma)) {
			pte_unmap_unlock(vmf->pte, vmf->ptl);
			return handle_userfault(vmf, VM_UFFD_MISSING);
		}
		if (vmf_orig_pte_uffd_wp(vmf))
			entry = pte_mkuffd_wp(entry);
		set_pte_at(vma->vm_mm, addr, vmf->pte, entry);

		/* No need to invalidate - it was non-present before */
		update_mmu_cache(vma, addr, vmf->pte);
		goto unlock;
	}

	/* Allocate our own private page. */
	ret = vmf_anon_prepare(vmf);
	if (ret)
		return ret;
	/* Returns NULL on OOM or ERR_PTR(-EAGAIN) if we must retry the fault */
	folio = alloc_anon_folio(vmf);
	if (IS_ERR(folio))
		return 0;
	if (!folio)
		goto oom;

do_anonymous_page 就是 malloc 首次写访问时真正分配物理页的地方,里面有两个极巧妙的点:读访问走零页——所有进程共享同一物理零页并映射为只读(zero_pfn),直到第一次写才真正 alloc_anon_folio 分配独立物理页(这同时也是 COW 的地基);物理页在缺页这一刻才诞生——直接把「malloc 只预留、不分配」的懒惰哲学落到了代码里。

COW(写时复制):fork 的魔法

fork() 创建子进程时,不会复制父进程的全部物理内存。子进程复制父进程的页表(指向相同的物理页),同时将双方的所有页标记为只读。只有一方尝试写入时,才触发缺页中断,内核复制该物理页,更新双方的页表指向各自的副本。

Redis RDB 持久化就是利用这个特性:fork() 子进程做快照,父子共享物理内存。子进程只读数据写入 RDB 文件,父进程写操作触发 COW 复制,二者互不阻塞。

💭 思考穿插fork 为什么不直接复制全部内存?因为大部分情况下子进程 fork 之后紧接着就 exec 换掉整个地址空间——如果先花大代价把父进程内存全复制一遍,马上又全扔掉,纯属浪费。COW 的思路是「先共享、后分裂」:只复制页表(指向同一批物理页),两边都标只读,谁先写谁触发缺页、只复制那一页。把「全量拷贝」推迟并细化为「按页、按需」拷贝,这是操作系统的又一处「懒惰即智慧」。

总结

虚拟内存是操作系统最精妙的设计之一——它通过 MMU + 页表 + TLB + 缺页中断这一整套机制,让每个进程拥有”独占全部内存”的幻觉,同时实现了隔离、保护和高效利用物理内存。

章末提问

Q1:为什么说「每个进程独占 4GB 是幻觉」?这个幻觉是怎么制造出来的?

回答思路:从「物理内存有限、进程众多」的矛盾切入,落到页表映射这个机制——每个进程一份独立页表,把同一套虚拟地址翻译到不同物理页(隔离),也把未分配的映射留给缺页中断(延迟分配)。核心一句:进程看到的连续地址空间是「页表 + MMU 翻译」构造出的逻辑视图,不是真实的物理排布。

Q2:TLB miss 之后会发生什么?为什么需要多级页表?

回答思路:TLB miss → 查内存中的多级页表 → 找到 PTE 填回 TLB。多级页表是为了省内存(稀疏,只分配用到的部分),代价是 miss 后遍历级数更多,所以 TLB 是用「命中率」换「平均延迟」。再补一句:Meltdown 这类攻击正是利用了越权访问在页表翻译上的时序差异。

Q3:malloc(1GB) 之后立刻 memset 整个 buffer,和只写一个字节,物理内存占用差多少?

回答思路:malloc 只预留虚拟空间,物理页按需分配。memset 1GB → 触发约 26 万个缺页(1GB/4KB)→ 分配约 1GB 物理页;只写一字节 → 只触发 1 次缺页 → 分配 1 页(4KB)。强调「虚拟空间 ≠ 物理占用」,再用 overcommit(内存超卖)机制放大这个差距。

Q4:fork 的 COW 场景里,父进程先写、子进程后写,各自的物理页会发生什么?

回答思路:先写者触发缺页 → 内核复制该物理页给「写入方」,更新它自己的页表指向新副本,并把双方该页都改为可写 → 后写者写的是自己那份,不再触发 COW。关键:COW 按粒度、按第一次写触发,不是整个地址空间一次性分裂。

Q5:缺页中断一定是「错误」吗?内核里缺页有几种常见原因?

回答思路:缺页是正常机制,不是 bug。至少四类:① 首次访问未映射页(do_pte_missingdo_anonymous_page/do_fault)② 页被换出到 swap(do_swap_page 换回)③ 写只读页(COW,do_wp_page)④ NUMA/保护页(do_numa_page)。可以对着 handle_pte_fault 的分派逻辑逐条讲。


Share this post on:

Previous Post
进程、线程、协程——三者的本质区别与适用场景
Next Post
线程间通讯方式:五种同步机制的原理与选型