☰
段式内存管理:课堂练习4.1逻辑地址到物理地址变换与越界判断
2026/9/30 0:40:57 网站建设 项目流程

段式内存管理这个词,第一次在课本上见到的时候我并没太当回事,觉得不就是把内存切成一段一段再分配吗,能有多难。结果真到课堂练习4.1,题目甩过来一张段表,让我算某个逻辑地址对应的物理地址,还要判断哪些地址访问会越界,我当场就卡住了——段号到底从哪一位开始?段长和偏移量谁跟谁比?基址是加在谁身上?一堆问号。后来把这条链路从头到尾捋了一遍,才发现段式内存管理的核心其实非常朴素:段号去查表,拿到基址和段长,偏移量先跟段长比一比,没越界再拿基址加偏移量。就这么一条主线。

这篇就把课堂练习4.1这道题背后牵扯的知识点整个摊开讲。从段式管理为什么会出现、段表和逻辑地址长什么样、地址变换每一步在干什么,到怎么用代码把变换过程跑通验证,再到做题和实验中踩过的那些坑。正在学操作系统内存管理这一章的同学可以拿来对着看,需要快速复习段式地址变换、准备考试或者做课程实验的人也能直接抄作业。

1. 段式内存管理到底在解决什么问题

1.1 从"程序被切成了什么"说起

要把段式管理搞明白,得先跳出内存本身,看看一个程序在运行前是怎么被拆开的。我们写的代码,编译链接之后并不是铁板一块,它天然就分成好几个逻辑上独立的部分:有放代码的代码段,有放全局变量和常量的数据段,有函数调用时用来压栈的栈段,还有动态申请的空间堆段。这些部分的作用、增长方式、访问权限完全不一样——代码段一般只读可执行,数据段可读可写,栈段会随着函数调用不断向下增长,堆段则可能向上长。它们之间没有任何理由被绑在一起、当成一整块连续的内存来管理。

段式内存管理干的事情,就是顺着程序这种天然的模块划分,把逻辑地址空间按逻辑意义切成若干段。每个段从 0 开始编址,有自己的段号和段内偏移。这样一来,程序员的视角和操作系统管理内存的视角就对上了:一个函数是一个段,一张表是一个段,一段共享库也是一个段。段与段之间互相独立,各管各的。这跟分页管理里那种"不管三七二十一,每 4K 切一刀"的机械做法,思路完全不在一个频道上。

我第一次真正理解"段是逻辑单位"这句话,是靠一个类比:分页像是把一本书强行按每 50 页装订成册,不管你章节在哪结束;分段则是按书本身的章、节来装订,每一章自成一本。哪种方式更符合"书的逻辑结构",一想就明白。段式管理之所以适合模块化、共享、保护这些场景,根子就在这里。

1.2 段式与分页的那条分水岭

很多人学到这里会糊涂:既然分页和分段最后都要做地址变换,都要查表,都要检查越界,那它们的区别到底在哪。我总结下来,最本质的分水岭只有一条——页是物理单位,段是逻辑单位。这一条决定了后面所有差异。

分页的页大小是系统固定死的,比如 4KB,由硬件决定,用户和程序完全感知不到页的存在,对它来说是透明的。分页的地址空间是一维的,你给一个线性地址,系统帮你切成页号和页内偏移,你不需要知道页边界在哪。而段的大小是可变的,每段长度由程序本身决定,短的几十字节,长的几兆都行,而且段边界对程序是可见的,你自己就知道哪个函数属于哪个段。

由此衍生出一连串差别。分页能有效减少内存的外部碎片,因为它把内存切成了固定小块,最后剩下的零头往往能塞进某一页里去,代价是可能产生内部碎片。分段因为段长不固定,分配和回收都更麻烦,容易留下外部碎片,碎片多了还得做内存紧凑。但换来的好处是它在共享、保护和动态增长这三件事上更顺手。为什么?因为你可以精确地共享"某一个段",而不用去担心共享的那一页里混进了别的不该共享的内容。这个点我在第 5 章还会细说,这里先埋个伏笔。

理解了这条分水岭,再看段式管理的一切设计,包括段表为什么长那样、地址为什么是二维的,就都顺理成章了。

2. 段表结构与逻辑地址:把这两样吃透,题就做对了一半

