1. 为什么非要把分段和分页凑在一起
“课堂练习4.3:段页式内存管理”——如果你正在啃操作系统第四章,这个标题大概率是你作业本上的一道题。它通常不长:给一张段表、几张页表,再扔来几个逻辑地址,让你算物理地址、数访存次数、求有效访问时间。很多人第一次做这类题,步骤能背下来,心里却没底:为什么一定要先查段表再查页表?段长到底是按字节存还是按页数存?页表和段表自身占的内存要不要算进去?越界判断该在哪一步做?
段页式内存管理,说白了就是分段和分页两个方案的合体。它先用“段”把程序按逻辑意义切成代码段、数据段、堆栈段,再用“页”把每个段切成大小固定的块。这样一个逻辑地址就被劈成三段:段号、段内页号、页内偏移。做题的核心,就是把这三段数字一层层翻译成物理地址。
这篇文章不打算给你复述教材定义,而是围绕这道练习,把每个数字背后的来路、每一步的意图、每一种常见问法的解法讲透。适合正在备考、赶作业、或者想真正搞懂地址变换的学生和转行者看。看完你应该能做到:拿到任意一张段表页表,自己出题、算题、验算,心里不慌。
1.1 纯分段方案的尴尬在哪
先聊聊为什么单用分段不够。分段的出发点是“程序视角”:一段代码、一块数据、一个栈,各自独立,长度随时可能变。这很符合人对程序的直觉,编译器和链接器也喜欢这种划分,因为不同段可以有不同的访问权限——代码段只读可执行,数据段可读可写。
问题出在物理内存分配上。段长是任意的,从几十字节到几兆字节都可能。当一段程序运行结束、一段新的进来,内存里就会留下各种大小不一的空洞。时间一长,空洞碎成一片,明明总空闲空间够,却找不到一块连续区域放新段。这就是外部碎片。为了缓解它,系统要么花时间做内存紧凑(把内存里所有段挪到一起),要么在分配时来回试探。这两种代价都不便宜。
更麻烦的是,段的长度可变,意味着“段表项”里必须记录一个段长,地址变换时得先比较偏移是否越界。计算量本身不大,但它把内存分配变成了一场打补丁的游戏。
1.2 纯分页方案的遗憾在哪
那分页呢?分页把内存切成固定大小的页框,程序也切成同样大小的页。这样一来,任何空闲页框都能直接用,外部碎片基本消失,只剩最后一页可能浪费一点内部碎片。分配变得极其简单。
但分页丢掉了程序的逻辑结构。一整个程序被均匀切成页,编译器眼里的“代码段”“数据段”在物理上荡然无存。结果是共享和权限管理变难——你想让两个进程共享同一份代码,或者给某块内存单独设个只读属性,在纯分页下都得靠额外的约定和映射,不够自然。
再者,分页的页表只解决“页到页框”的映射,它不管这段内存是干嘛的。所以在真实系统里,光靠分页很难表达“这是一段可执行代码”这种语义。
1.3 段页式的定位:一次妥协但很值的妥协
段页式就是让两者各司其职:用分段表达逻辑结构,用分页管理物理分配。每个段内部再按固定页大小拆分,于是段长不再是“任意长度”,而是“整数个页”。分配内存时只按页框来,外部碎片问题被压下去;同时段的逻辑边界还在,权限和共享都好办。
代价是地址变换多了一层。纯分页只要查一次页表,纯分段只要查一次段表,段页式得先查段表拿到该段的页表,再查页表拿到页框号。访存次数从两次变成三次。这也是为什么段页式几乎总是配一个快表来救场。
搞清楚了“为什么要三层”,后面做题时每次画箭头、每次加偏移,你都知道自己在干嘛,而不是机械套公式。
2. 逻辑地址怎么切:段页式的地址结构拆解
2.1 三段式地址:段号、段内页号、页内偏移
段页式的逻辑地址可以写成一个二元组(段号 S, 段内偏移 d),但真正落到硬件里,这个“段内偏移”又被拆成(页号 P, 页内偏移 W)。所以完整形式是(S, P, W)三元组。
地址的高位放段号,中间放段内页号,低位放页内偏移。具体各占多少位,由系统设计决定。举个例子,假设页大小是 4KB,也就是 2 的 12 次方字节,那页内偏移就得占 12 位;假设每段最多 256 页,那页号占 8 位;假设最多 16 个段,段号占 4 位。整个逻辑地址就是 4+8+12 = 24 位。
注意:位数的划分不是随便定的,它直接决定了“每段最大多少页”“总共多少段”“页多大”。题目里如果只给了地址位数和页大小,你要能反推出页号占多少位、段内页号上限是多少。
这里最容易翻车的地方,是把“段内偏移”当成“页内偏移”用。段内偏移是相对段起点算的,包含页号和页内偏移两部分;而页内偏移只是相对某一页起点算的低位。做题时务必先分层,别一上来就对整个地址取模。
2.2 段表和页表的表项到底存什么
段页式有两张表,各管一段路。
段表的每一项,通常包含:段号(有时隐含)、段长、该段页表的起始地址。段长用来做越界检查,页表起始地址用来找到下一跳。段表本身是一个数组,用段号做下标就能定位。
页表的每一项,通常包含:页框号(物理块号),再加上一些状态位,比如有效位、访问位、修改位、保护位。在课堂教学题里,往往只保留“页框号”这一列,其他位忽略。
这里有个关键区别要记住:段表项里的“页表起始地址”,在不同教材、不同系统里有可能是物理地址,也有可能是虚拟地址。如果页表本身也允许被换出内存,那它就是个虚拟地址,需要再变换一次——这就演变成多级页表了。课堂练习一般默认页表常驻内存,段表项里的页表地址就是物理地址,直接拿去访存。
还有个常被忽略的点:页表项本身也占内存。如果页表项是 4 字节,一页 4KB 就能装 1024 个页表项。这个数字在算“页表要占多少页”时非常有用,别漏。
2.3 硬件怎么配合:段表寄存器与地址变换机构
光有表不行,还得有硬件告诉 CPU“表在哪”。这就是段表寄存器的作用。它一般存两个东西:段表始址(段表在内存里的起始位置)和段表长度(一共多少项)。
每次地址变换,硬件的动作顺序大致是:先从逻辑地址里取出段号 S,拿 S 去和段表长度比较,判断是否越界;没越界就用“段表始址 + S × 段表项大小”算出段表项地址,读出来,拿到段长和页表始址;接着比较段内偏移是否小于段长,越界就报错;然后从段内偏移里拆出页号 P,用“页表始址 + P × 页表项大小”算出页表项地址,读出页框号;最后把页框号和页内偏移拼起来,得到物理地址。
这一串动作里,段表长度比较、段长比较是两个不同的检查点,分别在两级表访问之前。做题时如果题目问“哪个地址会越界”,你得判断它是在第一步(段号超范围)还是第三步(段内偏移超段长)被拦下。
3. 地址变换全流程:手算走一遍才算真懂
3.1 六步变换流程详解
把上面硬件的动作拆成做题能用的步骤,大概是这六步:
- 从逻辑地址中分离出段号 S、页号 P、页内偏移 W。
- 用 S 去查段表,检查 S 是否小于段表长度。越界则报“段号越界”或“缺段”。
- 取出该段的段长,检查“P × 页大小 + W”是否小于段长。越界则报“段内地址越界”。
- 取出该段的页表起始地址。
- 用 P 查页表,取出页框号(物理块号)。如果页表项有效位是 0,说明该页不在内存,触发缺页中断(本题通常不考)。
- 物理地址 = 页框号 × 页大小 + W。
这里第 3 步的检查方式很关键。为什么要算“P × 页大小 + W”而不是直接比 P?因为段长是以字节为单位的,而 P 是页号。要先把它换算回字节,才能和段长比。当然,你也可以先算出段内最大页号 = ⌈段长 ÷ 页大小⌉,然后检查 P 是否小于它。两种做法等价,但第二种更容易出错——边界值到底是“小于”还是“小于等于”,得看清楚。段长能整除页大小时,最大页号就是 段长÷页大小 的整数结果;不能整除时,最后一页只有部分有效,页号上限要向上取整。
提示:判断页号是否越界,本质是判断“这个页在段内是否存在”。只要页号小于该段实际占用的页数,就算合法,哪怕最后一页只用了一部分。
3.2 一道典型练习的完整手算过程
我们设计一套数据,把练习从头走一遍。规定:页大小 4KB,页内偏移 12 位;段内页号 8 位;段号 4 位。段表寄存器里,段表始址为 0x8000,段表长度为 3。
段表内容如下:
| 段号 | 段长(字节) | 页表始址 |
|---|---|---|
| 0 | 8192 | 0x9000 |
| 1 | 12288 | 0x9100 |
| 2 | 4096 | 0x9200 |
各段的页表(只列页框号):
| 段号 | 页号 0 | 页号 1 | 页号 2 |
|---|---|---|---|
| 0 | 5 | 8 | — |
| 1 | 2 | 9 | 7 |
| 2 | 11 | — | — |
现在依次求下面几个逻辑地址对应的物理地址。
第一题:逻辑地址 (0, 1, 100)。
段号 0 小于段表长度 3,合法。段 0 的段长是 8192 字节,等于 2 页。页号 1 小于 2,且 1×4096+100 = 4196 < 8192,合法。查段 0 的页表,页号 1 对应页框号 8。物理地址 = 8 × 4096 + 100 = 32768 + 100 = 32868。
第二题:逻辑地址 (1, 2, 0)。
段号 1 合法。段 1 段长 12288 字节,等于 3 页。页号 2 小于 3,合法。查段 1 页表,页号 2 对应页框号 7。物理地址 = 7 × 4096 + 0 = 28672。
第三题:逻辑地址 (0, 2, 0)。
段号 0 合法。但段 0 只有 2 页(页号 0、1),页号 2 已经超过范围,2×4096 = 8192,不小于段长 8192,触发段内地址越界。这一步拦下来的原因是页号越界,跟段号没关系。
第四题:逻辑地址 (3, 0, 0)。
段号 3 等于段表长度 3,超出了允许范围(合法段号是 0、1、2),触发段号越界。注意这里是在第一步就被拦截,根本不会去查页表。
| 逻辑地址 | 段号检查 | 页号/段内检查 | 页框号 | 物理地址 |
|---|---|---|---|---|
| (0,1,100) | 通过 | 通过 | 8 | 32868 |
| (1,2,0) | 通过 | 通过 | 7 | 28672 |
| (0,2,0) | 通过 | 越界 | — | 越界中断 |
| (3,0,0) | 越界 | — | — | 段号越界 |
3.3 两道防线:段内越界与页号越界
从上面第三、第四题能看出,段页式有两个独立的越界关口,物理含义完全不同。
段号越界是“你访问的段根本不存在”。每个进程的段表长度是固定的,段号超出了,就像你翻开一本书却要查第 100 章,书里没这章。这种错误通常意味着程序逻辑本身有问题,或者指针被写坏了。
段内地址越界是“段存在,但你访问的位置超出了这个段的长度”。段 0 只有 2 页,你要访问第 3 页,段在,但里面没那么多内容。这往往是数组下标越界、缓冲区溢出一类问题的直接表现。
很多人做练习时会把这两种情况混成一句“越界”,但考试里经常分开问。所以答题时一定要写清楚:是段号越界还是段内地址越界,分别在第几个检查点被拦下。
4. 性能与开销:访存次数和有效访问时间怎么算
4.1 无快表为什么要访存三次
段页式每次地址变换,硬件至少要读三块内存:第一块是段表项,第二块是页表项,第三块才是真正要访问的数据。这就是“三次访存”的由来。
对比一下会更清楚。纯分页只要读一次页表再一次数据,共两次;纯分段是读一次段表再一次数据,也是两次。段页式因为多了一层间接,变成三次。三次访存里,前两次纯粹是“寻路”开销,跟程序真正要干的事没关系。如果每次读写内存都要先花两个来回找路,性能损失是实打实的。
正因为这样,段页式系统几乎都会配一个快表(TLB)。快表是个小容量的高速缓存,专门缓存最近用过的段号加页号到页框号的映射。命中时,直接拿到页框号,省掉两次访存。
提示:快表里缓存的键,通常是“段号 + 页号”的组合,而不是单独的页号。因为不同段里可能有相同的页号,光凭页号无法区分。
4.2 引入快表后的EAT计算
有效访问时间(Effective Access Time,EAT)是这类练习的高频考点。我们设:访问快表耗时 10ns,访问一次内存耗时 100ns,快表命中率 98%。
没有快表时,每次都要三次访存:EAT = 3 × 100 = 300ns。
有快表时,分成两种情况:
- 快表命中:先花 10ns 查快表,拿到页框号,再花 100ns 访问数据,共 10 + 100 = 110ns。
- 快表未命中:查快表 10ns,然后依次访问段表 100ns、页表 100ns、数据 100ns,共 10 + 100 + 100 + 100 = 310ns。
于是 EAT = 98% × 110 + 2% × 310 = 107.8 + 6.2 = 114ns。
从 300ns 降到 114ns,提速接近三倍,这就是快表的价值。这里有个容易出错的地方:把命中率套反,或者把命中时的访存次数算成两次以上。记住,命中后物理地址已经确定,直接访存取数就行。
| 场景 | 快表访问 | 段表访问 | 页表访问 | 数据访问 | 总耗时 |
|---|---|---|---|---|---|
| 无快表 | — | 100 | 100 | 100 | 300ns |
| 命中 | 10 | — | — | 100 | 110ns |
| 未命中 | 10 | 100 | 100 | 100 | 310ns |
4.3 段表和页表自身占多少内存
练习里另一种常见问法,是问“这套机制要额外花掉多少内存来存表”。这时候要分两块算。
段表开销:段表项数等于进程的段数,乘以每个段表项的大小。比如有 3 个段,每个段表项 8 字节,段表就是 24 字节。真实系统里段数不多,段表通常很小。
页表开销:每个段都要维护一张自己的页表,页表项数等于该段的页数。段 0 有 2 页,页表就 2 项;段 1 有 3 页,页表就 3 项。假设页表项 4 字节,段 0 的页表占 8 字节,段 1 占 12 字节,段 2 占 4 字节,合计 24 字节。
这套例子里的表开销一共 48 字节,小到可以忽略。但真实系统可不是这样。假如一个进程最多有 16 个段,每段最多 256 页,页表项 4 字节,那么每段的页表固定要预留 256 × 4 = 1024 字节,16 段就是 16KB。注意,这里无论该段实际用了多少页,页表都按最大页数预留——这就是段页式的一个隐性代价。
更极端的推算:如果段内页号有 20 位,那每段页表最多 2 的 20 次方项,按每项 4 字节算就是 4MB。每个段都来这么一张,段一多,内存直接就爆了。这正是真实系统改用多级页表的根本原因——不是页变多了,而是单张页表太占地方。想通这一点,你对段页式的理解就上了一个台阶。
5. 常见错误与排查思路实录
5.1 计算题高频错点速查表
练习做多了会发现,错误翻来覆去就那几类。我把自己踩过和见过的坑整理成一张表,对着检查基本能救回来。
| 错误现象 | 真实原因 | 正确做法 |
|---|---|---|
| 物理地址算出来偏小 | 直接用页框号加偏移,忘了乘页大小 | 物理地址 = 页框号 × 页大小 + 页内偏移 |
| 段号判断反了 | 把“段号 < 段表长度”写成“≤” | 合法段号是 0 到 段表长度−1 |
| 页号判断出错 | 拿页号和段长直接比,单位不一致 | 换算成字节,或先算段内最大页数 |
| 段长单位搞混 | 题目给的是页数,当成字节用了 | 看清单位,必要时乘除页大小 |
| 访存次数漏算 | 忘了段表本身也要访存 | 无快表是三次访存,不是两次 |
| EAT 命中率用反 | 命中当未命中算 | 先写清两种情况各自耗时,再加权 |
| 十六进制十进制混算 | 0x1000 当成 1000 了 | 统一进制,建议全程用十进制算完再转 |
5.2 单位与进制的坑
这是最不起眼、也最容易丢分的地方。段长题里有时写“8192”,有时写“8KB”,有时写“2 页”。页大小有时是“4K”,有时是“4096”。地址有时是十六进制 0x9000,有时是十进制。
我的建议是:所有涉及大小的量,统一换算成字节再算。页内偏移是多少字节、段长是多少字节、页大小是多少字节,全对齐了,就不容易错。十六进制的地址(比如页表始址)在做单纯查表题时其实用不到,只有当你需要算出“页表项在内存中的具体地址”时才用得上,那一步再转。
举个具体的坑:题目说“页表起始地址为 0x9100,页表项大小 4 字节,求页号 2 对应的页表项地址”。答案是 0x9100 + 2 × 4 = 0x9108。这里的 0x9100 是物理地址,直接加偏移即可。但如果你误以为它是段式里的“逻辑地址”,就会多绕一圈。看清题目给的是物理地址还是逻辑地址,能省掉不少无效计算。
5.3 和真实系统的对应关系
课堂练习里的段页式模型,跟真实操作系统有对应也有差异,了解这些能帮你判断哪些细节是“题目简化”,哪些是“系统本质”。
在 x86 体系的早期设计里,段式机制是真实存在的,逻辑地址先经段寄存器加偏移得到线性地址,再经分页机制变成物理地址。这套流程本质上就是段页式。后来为了简化和兼容,主流系统把段基址统一设为 0、段界限设为最大,相当于“绕开”了分段,只保留分页——所以你现在看到的现代系统,地址变换基本是纯分页加多级页表。
但这个“绕开”不是否认分段的价值,而是因为现代编译器、链接器已经能用其他方式表达逻辑结构,不需要硬件分段来扛。反过来,练习题里坚持考段页式,是因为它是理解“逻辑结构如何映射到物理结构”最直观的模型。把段页式吃透,再去看多级页表、反向页表,会觉得顺理成章。
6. 练习拓展:自己动手改题目
6.1 把题目改难一点的几种方式
做完原题如果还有余力,可以自己给自己加难度,比刷重复的题有用得多。
第一种改法是引入二级页表。把段内页号拆成“页目录号 + 页表号”,这样段表项指向的不再是页表,而是页目录。地址变换多一层,访存次数变成四次,EAT 公式也要跟着改。这一改,你就能体会到为什么真实系统用多级结构。
第二种是改页大小。把页大小从 4KB 改成 1KB,页内偏移从 12 位变成 10 位,段内页号位数相应增加。算一遍页表大小,你会发现页变小会让页表项数暴涨,表开销反而更大。这个反直觉的结论值得亲手算一遍。
第三种是加入缺页处理。给页表项加上有效位,让某个页号对应“不在内存”。这时地址变换要触发缺页中断,流程从“查表拼地址”变成“查表—发现无效—中断—调页—重新执行”。把这条路径画清楚,对理解虚拟内存很有帮助。
6.2 用代码模拟一次地址变换
手动算几遍之后,写段代码把逻辑固化下来,理解会更深。下面这段 Python 用前面那套段表页表,完整实现地址变换,包括两级越界检查。
PAGE_SIZE = 4096 OFFSET_BITS = 12 PAGE_BITS = 8 SEG_BITS = 4 # 段表:段号 -> (段长字节, {页号: 页框号}) SEG_TABLE = { 0: (8192, {0: 5, 1: 8}), 1: (12288, {0: 2, 1: 9, 2: 7}), 2: (4096, {0: 11}), } def translate(logical_addr): seg_no = (logical_addr >> (OFFSET_BITS + PAGE_BITS)) & ((1 << SEG_BITS) - 1) page_no = (logical_addr >> OFFSET_BITS) & ((1 << PAGE_BITS) - 1) offset = logical_addr & ((1 << OFFSET_BITS) - 1) # 第一道防线:段号越界 if seg_no not in SEG_TABLE: return "段号越界: {}".format(seg_no) seg_len, page_table = SEG_TABLE[seg_no] # 第二道防线:段内地址越界 if page_no * PAGE_SIZE + offset >= seg_len: return "段内地址越界: 段{} 偏移{}".format(seg_no, page_no * PAGE_SIZE + offset) # 查页表 if page_no not in page_table: return "缺页: 段{} 页{}".format(seg_no, page_no) frame_no = page_table[page_no] return frame_no * PAGE_SIZE + offset # 验证 for addr in [(0 << 20) | (1 << 12) | 100, (1 << 20) | (2 << 12) | 0, (0 << 20) | (2 << 12) | 0, (3 << 20) | (0 << 12) | 0]: print(hex(addr), "->", translate(addr))把这段跑一遍,输出应该是:0x110064 -> 32868、0x120000 -> 28672、0x120000前面那个0x020000 -> 段内地址越界,以及0x300000 -> 段号越界。对照前面手算的结果,能立刻发现自己对位运算或者越界判断有没有理解偏。
提示:代码里的
seg_len是字节数,所以越界比较必须先把页号和偏移换算回字节。如果你直接把page_no和seg_len比,段长大的时候会误判通过,段长小的时候又会误判越界。这个坑我在第一次写的时候就踩过。
最后分享一个我自己做题时的习惯:每道题都在草稿纸上画一张三列的对照表——段号、页号、页内偏移,每算一个地址就往里填一次检查结果。这样即便算到第十个地址,也不会把哪一步的中间结果记混。段页式这类练习,错往往不是错在难,而是错在信息多、步骤长、单位杂。把中间过程摊开写,比在脑子里默算靠谱得多。要是你手头还有别的变体题目,不妨用这套“先分层、再查表、后检查、最后拼地址”的顺序过一遍,基本不会再卡壳。