从分段到分页:在GeeKOS项目4中亲手实现虚拟内存是种什么体验?

发布时间:2026/5/25 8:12:17

从分段到分页:在GeeKOS项目4中亲手实现虚拟内存是种什么体验? 从分段到分页在GeeKOS项目4中亲手实现虚拟内存是种什么体验第一次在屏幕上看到Page Fault Handler Invoked的调试信息时我的手指悬在键盘上方足足三秒钟——这行简单的日志意味着刚刚亲手实现的分页机制捕获到了首个缺页异常。作为计算机专业的学生没有什么比看着自己编写的内存管理代码真正开始工作更令人兴奋的了。GeeKOS项目4就像一场精心设计的探险带领我们从熟悉的分段式内存跨入虚拟内存的神秘领域。1. 虚拟内存从理论到代码的跨越在x86架构的演进史上分段机制曾是Intel处理器内存管理的唯一选择。早期的GeeKOS项目1-3都建立在分段基础上但随着项目复杂度提升这种机制的局限性逐渐显现内存碎片难以避免进程隔离性差最致命的是无法实现内存比物理RAM更大的魔法。当我第一次在mem.c文件中看到Init_Segment_Manager()函数时就意识到项目4将彻底重构这套机制。分页与分段的本质区别在于视角的转换。分段将内存视为连续的块而分页则将地址空间切割为固定大小的页面GeeKOS默认为4KB通过页表实现线性地址到物理地址的间接映射。这种间接性正是虚拟内存的魔法源泉——当CPU访问的页面不在物理内存时MMU会触发缺页异常这正是我开头看到的那条调试信息的由来。GeeKOS的巧妙之处在于其渐进式设计。项目4的代码框架已经预留了关键接口// 在paging.h中预定义的核心结构体 struct PageTable { ulong_t *entries; // 页表项数组 uint_t refCount; // 引用计数 }; // 需要实现的核心函数 void Init_Paging(void); int Handle_Page_Fault(ulong_t faultAddr);动手前需要理解三个关键数据结构页目录单个32位条目包含物理页框地址和标志位页表二级映射结构每个进程有自己的页表页表项包含Present、RW、User等控制位通过objdump -d kernel.bin反汇编可以看到GeeKOS内核的代码段起始于0x00100000这将成为我们建立第一个页表映射的基准点。2. 构建页表从物理内存到虚拟视图实际编码从paging.c的Init_Paging()开始。第一步需要分配物理页帧作为页目录这里直接重用GeeKOS现有的物理内存管理接口// 获取4KB对齐的物理页帧 pageDir (ulong_t*)Alloc_PageFrame(); memset(pageDir, 0, PAGE_SIZE); // 内核空间映射恒等映射前4MB for (ulong_t addr 0; addr KERNEL_END; addr PAGE_SIZE) { ulong_t flags PAGE_PRESENT | PAGE_WRITABLE; Map_Page(pageDir, addr, addr, flags); }这个阶段最容易犯的错误是忽略TLB刷新。在修改页表后必须调用invlpg指令或重新加载CR3寄存器// x86汇编指令示例 mov eax, cr3 mov cr3, eax通过Bochs的调试模式输入ctrlc后使用info tab命令可以验证页表是否正确建立。第一次运行时我的页表项Present位设置错误导致系统在开启分页后立即三重错误重启——这是初学者最常见的入门礼。页表初始化关键步骤对照表步骤操作常见陷阱1. 分配页目录调用Alloc_PageFrame()未检查返回NULL2. 建立内核映射恒等映射内核区漏掉某些特殊区域3. 设置CR3寄存器加载页目录物理地址使用虚拟地址而非物理地址4. 启用分页设置CR0.PG位忘记开启CR4.PSE5. 测试内核访问执行特权指令未正确处理权限位当系统终于能在分页模式下正常输出日志时真正的挑战才刚刚开始——动态页面管理需要处理缺页异常、页面置换等复杂场景。3. 缺页处理虚拟内存的动态舞蹈GeeKOS的缺页异常处理入口在exception.c的Page_Fault_Handler()函数。当CPU触发#PF异常时处理器会将错误代码和故障地址压栈我们的任务是解析这些信息并做出响应void Page_Fault_Handler(Interrupt_Stack* stack) { ulong_t faultAddr; asm volatile(mov %%cr2, %0 : r(faultAddr)); uint_t errCode stack-errorCode; int present !(errCode 0x1); // 页面不存在引发的异常 int writeOp errCode 0x2; // 写操作触发 if (present Is_User_Address(faultAddr)) { Handle_User_Page_Fault(faultAddr, writeOp); } else { Kernel_Panic(Invalid page fault); } }实现用户态缺页处理时需要特别注意惰性分配首次访问时才分配物理页帧写时复制fork子进程共享父进程页表页面置换当物理内存不足时换出页面我在实现交换分区支持时曾在swap.c中设计了一个简单的CLOCK算法// 简化的CLOCK置换算法实现 struct PageFrame* Find_Victim_Page(void) { static uint_t clockHand 0; while (true) { struct PageFrame* frame frameTable[clockHand]; if (frame-refCount 0) { if (frame-accessed) { frame-accessed 0; // 给第二次机会 } else { return frame; // 找到牺牲页 } } clockHand (clockHand 1) % NUM_PHYS_PAGES; } }通过dd if/dev/zero ofswap.img bs1M count16创建交换文件后系统终于能运行超出物理内存限制的程序——看着free命令显示的总内存量大于实际RAM时那种成就感难以言表。4. 调试技巧当虚拟内存出错时在内存管理开发过程中Bochs的调试功能是救命稻草。以下是我总结的实用调试命令组合bochs:1 info tab # 查看当前页表状态 bochs:2 page 0x12345678 # 解析特定地址的映射 bochs:3 trace-reg on # 跟踪寄存器变化 bochs:4 u/10 0x401000 # 反汇编指定地址代码当遇到三重错误时首先检查CR3是否加载了有效的页目录物理地址内核代码/数据区域的映射是否正确中断描述符表(IDT)是否已正确映射一个记忆犹新的bug是页表项的权限设置错误——用户程序能修改只读页面。通过以下测试用例发现了这个问题// 用户态测试程序 void test_ro_page() { char *p (char*)0x100000; // 映射为只读的页面 *p x; // 应触发保护异常 printf(Oops, write succeeded!\n); // 如果执行到这里说明有bug }在GeeKOS的defs.h中定义了几个关键宏帮助调试#define ASSERT(cond) do { \ if (!(cond)) { \ Print(Assertion failed at %s:%d\n, __FILE__, __LINE__); \ asm volatile(int $0x03); /* 触发调试中断 */ \ } \ } while(0) #define DEBUG_PAGE_FAULT 1 // 启用缺页调试日志5. 性能优化超越基础要求完成基本功能后我尝试了几项优化实验。首先是大页支持通过设置CR4.PSE标志和页表项的PS位可以将4MB作为页大小// 检查并启用PSE if (Has_CPUID_Feature(CPUID_FEATURE_PSE)) { Set_CR4(Get_CR4() | CR4_PSE); bigPageSupported true; }另一个有趣的尝试是页表共享。在创建新进程时不是复制整个页表而是让内核区域共享同一套页表// 在fork()中共享内核页表 for (int i KERNEL_PDE_START; i KERNEL_PDE_END; i) { childPageDir[i] parentPageDir[i]; // 共享页目录项 Get_Page_Table(i)-refCount; // 增加引用计数 }通过vmstat风格的监控工具可以观察页面置换频率Memory Stats: Active Pages: 243 Inactive Pages: 157 Swap Outs: 12 Page Faults: 284 (87% minor)这些优化使我的GeeKOS版本在运行内存测试程序时比基础实现快了两倍以上。最令人惊喜的是通过分析缺页模式我发现某些程序存在内存访问局部性差的问题——虚拟内存实现反过来帮助优化了应用程序。

相关新闻