虚拟内存基础概念:物理地址与虚拟地址、MMU工作原理、页表与页表项

好,我们正式开始聊虚拟内存。说实话,这个概念刚接触时很容易绕晕。我当年第一次看《深入理解Linux内核》时,看到「虚拟地址」和「物理地址」这两个词,脑子里全是问号——地址就地址,还分什么虚拟和物理?

后来在项目中踩过坑,才真正理解这套设计的精妙之处。今天我就把这块掰开了讲清楚。

物理地址 vs 虚拟地址

先说说最基础的概念。

物理地址,就是硬件层面实实在在的地址。CPU通过地址总线直接访问内存芯片上的某个存储单元。比如你的内存条是4GB,那物理地址范围就是0x00000000到0xFFFFFFFF。说白了,物理地址就是内存条上的门牌号。

虚拟地址,是每个进程看到的地址空间。在32位系统上,每个进程都觉得自己独占4GB内存(0x00000000到0xFFFFFFFF)。但实际上,这只是一个「幻觉」。

你想想看,如果同时跑10个进程,每个都觉得自己有4GB内存,那总共需要40GB。你的物理内存才4GB,怎么可能?

嗯,这就是虚拟内存要解决的问题——让多个进程安全地共享有限的物理内存。

核心要点:虚拟地址是进程看到的,物理地址是硬件实际使用的。中间有个翻译过程,这个翻译官就是MMU。

我在项目中遇到过一个问题:一个嵌入式设备,内存只有256MB,但有个应用非要申请512MB的缓冲区。当时同事说「这不可能」。我说「不一定,用虚拟内存映射,只要不全部同时访问,就能搞定」。后来我们用mmap做了文件映射,确实跑起来了。

MMU工作原理

MMU,全称Memory Management Unit,内存管理单元。它是CPU内部的一个硬件模块,专门负责地址翻译。

它的工作流程,我习惯用三步来理解:

  1. CPU发出虚拟地址:比如进程执行指令 mov eax, [0x8048000],CPU就把0x8048000这个虚拟地址发给MMU。
  2. MMU查页表:MMU拿着这个虚拟地址,去查当前进程的页表,找到对应的物理地址。
  3. 访问物理内存:MMU把翻译后的物理地址发给内存控制器,真正去读写数据。

整个过程对进程来说是透明的。进程根本不知道自己的地址被「翻译」过。它只管用虚拟地址,剩下的脏活累活全交给MMU。

为什么会这样设计?说白了,就是为了隔离和安全。进程A无法访问进程B的物理内存,因为它们的页表是独立的。MMU在翻译时,如果发现虚拟地址没有对应的物理页,直接触发缺页异常,操作系统就能介入处理。

个人经验:调试MMU相关的问题时,我最常用的工具是看页表转储。在Linux下,/proc/self/pagemap 这个文件能告诉你每个虚拟页对应的物理页框号。我曾经靠这个定位过一个DMA缓冲区地址不对的bug。

页表与页表项

页表,就是MMU用来翻译地址的「字典」。每个进程都有自己的页表。

页表的基本单位是页表项(PTE,Page Table Entry)。每个PTE对应一个虚拟页到物理页的映射关系。

一个典型的页表项包含以下字段:

字段 位数 说明
物理页框号 20位(32位系统) 映射到的物理页基地址
Present位 1位 该页是否在物理内存中
Read/Write位 1位 是否可写
User/Supervisor位 1位 用户态能否访问
Accessed位 1位 该页是否被访问过
Dirty位 1位 该页是否被写过

你看,一个PTE才32位(4字节),却能管理4KB的物理页。这就是映射的威力——用很小的元数据管理大块内存。

我记得在x86架构下,页表是两级结构(32位时代)。虚拟地址被拆成三部分:

  • 页目录索引(10位):在页目录表中找到对应的页目录项
  • 页表索引(10位):在页表中找到对应的页表项
  • 页内偏移(12位):在物理页内的具体位置

翻译过程就像查字典:先查目录,再查具体页表,最后找到物理地址。

// 虚拟地址 0x8048000 的分解示例(4KB页大小)
// 0x8048000 = 0000 0000 0000 1000 0000 0100 1000 0000 0000 0000
// 页目录索引: 0x01 (1)
// 页表索引:  0x01 (1)
// 页内偏移:  0x000 (0)

注意:多级页表虽然节省了内存(不需要为整个4GB空间都分配页表),但增加了翻译延迟。每次地址翻译都要查多次内存。为了解决这个问题,CPU引入了TLB(Translation Lookaside Buffer)——一个页表缓存。我调过TLB miss率过高的问题,那真是性能杀手。

在微内核设计中,页表的管理方式又有所不同。微内核通常把页表管理放在用户态的服务进程中,内核只提供最基础的映射操作。这样做的好处是灵活,坏处是上下文切换时页表切换开销更大。

我曾经在一个微内核项目里,为了优化进程切换时的TLB刷新,折腾了整整两周。最后发现,用ASID(Address Space Identifier)可以避免每次都刷TLB,性能提升很明显。

嗯,关于页表,还有一点要提:大页。现代CPU支持2MB甚至1GB的大页。大页的好处是减少页表级数,降低TLB miss。我在数据库场景下用过1GB大页,性能提升立竿见影。

好了,虚拟内存的基础概念就聊到这里。物理地址是硬件真实地址,虚拟地址是进程看到的假象,MMU负责翻译,页表是翻译的依据。这套机制,是操作系统的基石之一。