☰
操作系统存储器管理:从逻辑地址到物理地址的映射、分页分段与虚拟内存解析
2026/9/29 4:40:58 网站建设 项目流程

你写 C 语言时打印出的指针地址,比如0x7ffd8f2a1b40,看起来是物理内存的某个位置,但实际上,它极大概率只是一个虚拟地址。每个进程都从 0 地址开始,幻想自己独占整块内存,互不干扰——这背后真正干活的,就是操作系统的存储器管理。很多开发者和学生把存储器管理当成操作系统里的“纯理论章节”,背完概念就扔了,但等你去排查线上内存暴涨、配置容器内存限额、调优数据库缓冲池时,这套机制会一次又一次回来找你。

这篇文章不打算按教科书目录平铺直叙地讲,我会从“是什么在管”“怎么管”“为什么这么管”的角度,把逻辑地址到物理地址的映射、分页分段、虚拟内存和页面置换,以及现代 Linux 里的实际实现串起来。无论你是准备操作系统期末考试,还是想在开发中把内存问题看得更透,都值得继续往下看。

1. 先搞明白:存储器管理到底在管什么

1.1 一个进程眼中的“内存独占”假象

在单道程序时代,整个内存就一个程序用,偏移量就是物理地址,根本不需要管理。多道程序出现后,问题立刻来了:多个程序同时驻留在内存里,如果每个程序都直接写物理地址,那你访问0x1000,我也访问0x1000,互相覆盖,系统瞬间崩掉。

于是操作系统给每个进程画了一张“假地图”:你看到的地址空间从 0 开始,连续不断,想怎么布局都行;但实际上物理内存可能是东一块西一块的。那你怎么还能正常读写呢?因为每次 CPU 访存,都有一个部件帮你把进程看到的地址翻译成真正的物理地址。进程以为自己在独占内存,其实是操作系统在背后默默做地址翻译。

1.2 三类资源冲突:分配、隔离、保护

存储器管理要解决的核心问题,可以总结成三个词:

  • 分配:物理内存有限,来了新进程怎么分?分多少?回收时怎么管?
  • 隔离:进程 A 的指针乱飞,不能把进程 B 的内存冲了。
  • 保护:用户程序不能碰内核的数据,代码段不允许被修改。

这三件事不是独立存在的。比如你用了共享库,多个进程把同一个只读页面映射到自己的地址空间,这既是“共享”也是“保护”;如果你给容器设置了内存上限,内核在分配页面时发现超过 cgroup 限制,就会触发 OOM 处理。这些都是存储器管理在运行时干的事。

1.3 一个简单的映射模型

理解存储器管理最好的起点,是记住这个公式:

物理地址 = 某个基地址 + 进程内的偏移量

最简单的方式是给每个进程分配一块连续物理内存,然后用两个寄存器记录这块内存的“开始位置”和“长度”。进程永远只用自己的逻辑地址(从 0 开始),CPU 每次访存时,硬件检查逻辑地址有没有超长,然后自动加上基地址。这就是最原始的动态重定位。

从这个模型出发,后面所有复杂机制——分页、分段、虚拟内存——都是因为“连续分配”这条路走不通了,才一步步演化出来的。

2. 逻辑地址到物理地址:绑定的时机决定灵活性

2.1 从源码到运行:三种地址绑定方式

你写代码时用的是符号,编译器会把符号转成逻辑地址;逻辑地址最终要变成物理地址,这个“绑定”发生在哪个阶段,决定了系统的灵活性。

  • 编译时绑定:如果程序一开始就知道自己会被加载到内存的哪个固定位置,编译器直接生成绝对地址。单道程序时代可以这么干,但不灵活,地址一变程序就跑不了。
  • 装入时绑定:程序在加载时,操作系统根据当前空闲内存的起始位置,把代码里的逻辑地址字段全部改写成物理地址。如果内存不够需要移动该程序,就得重新改写一次,费时。
  • 运行时绑定:程序在 CPU 上执行时才做地址转换,硬件 MMU(内存管理单元)配合基址/界限寄存器完成翻译。程序可以在内存里移动而不影响运行。

