xv6 Huge Page 内存管理问题与解决方案
1. 初始问题:看似“空闲”的内存不可用
在实验中出现了一个悖论:系统报告有超过 120MB 的空闲内存,但当内存分配器尝试申请一个小的 4KB 页时却失败了。
根本原因在于 xv6 管理物理内存的方式:它维护了两个完全独立的空闲链表(freelist):
- 4KB freelist —— 存放标准小页 (4KB)。
- huge_freelist —— 存放大页 (2MB huge page)。
kalloc() 函数只会在 4KB freelist 里查找,而完全不了解 huge_freelist 的存在。
当 hugepagetest 启动时,它消耗了所有的 4KB 页。当 kalloc() 再次被调用时,发现 4KB freelist 已经空了,于是分配失败,尽管 huge_freelist 里还有大量内存。
freemem 命令会把两个 freelist 的内存相加显示,因此误导性地报告了“仍有大量空闲内存”,但对标准分配却不可用。
2. 调试过程与更深层的 bug
为了解决这个问题,第一步是让 kalloc() 更加智能:
- 当 4KB freelist 为空时,它应该从 huge_freelist 取出一个 2MB 页,将其拆分为 512 个 4KB 页。
- 返回其中一个 4KB 页,其余 511 个放回 4KB freelist 供后续使用。
但是中间修复版本出现了新的问题:
- 每次确实能成功拆出一个 4KB 页。
- 但另外 511 页未能正确放入 4KB freelist。
- 结果是:每次
kalloc都不得不重新拆一个全新的 2MB 页,极度浪费,很快耗尽 huge pages,导致同样的分配失败。
3. 最终解决方案:原子化拆分 (Atomic Splitting)
最终修复方案是对 kalloc 的拆分逻辑进行了“原子化”处理:
- 获取锁,确保没有其他进程干扰内存管理。
- 检查 4KB freelist 是否为空。
- 如果为空,就从 huge_freelist 取出一个 2MB 页。
-
在持锁状态下,立即把这个 2MB 页拆分:
- 取出一个 4KB 页返回给调用者。
- 将其余 511 个 4KB 页直接加入 freelist。
- 操作完成后释放锁。
这样保证了一次拆分就能把 4KB freelist 补充满 511 页,后续 511 次分配都能直接满足,不需要再拆 huge page。
4. 两个版本的区别(用收银员类比)
为了理解更直观,可以类比成收银员换钱:
有 bug 的版本
- 收银员(kalloc)发现抽屉(4KB freelist)里没有零钱了。
- 他进保险柜(加锁进入 huge_freelist),取出一卷 512 张 1 美元钞票(2MB huge page),放到柜台上。
- 关键是:他立刻就解锁离开保险柜。
- 然后他在大厅里,把这一卷的钱交给一个点钞机(kfree),让点钞机慢慢地把 511 张钞票放进抽屉。
- 同时,他拿出其中 1 张给顾客。
问题在于:点钞机处理的过程中,抽屉依然是空的,所以下一次他再看时,抽屉还是空的,只能继续拆新的一卷。结果就是巨大浪费。
正确的版本
- 收银员发现抽屉里没有零钱。
- 他进保险柜并锁上门。
- 他取出一卷 512 张钞票(2MB huge page)。
- 他在保险柜里亲自拆开,把 511 张直接放进抽屉,留 1 张给顾客。
- 确认抽屉里已经补满后,才解锁离开。
区别在于:整个过程是原子化的,保证了拆分和补充 freelist 在一个锁的保护下完成。
5. 地址对齐与 uvmalloc 的内存消耗
huge page 必须是 2MB 对齐的。这是硬件页表机制决定的:PDE 设置 PS 位后,能直接映射一个 2MB 页,而这个页必须从 2MB 的整数倍地址开始。
例如:
0x000000
0x200000 (2MB)
0x400000 (4MB)
0x600000 (6MB)
...
这些地址就是合法的 huge page 起始边界。
在实验中:
╭─────────────────────────────────────────────────────────────────╮
│ > uvmalloc: filling gap with 4KB page at va=0x2084864 │
│ uvmalloc: filling gap with 4KB page at va=0x2088960 │
│ uvmalloc: filling gap with 4KB page at va=0x2093056 │
│ uvmalloc: attempting to alloc 2MB huge page at va=0x2097152 │
│ uvmalloc: huge page allocated, mapping... │
│ uvmalloc: allocating 4KB remainder at va=0x4194304 │
│ uvmalloc: allocating 4KB remainder at va=0x4198400 │
│ uvmalloc: allocating 4KB remainder at va=0x4202496 │
│ uvmalloc: allocating 4KB remainder at va=0x4206592 │
│ uvmalloc: allocating 4KB remainder at va=0x4210688 │
│ uvmalloc: allocating 4KB remainder at va=0x4214784 │
│ uvmalloc: allocating 4KB remainder at va=0x4218880 │
│ uvmalloc: success, returning 0x4222976 │
│ Accessing memory at 2MB boundaries... │
│ After 4MB allocation: 126260 KB free (consumed: 4104 KB) │
│ Memory consumption analysis: │
│ - Expected data pages: 4096 KB (4MB) │
│ - Actual consumption: 4104 KB │
│ - Overhead: 8 KB │
│ - Overhead likely includes page table structures │
╰─────────────────────────────────────────────────────────────────╯
让我们来详细分解这 4104 KB 的内存消耗:
首先,程序申请了 4MB (4096 KB) 的用户空间内存。为了高效地使用 2MB 的巨大页,uvmalloc 函数的内存分配策略分为三步:
- 填补间隙 (Gap Filling):
2020 KB- 在进行巨大页分配之前,虚拟内存地址必须先对齐到 2MB 的边界。
- 当时您的程序大小是 0x7000。为了达到下一个 2MB 边界 (0x200000),内核必须先用标准的 4KB 小页来填补这之间的“间隙”。
- 这个间隙的大小是 0x200000 - 0x7000 = 0x1F9000 字节,也就是
2020 KB。
- 巨大页分配 (Huge Page Allocation):
2048 KB- 地址对齐后,内核现在可以高效地分配一个完整的 2MB 巨大页。这就是您在日志中看到的 huge page allocated。
- 这部分是
2048 KB。
- 分配剩余部分 (Remainder):
28 KB- 总共需要 4096 KB。我们已经分配了 2020 KB (间隙) + 2048 KB (巨大页) = 4068 KB。
- 还剩下 4096 - 4068 = 28 KB。
- 这最后的 28 KB 会作为“尾巴”,用 7 个 4KB 的小页来分配。
这三部分加起来是 2020 + 2048 + 28 = 4096 KB。这正好是程序申请的用户内存大小。
那么,最后的 8KB 是什么呢?
- 内核开销 (Overhead):
8 KB- 操作系统为了管理这 4096 KB 的新内存,它自身也需要消耗一点内存来存储“账本”,也就是页表 (Page Tables)。
- 为了映射这块巨大的新内存区域,内核需要分配新的页表页。在这个案例中,它分配了 2 个 4KB 的页表页,总共
8 KB。
所以,完整的内存消耗公式是:
2020 KB (间隙) + 2048 KB (巨大页) + 28 KB (剩余) + 8 KB (页表开销) = 4104 KB
总结一下:您看到的 2056 KB 剩余部分,是 2020 KB 的间隙填充、28 KB 的尾部剩余 和 8 KB 的内核管理开销 这三者的总和。
好的,我帮你将内容重新排版和格式化,使重点更加突出,结构清晰:
xv6 与现实 Linux 内存分配差异
1. xv6 的内存管理:简单但有限
xv6 的内存管理非常直接:
- 空闲链表:
kmem.freelist:4KB 小页链表kmem.huge_freelist:2MB 大页链表
- 特点:
- 简单易懂
-
缺点:
- 无法合并:小页释放后无法恢复成大页,导致大页越来越少
- 尺寸固定:只能管理两种尺寸,灵活性低
[!Tip] 思考误区:有人会想“如果 4KB 链表中数量 ≥ 512,就合并成 2MB 大页”。这种方法行不通,因为这些小页在物理上不一定连续,也可能不对齐。
2. Linux 的内存管理:伙伴系统 (Buddy System)
Linux 为了高效管理物理内存、减少碎片,采用了伙伴系统:
核心思想
-
分级管理
- 空闲内存按 2 的幂次方分组
- 例如:
- order-0 → 4KB
- order-1 → 8KB
- order-2 → 16KB
- …
- order-9 → 2MB
- order-10 → 4MB
-
分配 (Splitting)
- 请求 4KB 页时,先查 order-0 链表
- 若空闲块不足,向上查找更高阶链表
- 找到后,将高阶块分裂成两个“伙伴”,一块分配,一块回到对应空闲链表
-
释放 (Merging/Coalescing)
- 释放时检查“伙伴”是否空闲
- 若空闲,立即合并成更高阶块,递归进行
- 这样可有效恢复大页并减少碎片
伙伴系统与 Huge Page 的关系
- Huge Page = 高阶内存块 (如 2MB → order-9)
- 可以直接分配,也可以通过合并低阶块动态生成
- 透明大页 (THP):
- 背景进程
khugepaged会扫描应用内存 - 将连续的小页自动提升为大页
- 对应用完全透明
- 背后依赖的仍是伙伴系统
- 背景进程
3. xv6 vs Linux 对比表
| 特性 | xv6 | Linux (伙伴系统) |
|---|---|---|
| 空闲列表 | 2 个独立链表 (4KB, 2MB) | 多个阶的链表 (order-0 ~ order-10) |
| 分配 | 大页可分裂成小页 | 任意阶块可分裂成更小块 |
| 释放 | 无法合并小页成大页 | 可合并小伙伴块形成大块 |
| 灵活性 | 低,尺寸固定 | 高,可动态满足不同尺寸需求 |
| 碎片管理 | 差,分裂导致大页永久减少 | 优,通过合并机制有效对抗外部碎片 |
4. 总结
- xv6 = 伙伴系统的极简版
- 实现了“分裂”,没有“合并”
- Linux = 完整伙伴系统 + THP
- 分裂 + 合并 + 动态大页生成
- 灵活、高效、碎片少
xv6 的简化实现适合教学理解,但真实操作系统需要复杂机制来管理内存连续性和碎片问题。