☰
ucore Lab3物理内存管理:从页表到首次适应算法的避坑指南
2026/10/22 5:11:53 网站建设 项目流程

1. 从Lab2的页表说起:为什么物理内存管理是绕不过去的坎

做ucore实验的人,到了Lab3这里往往会卡住。Lab2刚把页表机制摸清楚,虚拟地址到物理地址的映射链路刚刚跑通,结果Lab3一上来就要求你管理物理内存的分配和回收。很多人第一反应是:页表都搞定了,物理内存还有什么好管的?直接拿一块用一块不就行了?

这个想法在单任务、小内存的场景下确实没问题。但ucore是一个教学操作系统,它要模拟的是真实操作系统面临的多任务、内存竞争、碎片化等一系列问题。物理内存管理是整个内存子系统的地基,页表是建立在这个地基之上的映射规则。如果地基没打好,上层再怎么折腾都是空中楼阁。

我在第一次做Lab3的时候,犯了一个很典型的错误:把物理内存管理和虚拟内存管理混为一谈。具体来说,我在实现alloc_pages的时候,直接返回了虚拟地址,然后在页表里做映射。结果跑测试用例的时候,check_alloc_page那一关直接挂了。后来才想明白,物理内存管理器只负责物理页的分配和回收,它返回的必须是物理地址,虚拟地址的映射是页表机制该干的事。这个边界如果搞混,后面所有实验都会出问题。

Lab3的核心任务其实就三件事:第一,搞清楚物理内存是怎么被组织起来的,也就是pmm_manager这个结构体到底在管什么;第二,实现首次适应或者最佳适应算法来分配和释放物理页;第三,把物理内存管理和页表机制对接起来,让虚拟地址能正确映射到分配到的物理页上。

这三件事听起来简单,但每一件都有坑。比如第一件事,ucore把物理内存划分成了几个区域,内核代码段、数据段、空闲内存区,这些区域的边界在哪里,怎么计算,直接影响到后面分配器的正确性。第二件事,首次适应算法虽然简单,但空闲块链表的管理、合并相邻空闲块的逻辑,写起来很容易出bug。第三件事,页目录和页表的物理地址怎么和分配到的物理页关联,boot_map_segment和page_insert这些函数的调用时机和参数传递,稍不注意就会导致页表项错误。

所以这篇文章不会给你一个标准答案让你抄,而是把我踩过的坑、想明白的道理、以及最终跑通测试的完整思路拆开来讲。如果你正在做Lab3,或者做完之后觉得似懂非懂,这篇文章应该能帮你把物理内存管理这条线彻底理清楚。

2. 物理内存的账本:ucore到底是怎么给内存记账的

2.1 从链接脚本看内存布局的起点

要管理物理内存,首先得知道物理内存里有什么。ucore的链接脚本kernel.ld定义了内核的加载地址和各个段的布局。在Lab3的语境下,你需要关注几个关键符号:end、KERNBASE、KERNTOP。end是内核代码段和数据段的结束地址,这个符号之后的内存才是真正可以拿来分配的。

但这里有一个容易忽略的细节:end是虚拟地址还是物理地址?在ucore的启动流程中,内核被加载到KERNBASE(通常是0x80000000)开始的虚拟地址空间,但物理上它可能被加载到0x100000(1MB)开始的位置。所以end这个符号的值是虚拟地址,你需要把它转换成物理地址才能用于物理内存管理。

我当时的做法是:在pmm_init函数里,先通过PADDR(end)把end转换成物理地址,然后向上对齐到页边界,得到freemem的起始物理地址。这个freemem就是物理内存分配器的起点。很多同学在这里直接用end的虚拟地址去初始化分配器,结果分配出来的物理页根本不在实际物理内存范围内,页表映射自然就错了。

2.2 物理页描述符数组:给每个物理页建一张身份证

ucore用struct Page来描述一个物理页。这个结构体里最重要的字段是ref(引用计数)和flags(标志位)。ref记录了这个物理页被多少个页表项引用,flags标记这个页是保留页、空闲页还是已分配页。

pages数组的大小是怎么确定的?在pmm_init里,通过npage = maxpa / PGSIZE计算得出。maxpa是物理内存的最大地址,通常由npages或者硬件探测得到。在QEMU模拟环境下,默认物理内存大小是128MB,所以npage就是32768。每个struct Page占用的空间不大,32768个描述符也就几百KB,放在内核数据段里完全没问题。

