Skip to content

第五十八章:虚实之映

元婴初期

涉及内核源码:

林小源站在一块 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 有 RWX 位,是叶子节点。

"叶子。"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 的金光再次亮起,又一个虚拟地址被瞬间翻译。

说到底,地址翻译不只是一场寻宝——它是一场有缓存加速、有容错机制的寻宝。


c
/*
 * 地址翻译的完整过程:
 *
 * 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");

道藏笔记

内核启示

地址翻译的过程说起来不复杂:先从 寄存器拿到根页表地址,然后用虚拟地址的 VPN[2]VPN[1]VPN[0] 分别索引三级页表,最后把拿到的 PPN 左移 12 位拼上偏移量,物理地址就出来了。三次查表,三次内存访问——如果每次都要走完这三步,程序早就饿死了。

所以有 TLB。它是页表的缓存,存着最近用过的翻译结果。命中时零次内存访问,直接拿结果走人。TLB 命中率通常超过 99%——大部分时候你根本感觉不到页表的存在。

什么时候会出问题?PTE 的 V 位为 0(页面不在内存)、权限检查失败、用户态试图访问内核页面——这三种情况都会触发 page fault。地址翻译是虚拟内存的"根基"——没有它,虚拟内存就是空中楼阁。


破关试炼

虚实之映之试

虚拟地址到物理地址的映射若能命中缓存,CPU 会优先查哪一个地址翻译缓存?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道