ARTICLE DETAIL

资讯详情

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

逻辑地址到物理地址转换全解析:分页、页表与快表

逻辑地址到物理地址转换全解析:分页、页表与快表 分页存储管理里最容易让人当场卡住的一步不是页表怎么建而是给你一个逻辑地址让你算出它落在哪个物理单元。很多人能背出逻辑地址等于页号加页内偏移也能背出物理地址等于页框号乘页面大小加页内偏移可一旦换成十六进制、换成 2KB 页、换成两级页表笔就停了。我自己带实验和复盘期末题的时候见过太多这种情况公式写得一字不差位数切错一位答案全废。这篇就专门把逻辑地址到物理地址的转换这条链路拆开从为什么要分页讲到硬件三步动作再讲到多级页表、快表命中率、真实系统上的验证方法最后把我踩过的、看别人踩过的坑一次性列清楚。适合正在学操作系统的同学也适合工作几年后需要重新把这套机制捋顺的开发者看完应该能独立手算任意一道地址转换题也能解释清楚机器上那些地址现象背后的原因。1. 分页要收拾的烂摊子连续分配留下的碎屑与搬迁1.1 连续分配的两个死结早期的内存分配是连续的一个进程要么整块占住一段内存要么不进来。这套办法简单但很快撞上两堵墙。第一堵墙是外部碎片内存里零散地留着很多小空洞单个空洞都不够放新进程可加起来明明够。第二堵是难以增长进程运行中需要更多内存时它后面那块地已经被别的进程占了只能整体搬到更大的空洞去这个搬运叫紧凑或者拼接代价是把大量数据在内存里挪来挪去还要暂停进程。我见过用生活类比讲这段最清楚的版本连续分配就像一群人排队坐长椅每个人占的长度不一样中间有人走了就留下空隙新来的人如果比空隙长就坐不进去哪怕所有空隙加起来能坐十个人。整理的办法是让所有人站起来重新紧凑地坐一遍——这就是拼接的代价人越多越痛苦。1.2 等长切块分页用内部碎片换掉了外部碎片分页的思路很直接既然长度不一样才麻烦那干脆规定所有人占的长度都一样。逻辑地址空间被切成大小相等的页物理内存被切成同样大小的页框也叫物理块、存储块。页和页框大小完全一致任何一个页都能装进任何一个页框。进程的第 0 页可以放在物理内存的第 5 块第 1 页放在第 2 块第 2 页放在第 9 块页与页之间不需要连续只有页内部是连续的。这个取舍的核心在于外部碎片被彻底消灭代价是产生了内部碎片。分页之后不再有空洞小到放不下的问题因为任何一块空闲页框都能用。但进程最后一页大概率填不满那一页里剩下的空间就浪费了——这叫内部碎片。平均来说每个进程浪费半页左右。用 4KB 页的话平均一个进程浪费 2KB 左右对现代内存规模来说完全可以接受。1.3 地址转换的两个前提别跳过在往下算之前有两个前提必须先钉死不然后面全是糊涂账。第一个前提是页大小必须是 2 的整数次幂。这不是审美偏好而是为了让硬件只用移位和掩码就能切分地址不需要做除法。第二个前提是转换由硬件完成页表由操作系统维护。操作系统负责在进程切换时把页表的起始地址告诉硬件之后每一次访存都是内存管理单元实时算出来的软件不参与。所以逻辑地址到物理地址的转换这句话的完整含义是硬件拿到 CPU 送出的逻辑地址拆成页号和页内偏移两部分用页号去查操作系统准备好的页表拿到页框号再把页框号和页内偏移拼成一个物理地址。整条链路上需要查表的是页号不需要查表的是偏移——偏移在转换前后一模一样地保留下来。这一点看起来是废话但它是所有手算题的命门。2. 把一个逻辑地址切两半页号和页内偏移的位数怎么定2.1 二进制视角高位是页号低位是偏移假设页面大小是 2 的 k 次幂字节那么偏移部分正好占低 k 位。为什么因为偏移的取值范围是 0 到页大小减 1刚好需要 k 个二进制位才能表示。剩下的高位全部用来表示页号。如果逻辑地址总共 m 位那页号就占高 m-k 位页号的最大值就是 2 的 (m-k) 次幂减一也就是说逻辑地址空间最多有 2^(m-k) 个页。这个关系可以写成一个很顺的公式页号 逻辑地址右移 k 位页内偏移 逻辑地址与页大小减 1做按位与。右移就是丢低位取高位按位与就是只留低位。我在做代码验证那一节会直接用这两条式子写程序你会发现它们和硬件做的事完全一样。2.2 页面大小是唯一的锚点位数全从它推所有位数计算的起点只有页面大小这一个数字剩下的都是顺推。我把常见的几组关系整理成表做题时可以直接对照。页面大小偏移位数 k页表项数32 位逻辑地址单级页表大小按每项 4B512B92^23 838860832MB1KB102^22 419430416MB2KB112^21 20971528MB4KB122^20 10485764MB8KB132^19 5242882MB16KB142^18 2621441MB从表里能一眼看出两件事。第一页面越大页表越小。第二页面越大最后一页的内部碎片越大。这就是页面大小设计的核心矛盾实际系统选 4KB 是一个折中结论不是随手定的。还有一个细节值得单独拎出来页框号的位数由物理地址位数减去偏移位数得到。比如物理内存 64MB也就是 2 的 26 次幂字节用 4KB 页偏移占 12 位那页框号就占 14 位。这意味着页表项至少要能存下 14 位页框号再加上有效位、修改位、访问位、保护位凑整到 4 字节是很自然的选择。这个推算过程经常出现在页表项占几字节的设计题里很多人只记住结论 4 字节被问为什么就答不上。2.3 页表项里到底存了什么页表本身就是一个数组下标是页号内容是页表项。页表项最核心的字段是页框号但只存这个是不够的。实际页表项里通常还有有效位/状态位标明这个页当前是否在内存里。不在就是缺页要触发缺页处理把它从外存调进来。修改位脏位标明这个页被写过没有。换出的时候没改过的页可以直接丢弃改过的必须先写回外存。访问位供置换算法参考标记这个页最近被访问过。保护位读、写、执行权限防止越权访问。这些位看起来只是附加信息但它们解释了机器上一大堆现象。为什么没访问过的内存不会真的占物理页为什么两个进程共享的库文件在内存里只有一份为什么一个野指针不一定立刻崩溃答案都藏在这些位里。我建议学地址转换的时候顺手把这些位记住否则后面学虚拟内存、缺页处理、页面置换时会一直觉得断片。3. 一次地址转换的完整链路跟着数字走一遍3.1 硬件视角的三步动作把前面所有铺垫合起来硬件做的是三件事顺序不能乱第一步越界检查。硬件拿到逻辑地址之后先看页号有没有超过页表长度寄存器的值。超了直接抛越界中断进程被终止。这一步的重要性在于它保证了后面查表不会查到页表外的野数据。第二步查页表。用页表基址寄存器里的起始地址加上页号乘以页表项大小定位到对应的页表项读出来。如果有效位是 0说明页不在内存触发缺页异常如果在取出页框号。第三步拼接。物理地址 页框号 × 页面大小 页内偏移。注意这里乘页面大小等价于把页框号左移 k 位再和偏移做按位或。硬件用的就是移位和或运算不是真的做乘法。这三步走完才是真正拿这个物理地址去访问内存。所以一次访存如果页表不在缓存里实际要访问内存两次一次读页表项一次读数据。这个两次访存的代价是所有性能优化的起点后面讲快表就是在填这个坑。3.2 手算演练十进制地址走完全程题目页面大小 1KB逻辑地址 2500十进制页表内容为 0 号页到 5 号块、1 号页到 3 号块、2 号页到 8 号块、3 号页到 2 号块、4 号页到 6 号块。求物理地址。按顺序来。先算页号2500 除以 1024商 2所以页号是 2 号页。再算偏移2500 减去 2 乘 1024 等于 2500 减 2048 等于 452。查表得 2 号页对应 8 号块。物理地址等于 8 乘 1024 加 452等于 8192 加 452等于8644。再用二进制验算一遍看看位数切得对不对。2500 的二进制是 100111000100一共 12 位。页面 1KB 对应偏移 10 位所以高 2 位是页号低 10 位是偏移。高 2 位是 10也就是十进制 2低 10 位是 0111000100等于 452。和除法算出来的完全一致。8644 的二进制是 10000111000100高 4 位 1000 就是 8 号块低 10 位 0111000100 还是 452偏移原封不动。这一遍验算花不了半分钟但能挡住 90% 的粗心错误。3.3 三道对照题专门练进制和非常规页大小只做两道十进制题是不够的考试和实践里最常见的坑就是进制混用和非 4KB 页。下面三道题我建议动手算一遍再对照答案。题号页面大小逻辑地址页表映射物理地址11KB2500十进制2 号页 → 8 号块864424KB0x3A5F3 号页 → 0x12 号块0x12A5F32KB6000十进制2 号页 → 7 号块16240第二题的算法4KB 偏移 12 位也就是十六进制 3 位。0x3A5F 的低 3 位是 0xA5F高 1 位是 3所以页号 3、偏移 0xA5F。页框号 0x12 左移 12 位得到 0x12000加上 0xA5F 得 0x12A5F。用十六进制做这种题最快因为 4KB 页对应的正是一个十六进制位等于一个页号的边界关系看到 4 位十六进制地址最后 3 位就是偏移。第三题的算法2KB 偏移 11 位6000 除以 2048 商 2 余 1904页号 2、偏移 1904页框号 7物理地址 7 乘 2048 加 1904 等于 14336 加 1904 等于 16240。这道题的意义在于提醒你页面大小不一定是 4KB 的整数倍关系2KB 页偏十六进制就不好看老老实实用除法反而稳。这三道题有一个共同的验算技巧把算出来的物理地址除以页面大小商应该正好等于页框号余数应该正好等于原来的偏移。只要这两个条件满足结果基本不会错。这个验算只需要一次除法比重新推导一遍快得多。4. 页表自己变大之后多级页表与快表的组合拳4.1 先给页表本身算一笔账前面那张表已经透露了问题32 位逻辑地址配 4KB 页单级页表有 2 的 20 次幂个页表项每项 4 字节总共 4MB。听起来不大但这是每个进程一份。系统里开着几十个进程光页表就吃掉上百兆。更糟的是一个进程实际用到的地址空间往往是稀疏的——代码在低地址栈在高地址中间大片没用。可单级页表不管用没用到全部项都得存在因为页号是连续索引的。这就是多级页表要解决的问题只给真正用到的区域建表。做法是把页号再切一刀切成多段每段索引一级页表。一级页表不指向页框而是指向二级页表的基址二级页表才真正指向页框。没被用到的地址区域对应的二级页表压根不创建省下来的就是这份空间。4.2 两级页表把地址再切一刀32 位地址、4KB 页、页表项 4 字节的情况下标准切法是 10 加 10 加 12段位数作用页目录号高 10 位索引页目录拿到二级页表基址页表索引中 10 位索引二级页表拿到页框号页内偏移低 12 位直接保留不参与查表为什么选 10 位因为每张页表要恰好占一页。2 的 10 次幂个页表项每项 4 字节正好 4KB和一页一样大。这样一来页表本身也能被分页管理可以被换出到外存不会被排除在虚拟内存体系之外。这个设计上的自洽是很漂亮的一点很多教材一句话带过但理解它之后你再看 x86-64 的 9 加 9 加 9 加 9 加 12 就不会觉得是拍脑袋定的——48 位地址、4KB 页、8 字节页表项每级 9 位正好是一页。拿一个具体地址走一遍。逻辑地址 0x00403ABC展开成 32 位二进制后按 10、10、12 切页目录号是 1页表索引是 3偏移是 0xABC。假设页目录第 1 项指向物理地址 0x1000 处的二级页表该页表第 3 项存的页框号是 0x25那么物理地址等于 0x25 乘 0x1000 加 0xABC等于 0x25000 加 0xABC等于0x25ABC。注意这里的代价两级页表下如果快表没命中一次访存要读三次内存——读页目录、读二级页表、读数据。所以多级页表是拿时间换空间必须有快表来兜住性能。4.3 快表命中率怎么影响有效访问时间快表TLB是一块很小但极快的相联存储器缓存最近用过的页号到页框号映射。程序访存有很强的局部性所以命中率通常很高。有效访问时间就是按命中率加权的平均耗时。公式可以这样记命中时耗时是查快表加访存未命中时耗时是查快表加逐级查页表加访存。以单级页表为例设快表访问 10ns内存访问 100ns命中率 95%命中路径10 加 100 等于 110ns未命中路径10 加 100 加 100 等于 210ns一次读页表项一次读数据有效访问时间0.95 乘 110 加 0.05 乘 210等于 104.5 加 10.5等于115ns如果没有快表单级页表每次都要 200ns。加了快表之后降到 115ns。换成两级页表、同样的 95% 命中率命中路径仍是 110ns未命中路径变成 10 加 100 加 100 加 100 等于 310ns读页目录、读页表、读数据有效访问时间0.95 乘 110 加 0.05 乘 310等于 104.5 加 15.5等于120ns对比一下很说明问题两级页表未命中时比单级贵了 100ns但因为命中率高达 95%最终只比单级慢了 5ns。这就是为什么实际系统敢用四级页表——局部性把多级查表的代价摊薄了。做这类题的诀窍是先把两条路径的耗时分别写出来再乘命中率相加不要试图一步写出综合公式容易漏掉查快表的那 10ns。顺便说一个容易被忽略的细节未命中路径里查快表的时间是省不掉的因为你必须先去查了才知道有没有命中。很多人算题时把未命中路径写成两次访存漏了那 10ns答案就差了 0.5ns虽然数值影响小但说明对流程的理解有缺口。5. 在真实机器上把这件事验证一遍5.1 页大小、内存映射与偏移计算纸上算完最好到机器上对一遍印象会深很多。Linux 下查看页大小和进程地址映射一条命令就够getconf PAGE_SIZE cat /proc/self/maps第一条通常输出 4096也就是 4KB和你书上背的数字对上了。第二条会打印出当前进程的地址区间形如55d3a2c00000-55d3a2c21000 r-xp左边是起始和结束的逻辑地址右边是权限。你会看到这些地址基本都是页对齐的末三位十六进制是 0因为每一段映射都必须从页边界开始。如果某段映射的起止地址不是 4KB 对齐的那说明你看到的输出有问题或者被截断了。也可以用cat /proc/meminfo看系统的页相关统计里面的PageTables一项就是所有进程页表占用的内存总量。在一台跑了很久的机器上看这个数字往往比想象的大你会对页表本身也是内存开销这件事有直观感受。5.2 一段小程序把高位页号、低位偏移打出来最直观的验证是写几行代码让程序自己把地址切开给你看。下面这段代码做的事和硬件在地址转换里做的第一步完全一样#include stdio.h #include unistd.h static unsigned long get_page_size(void) { long sz sysconf(_SC_PAGESIZE); return sz 0 ? (unsigned long)sz : 4096UL; } static void dump(const char *tag, void *p) { unsigned long page get_page_size(); unsigned long addr (unsigned long)p; unsigned long mask page - 1; printf(%-6s addr0x%012lx page_no%lu offset%lu (0x%lx)\n, tag, addr, addr 12, /* 页号右移偏移位数 */ addr mask, /* 偏移与上页大小减一 */ addr mask); printf( 页框基址0x%012lx\n, addr ~mask); } int main(void) { int stack_var 42; static int static_var 7; int *heap_var (int *)malloc(sizeof(int)); printf(PAGE_SIZE %lu\n, get_page_size()); dump(stack, stack_var); dump(static, static_var); dump(heap, heap_var); return 0; }编译运行之后你会看到三类变量的页号差得很远同一类变量在多次运行之间页号还会变化。那些变化正是地址空间布局随机化的结果跟分页机制无关但会让你的实验数据每次都不一样。重点是观察每个地址的低 12 位也就是偏移它是自己在页内的位置和页框在物理内存的哪个位置没有关系。这里有个必须强调的点你打印出来的是逻辑地址不是物理地址。程序里拿到的指针永远是被转换之前的地址转换发生在你看不见的地方。想看到物理地址得读/proc/self/pagemap而且通常需要较高权限文件里存的也不是直接可用的物理地址需要按位解析页框号再乘页大小。如果你只是想验证位数切分上面的代码足够了不必去碰 pagemap。5.3 缺页、写时复制这些现象背后的页表位把第一节提到的页表项那些位和实际现象对上会有一种豁然开朗的感觉。程序刚启动时申请的堆内存并不会立刻占用物理页真正写入的那一刻才触发缺页操作系统才分配一个页框并填进页表项同时把有效位置 1。所以申请了 1GB 内存但物理内存只涨了一点是正常的这是延迟分配。再看写时复制一个进程 fork 出子进程时父子共享所有页框页表项都指向同一个页框号且被标记为只读。任何一方要写的时候硬件发现权限不符触发异常操作系统这时才复制一份新页框、改掉页表项、恢复可写权限。这个流程里页表项的保护位就是触发器页框号就是被改写的目标。你在代码里看到的复制其实什么都没复制真正复制是延后到写的那一刻才发生的。理解了这些你会发现地址转换不是一段孤立的计算题它是整个虚拟内存机制的地基。页表项里的每一个位背后都对应一套真实的运行时行为。6. 手算题和工程实践里最容易翻车的几种情况6.1 页面大小不是整 KB 时的位数计算最容易错的就是页面大小给了 512B、2KB、8KB 这种值很多人下意识按 4KB 去切 12 位。正确做法只有一条把页面大小写成 2 的幂指数就是偏移位数。512B 是 2 的 9 次幂偏移 9 位2KB 是 2 的 11 次幂偏移 11 位8KB 是 2 的 13 次幂偏移 13 位。切位数之前先把这一步做出来别凭手感。另一个变体是题目给逻辑地址空间 4GB、页面大小 4KB让你求页表项数。4GB 是 2 的 32 次幂4KB 是 2 的 12 次幂页数就是 2 的 20 次幂。这个减法很直接但要小心单位1GB 是 2 的 30 次幂而不是 10 的 9 次幂这种常识要刻在脑子里。6.2 页号越界与保护位判定有一类题会给你一个逻辑地址但同时告诉你进程的页表只有 0 到 4 号页让你判断这次访问会不会出问题。这时先看页号是不是超过页表长度寄存器超了就是越界中断后面的查表步骤根本不会发生。还有一类题给出页表项的保护位问这次写操作是否合法。这两种题的共同点是要先检查再转换顺序错了答案就错。工程上的对应现象是段错误访问一个没映射的地址或者往只读页写数据都会立刻收到信号。所谓野指针不一定崩溃是因为它偶尔落在了一个已经映射、权限也允许的页里这时它只是悄悄改坏了别人的数据反而更危险。6.3 进制和单位混用十六进制和十进制混着用是手算题翻车的最大来源。我的建议是固定下来只要页面大小是 4KB 的整数倍关系就统一用十六进制因为每 3 个十六进制位正好对应 12 位偏移只要页面大小是 512B、1KB、2KB、8KB 这类就统一用十进制或者转成二进制用除法求页号余偏移最稳。最忌讳的是前半段用十六进制、后半段突然换成十进制去乘加中间一转换就出错。验算方法还是那条物理地址除以页面大小商必须等于页框号余数必须等于偏移。这条验算对任何进制、任何页面大小都成立成本极低我强烈建议养成习惯。6.4 有效访问时间的乘加关系有效访问时间这类题错法出奇地一致把命中率和访存次数乘错了对象。正确的思路是先写路径再乘概率。命中路径和未命中路径分别对应哪几次访存、每次多少时间列清楚之后再乘命中率相加。多级页表记得未命中的访存次数等于页表级数加一。另外要注意快表访问时间在两条路径里都要算别只在命中路径里算。还有一个常被问到的变体如果快表命中率是 100%有效访问时间等于多少答案是快表访问时间加一次访存时间跟页表有几级完全无关因为根本不需要查页表。反过来命中率是 0就是完整的多级查表加访存。把这两个极端情况想清楚中间情况就不会算错。最后分享一个我自己总结的检查顺序拿到题先圈出页面大小和地址位数写出偏移位数再把地址按位数切两半然后查表拿页框号最后做一次乘加并验算。这四步里任何一步卡壳都说明前面某一步没想清楚不要硬往下算。整套流程走顺之后一道地址转换题从读题到验算不会超过一分钟而且几乎不会错。这套机制看起来是操作系统课里最枯燥的一节但它其实是理解整台机器内存行为最短的一条路值得多花点时间把它算到手上生不出错。
返回列表