Appearance
内核内存分配
- 写作时间:
2026-03-04 首次提交,2026-04-03 最近修改 - 当前字符:
4116
上一课把用户态的链条接完了:malloc() 经过用户态分配器,必要时用 brk() 或 mmap() 向内核建立 VMA,第一次写入时缺页异常补上物理页。但这条链还有最后一个问题没有回答:缺页进入内核之后,那张物理页帧从哪里来?内核自己的数据结构(VMA、页表页、task_struct)又是怎样分配内存的?
页帧分配器
页帧分配器(page frame allocator)是内核管理空闲物理页帧并按页或按连续页块分配它们的机制。
Linux 用伙伴系统(Buddy System)维护空闲页帧。它的核心思想是:不只维护单页,还维护 2、4、8、16……页这种 2 的幂大小的连续块。这个大小等级叫 order:order = 0 是 1 页,order = 1 是 2 页,以此类推。
用一个具体的分配过程来看伙伴系统怎样工作。假设当前最小的空闲块是一个 order-3(8 页)的连续区域,而内核需要分配 1 页(order-0):
初始状态: order 3: [████████] (8 pages, 空闲)
申请 1 页:
step 1 order 3 分裂 → order 2: [████][████]
step 2 左半 order 2 分裂 → order 1: [██][██] [████]
step 3 左半 order 1 分裂 → order 0: [█][█] [██] [████]
分配第一个 order 0 给请求者
分配后: [已用][█] [██] [████]
↑ ↑ ↑ ↑
已分配 order0 order1 order2
空闲 空闲 空闲释放时,如果相邻的"伙伴"块也是空闲的,两个 order-0 块合并回 order-1,两个 order-1 合并回 order-2,以此类推。这种成对分裂和合并就是"伙伴"名称的由来。
物理页并不完全等价。有的设备只能访问低端物理地址(DMA 约束),有的页要长期留给内核。伙伴系统按内存区域(zone)分开管理,常见的有 DMA zone 和 Normal zone。分配请求不仅要问"有没有空闲页",还要问"哪一类空闲页适合这次请求"。
分页一课讲缺页异常时说"内核从页帧分配器申请一张物理页",这里就是它的来源。一次匿名页缺页,内核会申请一张 order-0 的页帧,清零后填入 PTE。如果通向目标页的某一级页表本身还不存在,内核还要先分配页表页,然后才能把用户页挂进去——一次看似普通的缺页,背后可能同时分配用户物理页和页表页。
SLUB
伙伴系统管理整页和连续页块,但内核里大量对象只有几十到几百字节:vm_area_struct、dentry、inode、task_struct。每次都分配一整页太浪费。
slab 分配器解决这个问题:从伙伴系统拿若干整页,预先切分成大量固定大小的对象槽位(slot)。同一类对象放进同一个缓存(cache),下次再需要同类对象时直接从已切好的槽位里取,不必每次回到伙伴系统。SLUB 是 Linux 当前主流的 slab 实现1。
一个 slab page 的内部布局长这样。假设 cache 的对象大小是 128 字节,一张 4KB 的页可以切出 31 个槽位(4096 / 128 = 32,减去一些元数据开销):
一张 4KB slab page(对象大小 = 128 字节):
┌─────────┬─────────┬─────────┬─────────┬───┬─────────┐
│ slot 0 │ slot 1 │ slot 2 │ slot 3 │...│ slot 30 │
│ 128B │ 128B │ 128B │ 128B │ │ 128B │
│ [已用] │ [空闲]→ │ [已用] │ [空闲]→ │ │ [空闲] │
└─────────┴────│────┴─────────┴────│────┴───┴─────────┘
│ │
└── freelist ───────┘── → slot 30 → NULL空闲槽位通过 freelist(一种嵌入在对象空间内部的单链表)串起来。kmalloc(128) 时内核从 freelist 头部取一个槽位,kfree() 时把槽位挂回 freelist 头部。整个过程不需要伙伴系统参与。
两种使用方式。kmalloc(size) 是通用接口,内核根据请求大小找到合适的 size class(如 64、128、256 字节)对应的 cache。kmem_cache_create() 则为特定结构体(如 task_struct、vm_area_struct)建立专属 cache,对象大小固定,分配和回收更高效。
内存映射一课的 mmap() 建立映射时,内核要分配一个 vm_area_struct 来记录 VMA——这个对象就来自 SLUB 的 cache。将来第一次触页时,再从伙伴系统分配用户物理页帧。两者走的不是同一层分配器:SLUB 负责内核小对象,伙伴系统负责物理页帧。
vmalloc
vmalloc 为内核提供虚拟地址连续但物理页可以不连续的大块内存分配。
假设内核需要一块 1MB 的缓冲区。用 kmalloc(1MB) 意味着伙伴系统要找到 256 张物理上连续的页帧(1MB / 4KB)。在内存碎片化严重的系统上,这可能找不到。vmalloc 的做法不同:先从伙伴系统拿 256 张分散的物理页,再在内核自己的虚拟地址空间中把它们拼成一段连续映射。内核代码用连续的虚拟地址访问这 1MB,但背后每张页可能在完全不同的物理位置。
代价是地址翻译更复杂、TLB 行为更差,因此 vmalloc 不适合频繁访问的热路径。
| 接口 | 虚拟地址 | 物理页 | 典型用途 |
|---|---|---|---|
kmalloc | 连续 | 连续 | 小到中等、高频访问的内核对象 |
vmalloc | 连续 | 可不连续 | 大块但不要求物理连续的内核缓冲区 |
Per-CPU 分配器
Per-CPU 分配器为每个 CPU 准备一份彼此独立的内存副本,以减少跨核共享和锁竞争。
考虑一个具体的场景:内核要统计系统中发生了多少次缺页。如果所有 CPU 共享同一个全局计数器,每次缺页处理都要对这个计数器执行原子加操作。在 64 核的机器上,这个计数器所在的缓存行会在核心之间来回弹跳(cache line bouncing),成为严重的性能瓶颈。
Per-CPU 分配器的做法是给每个 CPU 各分一份独立的计数器。CPU 0 只加自己的副本,CPU 1 只加自己的副本,互不干扰。需要读取全局总数时,才把所有 CPU 的副本加起来。写操作不再需要跨核同步,读操作偶尔才发生——这个代价比每次写都竞争一把全局锁便宜得多。
它和前面的 per-CPU 页缓存是两层不同的东西:页缓存是伙伴系统的快路径优化(减少单页分配时的锁竞争),Per-CPU 分配器则是在更高层面回答"这份数据到底要不要让所有 CPU 共享同一份"。
小结
| 概念 | 说明 |
|---|---|
| 页帧分配器 / 伙伴系统 | 按 2 的幂大小管理空闲物理页帧,通过分裂和合并降低碎片 |
| zone | 物理页按硬件约束划分的区域(DMA、Normal 等) |
| SLUB | 把整页切成小对象槽位的内核分配器,支撑 kmalloc() 和 kmem_cache |
vmalloc | 虚拟连续但物理可不连续的大块内核内存分配 |
| Per-CPU 分配器 | 为每个 CPU 维护独立副本,减少共享写入和锁竞争 |