这里有一个设计上的取舍值得说一下:为什么ucore选择用数组而不是链表来管理所有物理页描述符?数组的好处是随机访问快,通过物理地址除以页大小就能直接索引到对应的Page结构体,时间复杂度O(1)。链表的话查找一个物理页对应的描述符需要遍历,效率太低。但数组的缺点是大小固定,不能动态扩展。对于教学操作系统来说,物理内存大小是已知的,数组方案完全够用。

2.3 空闲页链表与分配器接口

pmm_manager是ucore定义的物理内存管理器接口,它包含init、init_memmap、alloc_pages、free_pages、nr_free_pages这几个函数指针。Lab3要求你实现的是default_pmm_manager,也就是默认的物理内存管理器。

init_memmap的作用是把一段物理内存标记为空闲,并加入到空闲页链表中。这个函数的参数是struct Page *base和size_t n,表示从base开始的n个物理页都是空闲的。在pmm_init里,你会看到它被调用来初始化freemem到物理内存末尾之间的所有页。

空闲页链表的管理方式直接决定了分配算法的效率。ucore的默认实现用的是双向链表,每个空闲页的page_link字段串联起来。free_list是链表头,nr_free记录空闲页总数。分配的时候从链表头取,释放的时候插回链表头。这种设计实现简单,但分配效率取决于空闲页的排列顺序。

注意:init_memmap里有一个容易写错的地方——base指针的偏移计算。base是pages数组中的某个元素的地址,而n是要初始化的页数。你需要用for循环遍历这n个页,把每个页的ref置0、flags置为PG_property,然后插入空闲链表。如果循环边界写错,比如写成i <= n,就会多初始化一个页,导致后续分配时越界。

3. 首次适应算法:从链表头取页到底哪里不靠谱

3.1 首次适应的实现逻辑与边界条件

首次适应算法的核心思想很简单:从空闲链表头开始,找到第一个满足要求的空闲块。但在ucore的Lab3里,分配的单位是单个物理页,不是任意大小的内存块。所以实际上alloc_pages每次只分配一个页,首次适应退化成了“从链表头取第一个空闲页”。

这个实现看起来毫无技术含量,但边界条件特别多。首先,分配之前要检查nr_free是否大于0,如果空闲页数为0,直接返回NULL。其次,从链表头取下一个页之后,要把这个页从链表中删除,nr_free减1,ref加1,flags清除PG_property标志。最后,返回这个页对应的物理地址,也就是page2pa(page)。

我见过很多同学在删除链表节点的时候写错指针操作。比如:

list_del(&page->page_link);

这行代码本身没问题,但如果你在list_del之后还去访问page->page_link.next,就会读到已经被修改的指针,导致不可预期的行为。正确的做法是先用一个临时变量保存page指针,删除之后再操作这个临时变量。

3.2 释放页时的合并逻辑:为什么需要检查相邻页

free_pages的实现比alloc_pages复杂得多。释放一个物理页的时候,你需要做几件事:把ref减1,如果ref变成0,说明这个页不再被任何页表引用,可以回收;把flags重新设置为PG_property;把页插回空闲链表;nr_free加1。

但这里有一个关键问题:释放的页是否和空闲链表中已有的页相邻?如果相邻,应该合并成一个更大的空闲块。虽然Lab3的分配单位是单页,合并与否不影响分配的正确性,但合并可以减少空闲链表的长度,提高后续分配的效率。

ucore的默认实现其实没有做合并,因为它的分配粒度就是单页,合并的意义不大。但如果你要实现一个更通用的物理内存管理器,比如支持连续多页分配的buddy系统,合并就是必须的。这也是Lab3之后Lab4要引入buddy系统的原因。

3.3 空闲页数量统计与调试技巧

nr_free_pages函数直接返回nr_free变量即可。但nr_free的维护很容易出错。比如在alloc_pages里忘记减1,或者在free_pages里忘记加1,都会导致空闲页数量统计错误。这个错误不会立刻导致崩溃,但会在后续的内存分配中逐渐暴露出来——要么提前耗尽内存,要么分配了已经被占用的页。

我的调试方法是:在alloc_pages和free_pages里加打印语句,每次操作后输出nr_free的值和当前空闲链表的长度。如果两者不一致,说明链表操作有问题。另外,可以用一个简单的测试用例:连续分配10个页,再连续释放10个页,检查nr_free是否回到初始值。如果没回到,说明释放逻辑有bug。

