Skip to content

内核内存分配

  • 写作时间: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。如果通向目标页的某一级页表本身还不存在,内核还要先分配页表页,然后才能把用户页挂进去——一次看似普通的缺页,背后可能同时分配用户物理页和页表页。

Per-CPU 页缓存

很多 order-0 请求甚至不会碰到全局伙伴结构。Linux 在每个 CPU 上保留一小批本地热页,形成 per-CPU 的页缓存。最常见的单页分配先在本地 CPU 快速完成,只有本地缓存耗尽或者需要更高阶连续块时,才回到全局伙伴系统。

SLUB

伙伴系统管理整页和连续页块,但内核里大量对象只有几十到几百字节:vm_area_structdentryinodetask_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_structvm_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 共享同一份"。

NUMA 内存策略

在多内存节点系统中,CPU 访问本地内存节点比远端节点快(基础与概览一章介绍过 NUMA 硬件背景)。最常见的默认策略是 first-touch:哪个 CPU 首先触碰某页,就优先从它所在的本地节点分配物理页帧。但 first-touch 有一个常见陷阱:线程 A 在节点 0 上初始化了一个大数组,之后线程 B 到 H 在其他节点上并行处理这块数组。按 first-touch,这批页大部分落在节点 0,后续线程要频繁跨节点访问远端内存。其他策略包括 interleave(页面交错分布到多个节点,均衡带宽)和 bind(强制绑定到特定节点)。

小结

概念说明
页帧分配器 / 伙伴系统按 2 的幂大小管理空闲物理页帧,通过分裂和合并降低碎片
zone物理页按硬件约束划分的区域(DMA、Normal 等)
SLUB把整页切成小对象槽位的内核分配器,支撑 kmalloc()kmem_cache
vmalloc虚拟连续但物理可不连续的大块内核内存分配
Per-CPU 分配器为每个 CPU 维护独立副本,减少共享写入和锁竞争


  1. mm/slub.c — SLUB 分配器实现 ↩︎