ARTICLE DETAIL

资讯详情

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

段页式内存管理:逻辑地址到物理地址的段表页表与TLB解析

段页式内存管理:逻辑地址到物理地址的段表页表与TLB解析 1. 段页式内存管理到底缝合了什么先看清段式与页式各自的算盘课堂练习 4.3 的题目往往只有一两行字——给你一个逻辑地址让你算出对应的物理地址。很多人第一次做就卡住了不是因为算不出来而是因为脑子里只有页表这一层压根没意识到段页式内存管理的翻译路径上摆着两张表。段页式内存管理不是把段式和页式简单地叠在一起它是把两种方案各自的优点挑出来、缺点互相抵消之后的一种折中设计。要真正做对练习先得搞清楚这场折中是怎么谈成的。1.1 段式管理的账按逻辑划分好共享却留下外碎片段式管理的出发点非常贴近程序员的直觉一个程序天然就分成代码段、数据段、堆栈段、共享库段这些逻辑上独立的部分。按段来分配内存段的长度随内容变化段与段之间没有固定大小约束所以一个 200 字节的小函数和一个 2MB 的大数组都能各自占一个段不浪费。更关键的是共享和保护——两个进程要共用同一份 libc 代码只需要让各自的段表项指向同一块物理内存改一处、到处生效要给某个段加上只读或只执行的属性也只需要在那一个段表项里改标志位。问题出在内存回收上。段是变长的程序反复申请释放之后物理内存里会出现大量大小不一的空洞这就是外部碎片。一个 100KB 的新段申请进来明明空闲总量有 300KB却因为没有一块连续的 100KB只能干等。解决办法只有压缩把已占用的段搬在一起而压缩意味着要改所有指向这些段的段表项还要暂停相关进程代价非常大。段式管理的好懂是用内存利用率换来的。1.2 页式管理的账碎片解决得干净逻辑关系全丢页式管理把物理内存切成固定大小的页框把逻辑地址空间也切成同样大小的页两者一一映射。因为所有单位都一样大任何空闲页框都能装任何一页外部碎片直接消失了剩下的只是最后一页没填满造成的内部碎片平均浪费半页。这个账算得非常漂亮所以现代通用操作系统的分页机制都是主角。但页式管理把程序原本的逻辑结构抹平了。在页式视角下代码段和堆栈段被切成了一页一页散落在物理内存各处谁跟谁是同一块逻辑单元硬件不知道操作系统也只能靠额外的数据结构去记。共享一整个代码段这件事变得麻烦如果代码段的页恰好和某个数据段的页共用了同一页的尾部空间你就没法单独把这段代码标成只读。保护粒度从段退化成了页而页是按 4KB 机械切的跟程序的逻辑边界对不上。1.3 段页式的两级映射与三次访存代价段页式内存管理的思路是先用段来划分逻辑结构再在段内部用分页来管理物理内存。一个进程的逻辑地址被拆成三段段号、段内页号、页内偏移。翻译的时候先拿段号查段表从段表项里拿到这个段的页表基址和页表长度再拿页号查那张页表得到物理页框号最后把页框号和页内偏移拼起来才得到物理地址。这个设计把段式的逻辑性和页式的物理管理能力都保留了下来代价是访问一次数据要查两次表。如果段表和页表都不在高速缓存里一次数据访问实际要访问内存三次读段表项、读页表项、读数据。这是段页式最硬的开销也是后文要重点讨论 TLB 和缺页处理的原因。我在做这道练习时最大的收获就是地址翻译不是一次映射而是一条链链条上任何一环出问题表现出的异常都不一样。2. 课堂练习 4.3 的完整推演一个逻辑地址要拆成三段查两次表练习的核心动作就是把一个逻辑地址按位切开。这一步如果位宽没定清楚后面全错。教材上通常会给一个 32 位或者 24 位的地址结构图但很多同学看到图就跳过去了直接凭感觉切——这是最常见的第一类错误。2.1 先定地址位宽段号、页号、偏移各占几位我们按一道典型题目来设定逻辑地址 24 位其中段号 4 位最多 16 个段、段内页号 8 位每段最多 256 页、页内偏移 12 位页大小 4KB。物理地址同样 24 位物理内存 16MB按 4KB 分页得到 4096 个页框所以页框号 12 位加上 12 位偏移正好 24 位。这三个位宽不是随便给的它们之间有硬约束字段位宽可表示范围与页大小的关系段号4 位0 ~ 15与页大小无关由段表长度寄存器限制段内页号8 位0 ~ 255每段最大长度 256 × 4KB 1MB页内偏移12 位0 ~ 4095页大小 2^12 4KB这里有个容易被忽略的推论页内偏移的位数由页大小决定段内页号的位数由单段最大长度决定。题目如果说页大小 1KB那偏移就是 10 位页框号也要跟着变。我见过有人把偏移写成 12 位但页大小按 1KB 算结果整个地址拼出来差了一截。2.2 第一次查表段表里到底存了什么段表的每一项至少要包含四样东西有效位valid、段基址/页表基址、段长或页表长度、保护位。在段页式里段基址的含义变了——它不再是段在物理内存里的起始地址而是这个段对应的页表所在的位置。段表项格式可以这样描述字段含义典型位宽valid该段是否合法、是否在内存1 位页表长度 pt_len该段包含多少页与应用规模相关页表基址 pt_base该段页表在物理内存中的起始位置通常是一个物理地址保护位 prot读/写/执行权限3 位以上这里必须强调一次段表基址寄存器里存的是段表本身的物理地址而不是逻辑地址。原因很朴素如果段表地址还要经过地址翻译才能得到那就陷入了无限递归——查段表要先翻译翻译又要先查段表。硬件层面必须有一个翻译起点是不需要翻译就能访问的段表基址寄存器就是这个起点。这也是做练习时判断对错的常用逻辑凡是把段表基址写成逻辑地址的答案直接可以判错。2.3 第二次查表页表与页表项格式页表项的结构和纯页式系统基本一致但作用域变了——每一段都有自己的页表而不是整个进程共用一张大页表。这个差别很关键它带来两个好处段内页号从 0 开始编号页表长度由段长决定不需要给不存在的地址预留页表项段的长度变化时只影响它自己那张页表其他段的页表不受牵连。页表项的典型字段如下字段含义valid该页是否在内存中0 表示需要缺页处理frame物理页框号prot页级保护位dirty / accessed写标记与访问标记供页面置换算法使用因为段号 页号共同定位一个页表项理论上 TLB 的标签也应该是段号 页号的组合而不是单纯的页号。如果实现时只拿页号做标签不同段的同一个页号会互相冲突翻译结果就会串段——这是段页式硬件设计中一个很容易踩的坑。2.4 完整算例从 0x23A56F 到 0x15C56F设逻辑地址为0x23A56F按上面的位宽拆分0x23A56F 展开成二进制0010 0011 1010 0101 0110 1111段号 高 4 位 00102段内页号 中间 8 位 0011 10100x3A 58页内偏移 低 12 位 0101 0110 11110x56F 1391查段表段 2 的段表项valid 1页表长度 pt_len 100页表基址 pt_base 0x8000。先做合法性检查58 100通过。假设页表项的字节数为 4那么第 58 项的地址是 0x8000 58 × 4 0x8000 0xE8 0x80E8。读到该页表项valid 1页框号 frame 0x15C 348。拼物理地址0x15C 12 0x15C000加上偏移 0x56F得到物理地址 0x15C000 | 0x56F 0x15C56F整个链条走完了段号定位页表页号定位页表项页表项给出页框号页框号与偏移拼接。做题时我会把这三个中间值段号、页号、偏移单独写一行即使最终答案错了也能从中间值看出是哪一步崩的阅卷老师或面试官也能一眼看出你的思路。2.5 三种异常的分界线段越界、页越界、缺页同一个逻辑地址链条上不同的环出错会产生完全不同的异常这三者的处理方式差别很大异常类型触发条件内核处理方式段越界段号 ≥ 段表长度或段表项 valid 0进程的地址访问非法通常是代码 bug一般直接终止进程页号越界页号 ≥ 该段的 pt_len段内访问超出了段本身长度同样属于非法访问缺页段表项和页表项合法但页表项 valid 0 或不在内存从磁盘或后备存储调入该页重新执行这条指令这三条分界线的意义不只是考试知识点。真实系统里段越界和页号越界对应的是访问了不属于自己的地址空间比如空指针解引用、数组越界而缺页对应的是合法但暂时不在内存的地址比如按需加载的代码段、被换出的匿名页。前者升级成异常信号例如某些平台上就是段错误后者对用户完全透明。我在练习里给三个测试用例分别覆盖这三种情况就是为了确认自己真的分得清它们。3. 硬件层面段表基址寄存器、TLB 与缺页处理链路光会算题还不够段页式内存管理真正复杂的地方在于硬件和内核的分工。做题时用的是纸上的表真实机器上这些表都在内存里每次访问都要靠寄存器和缓存来缩短路径。3.1 两个关键寄存器STBR 与 STLR段页式硬件至少要配两个寄存器**段表基址寄存器STBR**保存当前进程段表在物理内存中的起始地址**段表长度寄存器STLR**保存段表中有多少项。进程切换的时候内核把新进程的这两个值装进去地址翻译的上下文就切过去了。为什么要单独有 STLR而不是像纯页式那样在页表项里查边界因为段越界必须在查页表之前就被拦住。如果没有 STLR段号超出范围时会去读一段不属于段表的内存当段表项用读出来的垃圾数据完全可能带着一个 valid 1 和一份任意页表地址从而把翻译引到一个合法但错误的地址上。这种错误最难查——不报异常只是数据悄悄错了。所以 STLR 的作用不只是省一次检查它是把错误拦在可控范围内的必需品。顺带说一个反直觉的设计点段页式下 STBR 指向的段表本身也可以被分页。也就是说查段表那一步也可能触发缺页。这意味着一次地址翻译在极端情况下要经历两轮缺页处理先是段表所在页缺页补上之后再发现页表所在页也缺页。内核设计时要把这条路径想清楚否则容易在缺页处理里再触发缺页直接崩掉。这也是很多教学用模拟器会刻意简化掉的部分。3.2 TLB 在段页式下的标签设计与命中率估算TLB快表本质上是一个小型相联缓存缓存的是虚拟页号 → 物理页框号的映射结果。在段页式下虚拟页号就是段号与页号拼接后的组合。命中一次 TLB硬件就绕过了段表和页表两次访存直接拿到页框号。用一个具体数字感受一下收益。设一次内存访问 100ns一次 TLB 查找 10ns命中率 95%。简化模型下平均访问时间EAT t_TLB h × t_mem (1 - h) × 3 × t_mem 10 0.95 × 100 0.05 × 300 10 95 15 120 ns对比完全没有 TLB 时的 300ns快了 2.5 倍。这就是为什么 TLB 命中率是性能优化的核心指标之一。影响命中率的几个因素我在实测中感受很深TLB 条目数条目少了工作集稍大就频繁未命中。段页式下每条映射覆盖 4KB如果循环数组是 1MB就要 256 条映射普通 TLB 装不下。页大小把页从 4KB 换成 2MB大页同样 1MB 的数组只需要半条映射命中率立刻上去。代价是内部碎片变大小对象多的程序反而亏。段的数量段越多段号页号组合的取值空间越大TLB 冲突的概率越高。这也是段表项少而精的设计往往比段多而碎更友好的原因。进程切换切进程时 TLB 要么全部失效要么靠地址空间标识区分。前者带来一段命中率为零的爬坡期后者需要额外的位宽支持。3.3 缺页中断发生时内核做的六件事缺页是段页式链条上最耗时的环节处理流程可以拆成六步硬件陷入内核硬件把出错的逻辑地址存进专门的寄存器跳转到内核的缺页处理入口。定位出错地址属于谁内核拿这个地址去进程的地址空间描述里查找判断它是代码段、数据段、堆、栈还是完全不属于这个进程。判断合法性如果地址根本不在进程的合法范围内直接给进程发信号如果是访问权限不符比如往只读页写同样按异常处理。这一步就是页号越界和真实缺页分家的地方。找到或分配一个空闲页框可能从空闲链表中取也可能触发页面置换把某个页写回后备存储后腾出页框。把数据读进来匿名页置零文件映射页从文件读入按需加载的可执行页从可执行文件读入。这一步涉及磁盘 I/O是整个过程里最慢的部分。更新页表项并重新执行指令把新的页框号填进页表项置 valid 1刷新 TLB然后回到那条出错的指令重新跑一次。注意第 3 步和第 5 步的顺序。很多同学会以为缺页总是要读磁盘其实先查合法性再决定要不要读是更高效的设计非法访问根本不需要 I/O直接报错就完了。反之如果先分配页框再判断一次非法访问就会白白浪费一次内存分配和磁盘读取程序崩溃前还留下一堆脏页。4. 段页式在真实系统里的位置Linux 为什么把分段关掉了学完段页式很多人会有一个疑问既然这个方案这么完整为什么现在主流的通用操作系统几乎看不到段的影子这个问题在练习里不会考但想清楚它你对内存管理的理解会往上提一层。4.1 x86 的平坦模型分段被架空的经过早期的 x86 体系结构确实同时支持分段和分页硬件上就是段表描述符表加页表的组合。但操作系统在设计时选了一条务实的路把所有段的基址设为 0段界限设为整个地址空间的最大值。这样一来逻辑地址等于线性地址分段在数学上等于没做硬件那套检查虽然还在执行但永远不会触发。这么做的理由很实在。分段带来的是逻辑上的便利但它的检查逻辑会给翻译路径增加一级间接跨平台实现也麻烦——不同体系结构的段定义方式差别太大而分页的抽象几乎是通用的。把分段关掉之后操作系统只需要面对一层分页抽象代码可以同时跑在多种硬件上工程上的收益远大于段式带来的那点逻辑便利。代价是共享和保护的粒度全落在了页上所以现代系统要共享一大块代码得靠地址空间描述结构里的区域划分去表达这一片是同一个可执行文件的映射而不是靠硬件段。4.2 Linux 内存管理子系统的关键数据结构Linux 内存管理子系统里几个核心数据结构和段页式的对应关系其实很清楚数据结构作用对应段页式里的哪一层mm_struct描述一个进程的整个地址空间相当于段表 段表长度寄存器vm_area_struct描述一段连续的、属性相同的地址区间相当于一个段带有权限标志页全局目录 / 页中间目录 / 页表多级页表负责逻辑地址到物理页框的映射相当于段内页表的层级化实现struct page描述一个物理页框的元信息相当于页表项里的帧号 状态位address_space描述一段地址与文件或后备存储的关联缺页时从哪读数据的答案对照这张表能看出一个有意思的事实mm_struct 加 vm_area_struct 的组合本质上是在软件层面重新实现了一套段。vm_area_struct 记录了起始地址、结束地址、读写执行权限这不就是段表项的字段吗区别只在于软件层的段不参与地址翻译只是内核在缺页和权限检查时用来判断这个地址该不该给、该给什么权限。硬件负责效率软件负责语义分工非常干净。4.3 从 malloc 到物理页框C 语言内存管理在这条链上的位置理解了这套结构C 语言里的内存管理行为就顺理成章了。malloc(16)返回一个指针这个指针是逻辑地址那一刻它背后没有任何物理页框——页表项还不存在只是 vm_area_struct 里的空闲区间被推进了 16 字节。真正分配物理页框发生在你第一次读写这块内存的时候触发缺页内核补上页表项。也正因如此C 里两种分配路径的行为差异很明显小分配C 标准库从堆区拿内存堆顶通过系统调用整体向高地址推进。这条路径开销小、分配快但堆是连续的碎片化之后回收效率会下降。大分配通常直接走文件映射接口单独建立一块 vm_area_struct。开销比堆大但释放时直接归还给内核不留碎片。我在实际调试里遇到过一次典型现象程序申请了 1GB 内存malloc秒回但随后逐页写入时耗时几十秒还被系统判为占用大量内存。原因就是前者只改了地址空间描述后者才开始真正建页表、分配页框、触发缺页 I/O。申请成功和真正占用了物理内存是两件事这个认知对性能排查非常重要。4.4 从地址翻译迁移到 Julia 性能优化同样是内存密集型语言Julia 的性能优化里有一条几乎人人都提的原则类型稳定。原因和地址翻译是同源的。Julia 中一个元素类型不确定的容器比如装任意类型的数组每个元素都要额外存一个指向堆上对象的指针读写时还要做一次装箱拆箱。这意味着数据在物理内存里不连续缓存行利用率低TLB 也要为每次跳转建立新的映射。反过来元素类型确定的连续数组在物理内存里是紧凑排布的一次缺页调入 4KB 就能覆盖 512 个 8 字节元素TLB 一条映射搞定 4KB 范围CPU 预取器也能顺畅工作。所以 Julia 里那套用具体类型、预分配数组、避免在热循环里分配的建议本质上是在减少地址翻译的次数和缓存未命中的次数。预分配就是把每次迭代都申请新页框变成一次性申请好重复使用循环里不再触发缺页性能差异经常是数倍。5. 用 C 写一个段页式地址翻译模拟器纸上算完还不够把逻辑写成代码跑一遍那些含糊的地方会立刻暴露出来。下面这个模拟器不追求完整只实现翻译路径本身目的是把段表、页表、页框、偏移四者的关系钉死。5.1 数据结构怎么设计才贴近硬件设计上遵循三条规则字段和硬件段表项、页表项一一对应页表用一个连续的池子模拟物理内存页表基址用池内下标表示真实硬件里是物理地址这里为了可读性做了等价简化。#include stdio.h #include stdint.h #define SEG_BITS 4 #define PAGE_BITS 8 #define OFFSET_BITS 12 #define SEG_MASK ((1u SEG_BITS) - 1) #define PAGE_MASK ((1u PAGE_BITS) - 1) #define OFFSET_MASK ((1u OFFSET_BITS) - 1) #define SEG_TABLE_LEN 8 #define PT_POOL_SIZE 1024 typedef struct { uint8_t valid; /* 段是否合法 */ uint16_t pt_len; /* 段内页数 */ uint32_t pt_base; /* 页表在池中的起始下标 */ uint8_t prot; /* 段级保护位 */ } seg_entry_t; typedef struct { uint8_t valid; /* 页是否在内存 */ uint8_t prot; /* 页级保护位 */ uint32_t frame; /* 物理页框号 */ } pte_t; enum { TL_OK 0, TL_SEG_FAULT, TL_PAGE_OOR, TL_PAGE_FAULT };数组大小都有明确出处SEG_TABLE_LEN取 8是为了留出段号合法但段表项无效的情况PT_POOL_SIZE取 1024够放一个 100 项页表加若干调试用页表。5.2 翻译函数的完整实现核心函数只做一件事拆地址、查段表、查页表、拼物理地址每一步分别返回不同的状态码。static seg_entry_t seg_table[SEG_TABLE_LEN]; static pte_t pt_pool[PT_POOL_SIZE]; int translate(uint32_t la, uint32_t *pa, const char **msg) { uint32_t seg (la (PAGE_BITS OFFSET_BITS)) SEG_MASK; uint32_t pg (la OFFSET_BITS) PAGE_MASK; uint32_t off la OFFSET_MASK; /* 第一级段表 */ if (seg SEG_TABLE_LEN || !seg_table[seg].valid) { *msg 段越界或段无效; return TL_SEG_FAULT; } /* 第二级段内页号边界 */ if (pg seg_table[seg].pt_len) { *msg 段内页号越界; return TL_PAGE_OOR; } /* 第三级页表项 */ const pte_t *e pt_pool[seg_table[seg].pt_base pg]; if (!e-valid) { *msg 目标页不在内存触发缺页; return TL_PAGE_FAULT; } *pa (e-frame OFFSET_BITS) | off; *msg 翻译成功; return TL_OK; }注意pg seg_table[seg].pt_len这个检查放在读页表项之前。顺序反了会越界读取池子里别的段的页表项读出来的值恰好 valid 时你就得到一个合法但错误的物理地址排查起来极其痛苦。5.3 测试用例与预期输出按第 2 节的算例配置段表和页表再补三个异常用例int main(void) { /* 段 2页表长度 100页表起始下标 1 */ seg_table[2].valid 1; seg_table[2].pt_len 100; seg_table[2].pt_base 1; pt_pool[1 58].valid 1; pt_pool[1 58].prot 0x3; pt_pool[1 58].frame 0x15C; /* 段 5页表长度 150页表起始下标 200 */ seg_table[5].valid 1; seg_table[5].pt_len 150; seg_table[5].pt_base 200; uint32_t cases[] { 0x23A56F, /* 段2 页58 偏移0x56F - 期望 0x15C56F */ 0x5C8000, /* 段5 页200 - 期望 页号越界 */ 0xA00000, /* 段10 - 期望 段越界 */ 0x203000 /* 段2 页3页表项无效 - 期望 缺页 */ }; for (unsigned i 0; i sizeof(cases) / sizeof(cases[0]); i) { uint32_t pa 0; const char *msg ; int rc translate(cases[i], pa, msg); if (rc TL_OK) printf(LA0x%06X - PA0x%06X (%s)\n, cases[i], pa, msg); else printf(LA0x%06X - 失败 rc%d (%s)\n, cases[i], rc, msg); } return 0; }跑出来的第一行应该是LA0x23A56F - PA0x15C56F和第二节的纸面推导完全对上。剩下三行分别打印三种异常。最值得花时间的是把三个异常用例都写出来——只测成功路径的模拟器永远不知道自己有没有把边界检查写对。5.4 想继续深挖可以加的三个扩展写到这里模拟器已经能验证基本逻辑。往上加东西的时候我建议按下面的顺序来每一步都能带来新的理解加 TLB 层在translate前面插一个固定条目数的相联缓存键用(seg PAGE_BITS) | pg统计一万次随机访问的命中率。你会亲眼看到工作集超过 TLB 容量后命中率断崖式下跌这比看任何论文都直观。加多级页表把单张页表换成两级页表长度超过某个阈值时自动拆出下级目录。主要观察目标是页表本身占用的内存何时开始失控。加缺页计数与置换给页表项加访问位实现一个简易的最久未使用置换统计不同访问模式下的缺页次数。这个扩展最接近真实内核的行为也最费功夫。6. 踩坑记录与高频追问这些地方我在练习里错过一次最后把我在练习、实验和后来复习过程中真正绊倒过的地方列出来。这些内容教材上通常写得很简略但考试和面试偏偏爱问。6.1 页表本身就是内存开销算一算才知道有多贵段页式一个很少被强调的代价是页表本身要占内存而且可能占得很多。按第 2 节的设定每段一张页表每项 4 字节。如果一个段长 1MB即 256 页那这张页表就是 1KB看起来不多。但如果有 8 个段都用满页表开销就是 8KB而进程实际使用量可能只有几百 KB。页表开销占比接近 5%相当可观。更麻烦的是如果一个段在逻辑上很长但实际只用了几页典型的稀疏访问模式比如一个大数组只写了首尾按最大长度分配页表就会浪费大量内存。这也是多级页表存在的理由只为真正用到的部分建立下级页表。算这笔账是我做练习题时的一个额外收获——原来多一级间接换来的是实实在在的内存节省而不是纯粹的理论优雅。6.2 段页式下共享与保护的两种粒度段页式在共享和保护上有两个层级用法和代价各不相同层级实现方式适合场景代价段级共享两个进程的段表项指向同一张页表共享整个代码段、共享库粒度粗只要共享一段就要整段共享页级共享两个进程的页表项指向同一页框共享少量数据、写时复制粒度细但要维护引用计数写时复制是页级共享的经典应用父进程创建子进程时把两者的页表项都指向同一批页框同时把这些页标成只读。任何一方尝试写入硬件触发保护异常内核再复制一页给写入方并改成可写。这个机制能成立的关键前提就是页表项里有保护位而保护检查发生在写操作的那一刻——所以任何涉及页表项格式的设计保护位都不能省。段级共享有个隐含要求共享的段必须在两个进程的地址空间里处在相同或至少合法可映射的位置。如果两个进程的段号分配策略不同共享段的段号就不一样硬件查表时只能各查各的段表最后靠段表项里的页表基址指向同一张页表来实现共享。这也是段号是进程私有的页表可以是共享的这句话的准确含义。6.3 面试中被追问最多的四个问题问题一段页式相比纯页式多出来的开销具体体现在哪几个环节得分点要落到三处地址翻译多一次查表访存次数从 2 次变成 3 次每个段要维护独立的段表项和页表数据结构本身占内存段表基址和长度两个寄存器需要在进程切换时更新上下文切换成本上升。只答多查一次表是拿不到分的。问题二TLB 里缓存的键为什么必须包含段号因为段号加页号才唯一确定一个页表项。不同段里的同一个页号指向完全不同的物理页框只拿页号做键会导致跨段串页——这是功能错误不是性能问题。如果硬件实现了段基址为零的平坦模型段号恒为 0此时可以退化成只查页号这也解释了为什么现代硬件能把 TLB 简化成现在这个样子。问题三缺页处理时内核如何区分合法访问但页不在内存和非法访问靠的是软件层的地址空间描述结构而不是页表。内核拿着出错地址在进程的地址空间描述里查找命中某个区域并且权限匹配才算合法缺页查不到区域或者权限不符就是非法访问。问题四为什么段表基址寄存器里存的必须是物理地址因为它是地址翻译的起点如果它还需要翻译就会形成无限递归。同理多级页表的最高级基址寄存器也必须是物理地址。这个问题的答案可以一句话说完但它是判断一个人有没有真正理解翻译链路的好问题。动手把第 5 节的模拟器跑一遍、再把三种异常的触发点都手动打断点看一遍比反复读教材有效得多。我当时就是卡在页号越界和缺页分不清上直到把两个检查的前后顺序在代码里调换着试了一遍才彻底记住为什么边界检查必须放在页表项读取之前。
返回列表