提示:在QEMU里跑ucore的时候,可以用make qemu启动,然后在pmm_init结束后打印nr_free的初始值。这个值应该等于(maxpa - freemem) / PGSIZE。如果对不上,说明init_memmap的调用范围有问题。

4. 物理内存与页表的对接:那些让你怀疑人生的映射错误

4.1 boot_map_segment与page_insert的分工

boot_map_segment和page_insert是两个容易混淆的函数。boot_map_segment用于在启动阶段建立内核空间的映射,它直接操作页目录和页表,把一段连续的虚拟地址映射到连续的物理地址。page_insert用于在运行时建立用户空间的映射,它需要分配新的页表页,并处理页表项已存在的情况。

在Lab3里,boot_map_segment被用来映射内核代码段、数据段和pages数组。这些映射是恒等映射或者线性映射,虚拟地址和物理地址之间只差一个KERNBASE的偏移。而page_insert在Lab3里主要用于测试用例,验证物理页分配和页表映射的协同工作。

我踩过的一个坑是:在page_insert里调用alloc_page分配页表页的时候,忘记检查返回值是否为NULL。如果物理内存耗尽,alloc_page返回NULL,后续的memset操作就会导致空指针解引用,直接panic。正确的做法是:

struct Page *p = alloc_page(); if (p == NULL) { return -E_NO_MEM; }

这个错误在测试用例里不一定能触发,因为测试用例通常不会耗尽内存。但在实际运行中,内存耗尽是一个必须处理的边界情况。

4.2 页表项权限位与物理页标志的联动

页表项里的权限位(PTE_P、PTE_W、PTE_U)和物理页的flags字段是两套独立的机制,但它们之间有联动关系。比如,当一个物理页被映射为只读时,页表项的PTE_W位应该清除;当物理页被多个进程共享时,ref计数应该大于1。

在Lab3的测试用例里,有一个check_page_insert的测试,它会检查page_insert之后页表项的权限位是否正确。如果你在page_insert里没有根据perm参数设置页表项,这个测试就会失败。具体的设置逻辑是:

pte_t *ptep = get_pte(pgdir, la, 1); if (*ptep & PTE_P) { if (page2pa(page) != PTE_ADDR(*ptep)) { page_remove(pgdir, la); } } *ptep = page2pa(page) | PTE_P | perm;

注意PTE_P是必须设置的,表示页表项有效。perm参数包含了PTE_W和PTE_U等权限位。

4.3 物理页引用计数与释放时机的判断

ref计数是物理页管理的核心。每次page_insert成功建立映射,ref加1;每次page_remove解除映射,ref减1。当ref减到0时,物理页才可以被回收。

这里有一个经典的错误:在page_remove里直接调用free_page,而没有检查ref是否已经减到0。如果ref还大于0,说明还有其他页表项引用这个物理页,此时释放会导致其他映射失效。正确的做法是:

if (--(page->ref) == 0) { free_page(page); }

另外,page_remove还需要清除页表项的有效位,并刷新TLB。ucore提供了tlb_invalidate函数来完成这个操作。如果不刷新TLB,CPU可能仍然使用旧的页表项缓存,导致访问到已经被释放的物理页。

注意:tlb_invalidate在单核环境下可以简化为直接刷新整个TLB,但在多核环境下需要发送核间中断。ucore是单核教学系统,所以直接调用invlpg指令即可。

5. 测试用例背后的设计意图:check_alloc_page到底在查什么

5.1 check_alloc_page的检查项拆解

check_alloc_page是Lab3的第一个测试用例,它主要检查物理内存管理器的初始化是否正确。具体来说,它会验证nr_free的初始值是否等于预期值,以及alloc_pages和free_pages的基本功能是否正常。

预期值的计算方式是:(maxpa - freemem) / PGSIZE。maxpa是物理内存的最大地址,freemem是内核结束后的第一个空闲物理页的地址。如果你在pmm_init里没有正确计算freemem,这个测试就会失败。

我当时的错误是:把freemem设置成了end的虚拟地址,而不是物理地址。结果nr_free的初始值比预期值大了很多,因为虚拟地址0x80xxxxxx远大于物理地址0x1xxxxx。这个错误在测试输出里表现为nr_free的数值异常大,一眼就能看出来。

5.2 check_page_alloc与check_page的递进关系

