xv6 物理内存分配器如何实现?kalloc 页链表设计深度解析
【免费下载链接】xv6-publicxv6 OS项目地址: https://gitcode.com/gh_mirrors/xv/xv6-public
📖 xv6 是 MIT 6.828 课程经典的迷你操作系统,其物理内存分配器仅用约 100 行 C 代码,就实现了内核页面分配的核心逻辑。本文带你完整拆解 xv6 的 kalloc 与 kfree 设计:页链表(freelist)如何组织、两阶段初始化如何解决多核启动难题、自旋锁又如何保障并发安全,是学习操作系统内存管理不可多得的入门范本。
为什么 xv6 需要物理内存分配器?
在 xv6 中,内核需要频繁地向硬件申请"整页"物理内存:
- 为每个进程分配内核栈(proc.c 中的
allocproc) - 为页表分配页目录页和页表页(vm.c 中的
allocpgdir、allocptepages) - 为pipe 缓冲区分配共享页(pipe.c)
- 为新 AP 处理器分配启动栈(main.c 的
startothers)
这些需求有一个共同点:单位都是 4096 字节(PGSIZE)的整页。因此 xv6 的物理内存分配器只按页分配、按页释放,无需处理任意大小的块,这正是它能如此简洁的根源。
核心设计:把"页"本身当链表节点
传统分配器需要额外内存存放"哪块空闲"的元数据,而 xv6 采用了一个精妙的侵入式链表设计:空闲页的前 4 字节被重新解释为next指针,指向下一个空闲页。
struct run { struct run *next; }; struct { struct spinlock lock; int use_lock; struct run *freelist; } kmem;以上代码位于 kalloc.c,只有两个核心元素:
struct run—— 每个空闲页的开头 4 字节被当作指向下一空闲页的指针,空闲页通过"自描述"串成一条页链表;kmem结构体—— 包含自旋锁lock、开关标志use_lock和链表头freelist。
💡 关键技巧:链表节点不需要单独的内存,空闲页自己就是节点。这为零元数据开销的分配器奠定了基础。
两阶段初始化:kinit1 与 kinit2 的启动难题
xv6 的启动过程有一个微妙之处:内核链接地址在0xC0000000附近(内核映射区),但启动阶段只有低 4MB 物理内存同时映射到两个地址区间。如果一开始就把全部内存挂入链表,AP 处理器启动时可能访问到尚未映射的高地址内存。
因此 main.c 将初始化拆成两段:
kinit1(end, P2V(4*1024*1024)); // 阶段一:只放低 4MB 的页 kvmalloc(); // 建立完整的内核页表 ... startothers(); // 启动其他 CPU kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // 阶段二:放入其余所有页kinit1(kalloc.c):初始化自旋锁,将end(内核代码之后)到 4MB 之间的空闲页加入链表,此时use_lock = 0(单核运行,无需加锁);kinit2:在多核全部启动、页表已完整映射后,把 4MB 到PHYSTOP(224MB 上限,见 memlayout.h)之间的剩余页全部挂入链表,然后置use_lock = 1,此后每次分配/释放都必须持锁。
freerange函数负责批量入链:按页边界向上取整后,逐页调用kfree入链,代码简单到只有 5 行。
kalloc 分配流程:一次"摘链表头"
分配逻辑极其直白——从链表头部摘下一节,O(1) 完成:
char* kalloc(void) { struct run *r; if(kmem.use_lock) acquire(&kmem.lock); r = kmem.freelist; if(r) kmem.freelist = r->next; if(kmem.use_lock) release(&kmem.lock); return (char*)r; }三个要点:
- 加锁临界区极短:锁只保护"读表头 + 改表头"两步,持锁时间越短,其他 CPU 自旋等待的时间越少;
- 内存屏障保证顺序:
acquire/release内部通过__sync_synchronize()(见 spinlock.c)禁止编译器和 CPU 重排临界区内的读写,确保新 CPU 看到的链表状态是最新的; - 返回 0 表示耗尽:调用方(如 proc.c 创建进程时)必须检查返回值,分配失败则优雅回退。
kfree 释放流程:防"悬空引用"的细节
释放同样只需两步,但 xv6 额外做了一件常被忽略的安全工作:
memset(v, 1, PGSIZE); // 用垃圾值填充整页- 合法性检查:地址必须页对齐、不能低于
end(内核占用的区域)、物理地址必须小于PHYSTOP,任一条件不满足直接panic,防止误释放内核代码页; - 填充垃圾值:
memset(v, 1, PGSIZE)会立刻让任何仍在使用该页的代码"读错",从而快速暴露悬空指针(dangling reference)——这是调试友好型设计,生产分配器同样常用; - 头插法入链:
r->next = kmem.freelist; kmem.freelist = r;头插意味着最近释放的页会被最先复用,天然形成 LIFO(后进先出)的复用策略,局部性好、实现简单。
自旋锁:多核安全的最后一道防线
xv6 的页链表必须同时被多个 CPU 访问,保护它的是自旋锁(spinlock.c):
while(xchg(&lk->locked, 1) != 0) // 原子交换,抢到锁才继续 ;- 用硬件原子指令
xchg抢锁,抢不到就原地自旋,不睡眠; - 因为临界区只有几条指令,自旋比睡眠唤醒的开销小得多——这正是选择自旋锁而非睡眠锁的理由;
acquire前先pushcli()关中断,避免"持锁 CPU 被自己的中断打断又去抢同一把锁"的死锁。
kalloc 都在给谁分配内存?
| 调用方 | 用途 |
|---|---|
| proc.c | 每个新进程的内核栈(KSTACKSIZE = 1 页) |
| vm.c | 页目录页、页表页、fork 时复制用户页 |
| pipe.c | 进程间管道的共享缓冲页 |
| main.c | AP 处理器的启动栈 |
可以看到,页是整个内核资源调度的最小货币:进程、虚拟内存、IPC 全都向它"买内存"。
总结:小而美的教科书式设计
xv6 物理内存分配器值得记住的 4 个设计点:
- 侵入式页链表:空闲页自身充当链表节点,零元数据开销;
- 两阶段初始化:
kinit1/kinit2巧妙化解了"页表未建好就不能用全部内存"的启动悖论; - 极短临界区 + 自旋锁:O(1) 分配、持锁仅两步指令,多核高效安全;
- 调试友好的防御:
panic校验 + 垃圾填充,把隐性 bug 变成显性崩溃。
不足百行代码承载了整个 xv6 内核的内存底座,这正是它成为操作系统课程经典教材的原因。想动手实验,只需构建后运行:
make qemu核心文件清单:内存分配器 kalloc.c、内存布局常量 memlayout.h、启动流程 main.c、自旋锁 spinlock.c、消费者 vm.c 与 proc.c。
【免费下载链接】xv6-publicxv6 OS项目地址: https://gitcode.com/gh_mirrors/xv/xv6-public
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考