news 2026/9/26 15:20:53

《从零手写操作系统 (06):虚拟内存初探——启用分页与高半核映射》

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
《从零手写操作系统 (06):虚拟内存初探——启用分页与高半核映射》
前言:打破物理地址的枷锁

在上一章中,我们实现了物理页帧分配器(PMM),内核终于能够动态申请和释放4KB的物理内存块。但此时所有代码和数据仍然直接使用物理地址,这意味着:

  • 内核和用户程序共享同一个线性地址空间,毫无隔离可言;
  • 内核加载位置被物理内存布局死死绑定,无法灵活重定位;
  • 未来实现进程切换时,无法为每个进程提供独立的地址空间视图。

本章我们将跨过x86内存管理最重要的一道门槛:启用分页(Paging)。通过构建两级页表,我们将实现经典的高半核映射(Higher Half Kernel)——让内核运行在3GB以上的虚拟地址空间,而将0~3GB留给未来的用户态进程。这是现代操作系统架构的分水岭。

本章里程碑:

  • ✅ 理解x86两级分页机制(CR3 → PDE → PTE)
  • ✅ 构建恒等映射(Identity Mapping)确保分页开启瞬间不崩溃
  • ✅ 实现高半核映射(Kernel @ 0xC0000000+)
  • ✅ 启用分页后跳转到高地址执行,废弃低地址映射
  • ✅ 串口验证虚拟地址访问正确性

核心概念:x86两级分页模型
分页的本质:一层间接寻址

启用分页后,CPU看到的不再是物理地址,而是虚拟地址(Virtual Address)。硬件自动完成翻译:

虚拟地址 [31:22] → 页目录索引(PDE Index) 虚拟地址 [21:12] → 页表索引(PTE Index) 虚拟地址 [11:0] → 页内偏移(Offset)
寄存器/结构作用关键位
CR3存储页目录物理基地址(必须4KB对齐)高20位=页目录物理页帧号
PDE页目录项,指向一个页表的物理地址Present, R/W, U/S, PWT, PCD, PS
PTE页表项,指向一个4KB物理页帧Present, R/W, U/S, Dirty, Accessed
CR0.PG分页使能开关Bit 31 = 1 启用分页

⚠️致命陷阱:mov cr0, eax设置PG位的下一条指令,CPU立即按虚拟地址取指。如果该虚拟地址没有有效映射,立刻触发#PF异常且无法恢复。这就是为什么必须先建立恒等映射。

为什么需要恒等映射 + 高半核映射并存?

启用分页的瞬间,EIP仍指向低地址(如0x100000)。如果没有恒等映射,CPU翻译当前EIP就会失败。因此过渡期必须同时存在两套映射:

  1. 恒等映射:VA == PA,覆盖内核当前所在物理区域,保证分页开启后指令流不断裂。
  2. 高半核映射:VA = PA + 0xC0000000,将同一批物理页帧映射到高地址。

完成跳转后,即可安全拆除恒等映射,从此内核只通过高地址访问自身。


