
内存管理这一章在很多操作系统教材里排在进程和线程之后属于初读觉得平平无奇、回头再看才发现处处是伏笔的内容。我刚接触它的时候最大的困惑不是概念难而是这些概念到底对应真实机器里的哪块硬件、哪行代码。基址寄存器、限长寄存器、页表、TLB、多级页表、段页式这些名词单独看都能背下来可一旦让你算一道有效访问时间的题或者让你解释为什么程序里打印出来的地址每次运行都不一样很多人立刻就卡住了。这篇东西就是想把这一章从纸面拉回到机器上。它讲的是操作系统如何把程序员眼里的一片连续可用的内存变成物理内存条上一堆离散的页框如何在多个进程之间划分地盘、隔离越界、控制权限以及为了做到这些付出了多少空间和时间上的代价。适合正在啃操作系统课的学生、准备系统方向面试的开发者也适合写了几年业务代码却始终没搞清楚段错误到底是谁报的的工程师。下面我按自己的理解顺序拆从地址绑定一路讲到多级页表和真实内核里的分配器中间会穿插手算示例和可以直接在机器上敲的命令。1. 这一章到底在解决什么问题1.1 从一次踩内存的事故说起很多人对内存管理的第一印象来自一次莫名其妙的崩溃程序跑到某一行突然退出终端甩出一句段错误或者访问冲突连堆栈都没打印出来。这类问题的根源绝大多数是进程试图访问一个不属于它的地址或者用了一个自己没有权限的方式去访问。硬件在地址翻译那一层就把它拦下来了根本轮不到你的代码逻辑出错。理解这一点非常关键——内存管理不是操作系统顺手做的一件好事而是硬件和内核配合建立的一套强制边界。我们平时写代码函数里的局部变量有名字全局变量有名字malloc 返回的指针也有名字。可 CPU 执行指令时只认数字地址它不知道什么叫变量名。编译器把名字翻译成地址链接器把这些地址拼成一张表加载器把它们放进内存运行时的硬件再把这些看起来连续的地址映射到物理内存条上真正的位置。这条链路上任何一环出问题程序就会以各种奇怪的方式挂掉。这一章讲的就是这条链路上最核心的那一段从逻辑地址到物理地址的翻译规则以及围绕这条规则衍生出来的分配、保护、共享、扩充等一整套机制。1.2 内存管理的三条主线把这一章的内容摊开来看其实就三条主线抓住它们比死记名词有用得多。第一条是地址翻译。程序产生的地址是逻辑地址物理内存条上的地址是物理地址两者之间必须有一个映射关系。这个映射可以简单到一个基址寄存器加法也可以复杂到四级页表加 TLB 缓存。所有方案的差别本质上是在翻译速度和空间开销之间找平衡点。第二条是空间分配。物理内存就那么多多个进程要同时驻留怎么切、怎么给、怎么回收直接决定了碎片的形态和利用率。连续分配会产生外部碎片分页把碎片赶到页内部代价是页表本身要占内存。第三条是保护与共享。进程之间不能互相踩内存但又要能共享同一份代码段或者同一块共享内存。这要求映射关系不只是地址到地址还要带上权限位和共享标记。这三条主线在后文会反复出现。你在读任何一个小节的时候都可以问自己一句这个机制是在优化翻译速度还是在减少空间浪费还是在加强隔离1.3 需要提前备好的底子这一章对前置知识的要求其实不低如果下面几件事没搞明白读起来会非常别扭。一是二进制与十六进制的换算要熟。页号、页内偏移、页表索引这些东西全都是按位切分出来的算地址的时候基本都在十六进制下进行。4096 等于 2 的 12 次方这个数字要刻在脑子里因为它是绝大多数系统默认的页大小。二是编译、链接、加载三个阶段的产物要清楚。目标文件里的地址是什么、链接后变成什么、加载到内存后又变成什么这三步决定了地址绑定的时机。不清楚的话看到可重定位代码和绝对代码这两个词会一头雾水。三是中断和陷入的基本概念。地址翻译失败会触发一个异常内核接管后决定是补一页进来还是直接杀掉进程。这条路径不理解就无法解释缺页处理和非法访问的区别。这三样东西补上之后再往下读会顺畅很多。2. 地址绑定一个变量是怎么落到物理单元的2.1 三种绑定时机与它们的取舍指令和数据只有在被绑定了具体的存储地址之后才能被访问这个绑定发生的时刻直接决定了系统的灵活性和复杂度。教材里通常给出三个时机我在实际理解时更愿意把它们看成一条越来越晚、越来越灵活、也越来越依赖硬件的演进线。编译时绑定要求编译的那一刻就知道进程会被放到内存的哪个位置。这样编出来的代码里全是绝对地址执行效率极高连一次加法都省了。但代价是显而易见的程序一挪位置就得重新编译而且同一时刻内存里只能有一个这样的程序实例。早期的单道批处理系统就是这么干的现在基本只在嵌入式裸机程序里还能见到类似形态。加载时绑定把决定权推到了加载器身上。编译器生成的是可重定位代码加载器在把程序放进内存时统一加上一个偏移量把相对地址改成绝对地址。这解决了程序可以放在不同位置的问题但程序一旦开始运行就不能再挪动了。执行时绑定把最后的转换交给硬件在每条指令执行时完成。编译器生成的都是相对地址CPU 访问内存前先经过一个叫内存管理单元MMU的部件做转换。这套方案最灵活进程运行期间可以被换出再换入到不同的物理位置虚拟内存才有可能实现。代价是需要专门的硬件支持每次访存都要多走一步翻译。注意现代通用计算机基本都采用执行时绑定。你在教材上看到的基址寄存器 限长寄存器是这套机制最朴素的形态后面所有的页表机制本质上都是它的复杂化版本。理解这三种时机的关键在于想明白一件事绑定越晚运行时的开销越大但系统能提供的抽象越强。这不是一个哪个更好的问题而是一个在特定硬件成本下能做到哪一步的问题。2.2 逻辑地址、物理地址与 MMU 的分工逻辑地址是 CPU 执行指令时产生的地址也叫虚拟地址物理地址是内存总线真正送出去的地址。这两者在有地址翻译的机器上完全不是一回事而且差得非常远——你程序里看到的 0x7ffd 开头的栈地址物理上可能落在内存条的任何位置。MMU 是 CPU 内部的一块硬件它负责在每次访存时完成翻译。最朴素的实现是这样的CPU 送来一个逻辑地址MMU 先拿它和限长寄存器比较如果超出了进程的合法范围就触发一个陷入交给内核处理如果没超就把基址寄存器的值加上去得到物理地址送到总线上。这里有两个容易被忽略的细节。第一限长检查发生在加法之前用的是逻辑地址不是物理地址。这样即使用户程序算出了一个溢出的地址也不可能通过加法回绕绕到别人的地盘上去。第二基址和限长寄存器属于上下文的一部分进程切换时必须保存和恢复否则新进程就会用上一个进程的映射关系直接读到别人的数据。我见过不少人把 MMU 和 TLB 混为一谈。简单区分MMU 是执行翻译的那个部件TLB 是 MMU 内部用来加速翻译的一个小缓存。没有 TLB 的 MMU 依然能工作只是每次访存都要多读一次内存里的页表。2.3 基址-限长的手工验算光看文字容易飘我们拿一组具体数字走一遍。假设基址寄存器 0x00010000限长寄存器 0x00004000也就是 16KB。现在 CPU 要访问逻辑地址 0x00001234。第一步比较0x00001234 0x00004000合法继续。第二步相加0x00010000 0x00001234 0x00011234这就是物理地址。再假设 CPU 要访问逻辑地址 0x00005000。比较时发现 0x00005000 0x00004000越界MMU 直接触发异常内核介入通常的结果是给进程发一个段错误信号并终止它。这套机制只有两个寄存器却能提供基本的内存隔离成本极低。它的致命缺陷在于要求整个进程在物理内存里连续存放。一个进程如果占 200MB就得找到一块连续的 200MB 空闲区域跑上十几个进程之后内存里全是被切碎的小空洞明明总量够却分配不出来。这个问题怎么破就是后面分页机制要解决的核心矛盾。3. 连续分配从固定分区到动态分区3.1 固定分区与内部碎片最早的多道程序设计用的是一个非常粗暴的办法把物理内存提前切成若干个大小固定或大小不等但固定的分区每个分区装一个进程。分区的大小在系统启动时就定死了运行期间不改。这个方案的好处是实现简单分配和回收就是改一个状态位。坏处也很直观如果一个分区是 8MB而进程只需要 2MB剩下的 6MB 就白白浪费了而且是彻底浪费别的进程用不上。这种浪费发生在分区内部所以叫内部碎片。内部碎片的严重程度取决于分区大小和进程大小的匹配程度。如果分区统一按最大进程来设小进程的浪费会非常惊人如果分区大小参差不齐虽然能缓解一点但调度会变复杂而且依然无法避免进程比最大的分区还大这种尴尬情况。这个方案在今天看来很原始但它是理解碎片问题的最佳起点——后面分页机制里的页内浪费本质上就是缩小版的内部碎片。3.2 动态分区与四种放置策略固定分区的问题太明显于是有了动态分区不再预先切好而是记录哪些区域空闲、哪些被占用进程来了现场找一块够大的空地给它。这样分区大小就随进程大小走内部碎片基本消失但另一个问题冒出来了——空闲区域被切得越来越碎。当一堆进程陆续加载、退出之后内存里会散布着许多大小不一的空洞。这些空洞单个看都太小装不下一个新进程但它们加起来的总量可能远超新进程的需求。这种浪费发生在分区之间叫外部碎片。为了尽量延缓碎片的恶化需要一套选择空闲块的策略。常见的四种策略对比如下策略做法优点缺点首次适应从头扫描遇到第一个够大的就用实现简单扫描快大块容易保留在高地址低地址区被反复切割碎片集中下次适应从上次分配结束的位置继续扫分配更均匀避免低地址堆积大块会被打散整体碎片更严重最佳适应遍历全表选最小的够用块剩余空洞最小产生大量微小无用碎片扫描慢最坏适应选最大的块切完剩下的还够用剩余块较大还能再用大块被迅速消耗大进程后续难分配我个人的经验是首次适应在大多数场景下性价比最高这也是很多教材推荐它的原因。最佳适应听起来最聪明实际上会制造出一堆几个字节的空洞这些空洞永远不可能被利用反而让空闲链表越拉越长搜索成本上升。这算是一个反直觉的结论局部最优的策略在长期运行中往往表现更差。3.3 外部碎片、压缩与它的隐性成本外部碎片积累到一定程度就算总空闲量足够新进程也进不来。这时候有两条路。一条是压缩把所有已占用的块往一端挪把空闲区域挤成一整块。这个操作在原理上很简单但实际代价极高所有被移动进程的基址寄存器都要更新所有指向被移动内存的指针都要修正移动过程中进程必须暂停。而且压缩只在地址空间可以被随意重定位的时候才可行某些带硬件约束的场景根本做不了。另一条是换出把某个进程整个写到后备存储上腾出连续空间。这涉及大量的磁盘读写速度比内存慢几个数量级属于没办法时的办法。正是这两个方案都太贵才逼出了分页这条路子。分页的核心思路非常巧妙既然连续分配难就难在必须连续那干脆取消连续性要求把内存切成固定大小的小块进程也切成同样大小的小块最后一页可能装不满一块一块地随便放。这样一来外部碎片几乎消失代价是每个进程最后一页可能有浪费——也就是内部碎片但因为页很小这种浪费被控制在很小的范围内。4. 分页把碎片从碎片间赶到页内4.1 页、页框、页表的三个基本概念分页机制里最容易混淆的是三组词我一开始也绕了很久。页是逻辑地址空间切出来的固定大小单位页框也叫物理块、帧是物理内存切出来的同样大小的单位。两者大小必须一致典型值 4096 字节。进程的第 n 页可以装进物理内存的任意一个页框里不需要相邻。页表是记录这个对应关系的数据结构每一条表项告诉你第 n 页现在放在第 m 个页框里。页表本身也是放在内存里的数据由内核维护硬件来查询。这里有个关键点页号是逻辑层面的连续编号页框号是物理层面的位置。一个进程的 0 号页可能在第 8 号页框1 号页可能在第 2 号页框完全乱序。但对进程自己来说它的地址空间依然是连续的 0 到 n因为它只看到自己的页号序列。这种对进程连续、对物理离散的错位感是理解分页的分水岭。4.2 地址翻译全过程与手算示例分页的地址翻译可以用一句话概括拆成页号和偏移页号查表换成页框号再拼回偏移。具体来说逻辑地址被切成两段高位是页号 p低位是页内偏移 d。位数由页大小决定页大小是 2 的 k 次方那么低 k 位就是偏移剩下的都是页号。用 p 去页表里查得到页框号 f物理地址就是 f 乘以页大小再加上 d。我们拿一个具体例子走一遍。假设页大小 4KB2 的 12 次方逻辑地址是 0x2A5F。第一步拆低 12 位是偏移0x2A5F 的二进制是 0010 1010 0101 1111低 12 位取出来是 0xA5F高 4 位是 0x2所以页号 p 2偏移 d 0xA5F。第二步查表假设页表里第 2 项的值是 5说明 2 号页放在 5 号页框。第三步拼物理地址 5 × 0x1000 0xA5F 0x5000 0xA5F 0x5A5F。整个过程只有一次查表加一次移位拼接硬件实现起来非常快。但要注意这次查表意味着要访问一次内存也就是说程序访问一个变量实际上要访问两次内存一次读页表项一次读真正的数据。这就是后面 TLB 存在的理由。页大小的选择是一个典型的权衡。页太小页表项数量会急剧膨胀页表本身占用的内存可能超过进程数据页太大最后一页浪费的空间变多而且读写磁盘时一次搬运的数据量大效率反而下降。4KB 是一个经过长期实践验证的折中点当然也有系统用 2MB 甚至 1GB 的大页来专门服务大内存应用思路就是给不同的使用场景准备不同的粒度。4.3 页表项里到底存了哪些位很多人以为页表项就是一个页框号实际上远不止。一个典型的页表项除了页框号还会包含下面这些位有效/无效位标记这一页当前是否在内存里。无效可能意味着这一页压根没被分配也可能意味着它在磁盘上访问到它就会触发缺页异常。保护位读、写、执行三种权限的组合用来防止进程修改只读的代码段。脏位表示这一页自加载以来是否被写过。换出时如果没脏就不必写回磁盘能省一次 I/O。引用位表示这一页最近是否被访问过页面置换算法严重依赖这个信息。缓存控制位是否允许这一页走 CPU 缓存某些设备寄存器映射区必须关掉缓存。这些位加起来一个页表项通常占 4 字节或 8 字节。别看每个表项不大乘以页表项总数就很可观了。32 位地址空间、4KB 页、4 字节表项的情况下一个进程的完整页表需要 2 的 20 次方乘以 4 字节也就是 4MB。一个进程 4MB一百个进程就是 400MB光页表就把内存吃光了这显然不可接受。这个问题下一节专门讲。4.4 TLB 与有效访问时间的计算前面说了分页让每次访存变成两次内存访问。如果再多一级页表就是三次、四次。这个开销不能忍所以现代处理器都在 MMU 里放了一个专用的小缓存叫快表TLB用来存放最近用过的页表项。TLB 通常只有几十到几百个表项但得益于程序的局部性原理命中率往往能到 95% 以上。命中时一次访存只需要一次内存访问数据本身未命中才需要走完整页表。有效访问时间EAT的计算是这一章的必考题也是最容易算错的地方。公式是EAT 命中率 × (TLB查找时间 一次内存访问时间) (1 - 命中率) × (TLB查找时间 页表级数 × 内存访问时间 一次内存访问时间)举个具体数字。假设 TLB 查找耗时 10ns一次内存访问 100ns命中率 90%用的是单级页表。命中时10 100 110ns。 未命中时10 100读页表 100读数据 210ns。 EAT 0.9 × 110 0.1 × 210 99 21 120ns。如果没有 TLB每次都是 200ns。也就是说 90% 的命中率带来了接近 40% 的性能提升。这个数字很能说明问题TLB 的容量不需要大只要命中率够高就行。把条件换成二级页表其余不变未命中时变成 10 100 100 100 310nsEAT 0.9 × 110 0.1 × 310 130ns。页表级数每加一级未命中的代价就多 100ns而命中路径完全不受影响。这就是为什么多级页表敢在真实系统里用——只要 TLB 命中率撑得住级数增加对平均性能的伤害有限。提示算 EAT 时最常见的错误是把 TLB 查找时间只算在命中路径上或者忘记把最后读取数据的那一次访存算进去。建议每次做题时把命中路径和未命中路径分开写成两行再按概率加权不容易漏。5. 页表太占地方怎么办多级页表与倒排页表5.1 单级页表撑不住的原因上一节算过32 位系统里一个进程的完整页表要 4MB而且这 4MB 必须是连续的。这带来了两个问题。第一是空间浪费。绝大多数进程根本用不到完整的 4GB 地址空间一个跑着的小程序可能只用了 2MB 内存对应 512 个页但它依然被迫维护一张覆盖全部 4GB 的页表因为页号是直接拿来做索引的你不能只建一半。实际有效项占比可能不到千分之一剩下全是无效项。第二是分配困难。4MB 连续内存本身就不好找进程一多内存里塞满了一块块页表碎片问题又回来了。多级页表的核心思路是把页表本身也分页并且按需创建。你没有用到的那部分地址空间对应的二级页表压根不存在页表项里标记为无效即可。这样空间的占用就跟实际使用的地址范围成正比而不是跟地址空间的理论大小成正比。5.2 二级页表地址拆分实操还是用 32 位地址、4KB 页、4 字节表项这组参数。一个页表项 4 字节一个页能装 4096 / 4 1024 个表项也就是需要 10 位来做索引。于是逻辑地址被切成三段10 位一级索引 10 位二级索引 12 位页内偏移。翻译过程是这样先用一级索引去一级页表里查得到某个二级页表的基地址再用二级索引去那个二级页表里查得到页框号最后拼上 12 位偏移。空间账要这么算一级页表必须常驻大小 1024 × 4 4KB。每一个二级页表也是 4KB覆盖 1024 × 4KB 4MB 的地址范围。所以一个只用了 8MB 空间的进程只需要 1 个一级页表加 2 个二级页表总共 12KB而单级页表要 4MB。差距是三百多倍。一级页表能覆盖的范围是 1024 × 4MB 4GB刚好覆盖整个 32 位地址空间设计上是对齐的。到了 64 位时代有效地址通常是 48 位页大小还是 4KB页内偏移 12 位剩下 36 位索引按每级 9 位切成四级9 9 9 9 12 48。这就是四级页表的由来。级数虽然多但每级页表都是 512 项 × 8 字节 4KB刚好一页管理起来很规整。5.3 多级页表的代价与倒排页表思路多级页表省了空间但把翻译路径变长了。单级页表未命中 TLB 时读 1 次页表四级页表就要读 4 次。这也是为什么现代处理器对 TLB 命中率如此看重甚至会在 TLB 未命中时用硬件做页表遍历而不全交给软件。除了多级页表还有一种思路叫倒排页表。它不按逻辑页号建表而是按物理页框建表全系统只维护一张表表项数等于物理页框数跟进程数量无关。每个表项里记录这个页框当前被哪个进程的哪一页占用。这样做极大地压缩了页表的总量但代价是查找变成了搜索给你一个逻辑页号你得遍历整张表才能找到对应的页框没法直接索引。为了解决搜索慢的问题倒排页表通常配一个哈希表用进程号 页号做哈希键。这样查找的平均复杂度接近常数但哈希冲突和遍历顺序访问的问题依然存在尤其在 TLB 未命中时开销明显。方案空间占用查找方式典型适用场景单级页表与地址空间大小成正比直接索引一次地址空间小、进程少的场景多级页表与实际使用量成正比逐级索引多次通用桌面与服务器系统倒排页表与物理内存大小成正比哈希查找物理内存大、进程多的场景段页式介于两者之间先查段表再查页表需要逻辑分段的大型系统选哪种方案从来不是纯理论问题而是看你的硬件成本和软件复杂度预算。多级页表之所以成为主流是因为它把复杂度推给了硬件多做几次访存换来了内核实现的简洁。6. 分段与段页式另一种切分内存的思路6.1 分段的出发点分页是站在物理管理的角度切内存它关心的是怎么把碎片问题解决掉对程序内部的逻辑结构一无所知。一个函数的代码可能跨两个页一个数组可能跨三个页这对程序来说是透明的但也意味着你没法给这一段代码单独设权限。分段的出发点完全不同它按程序的逻辑结构来切。代码段、数据段、栈段、堆段每个段是一段有明确意义、长度不固定的连续区域。每个段有自己的名字或者段号、自己的长度、自己的保护权限。访问内存时用的地址是二维的段号段内偏移。地址翻译的过程是拿段号去段表里查得到这个段的基址和限长先检查偏移是否小于限长越界就陷入合法则基址加偏移得到物理地址。分段的好处很直接保护粒度细。代码段可以设成只读加可执行数据段可读写不可执行栈段只读写。任何试图往代码段写数据的行为都会在硬件层面被拦下来。共享也方便两个进程只要段表里指向同一个代码段基址就能共享同一份代码不用复制。动态增长也友好栈段需要变长时只要还有连续空间就能直接扩不像分页那样必须重新分配页。坏处同样明显外部碎片又回来了。段长不固定反复分配释放之后内存里全是大小不一的空洞跟动态分区遇到的问题一模一样。而且段必须连续存放一个大段如果找不到连续空间就装不进去。6.2 段页式怎么把两者拼起来分段解决逻辑问题但留下外部碎片分页解决碎片问题但丢了逻辑结构。把两者拼起来就是段页式先按逻辑分段每个段内部再分页。这样每个段不需要连续存放只要它的各个页能找到空闲页框就行外部碎片基本消失同时段表还保留了逻辑单位和权限信息保护和共享的能力也保住了。地址结构变成三段段号 段内页号 页内偏移。翻译过程是先查段表拿到该段的页表基址再用段内页号查页表拿到页框号最后拼上页内偏移。这条路径比纯分页长但换来的是两方面的好处。代价是内存访问次数增加段表要查一次页表还要查一次TLB 未命中时的开销比纯分页更大。而且段表、页表两级结构的管理复杂度上升内核代码也更容易出 bug。这也是为什么一些系统干脆只用分页把逻辑分段的语义放到用户态或者链接器层面去表达。6.3 分页与分段对照把两者的差别整理成一张表会更清楚维度分页分段切分依据固定大小对用户透明逻辑意义用户可见地址形式一维页号 偏移二维段号 偏移碎片类型内部碎片最后一页外部碎片段间空洞保护粒度页级较粗段级贴合逻辑结构共享能力按页共享需要页对齐按段共享自然增长支持需要重新分配页段可直接扩展实现复杂度相对简单相对复杂这张表我建议直接记下来。很多面试题就是绕着这几行问的比如为什么分页没有外部碎片、分段为什么利于共享答案全在表里。7. 覆盖、交换与真实内核里的分配器7.1 覆盖技术在主存极其紧张的年代程序员得自己想办法让程序跑起来覆盖技术就是那个时期的产物。它的思路是把程序切成若干个互不调用的模块同一时刻内存里只保留当前需要的那些其余的放在外存需要时再换进来。覆盖的关键约束是模块之间的调用关系必须由程序员手工分析。哪些模块会同时存在、哪些是互斥的全靠人来划分然后写成一张覆盖图交给链接器。这件事在大型程序里几乎不可维护改一个调用关系就可能要重画整张图。覆盖的优点是不需要任何硬件支持纯软件方案在内存只有几十 KB 的机器上是唯一可行的办法。缺点是对程序员要求极高而且划分结果跟程序结构强绑定无法自动化。虚拟内存出现之后覆盖技术基本退出了历史舞台因为它做的事情本质上就是手工版的按需调页。7.2 交换交换是另一种扩充内存的手段把整个进程从内存写到后备存储上需要时再读回来。它比覆盖粗暴但对程序员完全透明。交换的开销主要在传输时间上而传输时间跟交换的数据量成正比。一个占用几百兆的进程换出换入一次可能就是几百毫秒这个延迟在交互式系统里是完全不可接受的。所以实际系统很少做整进程交换而是做按页换出也就是虚拟内存里的页面置换。真要实现整进程交换还得考虑几个现实问题。一是换出时机的选择得挑那些长时间不活跃的进程。二是换入位置的确定回来的时候原来的物理位置可能已经被别人占了得重新分配。三是与 I/O 的配合一个正在进行磁盘读写的进程不能随便换出否则数据会乱。这些问题加起来使得纯粹的进程交换在现代系统里几乎绝迹。不过在理解层面交换依然是一个很重要的中间形态。它是从进程必须全在内存到进程可以部分在内存的桥梁想明白交换的问题再理解按需调页就水到渠成了。7.3 伙伴系统与 slab 分配器教材第 8 章通常讲到这里就结束了但真实内核里分配物理页框和分配小对象用的是两套很不一样的东西我觉得值得补上不然你对分页的理解会停留在课本层面。伙伴系统专门解决分配 2 的幂大小连续块的问题。它的做法是把内存按 2 的幂切成块维护若干个空闲链表每个链表对应一种块大小。分配时先把请求大小向上取整到最近的 2 的幂比如要 300KB 就取 512KB。如果对应链表为空就往上一级借把更大的块对半劈开一半用一半挂到下一级链表里。释放时检查它的伙伴块是否也空闲如果空闲就合并然后继续往上检查。这个劈开和合并的操作效率很高因为伙伴的地址可以直接通过异或算出来不需要遍历。而且合并能有效缓解外部碎片这是它比普通空闲链表强的地方。缺点也很明显内存利用率不高一个 300KB 的请求占掉 512KB浪费 41%。所以伙伴系统通常只用来管理大块也就是以页框为单位。slab 分配器解决的是另一个问题内核里有大量小对象比如进程描述符、文件对象、网络包缓存。这些对象大小固定但远小于一页。如果每次都从伙伴系统要一页然后自己切会有两个麻烦一是页内碎片二是对象初始化开销大——每次分配都要重新构造一遍。slab 的做法是给每种对象类型建一个专用缓存缓存里预先切好一堆大小刚好的槽位。分配时直接从空闲槽位里取一个初始化好的状态还在省掉了构造开销。释放时把槽位还回去不还给伙伴系统。这样既消除了页内碎片又大幅提升了分配速度。分配器管理单位主要解决的问题典型开销伙伴系统2 的幂大小的页块外部碎片、大块连续分配内部浪费较明显slab 类分配器固定大小对象小对象频繁分配、初始化开销需要预分配对突发不友好提示观察这两套机制不需要写代码。在常见系统上/proc/buddyinfo能看到伙伴系统各个阶的空闲块数量/proc/slabinfo能看到各种对象缓存的活跃和总量。这两个文件是我排查内存问题时最先看的地方。8. 常见问题与排查技巧实录8.1 常见问题速查表这一章的概念多容易在细节上翻车。我把平时被问得最多、自己也栽过跟头的问题整理成一张表现象或疑问常见原因处理思路程序打印的地址每次运行都不一样地址空间布局随机化执行时绑定这是正常现象说明用的是虚拟地址两个进程打印出相同地址各自独立的地址空间虚拟地址相同不代表物理相同对比物理地址需要内核视角的工具页表大小算出来跟答案差一倍忘记乘页表项大小或混淆了页大小和项大小先算项数再乘每项字节数EAT 算出来比一次访存还小漏掉了最后读取数据的那次内存访问分命中和未命中两条路径分别写多级页表越级越慢为什么还用慢的只是未命中路径命中路径不变结合 TLB 命中率看平均性能分页之后还有内部碎片吗有最后一页装不满的部分页越小浪费越少但页表越大分段为什么会产生外部碎片段长不固定且必须连续换成段页式可以消除伙伴系统分配 300KB 实际占了 512KB大小向上取整到 2 的幂改用 slab 或按页细粒度分配这张表建议放在手边做题和面试前扫一遍能避开大部分低级错误。8.2 手工验算与观察的几个小工具光看书不动手理解会很浅。我平时会用几个很简单的办法来验证这一章的内容。第一件事是确认页大小。在终端里执行getconf PAGESIZE多数系统会返回 4096。这个数字是后面所有计算的基准先确认它再谈页号偏移怎么切。第二件事是看进程的地址空间布局。随便写个小程序#include stdio.h #include stdlib.h static int g_init 1; int g_bss; int main(void) { int stack_var 0; int *heap malloc(4096); printf(main : %p\n, (void *)main); printf(g_init : %p\n, (void *)g_init); printf(g_bss : %p\n, (void *)g_bss); printf(heap : %p\n, (void *)heap); printf(stack_var : %p\n, (void *)stack_var); free(heap); return 0; }跑出来的结果会很有规律代码段和只读数据在高地址附近全局变量在稍低的位置堆往上长栈往下长两者中间留着一大片未映射的空洞。这正好对应了进程地址空间的经典布局。第三件事是看这块地址到底映射了什么。程序运行时在另一个终端里执行cat /proc/pid/maps你能看到一段段的映射区间每行开头是起止地址后面是权限位rwx、偏移、设备和文件路径。对照上面打印出来的地址就能确认哪个地址属于哪个段。这个习惯我保持了挺久对理解虚拟地址空间到底长什么样帮助极大。第四件事是看系统整体的内存分布。cat /proc/meminfo里几个字段值得关注MemTotal 是物理内存总量MemFree 是完全没有被使用的MemAvailable 是估算出来的还能给新程序用多少包含了可回收的缓存Buffers 和 Cached 是块设备和文件缓存。很多人看到 MemFree 很小就以为内存不够其实缓存是可回收的看 MemAvailable 更靠谱。8.3 我踩过的几个坑有几个误区我当年反复栽进去写出来给后来人省点时间。第一个坑是把逻辑地址和物理地址当成一回事。刚学的时候打印一个指针看到 0x7ffd 开头就以为物理内存里真的有这么个位置。实际上这个地址只在当前进程的地址空间里有意义换个进程同样的地址可能是完全不同的东西。想通这一点才能理解为什么共享内存需要专门的一整套机制。第二个坑是认为分页消除了所有碎片。分页消掉的是外部碎片内部碎片依然存在只是被限制在一页之内。如果你的程序申请了大量小对象而这些对象的生命周期又参差不齐页内的浪费加上分配器的元数据开销实际占用可能远大于你的估算。第三个坑是把页表当成每个进程一张、永远常驻。页表本身也是可以被换出的虽然一级页表通常必须常驻但更深层级的页表完全可能不在内存里。这一点在多级页表体系下尤其重要因为它意味着一次翻译可能需要多次缺页处理。第四个坑是忽视权限位。我调试过一个很隐蔽的崩溃原因是某块内存被映射成了可读可写但不可执行而代码跳转到了那里。这类问题在编译器和链接器的默认配置下不常见但一旦涉及动态生成代码或者某些特殊的运行时就会暴露出来。排查的时候看/proc/pid/maps里的权限位往往一眼就能定位。第五个坑是拿理论值去套实际系统。教材上的例子通常假设页表项 4 字节、页大小 4KB、地址 32 位实际系统的参数可能完全不同。做工程估算的时候先查清楚真实参数再动手算否则结论会偏得离谱。最后分享一个我自己复习这章的方法拿一张纸把一个变量从源码到物理内存的完整路径画一遍。编译器怎么给它分配偏移、链接器怎么组织段、加载器怎么建页表、MMU 怎么翻译、TLB 怎么加速、缺页时内核怎么介入。能把这条链路从头到尾讲顺这一章基本就吃透了剩下的只是参数和变体。