2.1 逻辑地址的二维结构

段式管理里,一个逻辑地址是个二维坐标,写成(段号 S, 段内偏移 W)的形式。这两个量在机器里其实是拼在一个二进制数里的,只不过高位表示段号,低位表示偏移量,中间的分界线由系统规定。比如常见的划分是前 16 位是段号、后 16 位是偏移量,那这个系统最多能容纳 2 的 16 次方也就是 65536 个段,每个段最大 64KB。到底用几位分给段号、几位分给偏移,是要在设计阶段算清楚的,因为段号位宽决定了段数上限,偏移位宽决定了单段的最大长度。

这里有个新手常犯的错误:拿到一个逻辑地址的十进制数值,不知道该怎么切。正确的做法是先确认地址结构,假设段号占高 16 位、偏移占低 16 位,那么逻辑地址 0x00010064 就表示段号 1、段内偏移 0x64(即 100)。计算方式是右移 16 位得到段号,与低 16 位掩码相与得到偏移。我见过有人直接把十进制数除以 65536 求段号、求余数当偏移,思路对,但一旦位宽不是 16 就容易算错,还是老老实实按位操作最稳妥。

段号从 0 开始编号。这意味着如果一个进程有 n 个段,段号的合法范围是 0 到 n-1。这个"从 0 开始、合法上限是 n-1"的细节,是后面越界判断的关键前提,记住它。

2.2 段表里每一项都是干什么用的

每个进程都有一张自己的段表,段表本质上是一个数组,按段号索引。段表的每一项(也叫段描述符、段表项)通常包含这么几个字段,我按重要性排个序:

字段作用课堂上最常考
段基址该段在物理内存中的起始地址是,算物理地址必用
段长该段的长度,用来做越界检查是,越界判断必用
存取控制位读、写、执行权限,用于保护偶尔考
存在位该段当前是否已调入内存偶尔考
访问位/修改位供置换算法参考较少考

段基址和段长是做题时的绝对主角。物理地址的计算就靠基址,越界判断就靠段长。段表的结构可以简化理解成一张二维表格,每行对应一个段,行号就是段号。有的教材会把段号也作为一个显式字段列在每一项里,其实是冗余的,因为段号就是索引本身,写出来只是为了阅读方便。

段长这个字段容易被忽视,但它是段式管理和分页管理做题时最大的差异点。分页里页大小固定,越界判断只需要偏移量小于页大小即可;而分段里每段长度都不一样,必须逐段查表拿段长,再和偏移量比。这就是为什么分段地址变换不能像分页那样"一刀切",必须老老实实查表。

2.3 段表寄存器与进程切换时会发生什么

段表存在内存里,CPU 怎么知道当前进程的段表在哪?靠一个专门的硬件寄存器——段表寄存器。它通常存两个信息:段表在内存里的起始地址,以及段表的长度(也就是有多少个段表项)。每次做地址变换,CPU 都先从这里拿到段表的入口,再去内存里查表项。

为什么还要存段表长度?因为这个长度就是段号是否越界的第一道关卡。拿到一个逻辑地址,先取出段号 S,跟段表长度一比,如果 S 大于等于段表长度,说明这个段号根本不存在,直接报段号越界,连查表都省了。这道检查比查段长还靠前,是地址变换的第一步。

那进程切换的时候呢?这就有意思了。每个进程有自己独立的段表,进程 A 切换到进程 B,操作系统必须把段表寄存器里的内容换成 B 的段表信息,否则 B 就会拿着 A 的段表去查自己的地址,结果必然错乱。这一步叫"切换段表基址",是进程上下文切换的一部分,和切换页表基址寄存器是一个道理。我在做实验的时候忘了在切换进程时更新段表寄存器,导致进程 B 一运行就疯狂报越界,排查了半天才发现是这里漏了更新,印象特别深。

3. 地址变换全过程:课堂练习4.1的核心考点逐条拆

3.1 从逻辑地址到物理地址的四步