实战代码
页表数据结构定义
// paging.h #ifndef PAGING_H #define PAGING_H #include <stdint.h> #define PAGE_SIZE 4096 #define KERNEL_VBASE 0xC0000000U // 3GB #define NUM_PDE 1024 #define NUM_PTE 1024 // 页目录/页表项标志位 #define PTE_PRESENT 0x001 #define PTE_WRITABLE 0x002 #define PTE_USER 0x004 typedef uint32_t page_entry_t; // 页目录和页表必须4KB对齐 extern page_entry_t kernel_page_dir[NUM_PDE] __attribute__((aligned(PAGE_SIZE))); void paging_init(void); void paging_map_page(uint32_t virt, uint32_t phys, uint32_t flags); void paging_unmap_page(uint32_t virt); #endif
页表构建与分页启用
// paging.c #include "paging.h" #include "memory.h" // pmm_alloc_page() #include "serial.h" // 内核页目录,BSS段中4KB对齐声明 page_entry_t kernel_page_dir[NUM_PDE] __attribute__((aligned(PAGE_SIZE))); // 临时页表池:用于存放动态分配的页表 static page_entry_t *alloc_page_table(void) { uint32_t phys = pmm_alloc_page(); if (!phys) { kprintf("[PAGING] FATAL: Cannot allocate page table!\n"); for (;;) asm volatile("cli; hlt"); } page_entry_t *table = (page_entry_t *)phys; for (int i = 0; i < NUM_PTE; i++) table[i] = 0; return table; } // 映射单个4KB页:virt → phys void paging_map_page(uint32_t virt, uint32_t phys, uint32_t flags) { uint32_t pde_idx = virt >> 22; uint32_t pte_idx = (virt >> 12) & 0x3FF; // 若PDE不存在,分配新页表 if (!(kernel_page_dir[pde_idx] & PTE_PRESENT)) { page_entry_t *pt = alloc_page_table(); kernel_page_dir[pde_idx] = (uint32_t)pt | PTE_PRESENT | PTE_WRITABLE; } // 获取页表基址并填写PTE page_entry_t *pt = (page_entry_t *)(kernel_page_dir[pde_idx] & ~0xFFF); pt[pte_idx] = (phys & ~0xFFF) | (flags & 0xFFF); } void paging_unmap_page(uint32_t virt) { uint32_t pde_idx = virt >> 22; uint32_t pte_idx = (virt >> 12) & 0x3FF; if (!(kernel_page_dir[pde_idx] & PTE_PRESENT)) return; page_entry_t *pt = (page_entry_t *)(kernel_page_dir[pde_idx] & ~0xFFF); pt[pte_idx] = 0; // 刷新TLB asm volatile("invlpg (%0)" :: "r"(virt) : "memory"); } void paging_init(void) { // === 阶段1:恒等映射前4MB(覆盖内核加载区域)=== // 使用一个大页或逐页映射均可,这里用单页表映射前4MB page_entry_t *identity_pt = alloc_page_table(); for (int i = 0; i < NUM_PTE; i++) { identity_pt[i] = (i * PAGE_SIZE) | PTE_PRESENT | PTE_WRITABLE; } kernel_page_dir[0] = (uint32_t)identity_pt | PTE_PRESENT | PTE_WRITABLE; // === 阶段2:高半核映射 0xC0000000 → 物理0x00000000 === // PDE index = 0xC0000000 >> 22 = 768 // 复用同一个页表!因为映射的是同一段物理内存 kernel_page_dir[KERNEL_VBASE >> 22] = (uint32_t)identity_pt | PTE_PRESENT | PTE_WRITABLE; kprintf("[PAGING] Identity + Higher-Half mapped.\n"); kprintf("[PAGING] Enabling paging...\n"); // === 阶段3:启用分页 === asm volatile( "mov %%eax, %%cr3\n\t" // 加载页目录 "mov %%cr0, %%eax\n\t" "or $0x80000000, %%eax\n\t" // 设置PG位 "mov %%eax, %%cr0\n\t" "jmp 1f\n\t" // 序列化流水线 "1:\n\t" : : "a"(kernel_page_dir) ); // 此刻CPU已在分页模式下运行 // EIP仍在低地址(靠恒等映射存活),接下来跳转到高地址 kprintf("[PAGING] Paging enabled! Jumping to higher half...\n"); }
跳转到高半核并清理恒等映射

⚠️此函数必须在汇编中完成跳转,因为C编译器生成的代码可能隐含对低地址的引用。

; boot/higher_half.asm global enter_higher_half extern kernel_higher_half_entry enter_higher_half: ; 绝对跳转到高地址标签 jmp kernel_higher_half_entry
// kernel.c - 高半核入口点 void kernel_higher_half_entry(void) { // 现在所有代码通过0xC0xxxxxx地址执行 // 可以安全拆除恒等映射了 paging_unmap_page(0x00000000); // 实际上应遍历整个identity PT并释放 kprintf("[KERNEL] Running in higher half at VA 0xC0xxxxxx\n"); kprintf("[KERNEL] Virtual memory subsystem initialized.\n"); // 继续后续初始化... }
Linker脚本适配(至关重要)

启用高半核后,链接器必须知道内核的虚拟基地址:

/* linker.ld */ ENTRY(kernel_main) SECTIONS { . = 0xC0100000; /* 内核虚拟地址起点 */ .text ALIGN(4K) : AT(ADDR(.text) - 0xC0000000) { *(.text*) } .rodata ALIGN(4K) : AT(ADDR(.rodata) - 0xC0000000) { *(.rodata*) } .data ALIGN(4K) : AT(ADDR(.data) - 0xC0000000) { *(.data*) } .bss ALIGN(4K) : AT(ADDR(.bss) - 0xC0000000) { *(.bss*) *(COMMON) } }

