
课堂练习4.1 的题目发下来的时候我第一反应是这题应该不难——段式内存管理翻来覆去就那几个公式拆地址、查段表、做检查、加基址。结果真拿起笔算第一道小题还是卡了两分钟。卡的点不在于公式本身而在于题干给的那组数字里藏了一个边界值我当时没想清楚偏移量等于段长到底算不算越界。后来和同组的人对了一遍答案才发现绝大多数人错的地方都一样不是算错而是对段式内存管理这套机制的边界条件理解得不到位。所以这篇东西我不打算复述课本而是想把这道练习从头到尾拆一遍段式管理为什么长这样、段表里为什么只有两个字段、一条逻辑地址怎么一步步变成物理地址、哪些地方最容易翻车。不管你是刚上操作系统课的学生还是隔了很久想把这块知识捡回来的开发者看完应该都能自己动手把这题算明白。1. 段式内存管理到底把程序切成了什么从课堂练习4.1的题干说起1.1 一维地址空间与二维地址空间的分水岭要理解段式内存管理先得搞清楚一个根本差别分页和分段对地址的理解不一样。分页把程序的逻辑地址空间看成一条连续的线从 0 开始一直到末尾然后硬件机械地按固定大小切成一块块块和块之间没有语义关系第 3 页装的是函数代码还是常量数据操作系统根本不在乎。这就是所谓的一维地址空间——你只需要一个数字就能定位任何一个字节。分段不这么看。分段认为程序天然是由若干有意义的部分拼起来的主函数是一段、工具函数是一段、全局变量是一段、栈又是一段。这些部分长度不一各自承担不同的职责程序员和编译器在写代码的时候心里是有这个划分的。所以段的逻辑地址天然就是二维的必须用段号 段内偏移这对组合才能定位。段号告诉你这是哪一块偏移告诉你在这块里的第几个字节。这个差别看起来只是形式上的但影响非常深远。一维地址空间意味着你没法从地址本身判断出访问的合法性硬件必须依赖额外的页表来标记权限二维地址空间则天然带有段这个语义标签硬件一看段号就知道这是代码段还是数据段保护检查可以做得更精细。课堂上老师一般会强调分页对用户透明、分段对用户可见这句话的实质就在这里——分页是操作系统背着你干的活分段是你在写代码时就已经参与决定了的事情。1.2 谁来决定分段的边界编译器、程序员和硬件的三方分工明白了二维地址的意义接下来的问题是段到底是谁划的答案不是操作系统单方面拍板而是三方协作的结果。编译器是第一道手。你在写 C 或者汇编的时候编译产物一般会被归到几个默认的段里.text装机器指令.data装已初始化的全局变量.bss装未初始化的全局变量栈和堆则运行时动态生长。这些段的长度在编译期或加载期就基本确定了栈和堆除外。也就是说段号到段的映射关系很大程度上是编译器和链接器提前规划好的。程序员是第二道手。在以段为基本组织的体系结构上你可以用汇编指令显式指定段寄存器比如mov ax, [es:bx]这种写法段前缀就决定了这次访问走哪个段。这种能力给了程序员很大的灵活性——你可以让两个进程共享同一个代码段而各自拥有独立的数据段节省内存。硬件是最后一道关。CPU 内部有一组专门的寄存器来记录当前段的基址和长度每次访存时用它们做检查和地址翻译。这就是段表存在的前提硬件需要一张表告诉它每个段现在被放在物理内存的哪个位置、有多长。所以段式管理的本质是把程序的结构划分这个原本属于软件层面的概念固化到了硬件机制里。这既是它的优点保护精细、共享方便也是它的包袱段长不固定内存分配麻烦。2. 段表里的两个字段撑起了整个地址变换2.1 段长和基址一个管边界一个管落点段表项其实简单得有点让人意外——最核心的就两个字段段长和基址。段长记录的是一段在逻辑上占多少个字节基址记录的是这段当前被放在物理内存的哪个起始位置。用这两个字段硬件就能完成一次完整的地址翻译。为什么只需要这两个因为段式管理的定位逻辑本身就是先检查、再加偏移。段长负责检查偏移量只要小于段长就说明这次访问落在了段内基址负责定位物理地址等于基址加上偏移量。两个字段各司其职没有冗余。这里有个容易被忽略的点段长的单位。题目里段长经常用字节数给但也有教材用字数或者直接给最大偏移。如果段长是 100那么合法偏移的范围是 0 到 99而不是 0 到 100。这个闭区间还是开区间的问题是练习里翻车率最高的一处我后面会专门展开讲。段表本身的存放也有讲究。段表通常放在内存里操作系统在进程切换时把当前进程段表的起始地址和长度装进一对寄存器——段表基址寄存器STBR和段表长度寄存器STLR。这个设计很像页式管理里的页表基址寄存器目的都是让硬件能快速找到当前进程的映射表。区别在于段表长度寄存器还要额外承担一个职责判断段号本身是否越界。如果逻辑地址里的段号大于等于段表长度说明这个段根本不存在硬件会直接抛出一个越界中断连查表都省了。2.2 段表寄存器与快表查一次的代价与查两次的代价段式地址变换有一个绕不开的性能问题每次访存都要先访问一次段表再访问真正的数据也就是说一条访存指令实际要访问两次内存。这在讲页式的时候我们叫两次访存问题分段同样存在。解决办法是引入一个高速缓存通常叫快表TLBTranslation Lookaside Buffer。快表里存的是最近用过的段表项结构是段号 → 段长 基址。硬件在变换地址时先查快表命中了就直接拿到段长和基址省掉一次内存访问没命中才去内存里查段表同时把这一项填进快表。这里必须强调一点快表是硬件自动维护的不像段表那样由操作系统显式管理。进程切换的时候快表里残留的是上一个进程的段表项如果不做处理就会出错所以进程切换时必须清空快表或者用带进程标识的机制区分。这个细节在课堂练习里一般不考但理解它对建立完整的机制认知很重要——它解释了为什么进程切换的代价那么高为什么操作系统要尽量避免频繁切换。用快表能不能完全消除两次访存理论上讲如果命中率足够高平均访存次数可以逼近一次。但命中率受限于快表的容量段的数量一多就会频繁换入换出。这也是分段的一个隐性成本段数越多快表压力越大。3. 手算练习4.1一条逻辑地址走完的完整链路3.1 先把逻辑地址拆成段号和段内偏移现在进入练习本身。假设题目给了这样一张段表我用自己构造的数据方便演示段号段长字节基址0800102412564096210248192364204845126144注意段表长度是 5也就是说合法段号只有 0、1、2、3、4 这五个。段表项按段号顺序排列第 i 项对应段号 i这是最朴素的组织方式。题目一般会给出若干逻辑地址常见形式有两种一种是直接写成十进制数比如逻辑地址 1100一种是写成(段号, 偏移)的二元组比如(2, 500)。两种形式处理方式完全不同这也是练习里第一个容易混淆的地方。如果题目给的是二元组那很省事段号和偏移直接读出来就行。如果给的是一个整数就要先按位拆。怎么拆取决于地址的位宽分配——典型的做法是把高位若干位作为段号、低位若干位作为段内偏移。比如约定逻辑地址是 32 位高 16 位是段号、低 16 位是偏移那么给定逻辑地址 X 之后段号就是 X 右移 16 位偏移就是 X 与 0xFFFF 做按位与。这一步如果位宽搞错后面全错。拆完之后立刻要做的一件事是判断段号是否越界。段号必须小于段表长度。比如逻辑地址的段号算出来是 5段表只有 5 项0 到 4那这次访问直接判定为非法不需要再查段表。这一步很多人会跳过直接去查表结果查了个不存在的段答案就崩了。3.2 越界检查的两个不等式缺一不可段号检查完之后进入第二层检查段内偏移是否越界。条件很简单偏移量必须小于该段的段长。用公式写就是0 ≤ offset length。注意左边这个 0偏移是无符号数不会小于 0但写出来是为了强调这是一个完整区间。我在这道练习里踩的坑就在这里。题目给的一个逻辑地址是(3, 64)段 3 的段长是 64。当时我脑子里第一反应是64 不大于 64没问题就把物理地址算成了2048 64 2112。结果错了正确答案是这次访问越界应该产生越界中断。为什么因为段内偏移是从 0 开始计数的。段长 64 意味着这段有 64 个字节编号是 0 到 63。偏移 64 指向的是第 65 个字节已经跑到段外面去了。所以正确的判断是offset length严格小于不能取等号。这类错误在做题时特别隐蔽因为它发生在你自认为最清楚的地方。我后来总结出一个土办法算地址之前先把段长减 1得到最大合法偏移然后拿题目的偏移去比。如果偏移大于这个最大合法偏移直接判越界。这个减一的动作能强迫你意识到边界的闭开性减少出错概率。同样的思路在处理数组下标的时候也适用算是一个跨领域的小技巧。3.3 基址加偏移以及进制转换这个隐形杀手两层检查都过了物理地址就是一次简单的加法物理地址 基址 段内偏移。这一步本身没有任何技术含量但它经常被进制转换搞砸。课堂练习给的段长和偏移经常混着十进制和十六进制。比如说段长写成0x100偏移写成200如果你直接把 256 和 200 相加那没问题但如果你把0x100误读成 100得到的结果就完全错了。更阴险的是段表里的基址常常是十六进制而偏移是十进制加完还要按题目要求转成某种进制输出。我给自己定的规矩是算之前统一转成十进制算完再按题目要求转回去。听起来笨但正确率高。转十六进制的时候注意区分大小写其实无所谓但位数要对齐——0x0ABC和0xABC是同一个数但答题时如果题目格式是四位写成三位可能会被扣分。按这套流程走一遍前面那几个地址(0, 100)段 0 段长 800100 800合法物理地址 1024 100 1124。(1, 300)段 1 段长 256300 ≥ 256越界中断。(2, 1023)段 2 段长 10241023 1024合法物理地址 8192 1023 9215。(3, 64)段 3 段长 6464 ≥ 64越界中断。(4, 0)段 4 段长 5120 512合法物理地址 6144 0 6144。(5, 10)段号 5 ≥ 段表长度 5段号越界直接中断。可以看到六个地址里有两个是偏移越界、一个是段号越界真正合法的只有三个。题目这样设计是有意的就是逼你把三种情况都区分清楚。4. 我在练习里翻车的三个地方以及它们错在哪4.1 段长与最大偏移边界到底闭不闭第一个坑前面说了(3, 64)这种偏移恰好等于段长的情况答案应该是越界。但很多人会答成合法理由五花八门段长 64 说明能装 64 个字节偏移 64 也是 64 以内啊我以为段长就是最大偏移。这个坑的根源在于把长度和最大下标混为一谈了。长度是元素个数最大下标是长度减一。1 米长的尺子刻度从 0 到 100 厘米能测量的最大长度是 100 厘米但你不会说 100 厘米这个刻度上还有一个额外的点。段式管理里的地址指的是字节的位置偏移 64 指向的是第 65 个字节的起始位置这个字节不存在所以越界。想彻底记住这一点可以回到段式管理的初衷段长是硬件做保护检查的依据它的语义是这个段的合法地址范围有多大而不是这个段的最后一个地址编号是多少。硬件比较的时候用的是不是。理解了这层语义就不会再犹豫了。4.2 段号越界和段内越界是两种中断第二个坑是把段号越界和段内偏移越界当成一回事。它们确实都叫越界但发生的位置和含义完全不同。段号越界发生在查表之前。段号大于等于段表长度说明这个进程压根没有这个段硬件在拿段号去索引段表的时候就会先把地址和段表长度比较一下不合法直接中断。这时你连段长和基址都取不到。段内偏移越界发生在查表之后。段号是合法的段表项取出来了但偏移超过了段长说明这次访问想伸到段外面去。这时硬件拿到的是真实的段长比较一下发现越界才中断。区分这两者的实际意义在于中断处理程序拿到的信息不一样。段号越界往往意味着程序逻辑出了大问题比如数组越界写穿了整个段或者逻辑地址构造错了段内偏移越界则更常见于缓冲区溢出之类的问题。在练习的答题里如果题目要求写明中断类型或者说明原因这两种情况要分开写不能笼统地说越界。4.3 拿页式的思路去套段式最典型的一类错第三个坑是最根本的把页式管理的算法直接搬到段式题目上。页式管理的地址变换是页号 → 页表项 → 物理块号然后物理地址 块号 × 块大小 页内偏移。很多人做熟了页式题目之后看到段式也下意识地去找块号 × 块大小结果发现段表里根本没有块号这个字段只有基址。段式和页式的核心区别在于页的大小是固定的、所有页都一样的所以物理地址可以用块号 × 块大小来算只需要一个块号就够了段的大小是不固定的、每段都不一样所以物理位置不能用编号乘以固定大小来算必须直接给出每段的真实起始地址也就是基址。换句话说页式管理的段表项可以只存块号节省空间段式管理的段表项必须存完整的基址因为段长不固定。理解了这一点你就明白为什么段表项要有基址这个字段也明白为什么段式管理会产生外部碎片而页式管理只产生内部碎片。这个对比我在下一节会展开。5. 段式、页式、段页式三个方案分别在赌什么5.1 碎片问题的两种解法外部碎片与内部碎片段式管理和页式管理最本质的差异最终会体现在内存碎片的形态上。段式管理按逻辑单位分配内存每个段的长度是任意的。当你不断申请和释放不同大小的段时内存里会留下许多大小不一的小空洞。这些空洞单个可能都不小但因为没有一段恰好需要这么大的连续空间它们就白白浪费了。这就是外部碎片——碎片在已分配区域的外部是有空间但用不上。解决办法只能是内存紧凑把已分配的内存整体搬移把空洞合并起来。搬移的代价很高而且需要暂停进程。页式管理按固定大小分配每一页和物理块大小完全一致所以理论上不会出现有空间但装不下的情况——任何空闲块都能装任何页。但它有另一个问题程序最后不满一页的部分照样占一整页页内剩余的空间就浪费了。这就是内部碎片——碎片在已分配区域的内部。页越大浪费越多。所以两种方案是在赌不同的东西段式赌的是程序的逻辑划分更重要愿意承担碎片整理的代价页式赌的是管理简单更重要愿意接受一点页内浪费。这是两种设计哲学的取舍没有绝对优劣取决于系统更看重什么。下面这张表把两者放在一起对照做练习的时候对着看会清楚很多对比项段式管理页式管理划分依据程序逻辑单位固定大小大小是否固定不固定固定逻辑地址形式段号 段内偏移二维页号 页内偏移一维对用户是否可见可见透明段表/页表项内容段长 基址块号可能加状态位碎片类型外部碎片内部碎片共享与保护天然支持粒度合理需要额外处理5.2 段页式是怎么把两者捏在一起的既然段式和页式各有优劣一个自然的想法是把两者结合起来这就是段页式管理。段页式的做法是先按程序的逻辑结构分段这是从分段那里继承来的保留了保护精细、共享方便的优点然后对每个段再按固定大小分页段内不再要求连续这是从分页那里继承来的解决了外部碎片问题。这样一来内存分配的最小单位变成了页物理内存里不会出现装不下的大空洞同时段的概念还在保护检查依然按段进行。代价是地址变换变得更复杂。一条逻辑地址现在是段号 段内页号 页内偏移三段式结构硬件要先查段表找到该段的页表地址再查页表找到物理块号最后拼出物理地址。三次访存段表、页表、数据对性能是很大的负担所以段页式系统里快表的地位更加关键通常会把段号 页号作为快表的索引键。做练习的时候段页式的题目错误率通常比纯段式高因为链路更长任何一步出错都会传导到最后。我的建议是不要跳步每一级都写清楚中间结果这样出错了也能快速定位是哪一层的问题。6. 课堂练习之外段式管理在今天系统的残留痕迹6.1 从处理器的分段寄存器看历史包袱很多人学完段式管理会有一个疑问既然现在主流系统都用分页或者段页式那分段是不是已经过时了答案是纯粹的段式管理确实很少作为唯一的机制使用但分段的影子一直都在。以常见的 x86 架构为例早期的实模式和保护模式里都有一组段寄存器CS、DS、SS、ES 等。保护模式下这些段寄存器不再直接存基址而是存一个选择子指向全局描述符表或局部描述符表里的一个段描述符描述符里包含基址、界限和访问权限。这其实就是段式管理中段表的思想只不过表的名字换了字段多了一些权限位。有意思的是现代 64 位模式下分段基本被扁平化处理了——段基址通常设为 0界限设为最大等于把所有段都重叠成一个覆盖整个地址空间的段分段的保护功能被分页接管。但这套机制并没有被删掉寄存器还在描述符表还在只是被设置成不影响寻址。这算是历史包袱也可以看作向后兼容的必然结果。理解这一段历史对做练习有帮助吗有。它能让你明白教材里讲的段表不是凭空发明的抽象概念而是真实硬件里存在过的机制。当你做题时把段表想象成一张真实的硬件查表很多细节比如为什么要有段表长度寄存器、为什么要检查段号就顺理成章了。6.2 段式思想真正留下来的是什么就算分段在地址翻译上退居二线它那种按逻辑结构划分的思想反而越来越重要。最典型的例子是现代操作系统里的内存段划分代码段、数据段、堆、栈这些逻辑分区依然存在只不过映射到物理内存时走的是页表。再比如可执行文件格式里的各个 section加载器会把它们放到不同的虚拟地址区域并设置不同的访问权限——代码段可读可执行不可写数据段可读写不可执行。这就是分段的保护思想在现代的延续只是实现手段从段表换成了页表项里的权限位。还有一个更贴近开发的例子虚拟内存的地址空间布局代码区、数据区、堆、栈、共享库区本质上还是一种分段只不过这些段的边界是操作系统在加载时动态决定的长度由链接脚本和运行时决定。你在调试越界访问崩溃的时候调试器给出的地址落在哪个区域判断依据就是这套逻辑划分。所以课堂上学的段式管理与其说是学一种已经过时的机制不如说是学一种分析内存的方法。分段的两个核心问题——怎么按逻辑划分和怎么检查访问是否越界——在任何内存管理方案里都存在只是表现形式不同。把这两个问题想通了再去学页式、段页式、多级页表思路会顺很多。最后分享一个我自己做这类练习的小习惯每道题先在草稿纸上画两栏左边写拆地址右边写查表校验每一栏下面按顺序列出具体步骤和中间结果。段式管理的地址变换是一条很短的链路但细节多画成两栏之后哪一步漏了、哪个边界忘了检查一眼就能看出来。这个习惯我是从做这道 4.1 开始养成的后来做段页式和带快表的题目也一直用正确率比心算高不少。如果你也总是栽在边界值上不妨试一次。