2. 内存基础

内存管理这个话题,说简单也简单,说复杂能写一本书。我做了这么多年系统优化,见过太多因为不懂内存管理而翻车的案例。今天咱们就把这块硬骨头啃下来。

物理内存 vs 虚拟内存

先搞清楚两个基本概念:物理内存和虚拟内存。

物理内存就是插在主板上的那根内存条。4GB、8GB、16GB,你买多大就是多大。程序跑起来,指令和数据最终都得放到物理内存里才能被 CPU 执行。

虚拟内存就不一样了。它是操作系统给每个进程画的一个「大饼」。每个进程都以为自己独占整个地址空间——32 位系统是 4GB,64 位系统更是大到离谱。

核心思想:虚拟内存让每个进程活在「独立的世界」里,互不干扰。进程 A 的地址 0x1000 和进程 B 的地址 0x1000,实际上是两块不同的物理内存。

为什么要搞这么复杂?直接让程序用物理地址不行吗?

我早年在一个嵌入式项目里就吃过这个亏。当时为了省事,让两个任务直接操作物理地址。结果一个任务写越界,直接把另一个任务的关键数据给覆盖了。系统跑着跑着就崩了,查了两天才找到原因。从那以后,我再也不敢绕过虚拟内存机制。

内存地址空间:三层映射

一个程序里的地址,到真正的物理内存,中间要经过三层转换:

  1. 逻辑地址(也叫虚拟地址)—— 程序里用的地址
  2. 线性地址 —— x86 架构下经过段机制转换后的地址
  3. 物理地址 —— 真正去访问内存条的地址

不过现代操作系统大多把段机制简化了。Linux 下,逻辑地址基本就等于线性地址。咱们重点看最后一步:从虚拟地址到物理地址的转换。

地址类型 谁在用 特点
虚拟地址 应用程序 连续、独立、4GB/128TB
物理地址 硬件/内存控制器 离散、共享、受限于实际容量
总线地址 DMA 设备 外设视角的地址空间

MMU:地址转换的「翻译官」

MMU(Memory Management Unit)是 CPU 内部的一个硬件模块。它的工作就是做地址翻译。

每次 CPU 执行一条指令,比如 mov eax, [0x8048000],这个 0x8048000 是虚拟地址。MMU 拿到这个地址后,会去查一个叫「页表」的数据结构。

我的习惯:把 MMU 想象成一个翻译官。CPU 说「我要找虚拟地址 0x8048000」,MMU 翻一下页表,告诉 CPU「这个地址对应的物理地址是 0x12345000,去吧」。

页表里存的是虚拟页号到物理页框号的映射关系。每个进程都有自己的页表。切换进程时,操作系统会更新页表基址寄存器(CR3 寄存器),MMU 就知道该查哪个页表了。

分页机制:把内存切成小块

虚拟内存和物理内存都被切成固定大小的块。虚拟内存这边叫「页」(Page),物理内存那边叫「页框」(Page Frame)。

常见的页大小是 4KB。为什么是 4KB?历史原因,早期硬件就这么设计的。不过现代 CPU 也支持 2MB、1GB 的大页。

虚拟地址结构(32位,4KB页):
+-----------+------------+------------+
|  页目录   |   页表     |  页内偏移  |
|  10位     |   10位     |   12位     |
+-----------+------------+------------+

这个结构意味着:

  • 页目录有 1024 项(2^10)
  • 每个页表有 1024 项
  • 每页 4KB(2^12)
  • 总共覆盖 4GB 地址空间

嗯,这里要注意:多级页表不是白给的。它的好处是——如果进程只用了一小部分地址空间,页目录里很多项是空的,对应的页表就不用分配。省内存。

TLB:加速地址转换的缓存

每次访问内存都要查页表,那性能就完蛋了。页表在内存里,查一次页表就要访问一次内存。本来一条指令就一次内存访问,现在变成两次甚至三次。

解决方案是 TLB(Translation Lookaside Buffer)。它是 MMU 内部的一个小硬件缓存,专门存最近用过的虚拟地址到物理地址的映射。

TLB 命中:一次地址转换,零额外内存访问。
TLB 未命中:硬件遍历页表,更新 TLB,然后重试。

TLB 的命中率直接影响程序性能。我优化过一个数据库服务,发现它的 TLB 缺失率特别高。查了一下,是因为数据访问模式太随机,TLB 根本缓存不住。后来改成用 2MB 大页,TLB 覆盖范围大了 512 倍,性能直接提升了 15%。

缺页异常:虚拟内存的基石

如果 MMU 查页表时发现某个页不在物理内存里(页表项里的「存在位」为 0),就会触发缺页异常(Page Fault)。

操作系统接管这个异常后,会做以下事情:

  1. 检查这个虚拟地址是否合法(是不是真的分配了)
  2. 如果合法,从磁盘(交换分区)把数据加载到物理内存
  3. 更新页表,设置存在位为 1
  4. 返回用户态,重新执行那条指令

这个过程对应用程序是完全透明的。程序根本不知道自己的数据被换出到磁盘又换回来了。

我曾经踩过的坑:在一个实时系统里,我分配了一大块内存然后 memset 了一下。以为这样就能保证后续访问不触发缺页。结果发现 memset 只是触发了「按需分配」,真正的物理页是在第一次读写时才分配的。实时性要求高的场景,一定要用 mlock 锁住内存。

SVG:虚拟内存地址转换全流程

虚拟内存地址转换流程 CPU 发出虚拟地址 MMU 查 TLB ? TLB 命中 遍历页表 更新 TLB 物理内存访问 缺页异常(Page Fault) OS 从磁盘加载数据 说明: 绿色路径:TLB 命中,最快路径 紫色路径:TLB 未命中,查页表后更新 TLB 红色路径:缺页异常,需要磁盘 I/O,最慢

内存保护:谁的地盘谁做主

页表里不光有地址映射,还有权限位。比如:

  • 读/写位:这个页能不能写
  • 用户/内核位:用户态能不能访问
  • 执行禁止位:这个页能不能执行代码

这就是现代操作系统实现内存保护的基础。用户程序想改内核数据?MMU 直接拒绝,触发段错误。想执行栈上的数据?NX 位(No Execute)一设,直接崩给你看。

我建议:做嵌入式开发的朋友,别为了省事关掉 MMU。虽然裸机跑起来简单,但一旦出问题,排查难度是指数级上升的。MMU 带来的内存保护和隔离,值得那点性能开销。

总结一下关键点

虚拟内存不是「虚拟」的,它是真实存在的机制。MMU 做地址翻译,TLB 做加速,缺页异常做按需加载。这三者配合,才让我们能跑着几十个进程还不打架。

下次你的程序出现段错误,别急着骂编译器。先想想:是不是访问了没有映射的虚拟地址?是不是越界了?是不是权限不对?理解了 MMU 的工作方式,很多内存问题一眼就能看穿。