check_page_alloc检查的是alloc_pages和free_pages的多次调用是否正确。它会连续分配多个页,然后检查这些页的物理地址是否在合法范围内,以及nr_free是否相应减少。接着释放这些页,检查nr_free是否恢复。

check_page则更进一步,它检查页表映射和物理页分配的协同工作。它会调用page_insert建立映射,然后通过虚拟地址访问物理页,验证数据是否正确。这个测试用例会暴露page_insert里的权限位设置错误、ref计数错误等问题。

这两个测试用例的递进关系体现了ucore实验的设计思路:先验证基本功能,再验证协同工作。如果你在check_alloc_page就挂了,说明物理内存管理器的初始化有问题;如果check_alloc_page过了但check_page挂了,说明页表映射和物理页管理的对接有问题。

5.3 测试失败时的排查顺序

当测试失败时,不要盲目改代码。按照以下顺序排查:

  1. 先看pmm_init里的freemem计算是否正确。打印freemem的物理地址和nr_free的初始值,和预期值对比。
  2. 再看init_memmap的循环边界是否正确。确认n的值是否等于(maxpa - freemem) / PGSIZE。
  3. 然后看alloc_pages和free_pages的链表操作是否正确。用简单的分配-释放测试验证nr_free是否恢复。
  4. 最后看page_insert和page_remove的ref计数和权限位设置。用check_page的输出来定位具体是哪个页表项出了问题。

这个排查顺序是从底层到上层,从简单到复杂。很多同学一上来就盯着page_insert看,结果发现是freemem算错了,浪费了很多时间。

6. 从Lab3到Lab4:物理内存管理的扩展方向

6.1 连续多页分配的需求与挑战

Lab3的分配粒度是单页,但实际操作系统经常需要分配连续的多个物理页,比如DMA缓冲区、大页映射等。连续多页分配比单页分配复杂得多,因为需要找到足够大的连续空闲块,而且分配和释放过程中会产生外部碎片。

解决外部碎片的经典方案是buddy系统。buddy系统把物理内存按2的幂次方划分成块,分配时找到最小的满足要求的块,释放时检查相邻的buddy块是否空闲,如果空闲就合并成更大的块。Lab4会引入buddy系统,替换掉Lab3的默认分配器。

6.2 slab分配器与内核对象缓存

除了buddy系统,Linux内核还引入了slab分配器来管理小对象。slab分配器从buddy系统获取大块内存,然后切割成固定大小的小块,用于分配内核对象(如task_struct、inode等)。这样可以减少内部碎片,提高分配效率。

ucore的教学实验没有涉及slab分配器,但理解slab的设计思想对理解真实操作系统的内存管理很有帮助。slab的核心是对象缓存:每种内核对象有一个对应的cache,cache里维护了若干个slab,每个slab包含多个对象。分配对象时从slab的空闲链表中取,释放时放回空闲链表。

6.3 物理内存管理器的可扩展性设计

ucore的pmm_manager接口设计得很好,它把物理内存管理的具体算法抽象成了函数指针。你可以实现多个不同的pmm_manager,然后在pmm_init里选择使用哪一个。这种设计模式叫策略模式,它让算法的替换变得非常灵活。

如果你要扩展ucore的物理内存管理,可以考虑实现一个基于红黑树的分配器,用红黑树来管理空闲块,按块大小排序,分配时快速找到最合适的块。或者实现一个基于位图的分配器,用位图来标记每个物理页的占用情况,分配时扫描位图找到空闲页。这些方案各有优劣,选择哪种取决于具体的应用场景。

提示:在实现新的pmm_manager时,记得在pmm_init里把pmm_manager指针指向你的实现,并确保所有函数指针都正确初始化。如果某个函数指针是NULL,调用时会直接panic。

7. 那些让我熬夜的bug:物理内存管理中的典型错误

7.1 页表项地址计算错误导致的映射偏移

页表项里存储的是物理地址,但页表项本身的地址是虚拟地址。在get_pte函数里,你需要通过KADDR宏把页目录项的物理地址转换成虚拟地址,然后才能访问页表。如果忘记转换,直接使用物理地址作为指针,在开启分页之后就会访问到错误的地址。

我当时的错误是:在pgdir_walk函数里,直接用pte_t *ptep = (pte_t *)pgdir + index来计算页目录项的地址。pgdir是页目录的虚拟地址,加上索引之后还是虚拟地址,这没问题。但页目录项里存储的页表物理地址,需要用KADDR转换成虚拟地址才能访问。我漏掉了这一步,结果访问页表时直接用了物理地址,导致页表项读写错误。