三种方式对比:

绑定方式特点灵活性典型场景
编译时绑定绝对地址,改动需重新编译差早期单道批处理
装入时绑定装载时统一改地址中静态重定位
运行时绑定靠 MMU 动态转换好现代操作系统

现代操作系统全部采用运行时绑定。因为只有它支持虚拟内存、页置换、内存紧缩这些高级功能。

2.2 MMU 与基址/界限寄存器:保护的第一道门

运行时绑定里,MMU 就是个“地址翻译官”。以最简单的连续分配方案为例,假设物理内存里给进程分配了从地址14000开始的一段空间,界限寄存器存长度4000。

当进程访问逻辑地址346时,MMU 做两步检查:

  1. 判断346是否小于界限寄存器的值4000,不满足就判定越界,触发异常;
  2. 把346加上基址寄存器的值14000,得到物理地址14346。
逻辑地址 : 346 基址寄存器: 14000 界限寄存器: 4000 检查: 346 < 4000 → 合法 物理地址 : 14000 + 346 = 14346

这就像你在酒店里,前台给了你一张房卡,只管刷“房间号相对于楼层的偏移”,电梯和门禁系统自动帮你定位到真实楼层。如果没这张卡,或者卡里存储的楼层信息越界,你根本进不了任何房间。

2.3 为什么现代系统还要“页”的概念

连续分配的优点是实现简单,缺点是外部碎片没法根治。你想想,如果内存里有一堆空闲块,单块不够大,但加起来很大,新进程依然无法装入。要解决这个问题,要么做“内存紧缩”——把所有进程往一头搬,代价很高;要么干脆放弃连续分配,把内存切成等长的“格子”,为每个进程按格子分配。这就是分页的起源。

3. 连续分配:为什么碎片会逼出非连续分配

3.1 固定分区与动态分区

固定分区是最早的多道程序设计方案:启动时把内存切成若干大小固定的区域,每个分区放一个进程。好处是管理简单,坏处是内碎片严重——进程可能只要 30KB,但分配了 64KB 分区,剩下 34KB 全浪费。

动态分区则不再提前划分,进程来了需要多少分多少。但伴随产生的是外碎片:内存不断分配、释放后,会产生很多夹在进程之间的小洞,每个洞单独不够大,拼起来却很大。比如四个进程占位,释放两个后留下两个 20KB 的洞,新来的进程要 35KB,两个洞都装不下,明明总空闲 40KB 却分配失败。

3.2 三种经典动态分区算法

动态分区搜索空闲块时,有几种经典算法:

算法策略优势劣势
首次适应从低地址找第一个够大的空闲块实现简单,分配快前端产生许多小碎片
最佳适应找最小的够用空闲块减少大块浪费留下很多难以利用的微型洞
最坏适应找最大的空闲块剩余块不至于太小大空间被频繁拆掉,浪费严重

实测下来,首次适应在时间和空间上通常表现最好,因为它把大块留在后部,有利于后续大分配。这些算法现在很少被用户态程序直接使用,但“适合的才是好的”这个思路,在各层内存分配器里一直延续着。

3.3 碎片与紧凑:理论很美,成本很高

外碎片最直接的解决思路是“紧凑”:把内存中所有进程搬到一起,空出连续大块。但这要求进程在运行时可以动态搬家,也就是必须支持运行时绑定;搬移过程中还要暂停进程、修改地址映射,开销非常大。多道程序越复杂,紧凑代价越高。

所以现代操作系统把思路彻底逆转了:不追求让物理内存连续,而是让逻辑地址连续。物理页面可以东一块西一块,页表负责把不连续的物理页框映射成连续的虚拟空间。这样一来,外碎片直接被消除,只剩不超过一页的内碎片。分页机制从此成为内存管理的主流。

4. 分页:把内存切成方块的车间式管理

4.1 基本分页:逻辑页到物理帧