📌AT()的作用:告诉链接器“虚拟地址是0xC0100000,但加载时的物理地址是0x00100000”。Bootloader按物理地址加载内核镜像,分页启用后CPU按虚拟地址访问——两者通过页表桥接。


关键细节解析

1. 为什么复用同一个页表做恒等和高半核映射?

因为两个虚拟地址范围映射到完全相同的物理页帧。恒等映射0x00000000→0x00000000和高半核映射0xC0000000→0x00000000只是PDE索引不同(0 vs 768),指向的页表内容完全一致。复用节省了一个页表的内存开销。

2. 跳转为什么不能用C语言的函数调用?

call kernel_higher_half_entry会将返回地址压栈,而栈指针ESP此时仍是低地址值。如果后续代码修改了页表或栈被重映射,返回时将访问无效地址。必须用绝对跳转jmp,并在高半核入口处重新设置ESP为高地址栈。

3. TLB一致性

修改页表后,旧翻译可能残留在TLB中。invlpg刷新单个条目,mov cr3, reg刷新全部TLB。在拆除恒等映射后建议执行一次完整的TLB刷新。


调试Checklist:分页相关崩溃排查
症状可能原因排查方法
mov cr0后立即Triple Fault恒等映射缺失或PDE/PTE标志位错误QEMU-d int,guest_errors查看#PF详情;检查PTE的Present位
跳转高地址后串口无输出串口MMIO地址未在高半核映射确认串口I/O端口(非MMIO)不受分页影响;若用MMIO串口需额外映射
全局变量读取乱码Linker脚本VMA/LMA不匹配objdump -h kernel.bin对比各section的VMA与LMA差值是否恰好为0xC0000000
随机#PF页表未4KB对齐 / PMM返回了已占用页帧断言(uint32_t)page_dir % 4096 == 0;检查PMM Bitmap初始化

🔧黄金法则:启用分页前的最后一步,永远用QEMU监控器手动验证页表内容:xp /1024wx [cr3_value]检查PDE,再xp /1024wx [pde_phys]检查PTE。不要盲信代码逻辑。


本章小结与下一步

今天我们完成了操作系统开发中最具仪式感的一步:

  • ✅ 理解了x86分页硬件的完整翻译流程
  • ✅ 安全地启用了分页并过渡到高半核地址空间
  • ✅ 掌握了Linker脚本中VMA/LMA分离的核心技巧
  • ✅ 为用户态隔离、按需映射、Copy-on-Write奠定了基础

从此,内核拥有了自己的“专属领地”,用户程序再也无法直接窥探或破坏内核数据。虚拟内存是现代OS安全与多任务的基石。

下一章预告:《中断与异常处理:让内核学会“响应”而非“轮询”》

有了虚拟内存,我们需要让CPU能在正确的时间执行正确的代码。下一章将搭建IDT、实现ISR框架、处理#PF/#GPF等关键异常,并接入PIC/APIC定时器,让内核从“顺序执行的死循环”进化为“事件驱动的真正系统”。


参考资料
  • Intel SDM Vol.3 Chapter 4 (Paging) & Chapter 6 (Interrupts)
  • OSDev Wiki - Setting Up Long Mode / Paging
  • OSDev Wiki - Higher Half Kernel
  • James Molloy's Kernel Tutorials (Higher Half章节)
  • 本系列完整代码:[你的GitHub仓库链接](Commit:v0m6e0m)

📝作者注:这是《从零手写操作系统》系列的第06篇。启用分页是整个教程中调试难度最高的章节之一,如果你在这里卡了超过两天,请记住:这不是你的问题,是每个OS开发者都经历过的洗礼。建议准备一个已知正确的页表dump作为对照基准。下一章,我们让内核活起来!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 15:20:50

Jev不是LLM平替:解析LLM、Agent与应用层产品的本质区别

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 15:20:32

SecureCRT合规使用与替代方案深度指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 15:19:16

Codebuddy TRAE降级安装C/C++插件v1.12.12实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 15:18:22

智能感知技术入门:从物理信号到场景决策的三层转化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华