7.2 空闲链表操作中的指针悬挂问题

链表操作是物理内存管理里最容易出bug的地方。list_del和list_add是ucore提供的链表操作宏,它们直接操作list_entry结构体的prev和next指针。如果你在删除节点之后还去访问这个节点的next指针,就会读到已经被修改的值。

我遇到的一个典型场景是:在free_pages里,我先调用list_add把页插回空闲链表,然后检查相邻页是否需要合并。但在检查相邻页的时候,我用了page->page_link.next来获取下一个页,而这个指针已经被list_add修改了,指向了链表头。结果合并逻辑完全错乱。

正确的做法是:在修改链表之前,先用临时变量保存需要的指针。比如:

struct Page *next = list_next(&page->page_link); list_add(&page->page_link, &free_list); // 使用next变量,而不是page->page_link.next

7.3 引用计数不平衡导致的页泄漏

ref计数不平衡是另一个常见问题。每次page_insert应该让ref加1,每次page_remove应该让ref减1。如果page_insert里漏掉了加1,或者page_remove里漏掉了减1,就会导致ref计数错误。

ref计数偏小会导致物理页被提前释放,其他映射访问到已释放的页,产生不可预期的行为。ref计数偏大会导致物理页永远无法释放,造成内存泄漏。在测试用例里,内存泄漏可能不会立刻暴露,但跑长时间的压力测试就会耗尽内存。

我的调试方法是:在alloc_pages和free_pages里加打印,输出每次操作的物理页地址和ref值。然后手动跟踪一个物理页的完整生命周期:分配时ref从0变1,page_insert时ref从1变2,page_remove时ref从2变1,free_pages时ref从1变0。如果中间有任何一步对不上,就说明有bug。

7.4 物理地址与虚拟地址混用引发的panic

ucore里有两套地址:物理地址和虚拟地址。PADDR宏把虚拟地址转物理地址,KADDR宏把物理地址转虚拟地址。这两个宏用错了,轻则映射错误,重则直接panic。

我印象最深的一次是:在page_insert里,我用page2pa(page)获取物理页的物理地址,然后直接把这个物理地址赋给了页表项。这本身没问题,页表项里存的就是物理地址。但接下来我用memset清零这个物理页的时候,直接用了物理地址作为指针:

memset((void *)page2pa(page), 0, PGSIZE); // 错误!

在开启分页之后,memset的参数必须是虚拟地址。物理地址0x1xxxxx在虚拟地址空间里可能映射到完全不同的位置,甚至可能没有映射,导致页错误。正确的做法是用page2kva(page)获取虚拟地址:

memset(page2kva(page), 0, PGSIZE); // 正确

这个错误在QEMU里表现为一个页错误异常,然后ucore打印出错误信息并进入死循环。排查的时候需要看CR2寄存器的值,确认出错的虚拟地址是什么,然后反推是哪个指针用错了。

8. 写在最后:物理内存管理教会我的事

做完Lab3之后,我对操作系统内存管理的理解上了一个台阶。以前觉得内存管理就是分配和释放,没什么大不了的。真正动手实现之后才发现,每一个看似简单的操作背后都有大量的边界条件和细节需要考虑。

物理内存管理最核心的思想是“记账”:每个物理页的状态(空闲、已分配、被引用)都必须准确记录,任何一笔账记错了,整个系统就会出问题。ref计数、flags标志、空闲链表,这些都是记账的工具。工具本身不复杂,难的是保证每一笔账都记得对、记得全。

另一个体会是:地址转换是操作系统里最容易出错的地方。物理地址和虚拟地址的混用、页表项的权限位设置、TLB的刷新时机,这些细节稍不注意就会导致难以调试的bug。我的建议是:在写任何涉及地址操作的代码之前,先画一张图,把虚拟地址、物理地址、页目录、页表、页表项之间的关系标清楚。图画明白了,代码自然就写对了。

最后说一个实用的调试技巧:在QEMU里跑ucore的时候,可以用-d int参数打开中断和异常的调试输出。当发生页错误时,QEMU会打印出错误码和出错的虚拟地址。结合ucore自己打印的panic信息,可以快速定位到出错的代码位置。这个技巧在调试Lab3的页表映射问题时特别有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询