分页的思路很简单:物理内存切成大小相等的“帧”(frame),进程的逻辑空间也切成同样大小的“页”(page)。分配内存时,不再要求连续,给进程分配若干页面对应的空闲帧即可。进程页面的物理帧号记录在页表里。

以 32 位地址、页大小 4KB 为例,逻辑地址低 12 位是页内偏移,高 20 位是页号。内核根据页号查页表得到物理帧号,再把物理帧号和偏移拼起来,就得到物理地址。

举一个例子:逻辑地址0x12345,页大小4KB,那么:

逻辑地址: 0x12345 页内偏移: 0x345 页号 : 0x12 页表映射: 0x12 → 物理帧号 0x2B 物理地址: 0x2B000 + 0x345 = 0x2B345

注意,这里的偏移量是直接拼接,不需要运算。因为页大小是 2 的幂,页内偏移刚好对应地址低位,帧号对应高位,所以 MMU 做起来非常快。

4.2 页表、页表项和页大小选择的折中

页表里每一项至少记录物理帧号、有效位、读写权限、用户/内核权限位。有效位特别关键:它告诉你这一页是否在物理内存中。如果有效位为 0,MMU 会触发缺页异常,操作系统再把页从磁盘读到内存后重新执行指令。

页大小为什么常见 4KB?太小了页表会巨大,太少了每页末尾浪费的内碎片又会增多。4KB 是性能和空间的平衡点。后来应对大内存和高并发场景,又出现了 2MB、1GB 的大页,就是进一步减少页表项数量、提高 TLB 命中率。

4.3 TLB:没有它,分页会慢到没法用

如果不做优化,一次访存要先读页表,再读数据,等于访问两次内存。为了加速,CPU 内部有一个很小的缓存叫做 TLB(Translation Lookaside Buffer),保存最近用过的页号到帧号的映射。

TLB 命中时,地址翻译几乎没有额外成本;TLB 未命中,CPU 必须到内存里一级一级翻页表,开销可能高出几十倍。上下文切换时,由于地址空间变了,TLB 往往要整体失效或打上标记,所以频繁切换进程过多会导致 TLB miss 飙升。实际调优时,加大页、减少进程切换频率,都能肉眼可见地降低系统延迟。

4.4 多级页表与 64 位下的地址变换

32 位系统用 2 级页表就能覆盖 4GB 空间,但 64 位系统如果仍用单层页表,每个进程的页表会占用天文数字级别的内存。于是现代 CPU 采用多级页表:x86-64 下把虚拟地址拆成 PML4、页目录指针、页目录、页表项、页内偏移五段,每段使用索引在对应层级查找。

多级页表最大的好处是“按需建立”:进程虚拟空间中大部分区域是空的,内核不需要为空洞部分建立中间页表节点,只有当真正访问到对应区域时才补齐。这既是虚拟内存能支持巨大稀疏地址空间的原因,也是 64 位程序启动时内存占用看起来怎么都不大的秘密所在。

5. 分段与段页式:从程序的结构出发

5.1 为什么说分页“不够讲究”

分页是站在物理资源利用角度设计的,对程序本身的逻辑结构完全无感。代码段、数据段、堆、栈被机械地切成固定大小的页,虽然共享和保护也能通过页表权限位实现,但很不直观。比如你想把整个代码段设为只读、所有进程共享同一个代码段,用分页做显得很别扭;而且代码段的大小本来就是变长的,用固定页去切,总显得“削足适履”。

分段把地址空间按程序逻辑分成若干个段:代码段、数据段、堆段、栈段。每个段在逻辑上是连续的,长度可变。访问时,逻辑地址 = 段号 + 段内偏移。段表记录每个段的基址和长度,这就天然解决了越界检查和共享保护的问题。

5.2 段页式:两全其美的拼装

分段虽好,但也有个老毛病:物理内存分配时如果不连续,一个段要占用多个连续物理页,依然可能产生外碎片。所以现实方案是“段页式结合”:先把程序按逻辑分段,再把每个段按固定页大小分页。逻辑地址被拆成三部分:段号、段内页号、页内偏移。