把前面铺垫的东西拼起来,段式管理的地址变换就是一个固定的四步流程,任何一道题都是这四步的排列组合。我把它写死,做题时照着套:

  1. 拆分:从逻辑地址里按位宽切出段号 S 和段内偏移 W。
  2. 查段号越界:判断 S 是否小于段表长度。不满足就抛"段号越界"异常,流程结束。
  3. 查段长越界:用 S 去段表里取出对应表项,判断 W 是否小于该段的段长。不满足就抛"段内偏移越界"异常,流程结束。
  4. 算物理地址:物理地址 = 段基址 + 段内偏移 W。

这四步里,最容易出错的不是加法,而是两个越界判断的顺序和比较对象。步骤 2 比的是段号和段表长度,步骤 3 比的是偏移量和段长,两个都是"严格小于才合法",不满足就报错。很多人图省事,直接跳过越界判断去加基址,结果题目问"能不能正常访问"的时候就答不上来。

我用一张流程性质的清单再强调一遍逻辑顺序,注意这里不用图表,就按文字走:先看段号在不在范围内,再看偏移量在不在这一段长度内,两道都过了才有资格做基址加偏移。越界检查是地址变换的安全闸门,不是可有可无的装饰。

3.2 题型一:给段表算物理地址

这是课堂练习里出现频率最高的题型。题目给一张段表,再给一个或几个逻辑地址,让你算出物理地址。我拿一组典型数据来演示,这套数据是我自己造的,贴合教材常见的数值风格:

段号段长段基址
010002000
120005000
230008000
3100012000

段表长度是 4,也就是有 4 个段,合法段号是 0、1、2、3。

现在来逻辑地址 (1, 500),意思是段号 1、段内偏移 500。第一步拆分没问题,S=1,W=500。第二步查段号,1 小于 4,通过。第三步查段长,段号 1 的段长是 2000,500 小于 2000,通过。第四步算物理地址:段基址 5000 加上偏移 500,结果是 5500。答案就是 5500。

再看一个 (2, 2900)。S=2,W=2900。段号合法,段号 2 的段长是 3000,2900 小于 3000,通过。物理地址 = 8000 + 2900 = 10900。

这两个例子都算得干净利落,但真正的考点往往藏在"陷阱地址"里,这就引出下一类题型。

3.3 题型二:越界判定,别只会算加法

还是上面那张段表,如果题目给的是逻辑地址 (2, 3500),你会怎么做?要是直接加基址,8000 + 3500 = 11500,看起来像个答案,但这其实是错的。因为段号 2 的段长只有 3000,偏移量 3500 已经超过了 3000,属于段内偏移越界,这次访问压根不合法,根本不该产生物理地址。

这就是段式地址变换里最关键的思维转变:算物理地址之前,必须先通过越界检查这道门。分页做多了会养成肌肉记忆,看到地址就往下加,但分段不一样,每段长度不同,必须查。这道题正确答案是"越界,无法访问"。

再看一个段号越界的例子,逻辑地址 (5, 0)。段表长度是 4,段号范围是 0 到 3,而这里段号是 5,5 不小于 4,段号越界,连查段表的机会都没有,直接报错。注意区分这两种越界:段号越界是"段根本不存在",段内偏移越界是"段存在但这一脚踩到了段外面",两者报的异常类型不同,有的题目会要求你明确指出是哪种越界。

有个细节要提醒:段长比较时是严格小于。如果偏移量刚好等于段长,比如段长 3000、偏移量 3000,这已经越界了,因为段内合法偏移是 0 到 2999。0 号偏移是段的第一字节,段长 3000 的段最后一字节偏移是 2999。这个"差一"问题坑过不少人,我第一次做练习就把等号当成合法,结果整道题的分都丢了。

3.4 题型三:段号范围和段表长度

有一类题不直接给段表,而是给地址结构的位宽,让你推断段号范围。比如题目说逻辑地址共 32 位,其中段号占 8 位、偏移占 24 位。这时候段号范围就是 0 到 2 的 8 次方减 1,也就是 0 到 255;每段最大长度是 2 的 24 次方字节。这类题考的是你对地址结构的理解,知道位宽怎么换算成范围。

还有一类题给的是十进制逻辑地址,让你先拆再判。假设段号高 16 位、偏移低 16 位,逻辑地址 131172 是多少?先拆:131172 除以 65536 等于 2,余数是 131172 - 2×65536 = 131172 - 131072 = 100。所以段号 2、偏移 100。然后拿段号 2 去查表看段长够不够,按上面那张表,段长 3000,100 合法,物理地址 = 8000 + 100 = 8100。

