ARTICLE DETAIL

资讯详情

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

段页式内存管理:段表页表、地址转换与Python模拟

段页式内存管理:段表页表、地址转换与Python模拟 1. 段页式到底解决什么问题段页式内存管理这个题目我第一次接触是在操作系统的课堂上练习册里编号 4.3 的那道题。当时前面刚学完分页和分段脑子里还残留着页表和段表两套公式结果一看到题目里同时出现段号和页号直接懵了——一个逻辑地址要被切成三块还得先查段表再查页表最后才算是摸到物理内存的边。后来工作里做性能调优、翻内核文档才发现这套东西不是课本为了考试硬凑出来的它是真实存在过的内存管理方案32 位保护模式下的地址翻译就是段式和分页叠在一起跑的。这篇东西我想按自己当年啃它的顺序写先说清楚它为什么要被设计出来再说数据结构长什么样、地址怎么一步步翻译过去然后拿几道典型题手算一遍最后用 Python 把整个过程模拟出来。如果你正在准备操作系统期末或者考研 408可以直接照着算例对照如果你已经工作只是想搞明白 CPU 到底怎么把虚拟地址变成物理地址那第 6 节关于真实机器那部分对你更有用。1.1 分页和分段各自的强项与软肋先把两套方案摆在一起看。分页的核心动作是把物理内存和逻辑地址空间都切成固定大小的块块大小由硬件定死常见的就是 4KB。它的好处很直接内存分配以页为单位任何空闲页都能塞给任何进程外部碎片基本被消灭只剩最后一页的内部碎片平均浪费半页。但这个整齐是有代价的——程序被机械地切开一个函数和它用不到的数据可能被塞进同一页逻辑上的边界完全被抹掉了。想共享一段代码只要那段代码不是刚好整页对齐就得处理跨页问题。想给某段代码单独设个只读权限做不到精细控制。分段走的是另一条路它按程序的自然逻辑单位来切主函数一个段、全局变量一个段、栈一个段、动态库一个段。段的长度不固定由编译器根据内容决定。这么切的好处是共享和保护都变得自然——整个代码段可以直接在多个进程的段表里指向同一块物理内存权限位写在段表项里读写执行一目了然。而且段可以动态增长堆区不够了就往外扩边界检查只需要比对段长。问题也很明显段长不固定意味着内存分配变成了一个动态适配问题从一堆大小不一的空洞里找一个能装下这段的洞装完之后剩下的碎片大小随机时间一长就会碎成一地这就是典型的外部碎片。虽然可以用紧凑技术把空闲区合并但移动内存里的段意味着要修改所有引用它的地址代价高得离谱。所以纯分段在现代系统里基本退出了主舞台。1.2 段页式是怎么把两者拼起来的段页式的思路其实特别朴素既然分段在逻辑层面好用、分页在物理层面好用那就让它们各管一头。作业还是先按逻辑单位分成若干个段但每个段内部不再要求连续存放而是再切成固定大小的页页可以散落在物理内存的任意位置。这样一来物理内存的分配单位始终是页外部碎片的问题被分页解决掉了而段的逻辑边界保留了下来共享和权限控制可以挂在段这一层做。段内逻辑上是连续的段间不要求连续——这正好贴合程序的实际访问模式代码段内部顺序执行跳转大多发生在段内或者少数几个固定段之间。代价是地址翻译的层数多了一层。原来分页只要查一次页表分段只要查一次段表现在要先查段表拿到这个段的页表在哪再查页表拿到物理块号最后拼上页内偏移。逻辑地址的结构也从两段变成了三段逻辑地址 段号 | 页号 | 页内偏移这个三段式是段页式所有考点和所有坑的源头。你后面遇到的绝大部分错误都出在对这三段的划分上。1.3 组合之后付出的代价天下没有白拿的好处。段页式把两套机制叠起来代价主要体现在三个地方。第一是访存次数。不加任何缓存的话一次数据访问要先读段表、再读页表、最后读数据三次访问物理内存。如果段表项或者页表项本身还不在内存里那还得先处理缺页次数继续往上翻。这就是为什么 TLB快表在这套体系里几乎是必需品——它把最近用过的段页映射结果缓存下来命中之后换算只需要一次访存。第二是数据结构的维护成本。每个进程需要一张段表每个段需要一张页表页表的大小跟段长成正比。段表寄存器指向段表起始地址段表项里存着段长和该段页表的起始地址。这些结构本身也占内存而且它们的创建、销毁、缺页时的换入换出都要操作系统内核来操心。第三是实现的复杂度。硬件需要支持两级或者更多级的查表流程操作系统需要同时管理段表和页表两套结构的生命周期还要处理两级保护检查段级权限 页级权限。这也是为什么后来的系统更倾向于只保留分页、把分段的逻辑保护功能用别的方式实现。2. 核心数据结构与地址转换全流程2.1 段表、页表、页表项里都存了什么段表是每个进程一张它的下标就是段号第 i 项描述第 i 个段。一个典型的段表项包含这几样段长注意单位通常用页数而不是字节数这是易错点、该段页表的起始地址物理地址、以及访问权限位可读、可写、可执行、存在位该段是否已调入内存、修改位和访问位供置换算法使用。段表寄存器里存放两个东西段表起始地址和段表长度。前者用来定位后者用来做段号越界检查——段号大于等于段表长度就直接触发越界中断连查表都不用。页表是每个段一张下标是页号第 j 项描述该段的第 j 页。页表项里最主要的是物理块号也叫页框号、帧号此外还有有效位、修改位、访问位、权限位、是否在磁盘上等等。页表的起始地址不放在别处就放在对应段表项的那个字段里——这是段页式跟纯分页最大的结构差异纯分页的页表基址放在专门的寄存器比如 CR3里段页式则是每个段自带页表。这里有个细节值得单独拎出来说段表项里的段长用页数表示。假设段长字段是 20 位、能表示的最大页数是 2²⁰页面大小 4KB那这个段最大就是 4GB。有些题目会说段长为 4MB你要自己换算成页数4MB ÷ 4KB 1024 页。如果题目给的段长已经是页数那就直接比对不用换算。这个换算我当年错过不止一次后面在 5.1 节专门写。2.2 逻辑地址的位段划分与三大尺寸计算段页式的题目里有一大半是算位数。规律很固定只要抓住两个公式页内偏移位数 log₂(页面大小)页号位数 log₂(每段最大页数) log₂(段最大长度 ÷ 页面大小)段号位数 逻辑地址总位数 − 页号位数 − 页内偏移位数反过来也成立知道了各段的位数就能推出系统支持的最大段数、每段最大长度和最大总空间。举几个常见的组合感受一下逻辑地址位数页面大小页内偏移位页号位数段号位数每段最大长度最大段数324KB1210104MB1024324KB1212816MB256321KB10814256KB16384324KB128121MB4096注意看第一行和第二行的差别页面大小没变但页号多给了 2 位每段最大长度就从 4MB 涨到 16MB代价是段号只剩 8 位整个系统最多 256 个段。总位数是守恒的页号和段号是此消彼长的关系这一点在设计的权衡里反复出现。做这类题的时候我有个习惯先把页面大小换算成 2 的幂秒出偏移位数然后看题目给的每段最大长度除以页面大小得到页数取 log₂最后用总位数一减。三步走下来基本不会错。要注意的是有些题目会绕个弯比如告诉你段表最多 256 项那其实是直接告诉你段号 8 位不用再推。2.3 一次地址转换要访问几次内存这是另一个高频考点也是理解 TLB 价值的关键。在不使用快表的情况下段页式的完整流程是这样用段表寄存器里的段表基址加上段号 × 段表项大小得到段表项地址读出段长和页表基址。这一步访问一次内存。比对页号和段长合法的话用页表基址加上页号 × 页表项大小得到页表项地址读出物理块号。这一步再访问一次内存。用物理块号乘以页面大小加上页内偏移得到物理地址读出数据。第三次访问内存。所以答案是三次。如果题目说引入了 TLB 且命中率为 h那命中时只要 1 次访存TLB 里直接拿到物理块号不命中时还是 3 次平均访存次数就是h × 1 (1 − h) × 3。有些题目还会让你把这个结果和纯分页对比——纯分页不命中时是 2 次所以段页式在访存效率上是吃亏的这也解释了为什么后来大家更愿意用多级页表而不是分段。有个延伸点容易被忽略如果段表项或页表项本身不在内存比如被换出到磁盘了那访存次数还要再加而且会触发缺页中断。教材上的三次访存都是默认表项常驻内存的情况考试里如果题目没特别说明按三次算就行。3. 课堂练习4.3 的典型题型手算实录3.1 题型归纳与固定的解题顺序这类练习题基本跑不出四种题型一是给逻辑地址格式和表内容求物理地址二是给地址位数要求反推各段位数三是算页表、段表占多少空间四是判断某个地址是否越界、是否缺页。其中第一种是综合题的主体通常会连带考查第三、第四种。我自己的解题顺序固定成四步写在草稿纸左上角做完一步划掉一步拆地址。按题目给的位数把逻辑地址的二进制或者十六进制拆成段号、页号、偏移三部分。查段表。算段表项位置读段长和页表基址先做越界判断。查页表。算页表项位置读物理块号。拼物理地址。物理块号 × 页面大小 偏移。这套顺序的关键是第 2 步的越界判断要放在读页表之前。很多同学着急往下算页号明明超了段长还在那查页表最后得一个看似正确的物理地址其实该报越界中断。答题时把判断过程写出来分数才拿得稳。3.2 综合算例从逻辑地址到物理地址来看一道完整的题。某系统采用段页式存储管理参数如下逻辑地址 32 位划分为段号 8 位 | 页号 12 位 | 页内偏移 12 位页面大小 4KB页表项大小 4 字节段表项大小 8 字节段表起始地址为 0x1000段表内容2 号段段长 6 页页表起始地址 0x80003 号段段长 4 页页表起始地址 0x90002 号段的页表中5 号页表项内容为物理块号 0x37现在求逻辑地址0x020050A3对应的物理地址。第一步拆地址。段号 8 位取最高的 8 位0x020050A3 24 0x02所以段号是 2。页号 12 位取接下来的 12 位(0x020050A3 12) 0xFFF。0x020050A3 12 0x02005再取低 12 位得到 0x005也就是 5。页内偏移是低 12 位0x020050A3 0xFFF 0x0A3。手算的话更直观把十六进制按 2/3/3 位切0x02 | 0x005 | 0x0A3一一对应段号、页号、偏移这种切法比敲计算器快。第二步查段表。段表项大小 8 字节2 号段表项的物理地址 0x1000 2 × 8 0x1010。读出段长 6 页页表起始地址 0x8000。检查页号5 6合法继续。第三步查页表。页表项大小 4 字节5 号页表项的物理地址 0x8000 5 × 4 0x8014。读出物理块号 0x37。第四步拼地址。物理地址 0x37 × 0x1000 0x0A3 0x37000 0x0A3 0x370A3。整个过程要访问三次内存0x1010段表项、0x8014页表项、0x370A3数据。再顺手把越界的情况过一遍。同一个系统里如果来的是逻辑地址0x030900C8拆出来段号 3、页号 9、偏移 0xC8。查段表第 3 项地址 0x1000 3 × 8 0x1018得到段长 4 页。页号 9 ≥ 4越界中断后面不用算了。这类题目经常在最后加一问如果逻辑地址是 X 呢就是在考你会不会多做一步判断。3.3 表空间开销类计算第二类高频题是算内存开销而且它特别能体现段页式相对纯分页的优势。还是上面那个系统段号 8 位意味着最多 256 个段段表项 8 字节所以段表本身占用 256 × 8 2048 字节 2KB。这部分不管进程用了几个段只要段号是索引段表就得按最大段数建或者至少建到当前最大段号所以是固定的。页表就不一样了。页表是按段建立、按需增长的。假设某个进程实际用了 4 个段分别有 3 页、8 页、20 页、64 页那么页表占用 (3 8 20 64) × 4 95 × 4 380 字节。整个进程的页表结构加起来不到 400 字节段表 2KB总计约 2.4KB。对比一下纯分页方案的极端情况32 位地址空间、4KB 页面页表需要 2³² ÷ 2¹² 2²⁰ 1048576 个页表项每项 4 字节单级页表就是 4MB。哪怕进程只用了 1MB 内存这 4MB 页表也得老老实实占着因为索引是连续的页号中间的洞也得填。差距是三个数量级。为什么差这么多因为纯分页的页表必须覆盖整个线性地址空间而段页式的页表只覆盖实际存在的段。程序不会用满 256 个段所以大多数段的页表压根不用建。这就是按需建立的威力。不过也不能光看好处。如果真有个程序用满了 256 个段、每段都 4096 页那页表总量就是 256 × 4096 × 4 4MB和纯分页一模一样还白白多花 2KB 段表。所以段页式的空间优势建立在程序对地址空间的稀疏使用这个前提上这也是几乎所有现代内存管理优化的共同前提。4. 用 Python 把地址转换跑一遍纸上算清楚之后我很建议用代码再实现一遍。原因有两个一是代码会逼你把每个边界条件想明白比如越界怎么判、表项不存在怎么处理二是调代码的时候你会对三次访存有肌肉记忆。4.1 数据结构设计我用字典来表示段表和页表键就是段号和页号值是对应的表项内容。真实系统里表是数组但这里用字典更直观也方便表达某些表项不存在。PAGE_SIZE 0x1000 # 4KB PTE_SIZE 4 # 页表项 4 字节 STE_SIZE 8 # 段表项 8 字节 SEG_BITS, PAGE_BITS, OFF_BITS 8, 12, 12 SEGMENT_TABLE_BASE 0x1000 # 段表起始物理地址 # 段表段号 - {limit: 段长(页数), pt_base: 页表起始地址} segment_table { 2: {limit: 6, pt_base: 0x8000}, 3: {limit: 4, pt_base: 0x9000}, } # 页表(段号, 页号) - 物理块号 page_tables { (2, 5): 0x37, (2, 0): 0x12, (3, 1): 0x05, }这里有个刻意的设计我把页表拍平成了一个大字典键是(段号, 页号)元组。真实系统里是段表项指向一块独立的页表内存区用pt_base page * PTE_SIZE去索引。之所以拍平是为了代码短、跑起来直观但我在 4.3 节会额外加一个函数专门把两级索引的真实地址算出来保留三次访存的物理含义。4.2 转换函数实现拆地址的三个位运算是一切的起点写成函数方便复用def split_logical_addr(addr): 把 32 位逻辑地址拆成 (段号, 页号, 页内偏移) seg (addr (PAGE_BITS OFF_BITS)) ((1 SEG_BITS) - 1) page (addr OFF_BITS) ((1 PAGE_BITS) - 1) off addr ((1 OFF_BITS) - 1) return seg, page, off注意这里用的是位移位数而不是写死 24、12这样以后改配置比如页面大小换成 1KB只要改常量函数不用动。这是我自己踩过的一个坑一开始硬编码了 24后来想试试 16 位的地址格式改了一堆地方还漏了一处。然后是主转换函数把四次访存的地址都返回出来方便观察def translate(logical_addr): seg, page, off split_logical_addr(logical_addr) # 访存 1读段表项 if seg not in segment_table: raise ValueError(f段号 {seg} 越界段表长度不足) ste_addr SEGMENT_TABLE_BASE seg * STE_SIZE entry segment_table[seg] # 越界检查必须在读页表之前 if page entry[limit]: raise ValueError( f页号 {page} 超出段 {seg} 的长度 {entry[limit]} 页越界中断 ) # 访存 2读页表项 pte_addr entry[pt_base] page * PTE_SIZE if (seg, page) not in page_tables: raise ValueError(f段 {seg} 页 {page} 不在内存缺页中断) frame page_tables[(seg, page)] # 访存 3合成物理地址并读数据 phys_addr frame * PAGE_SIZE off return { seg: seg, page: page, offset: off, ste_addr: ste_addr, pte_addr: pte_addr, frame: frame, phys_addr: phys_addr, }有两个地方值得说明。第一越界检查和读页表的顺序不能反代码顺序就体现了这一点。第二我把三种异常分开抛段号越界、页号越界、缺页。真实系统里它们对应完全不同的中断处理程序混在一起会掩盖问题。有次我图省事只抛一个通用异常结果调了半天才发现是页号越界而不是缺页白折腾。4.3 测试用例与结果验证先跑 3.2 节那道题看看结果对不对r translate(0x020050A3) print(hex(r[phys_addr]), hex(r[ste_addr]), hex(r[pte_addr])) # 0x370a3 0x1010 0x8014三个值跟手算完全一致说明位划分和索引计算都对上了。再补一个逆向生成的验证函数随机造一批地址正着算一遍、反着算一遍能抓出隐藏的位运算错误def build_logical_addr(seg, page, off): return (seg (PAGE_BITS OFF_BITS)) | (page OFF_BITS) | off # 往返测试 for seg, page, off in [(2, 0, 0x000), (2, 5, 0x0A3), (3, 1, 0xFFF)]: addr build_logical_addr(seg, page, off) s, p, o split_logical_addr(addr) assert (s, p, o) (seg, page, off) print(往返测试通过)最后跑一遍越界和缺页try: translate(0x030900C8) # 段3、页9、偏移0xC8页号超过段长4 except ValueError as e: print(e) # 页号 9 超出段 3 的长度 4 页越界中断 try: translate(build_logical_addr(2, 3, 0x10)) # 页表里没建(2,3) except ValueError as e: print(e) # 段 2 页 3 不在内存缺页中断调试的时候我一直用build_logical_addr造输入比手敲十六进制安全得多。手敲0x020050A3这种数多一个 0 少一个 0 结果全变而且错了还很难一眼看出来。5. 常见错误与排查速查5.1 位划分和进制换算类错误这类错误占了我在这个练习里翻车的至少一半。最典型的是混淆段长的单位。题目说段长 4MB你直接拿去和页号比那就全错了因为页号是页的数量单位必须统一成页。4MB ÷ 4KB 1024 页页号必须小于 1024。第二个坑是十六进制切分的位置。0x020050A3按 8/12/12 位划分对应的十六进制正好是 2 位/3 位/3 位。但换成别的位数就不一定整了比如页号 10 位、偏移 12 位那就不能简单地按 nibble 切得老老实实转二进制或者用位移。我见过有人按每 4 位一个十六进制字符硬切结果在非 4 倍数位宽下切错了还浑然不觉。第三个坑是总位数守恒。把段号、页号、偏移三位数加起来必须等于逻辑地址总位数。如果算出来是 31 或者 33那一定哪里错了。这个检查只要 2 秒建议每道题都做。5.2 表项大小和页表开销类错误计算段表项地址的时候公式是段表基址 段号 × 段表项大小。这里的项大小是字节数不是位、不是字。有些题目会说段表项占 1 个字字长 32 位那就要换算成 4 字节。同理页表项。另一个容易被忽略的点段表和页表本身也占内存而且它们的换入换出也要消耗 I/O。算总开销的时候别只算页表忘了段表那 2KB。虽然量级上段表很小但题目如果问总共需要多少空间来存放地址映射结构漏掉就是错。还有一个更隐蔽的页表项的物理块号不是物理地址。0x37这个块号要乘以页面大小才能变成地址的基址部分。直接把块号当成地址的一部分去加偏移是最典型的低级错误但它错得很自然因为块号往往也是十六进制数。5.3 越界判断与权限检查现象常见原因排查动作算出的物理地址看起来很正常漏做越界判断先比对页号和段长再往下走段表项地址算出来不对齐项大小用错单位位/字节混淆统一换算成字节再乘页号合法但查不到表项该页未调入内存这是缺页不是越界异常分类要分清权限报错但地址转换正确段级权限和页级权限都要查两级都要比对任一级拒绝即失败换了大页面大小结果全错位宽常量没跟着改检查偏移位数是否随页面大小更新段级保护和页级保护是两次独立检查段表项里有读/写/执行权限位页表项里也有。一次访问必须两级都通过。教材上的题目通常只考越界但真机上是双重检查我在代码里留了口子没实现权限位如果要做完整模拟得在translate里再加一层判断。另外越界中断和缺页中断的处理方式完全不同越界是程序 bug一般直接终止进程缺页是正常现象操作系统分配物理页、从磁盘读入、更新页表然后重新执行那条指令。这个区别在选择题里反复出现。6. 从课本到真实机器6.1 32 位保护模式下的段页叠加学到这里你可能会问段页式到底有没有真实实现过有而且相当典型。32 位保护模式下的地址翻译就是先分段、再分页两段式流程程序给出的段选择符:偏移叫逻辑地址段选择符是 16 位其中 13 位是索引能索引 8192 个段描述符段描述符里存着 32 位段基址和 20 位段界限还有个粒度位粒度设为 1 时界限以 4KB 为单位最大能表示 4GB。把段基址加上偏移得到 32 位线性地址然后线性地址再按 10/10/12 位切成页目录索引、页表索引、页内偏移查两级页表得到物理地址。所以它是两级查表 两级分页比课本的段页式还要多一层页表层级。但结构思想完全一样分段负责逻辑隔离和保护分页负责物理内存的离散分配。有意思的是真正跑在它上面的操作系统往往把分段给关掉了。做法很取巧把代码段、数据段、栈段的基址全设成 0界限全设成 4GB这样逻辑地址和线性地址就完全相等分段在功能上被架空了。这么做不是为了偷懒而是因为分段机制在多任务、多线程环境下的管理成本太高而分页配合页级权限位已经能满足大体上的保护需求。这是个很典型的工程选择——机制还在但被配置成了直通。6.2 TLB 命中与缺页的配合段页式的三次访存在真机上并没有把人拖垮靠的就是 TLB。TLB 是一块容量很小但极快的相联存储器通常几十到几百项缓存的是逻辑页 → 物理块的映射。注意它缓存的是最终的映射结果段表和页表的中间过程不用重复走。命中时一次访存搞定不命中时才去趟内存。现代处理器一般把 TLB 分成指令 TLB 和数据 TLB各自还可能有多级。这个设计跟段页式的结构配合得挺自然段级信息权限、边界变更频率很低页级信息变更更频繁缺页、换出都要改所以缓存策略也不一样。缺页的处理流程也值得理一遍。访问某个页发现页表项的有效位是 0触发缺页中断操作系统接管找一个空闲物理块没有就按置换算法淘汰一个脏页要先写回磁盘把目标页从磁盘读进来更新页表项然后重新执行刚才那条指令。这条指令重新执行时 TLB 已经有了新映射就能顺利走完。整个过程对用户程序完全透明。有个细节值得注意换出页面会不会把段表、页表也换出去。会操作系统把页表本身也当成可以换出的内存内容。如果段表项被换出那查段表时也会触发缺页如果页表项所在的那一页被换出查页表时同样会缺页。这就是为什么最坏情况下一次访存需要五六次缺页处理这种说法会出现。6.3 为什么今天还在学分段现在主流的做法是分页配合多级页表分段在地址翻译这条路上基本退场了。那为什么操作系统课还要花整整一节讲段页式我的理解是分段解决的是一个分页解决不好的问题程序的结构信息。程序天生就是有结构的——函数、数据、栈、堆这些边界是编译器知道的。把这些信息告诉硬件硬件就能做更精细的保护和共享。分页做不到这一点因为页是固定大小的、与程序结构无关的切割。现代系统把这份结构信息交给了别的东西可执行文件格式里的段表实际上也是按权限分组的页集合、页表项里的权限位、以及操作系统维护的虚拟内存区域描述结构。功能还在只是承载它的抽象换了。理解了段页式你就理解了为什么要有这一层结构描述这个问题本身这比记住三次访存的计算公式重要得多。回到课堂练习 4.3它的价值不在于让你背出转换流程而在于逼你把一个多层索引的地址翻译过程完整地推一遍。这种分层查表的思维在文件系统索引节点、多级页表、数据库 B 树索引里都在反复出现形式不同骨架是一个。最后分享一个我自己的小习惯做完每道段页式的题我都会顺手在纸上画一遍那条逻辑地址 → 段表 → 页表 → 物理地址的路径把经过的每个地址都标出来。画上四五遍之后这类题的解题速度会明显变快因为脑子里有了一张固定的地图看到题目就知道下一步该去哪、该读哪个数。真正上考场的时候能省下来的就是这几秒钟的判断时间。
返回列表