一次访存要查段表和页表,做两次翻译,开销更大。因此现代操作系统没有普遍使用完整的段页式,而是采用“平坦内存模型”:让分段机制中的每个段基址都从 0 开始,段长设为最大,然后完全靠分页来管理内存。x86 架构就是这样,分段其实还在,但被“架空”了。

5.3 分页和分段的经典区分

如果你在准备操作系统期末复习,这道题很常考。记住一句话:分页是物理单位,分段是逻辑单位。分页对程序员透明,碎片是内碎片;分段对程序员可见,方便共享和保护,碎片是外碎片。理解了这句话,和面试官聊分页和分段时就不会绕晕。

6. 虚拟存储:让物理内存不够用成为可能

6.1 局部性原理是虚拟内存的地基

为什么虚拟内存可行?核心依据是局部性原理。程序在一段时间内往往只会集中访问一小部分页面:

  • 时间局部性:循环结构会反复访问同一个变量或同一个指令。
  • 空间局部性:数组、顺序执行指令会让相邻内存被连续访问。

所以操作系统不需要一次性把整个程序装入内存,只需要装入当前正在用的页面;当发现某个页面不在内存时,才从磁盘按需调入。这个过程由硬件异常触发,再由操作系统完成磁盘 I/O 和页表更新,对用户进程完全透明。

6.2 页面置换算法:LRU 是理论标杆,Clock 是工程实现

物理内存不够时,内核必须把某些页面换出去。怎么选“受害者”,是虚拟存储的核心策略。

算法思路缺点
OPT替换未来最长时间不再访问的页面理想算法,无法实现,用于对比
FIFO替换最早进入的页面可能发生 Belady 异常
LRU替换最近最久未使用的页面硬件实现代价高
Clock用访问位循环扫描近似 LRU是主流选择,但仍是近似

一个经典的 Belady 异常例子:访问序列1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5,物理帧数从 3 增加到 4 时,FIFO 的缺页次数反而从 9 次增加到 10 次。这个反直觉现象说明 FIFO 存在结构性缺陷,而 LRU 不会出现这种情况。

现代操作系统的页面置换大多基于 Clock 算法的变种:每个页表项维护一个访问位,需要替换时按环形队列扫描,如果访问位为 1 就清 0、给一次机会,直到遇到访问位为 0 的页面。这比精确 LRU 省硬件,又能获得接近 LRU 的效果。

6.3 工作集与抖动:系统卡死的元凶

如果一个进程频繁访问的页面集合(工作集)大于分配给它的物理页面数,就会陷入“刚换出去立刻又缺页调回来”的循环,这叫抖动(thrashing)。表现为 CPU 使用率不高,但磁盘 I/O 饱和,系统慢到像死机。

原因往往不是某一个进程的问题,而是内存中驻留进程太多,每个进程都分不到足够的帧。解决办法是降低多道程序度、直接杀死一些进程,或者限制进程可用的工作集大小。实际调优时,如果看到vmstat里si和so(swap in/out)居高不下,基本可以判定系统在抖动。

6.4 实操经验:该不该关 swap

很多人以为 swap 越多越好,或者反过来,听说“关闭 swap 能提升性能”就立刻swapoff -a。我自己的经验是,这条命令不能无脑执行。

  • 对数据库、Redis 这类延迟敏感型服务,swap 一旦发生,性能会断崖式下跌,如果内存充足,关闭 swap 是合理选择。
  • 但对桌面环境或内存不宽裕的服务器,保留一定 swap 可以避免 OOM 直接把整个进程杀死,属于“保命”手段。

正确做法是先把内存压力看明白:用free -h看整体水位,用vmstat 1观察持续 swap 状态。如果free显示物理内存仍有大量剩余但 swap 持续进出,那大概率是分配策略或者配置问题,而不是物理内存不够。

