ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

操作系统段式内存管理:课堂练习4.1地址变换与越界判断

操作系统段式内存管理:课堂练习4.1地址变换与越界判断 操作系统课讲到内存管理老师随手甩出一道课堂练习4.1题目名字就叫段式内存管理给一张段表让你算几个逻辑地址对应的物理地址顺便判断哪些访问会触发越界。别看它只是一道课堂练习段式内存管理是通往段页式、虚拟内存那套体系的第一块垫脚石也是期末考卷和考研专业课里反复出镜的老朋友。它看起来只有查表、比较、相加三个动作真动手算的时候边界值、进制、异常类型全都能把人绊一跤。这篇内容就是我把这道练习从原理到代码完整走一遍的记录适合正在上操作系统课的同学、准备考研408的备考党以及那些当年学过但早就还给老师的回炉选手。看完你至少能搞明白三件事段表每一项到底存了什么、逻辑地址是怎么一步步变成物理地址的、以及为什么偏移量刚好等于段长会直接报错。1. 先弄明白段这个抽象到底从哪来很多人做这道练习时直接背下了段号查表、偏移比较、基址相加的口诀题目也能做对但一旦题目换个问法——比如问为什么段长要存下来——就卡壳了。问题出在跳过了最源头的那一步段这个东西不是凭空设计出来的它是程序本身的结构逼出来的。1.1 编译器眼里的程序天生就是几块拼起来的你写一个最简单的 C 程序编译链接之后可执行文件里面的内容并不是一大坨连续的东西而是被切成了好几块。代码指令在一块叫代码段已经赋初值的全局变量和静态变量在一块叫数据段没赋初值或者初值为零的全局变量在一块叫 BSS 段运行时动态申请的堆是一块函数调用用的栈又是一块。这几块东西的脾气完全不一样。代码段从头到尾只读谁改它谁就是 bug甚至可能是恶意行为数据段可读可写栈可读可写而且频繁伸缩。如果操作系统把这些块揉成一个大连续空间来管那就没法单独给代码段挂一个只读可执行的标签也没法让多个进程共享同一份代码段——两个进程跑同一个程序代码部分明明一模一样为什么要在物理内存里存两份提示段的第一价值不是能寻址而是能把不同权限、不同生命周期、可以共享和不可以共享的东西分开管。这一点想通了后面段式管理所有的设计选择都顺理成章。所以段这个抽象的本质是按程序的逻辑结构去切分地址空间而不是按物理内存的方便程度去切。切出来的每一块有自己的名字段号、自己的大小段长、自己该待在物理内存的哪里基址还有自己的一套访问权限。1.2 段是逻辑单位页是物理单位这句话得掰开讲教材上有一句特别爱考的话段是信息的逻辑单位页是信息的物理单位。字都认识合起来读不懂。我换个比方你立刻就明白了。把整个地址空间想象成一本书。段式管理相当于按章节来切第一章讲引言二十页第二章讲原理五十页第三章讲案例八十页。章节之间的边界是由内容决定的每章长度各不相同而且章与章之间的分界对读者是有意义的——你想找讲原理的部分直接翻到第二章就行。分页管理则相当于把整本书重新排成统一规格的活页纸每页固定五百字管你是章节开头还是段落中间一律按位置硬切页的边界对读者没有任何意义。这个差别直接导致了两边的使用体验不同。分段带来的是对用户可见的地址空间你写的逻辑地址里段号是明确的编译器知道哪个变量落在哪个段分页对用户完全透明进程视角里就是一条连续的线性地址页的切分是操作系统和硬件背着你干的活。也正因为段是逻辑单位、长度可变它才有办法处理一个段要动态长大这种事——比如栈段的增长只要它上面的空间没被别的段占走改一下段长就行了。1.3 段表段式内存管理的中枢数据结构段式内存管理里最核心的一张表就是段表。每个进程一张进程切换的时候跟着一起换。表里每一项通常长这样字段含义用途段长 limit这个段有多大做越界检查防止访问跑到别的段里去基址 base这个段在物理内存中的起始地址加上段内偏移得到物理地址保护位读/写/执行权限权限校验越权就触发保护异常存在位该段当前是否在内存里不在就触发缺段中断从外存调入段号本身是隐含的它就是表项的下标。段表寄存器里存两样东西段表的起始地址和段表的长度也就是一共有多少个段。这两样东西在地址变换时会先派上用场用来判断你给的段号是不是超出了段表范围。注意段表长度和段长是两个完全不同的概念考试里特别爱拿这两个名字做文章。段表长度是有多少个段段长是某一个段有多大。段号越界看的是前者段内偏移越界看的是后者判断依据不一样报的异常也不一样。2. 段式内存管理的地址变换我一步步推给你看原理讲完进入这道练习的正题。段式内存管理的地址变换说穿了就是三个动作但每一步都有它的道理漏一步就出错。2.1 逻辑地址长什么样段号加段内偏移段式管理下的逻辑地址是一个二元组(段号, 段内偏移)。硬件层面它通常被压在一个整数里高位放段号低位放段内偏移。比如一个 32 位的逻辑地址规定高 8 位是段号低 24 位是段内偏移那么能支持最多 256 个段每个段最大 16MB。举个数你就懂怎么拆了。假设逻辑地址写成十六进制是 0x0000_2000按高 8 位段号、低 24 位偏移来拆0x00 就是段号 00x002000 就是段内偏移 8192。这种拆法在题目里如果没给位数约定你千万别自己瞎猜老老实实用段号 偏移的二元组形式表达最稳妥。我见过有人把逻辑地址当成了一个普通整数直接去加基址结果把地址的高位部分也算进去了物理地址自然错得离谱。记住段式变换里加法只发生在基址和段内偏移之间段号根本不参与加法运算它只是个查表的钥匙。2.2 查表、越界、加法变换三个动作规范一点的流程是这样取出逻辑地址里的段号拿它去和段表长度比较。如果段号大于等于段表长度说明这个段根本不存在直接触发段号越界异常。用段号作为下标从段表里取出对应表项拿到段长和基址。把段内偏移和段长比较。如果偏移大于等于段长注意是大于等于不是大于说明要访问的地址跑出这个段的范围了触发段内越界异常。前面两关都过了才做加法物理地址 基址 段内偏移。这里有个细节值得说为什么第 3 步是用偏移和段长比而不是用算出来的物理地址去比因为物理内存里段和段之间可能是紧挨着的你算出来的物理地址整好落进了下一个段的范围从数值上看毫无问题但逻辑上这就是越界了。段的边界必须靠段长守住不能靠物理地址去撞运气。提示段式管理里的越界检查用的是逻辑地址本身的信息不需要真的访问内存就能判断出来。这也是它能做到访问前保护的原因比事后发现问题要安全得多。2.3 手算演示一张段表走完四道小题假设现在给你这样一张段表单位按字节算段号段长基址0100020001500800021200300038007000段表长度为 4所以合法的段号只有 0、1、2、3。现在来四个逻辑地址(0, 800)段号 0 合法偏移 800 小于段长 1000合法物理地址 2000 800 2800。(1, 600)段号 1 合法但偏移 600 已经大于等于段长 500触发段内越界无物理地址。(2, 500)段号 2 合法偏移 500 小于 1200合法物理地址 3000 500 3500。(3, 800)段号 3 合法偏移 800 等于段长 800触发段内越界无物理地址。(5, 100)段号 5 大于等于段表长度 4触发段号越界无物理地址。这五个小问覆盖了几乎所有的考点正常变换、段内越界、边界值越界、段号越界。你把这五个吃透这一章的计算题基本不会失分。看起来平平无奇但第 4 问那个等于段长的陷阱我自己当年第一次做就翻了车。3. 做课堂练习4.1时最容易栽的三个坑讲完标准流程来说点教材不会专门强调、但一考就错的地方。这几处是我做完题回头复盘时印象最深的也是每次帮别人看这道题都能看到的重复错误。3.1 边界值偏移等于段长算不算越界这是段式内存管理里最经典的边界问题。答案很明确算越界。因为段内偏移是从 0 开始数的一个段长 800 的段合法的偏移范围是 0 到 799一共 800 个字节偏移 800 指的是这个段之后的第一个字节已经不属于这个段了。所以在做越界判断时条件写的是offset limit触发异常而不是offset limit。这两者差一个等号题目的答案就完全不同。很多同学写伪代码、写程序模拟时习惯性写成大于看着特别自然结果一跑测试用例就发现边界那组挂了。我在纸上做题时养成了一个习惯先把每个段的合法偏移区间标在旁边比如段 3 标上[0, 799]然后拿题目给的偏移去对处在区间内就合法落在区间外就报错。这样比每次都背大于等于还是大于要可靠得多尤其是时间紧的时候不会想岔。3.2 段号越界和段内越界报的不是同一个异常不少同学把这两种越界混成一个都叫越界错误考试要求你描述异常类型或者处理流程时就露馅了。它们其实是两道不同的关卡发生在不同的时刻处理方式也不一样。段号越界发生在查段表的时候。你给的段号压根没有对应的表项说明程序引用了一个不存在的段这通常意味着程序本身有问题比如野指针乱指或者逻辑地址被构造错了。这类异常一般当成非法访问直接终止相关操作。段内越界发生在查完段表之后。段是存在的但你在这个段里的偏移跑出了它的长度。这种情况可能是数组越界、栈溢出、指针算错它同样是非法访问但起码说明段本身是有效的。判断顺序也很关键必须先判段号越界再判段内越界。因为如果段号本身就非法你连表项都取不到段长和基址都没拿到压根没法做第二道判断。有些同学为了省事先算物理地址再判越界逻辑上就颠倒了。异常类型触发时机判断依据典型原因段号越界查段表之前段号 ≥ 段表长度野指针、逻辑地址构造错误段内越界查段表之后段内偏移 ≥ 段长数组越界、栈溢出、指针算错3.3 单位换算段长 1K 到底是 1000 还是 1024这也是个高频翻车点。题目里的段长有时候写的是1K有时候写的是1000 字节这两个数在内存管理的语境里默认按 1024 算也就是 1K 1024 字节。为什么因为内存地址是按二进制组织的1K 指的是 2 的 10 次方。但麻烦在于有些题目偏偏又混用了十进制。比如段长给 1000 字节、基址给 2000你算出来的物理地址就是 2800如果段长给 1K那合法偏移范围就是 0 到 1023。做题时先别急着算先把题目里所有带单位的数字统一换算成字节写在草稿纸最上面后面全用这个统一口径能省掉一大半低级错误。注意如果题目明确用了十六进制地址那么所有的加减都要在十六进制下做。把 0x2000 和十进制 800 直接相加是新手最容易犯的错前者等于 8192后者是 800混着算出来的结果没人看得懂。进制要么全十要么全十六统一到底。4. 段式与分页的对比以及段页式是怎么缝合的做完计算题很多人会顺手把段式和分页搞混。这一章我把两者的取舍摊开讲顺便说说段页式是怎么把这两套思路拼到一起的这部分内容在段式内存管理这一章里属于拔高但考研和期末都爱出。4.1 一张表看清两种管理方式的取舍对比维度段式管理分页管理划分依据程序的逻辑结构物理内存的固定大小单位长度可变每段不同固定每页相同对用户可见性可见段号由程序决定透明用户只看到线性地址地址空间二维段号 偏移一维页号 页内偏移主要优点便于共享、保护、动态增长无外部碎片、内存利用率高主要缺点有外部碎片、分配复杂有内部碎片、逻辑上不直观共享粒度以逻辑段为单位天然合适需要按页拼粒度较碎解释一下核心冲突分页把内存切成等长小块分配的时候不管什么需求都给你整页剩下的那点尾数就浪费掉了这叫内部碎片但它不会有整块找不到位置的问题。段式反过来按需分配整段长度各不相同时间一长内存里就会出现很多夹缝一个稍大的段就塞不进去这叫外部碎片。这就像停车场分页是统一规格的车位小车上大车位会浪费空间段式是按车型划区时间久了容易剩下各种谁也停不进去的零碎空当。4.2 硬件层面段表寄存器、快表与访问次数光有段表还不够硬件上还需要一套东西把变换做快。段表本身放在内存里段表寄存器里存着段表的起始地址和段表长度。每次地址变换都要先访问一次内存去读段表再访问一次内存去拿数据等于访存次数翻倍。为了解决这个性能问题硬件里加了一个叫快表TLB的小高速缓存专门缓存最近用过的段表项。命中快表就省掉了一次访存。你可以把快表理解成段表的热点缓存程序访问局部性越好命中率越高平均访存次数就越接近 1。有意思的是段式管理下每次访存至少要查一次表而分页管理也面临同样的问题。到了段页式一次访问要同时查段表和页表如果两次都命中快表理论上也能压到一次访存。不过段页式的表结构更复杂缓存命中带来的收益也更明显。4.3 段页式先分段再分页的地址变换链条段页式的思路就是把段式和分页的优点都留下地址空间先按程序的逻辑结构分成若干段这是保留了段的共享和保护能力然后每个段内部再按固定大小切成若干页这是用分页解决外部碎片问题。这样一来逻辑地址就变成了三段(段号, 页号, 页内偏移)。地址变换也要走两趟用段号查段表得到该段对应页表的起始地址和页表长度。用页号查页表得到物理块号。物理地址 物理块号 × 页大小 页内偏移。流程听起来绕但它把逻辑上可分、物理上整齐这两件事一次满足了。代价是每次访问要查两级表还好有快表兜着不然性能没法看。提示段页式里最容易出错的地方是页表的定位。每个段都有自己的页表段表项里存的不是数据段的基址而是它那个页表的基址。这个区别在画流程图的时候特别容易画错要记牢。5. 用代码把这次练习跑一遍纸上算完我习惯用代码再实现一遍。一来验证自己的手算结果二来把越界判断这种容易写错的逻辑落到可执行的层面上错了立刻就能发现。下面这段用 Python 写的模拟结构上完全照搬段式内存管理的那三个动作。5.1 段表的数据结构设计段表的核心是一个从段号到段长基址的映射。用字典来存最自然段号就是键。另外单独记录一个段表长度虽然用字典的键数量也能推出来但显式存一份更贴合硬件里段表寄存器记住长度的设计。class SegmentTable: def __init__(self): self.entries {} # 段号 - (段长, 基址) self.protection {} # 段号 - 权限标记这里用字符串表示 def add_segment(self, seg_no, limit, base, permrw): self.entries[seg_no] (limit, base) self.protection[seg_no] perm property def table_length(self): return len(self.entries)这里把权限位单独放一份是为了后面扩展保护功能时不用改动主结构。如果你只想跑通地址变换把权限那部分删掉也不影响。5.2 地址变换函数与两类异常的抛法变换函数是整个模拟的核心它把第 2 章讲的三步动作一一对应下来两种越界分别抛出不同的异常方便测试用例区分。class SegmentFault(Exception): pass class SegmentTable: # ... 承接上面的代码 def translate(self, seg_no, offset): # 第一关段号越界检查 if seg_no 0 or seg_no self.table_length: raise SegmentFault( f段号越界段号{seg_no}段表长度{self.table_length} ) limit, base self.entries[seg_no] # 第二关段内偏移越界检查注意是 if offset 0 or offset limit: raise SegmentFault( f段内越界偏移{offset}段长{limit} ) # 通过检查后做加法 return base offset写这个函数时我特意把两种异常用了同一类但不同的提示信息。如果你想让段号越界和段内越界在类型上就能区分可以派生出两个子类比如SegmentNumberFault和SegmentationOffsetFault这样上层调用时能针对性地处理。5.3 测试用例与手算结果对照把第 2.3 节那张段表原样搬进代码跑一遍那五个逻辑地址结果应当和手算完全一致。st SegmentTable() st.add_segment(0, 1000, 2000) st.add_segment(1, 500, 8000) st.add_segment(2, 1200, 3000) st.add_segment(3, 800, 7000) cases [(0, 800), (1, 600), (2, 500), (3, 800), (5, 100)] for seg_no, offset in cases: try: pa st.translate(seg_no, offset) print(f({seg_no}, {offset}) - 物理地址 {pa}) except SegmentFault as e: print(f({seg_no}, {offset}) - {e})预期输出里(0, 800) 得到 2800(2, 500) 得到 3500其余三个都会抛异常。如果你跑出来 (3, 800) 没报错八成是越界判断写成了大于号回去查 5.2 那行注释写着注意是 的代码。逻辑地址手算结果程序结果是否一致(0, 800)物理地址 28002800一致(1, 600)段内越界段内越界一致(2, 500)物理地址 35003500一致(3, 800)段内越界段内越界一致(5, 100)段号越界段号越界一致提示用代码复现课堂练习有个额外好处——你可以随手把段长改成 0 或者把偏移改成负数看看边界情况会不会出问题。这些极端用例老师不会考但你自己动手时写出来的代码健壮不健壮一眼就能看出来。6. 从课堂练习落到真实系统段管理留下的痕迹很多同学做完题会有个疑问现在都讲分页、讲虚拟内存了段式管理是不是已经过时了其实并没有。段这个思想以各种方式留在现代系统里只是名字换了、形式变了。6.1 段寄存器与保护模式的往事早期的 x86 架构里段寄存器是地址计算的一等公民段寄存器里存段基址加上偏移得到物理地址。后来架构演进段寄存器的作用变成了选择子它不再直接提供基址而是去全局或局部的描述符表里查一个段描述符描述符里记录着段的基址、界限和权限。形式上还是段号查表 基址加偏移那一套只是表从内存里挪到了专门的描述符表里。这个设计说明了一件事硬件厂商始终没有彻底丢掉分段这套机制因为它在权限管理和隔离上有不可替代的价值。代码段只能执行、数据段可读写、栈段向下增长这些属性用分段来表达是最自然的。6.2 段错误这个词是怎么来的如果你在 Linux 下写 C 程序肯定见过那句经典的 Segmentation fault。这个报错的字面意思就是段错误它正是分段机制留下来的语言遗产。虽然今天大多数系统的实际内存管理以分页为主但程序访问了不属于它的地址范围时那句报错依然沿用着分段的说法。从原理上理解段错误对应的就是访问越界。它可能是数组下标越了界、指针指向了已经释放的内存、或者栈递归太深撑破了栈段。我在 debug 这类问题时有个笨办法但特别好用先看报错的位置然后往上看有没有对数组的循环访问八成问题就出在那里。比起盯着一堆寄存器值发呆这个思路效率高得多。6.3 共享与保护段式管理仍然被借用的场景分段最被低估的能力其实是共享。多个进程跑同一个程序它们的代码段内容完全一样用段式管理的话只要让这些进程的段表项指向同一块物理内存就实现了代码共享物理内存里只存一份。分页也能做共享但需要把共享的部分按页对齐粒度上不如按逻辑段来得整齐。保护也是同样的道理。把一个段标成只读任何写操作都会被硬件拦下来这种整块设属性的方式比分页更符合程序的语义。现代的进程内存布局里代码段、数据段、堆、栈这些区域依然是按逻辑分开管理的只读的可执行区域不可写、可写的堆栈不可执行这些保护规则的底层逻辑和段式管理一脉相承。提示如果你在准备系统编程或者安全相关的考试段式管理的共享和保护的天然优势这个点一定要能说清楚。对比分页时从共享粒度和属性设置两个角度切入是最容易拿分也最容易讲明白的。我在实际项目里碰到过因为权限设置不当导致的奇怪崩溃一段本该只读的常量数据被写坏了排查了半天才发现是某处代码把它当成了可写区域。如果当初内存区域是用段的方式严格划分了读写属性的这类问题在第一次写操作时就会被硬件拦下来根本不会等到后面才暴露。这也是我做完这道课堂练习之后对段的保护能力印象最深的一点——它不只是课本上的概念是真的能帮你在开发阶段就拦住麻烦的机制。
返回列表