虚拟内存基础:物理地址 vs 虚拟地址,MMU的作用,页表与页表项结构,TLB快表原理
好,咱们直接进入正题。虚拟内存这玩意儿,说白了就是给每个进程画了个「大饼」——让每个进程都觉得自己独占整个地址空间。但实际上,物理内存就那么点,大家挤着用。我刚开始接触这部分时也觉得挺绕,但搞懂了地址翻译这条线,后面就顺了。
物理地址 vs 虚拟地址
先说说这两个地址到底啥区别。
- 物理地址:就是内存芯片上实实在在的地址。CPU通过地址总线直接发过去,就能读写那个位置的数据。比如 0x1000,那就是内存条上的第 4096 个字节。
- 虚拟地址:是进程看到的地址。每个进程从 0 开始,到 4GB(32位)或更大。但这地址是假的,得翻译成物理地址才能用。
你想想看,如果没有虚拟地址,两个程序都编译到 0x400000 启动,那不就打架了?虚拟地址就是给每个进程一个独立的「假地址空间」,互不干扰。
核心要点:虚拟地址是进程的「幻觉」,物理地址是硬件的「现实」。MMU 就是那个负责翻译的中间人。
MMU 的作用
MMU,全称 Memory Management Unit,内存管理单元。它通常是 CPU 内部的一个硬件模块。我当年调一个嵌入式板子时,MMU 没配好,程序一跑就崩,查了两天才发现是页表没设对……嗯,从那以后我对 MMU 的初始化流程就特别敏感。
MMU 干的事其实很简单:
- 接收 CPU 发出的虚拟地址
- 查页表,找到对应的物理地址
- 检查权限(读、写、执行)
- 把物理地址发到内存总线
说白了,它就是地址翻译器 + 权限检查员。没有 MMU,操作系统根本没法做进程隔离,一个野指针就能把整个系统搞挂。
页表与页表项结构
页表是 MMU 用来翻译地址的「字典」。每个进程都有自己的页表,操作系统负责维护。
页表里每一项叫 PTE(Page Table Entry),结构大致如下:
// 典型的 32位 x86 页表项结构
// 每个 PTE 占 4 字节
struct pte_t {
unsigned int present : 1; // 页面是否在物理内存中
unsigned int writable : 1; // 是否可写
unsigned int user : 1; // 用户态是否可访问
unsigned int accessed : 1; // 是否被访问过
unsigned int dirty : 1; // 是否被写过
unsigned int reserved : 7; // 保留位
unsigned int frame_addr : 20; // 物理页框号(4KB 对齐)
};
这里要注意,present 位特别关键。如果页面不在内存中(比如被换出到磁盘了),MMU 会触发缺页异常,操作系统再把它加载回来。我见过不少新手写驱动时忘了设 present 位,结果一访问就死机。
| 位域 | 宽度 | 说明 |
|---|---|---|
| present | 1 bit | 页面是否在物理内存中 |
| writable | 1 bit | 是否允许写操作 |
| user | 1 bit | 用户态能否访问 |
| accessed | 1 bit | 硬件自动置位,表示被访问过 |
| dirty | 1 bit | 硬件自动置位,表示被写过 |
| frame_addr | 20 bits | 物理页框号,指向 4KB 物理页 |
多级页表是现代系统的标配。比如 x86_64 用 4 级页表,ARMv8 用 3 级或 4 级。为什么?因为单级页表太费内存了——32位系统单级页表就要 4MB,64位更是天文数字。多级页表按需分配,只给实际使用的虚拟地址范围建页表,省内存。
个人经验:调试页表问题时,我习惯先检查 present 和 writable 位。90% 的页表 bug 都是这两个位没设对。另外,多级页表的遍历逻辑一定要画图,光靠脑子想容易晕。
TLB 快表原理
页表在内存里,每次地址翻译都要查内存,那得多慢?一次内存访问可能就要几十纳秒,翻译一次地址再访问一次,性能直接腰斩。
所以硬件搞了个缓存——TLB(Translation Lookaside Buffer)。它把最近用过的虚拟地址到物理地址的映射存下来。说白了,就是页表的「快照缓存」。
TLB 的工作原理:
- CPU 发出虚拟地址
- MMU 先查 TLB
- 如果命中(TLB hit),直接拿到物理地址,完事
- 如果没命中(TLB miss),再去查页表,然后把结果塞进 TLB
TLB 命中率通常超过 99%,所以大部分地址翻译几乎不花额外时间。但 TLB 有个问题——它很小,一般只有几十到几百个条目。进程切换时,TLB 里的内容就全废了,得重新填充。这就是为什么上下文切换开销大。
避坑指南:我曾经在一个多线程服务器上发现性能抖动,查了半天,发现是频繁的进程切换导致 TLB 刷得太勤。后来改用大页(2MB 或 1GB 页),TLB 覆盖范围大了很多,性能就稳了。大页能减少 TLB miss,但分配和回收更麻烦,得权衡。
TLB 还有个细节——它分指令 TLB 和数据 TLB。指令 TLB 缓存代码页的映射,数据 TLB 缓存数据页的映射。分开缓存能减少竞争,提升性能。现代 CPU 还有二级 TLB,跟 CPU 缓存的多级设计一个道理。
小结一下
虚拟内存这套机制,核心就是「地址翻译」。物理地址是硬件真实地址,虚拟地址是进程看到的假地址。MMU 负责翻译,页表是翻译的依据,TLB 是翻译的加速器。
我个人觉得,理解虚拟内存的关键在于想清楚「谁在看什么地址」——CPU 看虚拟地址,内存控制器看物理地址,MMU 在中间当翻译。搞明白这条线,后面讲页面置换、缺页处理、共享内存什么的,就都好办了。
下一章咱们聊聊页面分配与回收,那是操作系统内存管理的另一个核心。到时候我会分享一些我在嵌入式系统里调内存分配器的踩坑经历,挺有意思的。