7. 现代操作系统里的存储管理长什么样

7.1 Linux 的内存分配引擎:伙伴系统 + slab

传统上,操作系统的连续分配算法已经不适合直接当作通用分配器,Linux 内核做了两套配合:

  • 伙伴系统(Buddy Allocator):把物理页按 2 的幂次分成块,分配和释放时合并相邻的空闲块。目的是高效分配整页内存,减少外部碎片。
  • slab 分配器:内核频繁创建和销毁同一类小对象,比如task_struct、inode等,如果每次都用伙伴系统分配整页,开销太大。slab 为每种对象维护一个缓存池,对象用完不释放回伙伴系统,而是保留现场供下次复用。

这两层分配器配合,使内核既能高效分配大块物理页,也能低开销分配大量小对象。用户态malloc底层则走另一套路径,在小块分配时用brk扩展堆,在大块分配时用mmap映射匿名内存。

7.2 mmap 和 HugePages:绕过偏小页面

mmap是 Linux 中非常核心的接口,它能把文件或设备的一段内容映射到进程地址空间。映射之后,读写文件就像读写内存一样,由内核负责在后台把脏页写回磁盘。使用mmap加载大文件,既能减少read/write系统调用的拷贝开销,又能利用页面缓存的机制,省一份用户态缓冲区的内存。

对于内存需求量极大、且对延迟敏感的应用,4KB 标准页已经不够用了。现代 CPU 支持 2MB、1GB 的HugePages。大页可以减少页表项数量、提升 TLB 命中率。数据库服务、JVM、高性能计算场景中,配置大页经常能看到明显的性能收益。

注意:HugePages 预留后是专门给它用的,其他进程不能挪用。如果预留过大,反而会把普通内存挤掉;如果应用想用但系统没预留,分配会失败。配置前一定要算好总内存、大页数量和应用实际需求。

7.3 NUMA、cgroup 与内存配额

在服务器上,多颗 CPU 各自贴着本地的内存条,访问自己身边的内存比访问远端内存快得多,这就是 NUMA。调度器会尽量把进程和它的内存放到同一节点上;如果内存分配跨度太大,跨节点访存会拖慢性能。用numastat能观察到 node miss 情况,优化思路是让线程尽量绑核、绑内存节点。

在云原生的世界里,存储器管理还多了一层容器限制。Docker 下配置--memory后,内核通过 cgroup 的memory控制器限制进程组的内存用量。超过限制不去 swap,而是直接触发 OOM 策略。OOM Killer 会选择一个“得分最高”的进程杀掉。所以容器老是无缘无故被杀,很多时候不是代码问题,而是物理内存真的不够,或者某个线程偷偷吃掉了配额。

7.4 排查内存问题的实用思路

我每次接手一个内存异常项目,都会按这个顺序排查:

  • 先看系统水位:free -h,区分已用内存、页缓存、swap。
  • 再看进程维度:pidstat -r 1,观察RSS是否只涨不降。
  • 查看内存细节:cat /proc/meminfo,关注MemAvailable、HugePages_Total、SwapCached等指标。
  • 如果怀疑虚拟内存过大,用pmap -x <pid>看每个映射段的内存占用,找出是堆、栈还是某个共享库异常。
  • 配上/proc/<pid>/smaps和numastat,能进一步定位是不是 NUMA 跨节点访问导致延迟增大。

这套链路不花哨,但每次都能快速圈定方向。很多同学内存问题查半天,最后发现自己连free和top里的buff/cache都没有理解清楚,所以先把这些基础工具吃透,比背复杂的理论题有用得多。

最后分享一个小习惯:每次调整完内存参数,我都会跑一遍压力测试,记录vmstat里的si/so和CPU的cs(上下文切换)数据,再看业务延迟曲线。理论说得再好,最终都要落到数据上。存储器管理是一门“看得见摸不着”的课,但只要你顺着地址翻译这条主线,一步步看内核怎么分配、怎么置换、怎么保护,很多困惑自然就解开了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询