这一段的核心心得是:拆地址、查段号、查段长、算结果,四步一步都不能少,顺序也不能乱。把这三类题型都练熟,课堂练习4.1基本就是送分题。真正拉开差距的,是有人直接跳步算加法,被"等号越界""段号越界""偏移越界"这些陷阱反复绊倒。

4. 用代码把地址变换跑一遍

纸上推演容易有疏漏,我习惯把地址变换用代码实现一遍,跑几个测试用例,对不对一眼就能看出来。下面用 Python 写,思路清晰,也方便改成其他语言。

4.1 段表和段表项的数据结构

先定义段表项。为贴合课堂简化模型,我只保留段号、段长、基址和存取权限四个字段,权限用 3 位表示读、写、执行。

class SegmentTableEntry: def __init__(self, base, length, protection=0b111): # base: 段基址,length: 段长 # protection: 保护位,默认读、写、执行全开 self.base = base self.length = length self.protection = protection class SegmentTable: def __init__(self, offset_bits): # offset_bits: 逻辑地址中偏移量占的位数 self.entries = [] self.offset_bits = offset_bits self.table_len = 0 # 段表长度,等于段表项数量 def add_segment(self, base, length, protection=0b111): entry = SegmentTableEntry(base, length, protection) self.entries.append(entry) self.table_len = len(self.entries) return self.table_len - 1 # 返回该段的段号

这里 offset_bits 很关键,它决定了从逻辑地址里怎么切。段号占的位数由段表长度反推,其实在拆分的时候我们只需要知道偏移占多少位,高位剩下的就是段号。这个设计比写死段号位宽更灵活。

4.2 地址变换函数的实现

核心的变换函数严格照着四步走,任何一步失败都抛异常,绝不偷偷跳过。

def translate(logical_addr, seg_table): offset_bits = seg_table.offset_bits offset_mask = (1 << offset_bits) - 1 # 第一步:拆分段号和段内偏移 seg_no = logical_addr >> offset_bits offset = logical_addr & offset_mask # 第二步:段号越界检查 if seg_no >= seg_table.table_len: raise ValueError( "段号越界: 段号=%d, 段表长度=%d" % (seg_no, seg_table.table_len) ) # 第三步:段内偏移越界检查 entry = seg_table.entries[seg_no] if offset >= entry.length: raise ValueError( "段内偏移越界: 偏移=%d, 段长=%d" % (offset, entry.length) ) # 第四步:计算物理地址 physical_addr = entry.base + offset return physical_addr

函数的返回值和异常设计得很直白:合法就返回物理地址,非法就抛异常并带上诊断信息。这样跑测试用例的时候,出错信息直接告诉你卡在段号还是段长,省得自己猜。

4.3 测试用例跑出来的结果

用第 3 章那张段表来搭测试。段基址分别是 2000、5000、8000、12000,段长是 1000、2000、3000、1000。为了能构造出逻辑地址数值,我把偏移位宽设成 16 位。

seg_table = SegmentTable(offset_bits=16) seg_table.add_segment(base=2000, length=1000) # 段 0 seg_table.add_segment(base=5000, length=2000) # 段 1 seg_table.add_segment(base=8000, length=3000) # 段 2 seg_table.add_segment(base=12000, length=1000) # 段 3 def make_addr(seg_no, offset): return (seg_no << 16) | offset # 用例1:合法,(1, 500) 期望 5500 print(translate(make_addr(1, 500), seg_table)) # 输出 5500 # 用例2:合法,(2, 2900) 期望 10900 print(translate(make_addr(2, 2900), seg_table)) # 输出 10900 # 用例3:偏移越界,(2, 3500) try: translate(make_addr(2, 3500), seg_table) except ValueError as e: print(e) # 段内偏移越界: 偏移=3500, 段长=3000 # 用例4:段号越界,(5, 0) try: translate(make_addr(5, 0), seg_table) except ValueError as e: print(e) # 段号越界: 段号=5, 段表长度=4 # 用例5:边界,(0, 999) 合法,(0, 1000) 越界 print(translate(make_addr(0, 999), seg_table)) # 输出 2999 try: translate(make_addr(0, 1000), seg_table) except ValueError as e: print(e) # 段内偏移越界: 偏移=1000, 段长=1000

