ARTICLE DETAIL

资讯详情

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

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

《从零手写操作系统 (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, PSPTE页表项指向一个4KB物理页帧Present, R/W, U/S, Dirty, AccessedCR0.PG分页使能开关Bit 31 1 启用分页⚠️致命陷阱mov cr0, eax设置PG位的下一条指令CPU立即按虚拟地址取指。如果该虚拟地址没有有效映射立刻触发#PF异常且无法恢复。这就是为什么必须先建立恒等映射。为什么需要恒等映射 高半核映射并存启用分页的瞬间EIP仍指向低地址如0x100000。如果没有恒等映射CPU翻译当前EIP就会失败。因此过渡期必须同时存在两套映射恒等映射VA PA覆盖内核当前所在物理区域保证分页开启后指令流不断裂。高半核映射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 / PagingOSDev Wiki - Higher Half KernelJames Molloys Kernel Tutorials (Higher Half章节)本系列完整代码[你的GitHub仓库链接]Commit:v0m6e0m作者注这是《从零手写操作系统》系列的第06篇。启用分页是整个教程中调试难度最高的章节之一如果你在这里卡了超过两天请记住这不是你的问题是每个OS开发者都经历过的洗礼。建议准备一个已知正确的页表dump作为对照基准。下一章我们让内核活起来
返回列表