虚拟内存:操作系统最优雅的”骗局”
一句话结论(30s)
虚拟内存是操作系统给每个进程造的「独占全部内存」的幻觉。因为每个进程都拿到独立的 4GB 地址空间、但物理内存有限且几十个进程共享,所以必须靠 MMU + 页表做地址翻译、靠 TLB 加速、靠缺页中断延迟分配——这一整套机制同时实现了进程隔离、内存保护和物理内存的高效利用,是操作系统最优雅的设计。
核心原理(2min)
- 虚拟地址 → 物理地址:MMU 提取页号查页表得物理页框号,页框号 + 页内偏移 = 物理地址
- TLB:MMU 内部的页表项硬件缓存,命中时翻译近乎零延迟
- 多级页表:页表本身也分页,只分配真正使用的地址范围,避免全量页表(4MB/进程)浪费
- 缺页中断:
malloc只预留虚拟空间不分配物理页,首次访问才触发缺页真正分配——“懒惰是操作系统最重要的美德” - COW(写时复制):
fork父子共享物理页并标记只读,写入时才复制;Redis RDB 快照就靠这个实现无阻塞备份
底层深入(5-10min)
每个进程都活在自己的”平行宇宙”里
启动一个 32 位程序,它看到的地址空间是从 0x00000000 到 0xFFFFFFFF 的完整 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_missing → do_anonymous_page/do_fault)② 页被换出到 swap(do_swap_page 换回)③ 写只读页(COW,do_wp_page)④ NUMA/保护页(do_numa_page)。可以对着 handle_pte_fault 的分派逻辑逐条讲。