内存管理¶
内存管理是操作系统最基础、最复杂的子系统之一。它决定了内核如何使用有限的物理内存,如何为进程提供隔离的地址空间。
内存管理的核心问题¶
在深入实现细节之前,先理解内存管理要解决什么问题。
三大问题¶
- 分配效率:如何快速分配和释放内存?
- 碎片问题:如何避免内存碎片化?
- 隔离保护:如何让进程之间互不干扰?
层次化的解决方案¶
NoobKernel 采用层次化设计,每层解决不同的问题:
┌─────────────────────────────────────────┐
│ kmalloc / kfree │ ← 用户接口
│ 统一的分配接口 │
├─────────────────────────────────────────┤
│ Slab 分配器 │ ← 小对象(≤4KB)
│ 高效、低碎片 │
├─────────────────────────────────────────┤
│ Buddy 分配器 │ ← 大块内存(≤8MB)
│ 快速合并、连续分配 │
├─────────────────────────────────────────┤
│ 物理页面管理 │ ← 页面级别
│ 引用计数、状态追踪 │
└─────────────────────────────────────────┘
为什么需要两层分配器?因为它们各有优劣:
| 特性 | Slab | Buddy |
|---|---|---|
| 分配大小 | 固定(8B~4KB) | 灵活(4KB~8MB) |
| 分配速度 | 极快(O(1)) | 较快(O(log n)) |
| 碎片 | 无内部碎片 | 可能有内部碎片 |
| 适用场景 | 小对象频繁分配 | 大块连续内存 |
物理内存管理:页是基本单位¶
为什么按页管理?¶
操作系统以"页"(通常是 4KB)为单位管理物理内存,而不是字节。原因:
- 管理开销:如果每个字节都独立管理,需要大量元数据
- 对齐要求:硬件要求内存页对齐(页表、DMA 等)
- 换页基础:为将来的换页机制做准备
page 结构:每页一本书¶
NoobKernel 为每个物理页维护一个 page 结构,记录该页的状态:
物理内存 page 数组
┌──────────┐ ┌──────────┐
│ 页 0 │ ←───────────────────│ page[0] │ refs=1, flags=PM_SLAB
├──────────┤ ├──────────┤
│ 页 1 │ ←───────────────────│ page[1] │ refs=0, flags=0
├──────────┤ ├──────────┤
│ 页 2 │ ←───────────────────│ page[2] │ refs=0, flags=PM_BUDDY
├──────────┤ ├──────────┤
│ ... │ │ ... │
└──────────┘ └──────────┘
关键信息:
- refs(引用计数):有多少地方在使用这一页。为 0 表示空闲。
- flags(标志):谁在管理这一页?PM_BUDDY 表示由 Buddy 管理,PM_SLAB 表示由 Slab 管理。
- order(阶数):在 Buddy 中,这一页是多大块的一部分。
地址转换¶
给定一个地址,如何找到对应的 page 结构?
page_index = (address - PM_START) / PAGE_SIZE
page_struct = &pages[page_index]
物理内存从 PM_START(0x80000000)开始连续排列,所以地址到索引是简单的线性映射。
Buddy 分配器:快速合并的艺术¶
Buddy 是什么?¶
"Buddy"(伙伴)分配器是一种高效的连续内存分配算法。它的核心思想是:大小相等的相邻块互为伙伴,可以快速合并。
工作原理¶
Buddy 将内存按 2 的幂次方划分:
阶数(order) 块大小 页数
order 0 4 KB 1 页
order 1 8 KB 2 页
order 2 16 KB 4 页
...
order 11 8 MB 2048 页
每个阶数维护一个空闲链表:
order 0: [页A] → [页B] → [页C] → ...
order 1: [页D-页E] → [页F-页G] → ...
order 2: [页H-页K] → ...
分配过程¶
假设要分配 12 KB:
- 12 KB 介于 8 KB(order 1)和 16 KB(order 2)之间,需要 order 2
- 检查 order 2 的空闲链表
- 如果为空,向 order 3 请求,分裂成两个 order 2
- 取出一个 order 2 块,返回地址
释放与合并¶
释放是 Buddy 的精髓。假设释放一个 order 2 的块:
- 计算它的"伙伴"地址:
buddy_addr = addr ^ (PAGE_SIZE << order) - 检查伙伴是否空闲且同阶数
- 如果是,合并成一个 order 3 块
- 递归检查 order 3 的伙伴...
为什么能快速找到伙伴?因为伙伴的地址只有一个 bit 不同:
块地址: 0x80001000
伙伴地址: 0x80003000
不同位: ^^^^^^^
这一位就是 order 对应的位
通过异或操作,瞬间找到伙伴地址。
内部碎片问题¶
Buddy 的缺点是内部碎片:如果请求 5 KB,分配 8 KB,浪费 3 KB。
这就是为什么需要 Slab 分配器处理小对象。
Slab 分配器:小对象的高效管理¶
为什么需要 Slab?¶
假设内核频繁分配 32 字节的结构体:
- 如果用 Buddy:每次分配 4 KB,浪费 4064 字节!
- 如果自己管理:需要复杂的分配算法
Slab 专门解决这个问题:预先分配大块内存,切分成固定大小的小对象。
Slab 的结构¶
┌─────────────────────────────────────────────────┐
│ Slab 块(64 KB) │
├──────────┬──────────────────────────────────────┤
│ Slab 头 │ 对象区域 │
│ 元数据 │ [obj][obj][obj][obj]... │
└──────────┴──────────────────────────────────────┘
一个 Slab 块(通常是 16 页,64 KB)包含: - Slab 头:记录元数据(总对象数、空闲数、位图等) - 对象区域:连续的固定大小对象
分配过程¶
分配一个对象:
- 查找对应大小的
kmem_cache - 从
slabs_partial链表找一个有空闲的 slab - 根据位图找到空闲对象索引
- 返回对象地址,更新位图
关键点:位图标记哪些对象已用。查找空闲对象就是找位图中的 0 位。
缓存热度¶
每个 kmem_cache 维护三条链:
slabs_full: 对象全部已用
slabs_partial: 部分已用(优先从这里分配)
slabs_free: 全部空闲
优先从 partial 分配,因为: - full 没有空位 - free 需要重新初始化 - partial 拿来就用,最快
为什么 Slab 快?¶
- O(1) 分配:不需要搜索,直接从预分配池取
- 无碎片:对象大小固定,没有内存碎片
- 缓存友好:对象连续存放,提高缓存命中率
- 批量分配:一次分配 16 页,服务多次请求
内核内存分配:统一接口¶
用户不关心底层是用 Slab 还是 Buddy,只需要 kmalloc 和 kfree。
分配策略¶
void *kmalloc(size_t size) {
if (size <= 4096) {
// 用 Slab:找到合适的 kmem_cache
return kmem_cache_alloc(cache_for_size(size));
} else {
// 用 Buddy:直接分配连续页
return buddy_alloc(size);
}
}
预定义的内存池¶
NoobKernel 预先创建了多个固定大小的 kmem_cache:
kmalloc-8: 分配 8 字节
kmalloc-16: 分配 16 字节
kmalloc-32: 分配 32 字节
...
kmalloc-4096: 分配 4096 字节
请求 20 字节时,用 kmalloc-32;请求 100 字节时,用 kmalloc-128。
这有一点浪费(分配 20 字节实际占用 32 字节),但换来简单高效。
虚拟内存:地址空间的抽象¶
为什么需要虚拟内存?¶
- 隔离:每个进程有独立的地址空间
- 简化编程:不需要关心物理内存布局
- 保护:可以设置权限(只读、不可执行等)
- 扩展:支持换页、内存映射文件等
Sv39:RISC-V 的页表方案¶
RISC-V Sv39 使用三级页表,支持 39 位虚拟地址(512 GB):
虚拟地址(39位)
┌─────────┬─────────┬─────────┬────────────┐
│ VPN[2] │ VPN[1] │ VPN[0] │ Offset │
│ 9位 │ 9位 │ 9位 │ 12位 │
└─────────┴─────────┴─────────┴────────────┘
VPN: Virtual Page Number(虚拟页号)
Offset: 页内偏移(4KB 页)
三级页表意味着需要三次查找才能完成地址翻译:
虚拟地址 → 页表项 → 物理地址
↑
三级查找
页表项(PTE)的权限控制¶
每个页表项包含权限位:
┌──────────────────────────────────────────┬──────┐
│ 物理页号(44位) │权限位│
└──────────────────────────────────────────┴──────┘
权限位:
V (Valid):页表项有效
R (Read):可读
W (Write):可写
X (Execute):可执行
U (User):用户态可访问
通过权限位,可以实现: - 代码段:R+X(可读可执行) - 数据段:R+W(可读可写) - 只读数据:R(只读)
内核的恒等映射¶
NoobKernel 使用恒等映射:虚拟地址 = 物理地址。
例如,物理地址 0x80200000 对应虚拟地址也是 0x80200000。
为什么这样设计?
- 简单:不需要计算地址转换
- 启动阶段友好:在开启 MMU 前后,地址不变
- 直接访问设备:设备的物理地址可以直接使用
代价:没有利用虚拟内存的隔离能力。但这对于单内核(整个内核在同一个地址空间)是可以接受的。
缓冲区缓存:块设备的缓存层¶
为什么需要缓存?¶
磁盘 I/O 很慢(毫秒级),内存访问很快(纳秒级)。为每个磁盘块缓存一份数据,避免重复读取。
缓存的结构¶
┌──────────────────────────────────────────────┐
│ 缓冲区缓存 │
├──────────┬──────────┬──────────┬─────────────┤
│ buf[0] │ buf[1] │ buf[2] │ ... │
│ 块号:10 │ 块号:25 │ 块号:3 │ │
│ dirty:0 │ dirty:1 │ dirty:0 │ │
└──────────┴──────────┴──────────┴─────────────┘
每个 buf 结构包含:
- data:512 字节数据
- blockno:对应的磁盘块号
- dirty:是否被修改(需要写回磁盘)
- valid:数据是否有效
读块的过程¶
bread(blockno=10):
1. 查缓存:有块 10 的缓存?
- 有:直接返回
- 没有:继续
2. 找空闲 buf(或淘汰最久未用的)
3. 从磁盘读取块 10 到 buf->data
4. 返回 buf
写块的过程¶
bwrite(buf):
1. 标记 buf->dirty = true
2. 等待合适时机写回磁盘
brelse(buf):
释放 buf 引用(不是立即写回)
延迟写入:不是每次修改都立即写磁盘,而是标记 dirty,批量写回。这减少了磁盘 I/O 次数。
LRU 淘汰策略¶
缓存有限,当缓存满时,需要淘汰最久未使用的块。
NoobKernel 维护一个 LRU 链表: - 每次访问,把 buf 移到链表头部 - 淘汰时,从链表尾部取
内存管理的设计权衡¶
为什么用 Buddy + Slab,而不是简单的首次适配?¶
首次适配:从链表头开始找第一个够大的块。
问题: 1. 外部碎片严重:大量小块散布 2. 分配速度不稳定:可能要遍历整个链表 3. 难以合并:相邻块可能不连续
Buddy + Slab 牺牲了一点空间(内部碎片),换来: 1. 稳定的 O(1) 分配速度 2. 自动合并,减少外部碎片 3. 对不同大小请求都有良好表现
为什么不用更先进的分配器?¶
Linux 使用更复杂的分配器(如 SLUB、SLAB 的改进版)。NoobKernel 选择简单的实现,因为:
- 教学目的:易于理解核心思想
- 单核场景:不需要复杂的并发优化
- 内存充足:128MB 对内核来说绰绰有余
思考题¶
- 如果物理内存不连续(有空洞),
pages数组如何处理? - Buddy 分配器中,为什么最大阶数是 11(8 MB)而不是更大?
- Slab 分配器中,为什么对象大小对齐到 8 字节边界?
- 恒等映射有什么安全隐患?什么情况下需要打破恒等映射?