第五十八章:虚实之映
元婴初期涉及内核源码:
一
林小源站在一块 VMA 大陆的中央,脚下是半透明的页表冰层。他闭上眼,尝试感知更深层的东西。
"你想干什么?"mm_struct 的声音从四面八方传来,带着警惕。
"我想看地址翻译的全过程。"林小源睁开眼,"上次你告诉我三级页表的结构,但我只看到了静态的表格。我想看它动起来。"
mm_struct 沉默了片刻。
"你确定?"它的声音变得严肃,"地址翻译是硬件的行为。你没有资格参与——你只能旁观。而且一旦开始,中途不能打断。"
"我确定。"
脚下的冰层突然变得完全透明。林小源看到一个虚拟地址从天而降——一串发光的数字,0x0000003FCA123456——像一颗流星,砸向地面。
"VPN[2]。"mm_struct 的声音变成了某种低沉的吟诵,"第一位索引。"
流星的第一段——高 9 位——分离出来,化作一道光箭,射入地底深处。林小源跟随着光箭的轨迹,看到它刺入一张巨大的表格。表格有 512 行,每一行都是一个 PTE。光箭命中了其中一行,那个 PTE 亮了起来。
"有效,非叶子。"mm_struct 说,"获取下一级页表地址。"
PTE 中的 PPN 字段变成了一条通道,光箭穿过通道,进入第二层表格。
"VPN[1]。第二位索引。"
又一道光箭射出,又命中一个 PTE。有效,非叶子。继续深入。
"VPN[0]。最后一位索引。"
第三道光箭射入最深层的表格。这一次,PTE 亮起时,林小源看到了不同的东西——这个 PTE 有 R、W、X 位,是叶子节点。
"叶子。"mm_struct 说,"翻译完成。"
PPN 字段和虚拟地址的低 12 位偏移合并在一起,化作一个物理地址——0x20008000 加上偏移。整个过程,从虚拟地址落下到物理地址生成,不过一瞬间。
"三次内存访问。"林小源喃喃自语,"每次都是一次查表。"
"每次查表都可能失败。"mm_struct 补充道,"如果任何一个 PTE 的 V 位为 0——翻译中断,page fault。"
二
林小源还想看更多。mm_struct 没有阻止他,但它的语气变得更加谨慎。
"接下来我要模拟一个失败的翻译。"它说,"你准备好了吗?"
又一个虚拟地址从天而降。这次的地址看起来和上次差不多,但低几位不同。光箭按照同样的路径射入三层表格——第一层通过,第二层通过——但在第三层,它命中了一个 V 位为 0 的 PTE。
光箭撞上那个 PTE 的瞬间,整个内景剧烈震动。PTE 的位置裂开一道缝隙,从中涌出一股冰冷的气息。
"page fault。"mm_struct 的声音变得低沉,"页面不在内存中。CPU 触发异常,控制权交给内核。"
林小源看到裂缝中浮现出一个模糊的身影——那是内核的 page fault handler。它没有说话,只是快速地检查了几个东西。
"第一步,查找 VMA。"mm_struct 解说,"内核拿 fault address 去 VMA 红黑树里搜索。如果地址不在任何 VMA 中——"
handler 的手在空中一挥,一块 VMA 片段浮现出来。它对比了 fault address 和 VMA 的范围。
"在 VMA 内。"mm_struct 说,"不是段错误,继续处理。"
"第二步,权限检查。进程想写入,但 VMA 的权限是只读——"
handler 停下了动作。林小源看到 VMA 的 flags 字段上, 位是暗的。
"权限不足。"mm_struct 说,"SIGSEGV。进程死。"
裂缝猛然扩大,吞没了那个 handler。林小源后退一步,心跳加速。
"这就是 page fault 的两种死法。"mm_struct 的声音恢复了平静,"地址不合法——SIGSEGV。权限不足——SIGSEGV。只有地址合法且权限匹配时,内核才会分配物理页,更新页表,让你继续走。"
三
林小源在内景中缓了很久,才平复下来。
"还有一个问题。"他说,"每次翻译都要查三次页表,这也太慢了。有没有办法加速?"
mm_struct 没有直接回答。它让林小源再看一次地址翻译的过程——但这一次,在虚拟地址落下的瞬间,旁边突然闪过一道金光。
"TLB。"mm_struct 说。
金光是一个小得几乎看不见的结构,但它拦截了虚拟地址。只是一瞬间——没有查表,没有三次内存访问——物理地址就直接出现了。
"Translation Lookaside Buffer。"mm_struct 的声音里带着一丝难得的赞许,"页表的缓存。最近使用过的翻译结果存在里面。命中时,零次内存访问。"
林小源看着那个小小的结构,金光流转。它太小了——最多存几百条记录。但就是这几百条,覆盖了绝大部分的内存访问。
"如果页表变了呢?"林小源问。
金光突然暗了一下。mm_struct 的声音变得严厉:"问得好。如果页表变了——比如 VMA 被 munmap 了,或者页面被交换出去了——TLB 里的翻译就过时了。内核必须刷新 TLB,否则你会访问到错误的物理地址。"
"怎么刷新?"
"RISC-V 有 sfence.vma 指令。"mm_struct 说,"但具体怎么刷、刷哪条、什么时候刷——那是后面的事。你先记住一点:缓存是信任,刷新是验证。"
林小源点了点头。他看着 TLB 的金光再次亮起,又一个虚拟地址被瞬间翻译。
说到底,地址翻译不只是一场寻宝——它是一场有缓存加速、有容错机制的寻宝。
/*
* 地址翻译的完整过程:
*
* 1. CPU 发出虚拟地址
* 2. MMU 查找 TLB
* - TLB 命中 → 直接得到物理地址
* - TLB 未命中 → 开始页表遍历
* 3. 读取 satp 寄存器,获取根页表地址
* 4. 用 VPN[2] 索引 L2 页表
* - 如果 PTE 是叶子节点(1GB 大页)→ 得到物理地址
* - 否则 → 获取 L1 页表地址
* 5. 用 VPN[1] 索引 L1 页表
* - 如果 PTE 是叶子节点(2MB 大页)→ 得到物理地址
* - 否则 → 获取 L0 页表地址
* 6. 用 VPN[0] 索引 L0 页表
* - 获取物理页号
* 7. 物理地址 = PPN << 12 | offset
* 8. 更新 TLB
*/
#define PTE_V (1 << 0)
#define PTE_R (1 << 1)
#define PTE_W (1 << 2)
#define PTE_X (1 << 3)
int is_leaf(unsigned long pte) {
return (pte & PTE_R) || (pte & PTE_W) || (pte & PTE_X);
}
printf("=== 地址翻译 — 从虚拟到物理 ===\n\n");
unsigned long va = 0x0000003FCA123456;
printf("虚拟地址: 0x%016lx\n\n", va);
/* 模拟页表遍历 */
unsigned long vpn2 = (va >> 30) & 0x1FF;
unsigned long vpn1 = (va >> 21) & 0x1FF;
unsigned long vpn0 = (va >> 12) & 0x1FF;
unsigned long offset = va & 0xFFF;
/* 模拟 PTE(简化) */
unsigned long l2_pte = 0x20000001 | PTE_V; /* 非叶子,指向 L1 */
unsigned long l1_pte = 0x20004001 | PTE_V; /* 非叶子,指向 L0 */
unsigned long l0_pte = 0x2000800F | PTE_V | PTE_R | PTE_W | PTE_X;
printf("页表遍历过程:\n\n");
printf("Step 1: 读取 satp,获取根页表地址\n");
printf(" 根页表: 0xFFFF800000001000\n\n");
printf("Step 2: VPN[2] = %lu,索引 L2 页表\n", vpn2);
printf(" L2 PTE: 0x%lx\n", l2_pte);
printf(" 有效=%d, 叶子=%d\n",
(l2_pte & PTE_V) ? 1 : 0, is_leaf(l2_pte));
printf(" → 不是叶子,获取 L1 页表地址\n\n");
printf("Step 3: VPN[1] = %lu,索引 L1 页表\n", vpn1);
printf(" L1 PTE: 0x%lx\n", l1_pte);
printf(" 有效=%d, 叶子=%d\n",
(l1_pte & PTE_V) ? 1 : 0, is_leaf(l1_pte));
printf(" → 不是叶子,获取 L0 页表地址\n\n");
printf("Step 4: VPN[0] = %lu,索引 L0 页表\n", vpn0);
printf(" L0 PTE: 0x%lx\n", l0_pte);
printf(" 有效=%d, 叶子=%d\n",
(l0_pte & PTE_V) ? 1 : 0, is_leaf(l0_pte));
printf(" → 是叶子,获取物理页号\n\n");
unsigned long ppn = (l0_pte >> 10) & 0xFFFFFFFFF;
unsigned long pa = (ppn << 12) | offset;
printf("Step 5: 物理地址 = PPN << 12 | offset\n");
printf(" PPN: 0x%09lx\n", ppn);
printf(" 物理地址: 0x%016lx\n", pa);
printf("\n--- TLB 的作用 ---\n");
printf("页表遍历需要 3 次内存访问\n");
printf("TLB 缓存最近的翻译结果\n");
printf("TLB 命中 → 0 次内存访问\n");
printf("TLB 未命中 → 3 次内存访问\n");
printf("TLB 命中率通常 > 99%%\n");#include <stdio.h>
/*
* 地址翻译的完整过程:
*
* 1. CPU 发出虚拟地址
* 2. MMU 查找 TLB
* - TLB 命中 → 直接得到物理地址
* - TLB 未命中 → 开始页表遍历
* 3. 读取 satp 寄存器,获取根页表地址
* 4. 用 VPN[2] 索引 L2 页表
* - 如果 PTE 是叶子节点(1GB 大页)→ 得到物理地址
* - 否则 → 获取 L1 页表地址
* 5. 用 VPN[1] 索引 L1 页表
* - 如果 PTE 是叶子节点(2MB 大页)→ 得到物理地址
* - 否则 → 获取 L0 页表地址
* 6. 用 VPN[0] 索引 L0 页表
* - 获取物理页号
* 7. 物理地址 = PPN << 12 | offset
* 8. 更新 TLB
*/
#define PTE_V (1 << 0)
#define PTE_R (1 << 1)
#define PTE_W (1 << 2)
#define PTE_X (1 << 3)
int is_leaf(unsigned long pte) {
return (pte & PTE_R) || (pte & PTE_W) || (pte & PTE_X);
}
int main() {
printf("=== 地址翻译 — 从虚拟到物理 ===\n\n");
unsigned long va = 0x0000003FCA123456;
printf("虚拟地址: 0x%016lx\n\n", va);
/* 模拟页表遍历 */
unsigned long vpn2 = (va >> 30) & 0x1FF;
unsigned long vpn1 = (va >> 21) & 0x1FF;
unsigned long vpn0 = (va >> 12) & 0x1FF;
unsigned long offset = va & 0xFFF;
/* 模拟 PTE(简化) */
unsigned long l2_pte = 0x20000001 | PTE_V; /* 非叶子,指向 L1 */
unsigned long l1_pte = 0x20004001 | PTE_V; /* 非叶子,指向 L0 */
unsigned long l0_pte = 0x2000800F | PTE_V | PTE_R | PTE_W | PTE_X;
printf("页表遍历过程:\n\n");
printf("Step 1: 读取 satp,获取根页表地址\n");
printf(" 根页表: 0xFFFF800000001000\n\n");
printf("Step 2: VPN[2] = %lu,索引 L2 页表\n", vpn2);
printf(" L2 PTE: 0x%lx\n", l2_pte);
printf(" 有效=%d, 叶子=%d\n",
(l2_pte & PTE_V) ? 1 : 0, is_leaf(l2_pte));
printf(" → 不是叶子,获取 L1 页表地址\n\n");
printf("Step 3: VPN[1] = %lu,索引 L1 页表\n", vpn1);
printf(" L1 PTE: 0x%lx\n", l1_pte);
printf(" 有效=%d, 叶子=%d\n",
(l1_pte & PTE_V) ? 1 : 0, is_leaf(l1_pte));
printf(" → 不是叶子,获取 L0 页表地址\n\n");
printf("Step 4: VPN[0] = %lu,索引 L0 页表\n", vpn0);
printf(" L0 PTE: 0x%lx\n", l0_pte);
printf(" 有效=%d, 叶子=%d\n",
(l0_pte & PTE_V) ? 1 : 0, is_leaf(l0_pte));
printf(" → 是叶子,获取物理页号\n\n");
unsigned long ppn = (l0_pte >> 10) & 0xFFFFFFFFF;
unsigned long pa = (ppn << 12) | offset;
printf("Step 5: 物理地址 = PPN << 12 | offset\n");
printf(" PPN: 0x%09lx\n", ppn);
printf(" 物理地址: 0x%016lx\n", pa);
printf("\n--- TLB 的作用 ---\n");
printf("页表遍历需要 3 次内存访问\n");
printf("TLB 缓存最近的翻译结果\n");
printf("TLB 命中 → 0 次内存访问\n");
printf("TLB 未命中 → 3 次内存访问\n");
printf("TLB 命中率通常 > 99%%\n");
return 0;
}道藏笔记
内核启示
地址翻译的过程说起来不复杂:先从 寄存器拿到根页表地址,然后用虚拟地址的 VPN[2]、VPN[1]、VPN[0] 分别索引三级页表,最后把拿到的 PPN 左移 12 位拼上偏移量,物理地址就出来了。三次查表,三次内存访问——如果每次都要走完这三步,程序早就饿死了。
所以有 TLB。它是页表的缓存,存着最近用过的翻译结果。命中时零次内存访问,直接拿结果走人。TLB 命中率通常超过 99%——大部分时候你根本感觉不到页表的存在。
什么时候会出问题?PTE 的 V 位为 0(页面不在内存)、权限检查失败、用户态试图访问内核页面——这三种情况都会触发 page fault。地址翻译是虚拟内存的"根基"——没有它,虚拟内存就是空中楼阁。
虚实之映之试
虚拟地址到物理地址的映射若能命中缓存,CPU 会优先查哪一个地址翻译缓存?