虚拟内存与页表
难度:⭐⭐ | 高频指数:🔥🔥🔥
面试回答
常见问法
- 为什么需要虚拟内存?
- 页表是什么?多级页表解决什么问题?
- TLB 是什么?为什么需要它?
- 缺页中断的流程是什么?
- malloc 底层是怎么实现的?
- 什么是内存映射文件?
回答
虚拟内存是操作系统提供的一层抽象,让每个进程认为自己拥有独立的、连续的地址空间。实际的物理内存由操作系统通过页表进行映射和管理。
为什么需要虚拟内存:
- 隔离性:每个进程有独立地址空间,互不干扰
- 简化编程:程序员不需要关心物理内存布局
- 内存超卖:所有进程的虚拟内存总和可以超过物理内存(靠换页)
- 共享:多个进程可以映射同一物理页(如共享库)
- 保护:通过页表权限位实现读/写/执行权限控制
页表是虚拟地址到物理地址的映射表。虚拟地址分为”页号 + 页内偏移”,通过页号查页表得到物理页框号,拼上偏移就是物理地址。
多级页表解决的是页表本身太大的问题。以 32 位系统为例,4KB 页大小需要 100 万个页表项(4MB),如果用二级页表,只需要为实际使用的虚拟地址分配页表页,大幅节省内存。
追问
1. TLB 是什么?
TLB(Translation Lookaside Buffer)是页表的高速缓存,位于 CPU 内部。因为每次内存访问都要查页表(多级页表要查多次),TLB 缓存最近使用的页表项,命中时一次就能完成地址转换。TLB 未命中才需要走完整的页表查询(page table walk)。
2. 缺页中断流程?
- CPU 访问虚拟地址,查页表发现该页不在物理内存(有效位为 0)
- 触发缺页中断(Page Fault),陷入内核
- 内核检查该地址是否合法(是否在进程的虚拟地址空间内)
- 如果合法,在物理内存中找一个空闲页框
- 如果没有空闲页框,用页面置换算法(LRU 等)换出一页
- 从磁盘(swap 或文件)读入所需页面
- 更新页表项,设置有效位
- 重新执行触发缺页的指令
3. malloc 底层实现?
- 小块内存(< 128KB):调用
brk/sbrk扩展堆顶 - 大块内存(>= 128KB):调用
mmap分配独立的虚拟内存区域 - malloc 内部维护空闲链表,尽量复用已释放的内存块
- glibc 的 ptmalloc 使用 arena + bin 的分配策略
原理展开
1. 虚拟地址到物理地址的转换
以 32 位系统、4KB 页为例:
虚拟地址(32 位):
┌──────────────┬──────────────┐
│ 页号(20位) │ 页内偏移(12位)│
└──────────────┴──────────────┘
转换过程:
虚拟页号 → 查页表 → 物理页框号
物理地址 = 物理页框号 × 页大小 + 页内偏移
2. 多级页表
问题:32 位地址空间,4KB 页,需要 2^20 = 100 万个页表项。每项 4 字节,页表就要 4MB。每个进程都要 4MB 页表,太浪费。
二级页表方案:
虚拟地址(32 位):
┌────────────┬────────────┬──────────────┐
│ 一级页号(10)│ 二级页号(10)│ 页内偏移(12) │
└────────────┴────────────┴──────────────┘
一级页表(页目录):1024 项,每项指向一个二级页表
二级页表:1024 项,每项指向一个物理页框
好处:只有实际使用的虚拟地址范围才需要分配二级页表。大部分进程只用了很小一部分地址空间,节省大量内存。
64 位系统(x86-64)使用四级页表:PGD → PUD → PMD → PTE,每级 9 位索引。
3. TLB 工作原理
CPU 访问虚拟地址
│
▼
查 TLB(硬件并行比较)
│
├── 命中 → 直接得到物理地址(1 个时钟周期)
│
└── 未命中 → Page Table Walk(多次内存访问)
│
▼
更新 TLB,返回物理地址
TLB 的关键特性:
- 容量小(通常 64-1024 项),但命中率高(> 99%)
- 全相联或组相联结构
- 进程切换时需要刷新 TLB(或使用 ASID 标记)
- TLB miss 的代价:多级页表需要多次内存访问
4. 页面置换算法
| 算法 | 原理 | 优缺点 |
|---|---|---|
| FIFO | 淘汰最早进入的页 | 简单但可能淘汰常用页 |
| LRU | 淘汰最久未使用的页 | 效果好但实现开销大 |
| Clock | LRU 的近似,用访问位 | 实际系统常用 |
| LFU | 淘汰使用频率最低的页 | 对突发访问不友好 |
Linux 使用的是改进的 Clock 算法(双链表 + 活跃/不活跃列表)。
5. malloc 的底层机制
// 小内存:brk 扩展堆
void* p = malloc(64); // 内部调用 brk/sbrk
// 大内存:mmap 映射
void* p = malloc(256 * 1024); // 内部调用 mmap
brk vs mmap:
| 对比项 | brk | mmap |
|---|---|---|
| 分配方式 | 移动堆顶指针 | 映射新的虚拟内存区域 |
| 释放 | 只有堆顶连续空闲才能归还 | 可以直接 munmap 归还 |
| 碎片 | 容易产生外部碎片 | 不会(独立区域) |
| 适用 | 小块频繁分配 | 大块内存 |
ptmalloc 的 bin 结构:
- fast bins:小块(< 64B),单链表,不合并
- small bins:中等块,双链表,精确大小匹配
- large bins:大块,按大小范围排序
- unsorted bin:刚释放的块暂存,下次分配时整理
6. 内存映射文件(mmap)
// 将文件映射到进程地址空间
int fd = open("data.bin", O_RDWR);
void* addr = mmap(NULL, file_size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
// 直接通过指针读写文件内容
char* data = (char*)addr;
data[0] = 'A'; // 修改会写回文件
munmap(addr, file_size);
mmap 的优势:
- 避免 read/write 的用户态-内核态数据拷贝
- 利用页缓存,多个进程可共享同一映射
- 适合大文件随机访问
- 共享库(.so)就是通过 mmap 加载的
mmap 的注意事项:
- 不适合小文件频繁读写(页对齐浪费)
- 写入不是立即落盘(需要 msync 或等内核回写)
- 映射区域访问越界会触发 SIGBUS
7. 虚拟内存的其他应用
- Copy-on-Write:fork 后共享页面,写时才复制
- Demand Paging:页面首次访问时才分配物理内存
- Memory-mapped IO:将设备寄存器映射到虚拟地址空间
- Guard Page:栈溢出检测,栈底放一个不可访问的页
易错点
- 说”虚拟内存就是用磁盘当内存”——这只是虚拟内存的一个功能(swap),不是全部。核心是地址空间隔离和抽象。
- 混淆”页”和”段”——现代系统主要用分页,分段已经很少用了。
- 认为 malloc 直接调用系统调用——malloc 内部有内存池,大部分分配不需要系统调用。
- 说”TLB 是页表的一部分”——TLB 是独立的硬件缓存,不是页表的组成部分。
- 忘记多级页表的好处——不是为了加速查找,而是为了节省页表本身占用的内存。
- 认为 mmap 分配的内存立即占用物理内存——实际是 demand paging,首次访问才分配。
记忆技巧
- 虚拟内存三大好处:隔离、超卖、共享
- 地址转换链路:虚拟地址 → TLB/页表 → 物理地址
- 多级页表口诀:不是为了快,是为了省(省页表内存)
- 缺页中断流程:查页表 → 不在 → 中断 → 找空闲帧 → 读磁盘 → 更新页表 → 重执行
- malloc 分界线:128KB 以下 brk,以上 mmap
- TLB 一句话:页表的 cache,命中率 99%+
面试速答版
虚拟内存让每个进程拥有独立的地址空间,核心好处是隔离、超卖和共享。地址转换通过页表完成:虚拟页号查页表得到物理页框号。多级页表解决的是页表本身太大的问题,只为实际使用的地址范围分配页表页。TLB 是页表的硬件缓存,命中率通常 99% 以上。缺页中断发生在访问的页不在物理内存时,内核会从磁盘读入并更新页表。malloc 小块用 brk 扩展堆,大块用 mmap 映射独立区域,内部维护空闲链表减少系统调用。