用例 5 就是我前面强调的"等号越界"边界测试。跑通这五个用例,基本就把课堂练习4.1所有考点覆盖全了。这里有个操作上的小技巧:测试用例一定要把边界值单独拎出来测,偏移等于段长、段号等于段表长度、段号为 0 这些位置最容易暴露逻辑错误。我一开始把比较写成了小于等于,用例 5 直接失败,一秒钟就定位到了问题。

5. 段式管理的优势和做题时的踩坑记录

5.1 为什么段式在共享和保护上更顺手

回到最开始那条分水岭,段是逻辑单位这件事,决定了它在两个场景上特别占便宜。第一个是共享。多个进程想共用一段代码(比如同一个库函数),在段式管理下,只要让每个进程的段表里对应表项指向同一个物理基址、同样的段长即可。因为共享的粒度正好就是逻辑上的一个完整段,不会多也不会少。换成分页,共享模块的边界往往和页边界对不齐,可能一页里一半是要共享的、一半是不该共享的,处理起来就很别扭,得分情况讨论。

第二个是保护。因为每一段都有明确的存取权限,代码段可以设成只读可执行、数据段设成可读可写、栈段设成读写,硬件在做地址变换查段表的时候顺手就把权限检查一起做了。想非法写代码段?地址变换阶段直接拦截。分页虽然也能加权限位,但因为页是按固定大小机械切分的,一页里混着代码和数据是常事,权限就没法设得那么精细。这也是为什么段式管理在动态增长上更舒服——栈段要向下长、堆段要向上长,段本身可以动态调整长度,而页的边界是死的。

当然,代价就是前面说的外部碎片和内存紧凑的开销。世界上没有免费午餐,选哪种管理方式,本质上是在"内存利用率"和"便于共享保护"之间做权衡。段页式管理就是想把两边的优点都拿过来:先按逻辑分段,方便共享保护;段内再分页,消除外部碎片。

5.2 常见问题速查表

我把做题和处理段式管理相关问题时最常踩的坑整理成一张表,遇到问题先来这里对号入座。

问题现象可能原因解决办法
算出的物理地址看似合理但被判错跳过了越界检查,非法地址也算出了值牢记必须先做两步越界判断再算加法
偏移等于段长时被判合法比较用了小于等于改成严格小于,偏移合法范围是 0 到段长减 1
段号明明存在却报越界混淆了段表长度和最大段号段号合法范围是 0 到段表长度减 1
逻辑地址拆不出正确的段号偏移偏移位宽记错或没按位拆分先确认地址结构位宽,用移位和掩码拆
进程切换后新进程疯狂报越界忘记更新段表寄存器切换时同步更新段表起始地址和长度
两个进程共享同一段但权限冲突各段表项权限位设置不一致统一共享段的存取控制位
内存里出现很多小块空洞无法利用段长不一导致外部碎片定期做内存紧凑,或改用段页式

这张表里的每一条我基本都亲身踩过。尤其是"偏移等于段长"和"段号范围"这两条,属于经典差一错误,看起来是小问题,但在考试和实验里能直接让结果翻车。我个人的经验是,做这类题的时候别急着算,先在草稿上把"段号合法性"和"偏移合法性"两个判断写成两行明确的检查式,再动笔算加法,错漏率能降一大截。

用代码实现的时候也一样,把两个越界判断拆成两个独立的 if,异常信息带上具体数值,调试的时候一眼就能看出是哪一步没过。这个习惯我在做课程设计时养成的,后来做任何涉及地址映射、数组边界、缓冲区校验的活儿,都会下意识先写边界检查再加偏移,很少再出越界相关的 bug。

原文的图 4-1 展示的是段式管理下逻辑地址到物理地址的变换流程,画的是"段表寄存器指向段表、段号查表、越界检查、基址加偏移"这一整套链路。对照那张图把上面四步走一遍,再拿代码跑几个用例验证,段式内存管理这块就算真正捏在手里了。段页式管理无非是在这条链路里再加一层页表查询,主干逻辑还是今天这一套,把段式这关过了,后面学段页式会轻松很多。

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

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

立即咨询