Skip to content

内存管理

内存管理是操作系统最基础、最复杂的子系统之一。它决定了内核如何使用有限的物理内存,如何为进程提供隔离的地址空间。

内存管理的核心问题

在深入实现细节之前,先理解内存管理要解决什么问题。

三大问题

  1. 分配效率:如何快速分配和释放内存?
  2. 碎片问题:如何避免内存碎片化?
  3. 隔离保护:如何让进程之间互不干扰?

层次化的解决方案

NoobKernel 采用层次化设计,每层解决不同的问题:

┌─────────────────────────────────────────┐
│         kmalloc / kfree                 │  ← 用户接口
│         统一的分配接口                   │
├─────────────────────────────────────────┤
│         Slab 分配器                      │  ← 小对象(≤4KB)
│         高效、低碎片                     │
├─────────────────────────────────────────┤
│         Buddy 分配器                     │  ← 大块内存(≤8MB)
│         快速合并、连续分配               │
├─────────────────────────────────────────┤
│         物理页面管理                     │  ← 页面级别
│         引用计数、状态追踪               │
└─────────────────────────────────────────┘

为什么需要两层分配器?因为它们各有优劣:

特性 Slab Buddy
分配大小 固定(8B~4KB) 灵活(4KB~8MB)
分配速度 极快(O(1)) 较快(O(log n))
碎片 无内部碎片 可能有内部碎片
适用场景 小对象频繁分配 大块连续内存

物理内存管理:页是基本单位

为什么按页管理?

操作系统以"页"(通常是 4KB)为单位管理物理内存,而不是字节。原因:

  1. 管理开销:如果每个字节都独立管理,需要大量元数据
  2. 对齐要求:硬件要求内存页对齐(页表、DMA 等)
  3. 换页基础:为将来的换页机制做准备

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:

  1. 12 KB 介于 8 KB(order 1)和 16 KB(order 2)之间,需要 order 2
  2. 检查 order 2 的空闲链表
  3. 如果为空,向 order 3 请求,分裂成两个 order 2
  4. 取出一个 order 2 块,返回地址

释放与合并

释放是 Buddy 的精髓。假设释放一个 order 2 的块:

  1. 计算它的"伙伴"地址:buddy_addr = addr ^ (PAGE_SIZE << order)
  2. 检查伙伴是否空闲且同阶数
  3. 如果是,合并成一个 order 3 块
  4. 递归检查 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 头:记录元数据(总对象数、空闲数、位图等) - 对象区域:连续的固定大小对象

分配过程

分配一个对象:

  1. 查找对应大小的 kmem_cache
  2. slabs_partial 链表找一个有空闲的 slab
  3. 根据位图找到空闲对象索引
  4. 返回对象地址,更新位图

关键点:位图标记哪些对象已用。查找空闲对象就是找位图中的 0 位。

缓存热度

每个 kmem_cache 维护三条链:

slabs_full:    对象全部已用
slabs_partial: 部分已用(优先从这里分配)
slabs_free:    全部空闲

优先从 partial 分配,因为: - full 没有空位 - free 需要重新初始化 - partial 拿来就用,最快

为什么 Slab 快?

  1. O(1) 分配:不需要搜索,直接从预分配池取
  2. 无碎片:对象大小固定,没有内存碎片
  3. 缓存友好:对象连续存放,提高缓存命中率
  4. 批量分配:一次分配 16 页,服务多次请求

内核内存分配:统一接口

用户不关心底层是用 Slab 还是 Buddy,只需要 kmallockfree

分配策略

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 字节),但换来简单高效。

虚拟内存:地址空间的抽象

为什么需要虚拟内存?

  1. 隔离:每个进程有独立的地址空间
  2. 简化编程:不需要关心物理内存布局
  3. 保护:可以设置权限(只读、不可执行等)
  4. 扩展:支持换页、内存映射文件等

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。

为什么这样设计?

  1. 简单:不需要计算地址转换
  2. 启动阶段友好:在开启 MMU 前后,地址不变
  3. 直接访问设备:设备的物理地址可以直接使用

代价:没有利用虚拟内存的隔离能力。但这对于单内核(整个内核在同一个地址空间)是可以接受的。

缓冲区缓存:块设备的缓存层

为什么需要缓存?

磁盘 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 选择简单的实现,因为:

  1. 教学目的:易于理解核心思想
  2. 单核场景:不需要复杂的并发优化
  3. 内存充足:128MB 对内核来说绰绰有余

思考题

  1. 如果物理内存不连续(有空洞),pages 数组如何处理?
  2. Buddy 分配器中,为什么最大阶数是 11(8 MB)而不是更大?
  3. Slab 分配器中,为什么对象大小对齐到 8 字节边界?
  4. 恒等映射有什么安全隐患?什么情况下需要打破恒等映射?

下一步

  • 进程管理:理解进程如何拥有独立的地址空间
  • 文件系统:理解缓冲区缓存如何服务于文件系统
  • 中断处理:理解内存分配在中断上下文的限制