2. Linux内存管理核心:伙伴系统与slab分配器、vmalloc与kmalloc、内存碎片化问题

内存管理,说白了就是操作系统怎么把有限的物理内存,高效地分配给各个模块。你写驱动时,kmalloc、vmalloc 这些接口天天用,但底层到底怎么运作的?为什么有时候申请内存会失败?碎片化又是怎么产生的?

这一章,我们就把这些核心机制掰开揉碎了讲清楚。我做了十几年底层驱动,跟内存管理打过无数次交道,踩过的坑不少,积累的经验也都在这里了。

2.1 伙伴系统(Buddy System)——物理内存的“大管家”

伙伴系统是Linux内核管理物理内存页框的核心算法。它负责分配和回收连续的物理页。

核心思想:把空闲内存按2的幂次方大小(order)分成不同的链表。比如 order=0 是1页(4KB),order=1 是2页(8KB),order=2 是4页(16KB),以此类推,最大到 order=10(4MB)。

关键点:伙伴系统只分配连续的物理内存。这是它和后面要讲的 vmalloc 最大的区别。

分配流程:

  1. 假设你要申请 8KB(order=1)的连续物理内存。
  2. 系统先到 order=1 的空闲链表里找。如果有,直接分配。
  3. 如果没有,就去 order=2(16KB)的链表里找。找到一块16KB的空闲块,把它一分为二(分裂),两块都是8KB。
  4. 一块分配给你,另一块挂到 order=1 的空闲链表里。
  5. 如果 order=2 也没有,就继续往 order=3 找,以此类推。

回收流程:反过来。释放一块8KB内存时,系统会检查它的“伙伴”(即地址相邻的另一块8KB内存)是否也是空闲的。如果是,就把它们合并成一块16KB,挂到 order=2 的链表里。然后继续检查这个16KB的伙伴是否空闲,能合并就继续合并。

我的经验:我曾经在一个网络设备驱动里,频繁申请和释放 order=0 的小页,导致大量 order=3 以上的大块内存被分裂成小块,再也无法合并回去。结果系统运行几天后,申请 order=4 的连续内存就总是失败。这就是典型的“外碎片化”问题。

2.2 Slab分配器——小对象的“专属管家”

伙伴系统管理的是“页”,粒度是4KB。但内核里大量对象其实很小,比如一个 task_struct 结构体、一个 inode 结构体,可能只有几百字节。如果每次都通过伙伴系统申请一页,太浪费了。

Slab分配器就是为解决这个问题而生的。它基于伙伴系统提供的物理页,进一步细粒度管理小内存对象。

核心概念:

  • Cache(缓存):每种对象类型对应一个 slab cache。比如 task_struct_cache 专门分配 task_struct 对象。
  • Slab:一个 slab 由一个或多个连续的物理页组成。这些页面被划分成若干个固定大小的对象。
  • 对象:就是你要分配的实际数据结构实例。

工作流程:

  1. 创建 cache:kmem_cache_create("my_struct_cache", sizeof(struct my_struct), 0, 0, NULL);
  2. 分配对象:struct my_struct *p = kmem_cache_alloc(my_cache, GFP_KERNEL);
  3. 释放对象:kmem_cache_free(my_cache, p);
  4. 销毁 cache:kmem_cache_destroy(my_cache);

注意:Slab分配器能有效避免“内碎片化”。因为每个对象大小固定,不存在内部碎片。但它无法解决伙伴系统的“外碎片化”问题。

我习惯在驱动里为频繁使用的结构体创建独立的 slab cache。比如一个字符设备驱动,每次 open 都要分配一个私有结构体,用 slab cache 管理,分配和释放速度比 kmalloc 快得多,而且不会产生碎片。

2.3 kmalloc vs vmalloc——你该用哪个?

这两个接口是驱动开发中最常用的内存分配函数。很多人搞不清它们的区别,我简单总结一下。

特性 kmalloc vmalloc
物理连续性 保证物理地址连续 不保证物理地址连续
虚拟连续性 保证虚拟地址连续 保证虚拟地址连续
分配速度 快(slab或伙伴系统直接分配) 慢(需要建立页表映射)
分配大小限制 通常 < 4MB(取决于order上限) 可以很大(依赖虚拟地址空间)
适用场景 DMA缓冲区、中断处理函数、性能敏感路径 大块内存、模块加载、非性能关键路径
底层实现 基于 slab 或伙伴系统 基于伙伴系统 + 页表映射

核心原则:能用 kmalloc 就别用 vmalloc。kmalloc 分配的内存物理连续,对 DMA 和硬件访问友好。vmalloc 分配的内存物理上可能散布在各处,每次访问都要查页表,性能开销大。

举个例子。你写一个网卡驱动,需要分配一个描述符环(descriptor ring)。硬件要求这个环在物理内存中是连续的,所以必须用 kmalloc 或 dma_alloc_coherent。如果你用 vmalloc,硬件根本找不到下一块内存在哪里。

反过来,如果你要加载一个内核模块,模块大小可能有几百KB甚至几MB,用 vmalloc 就合适。因为模块加载不要求物理连续,而且 kmalloc 很可能分配不出那么大的连续物理块。

2.4 内存碎片化——驱动开发的“隐形杀手”

内存碎片化是嵌入式系统里最头疼的问题之一。系统跑着跑着,突然 kmalloc 返回 NULL,或者 dma_alloc_coherent 失败,十有八九是碎片化搞的鬼。

碎片化类型:

  • 外碎片化(External Fragmentation):空闲内存总量足够,但没有一块足够大的连续物理内存。这是伙伴系统的主要问题。
  • 内碎片化(Internal Fragmentation):分配的内存块比实际需要的大,造成浪费。比如你申请30字节,系统给了你32字节(对齐),那2字节就是内碎片。

碎片化产生的原因:

  1. 频繁分配和释放不同大小的内存块。
  2. 长时间运行,小内存块散布在物理地址空间各处,阻碍大块内存的合并。
  3. 某些内存被长期占用(比如内核代码段、DMA缓冲区),形成“不可移动”的障碍。

我曾经踩过的坑:在一个视频采集驱动里,我每帧都分配一个 2MB 的连续 DMA 缓冲区,处理完就释放。系统刚启动时没问题,跑了几个小时后,dma_alloc_coherent 开始间歇性失败。查了很久才发现,是频繁的分配释放导致 2MB 的连续物理页被拆散,无法再合并回来。后来我改用内存池(mempool)预分配,才彻底解决。

应对策略:

  • 使用内存池(mempool):在初始化时预分配好所需的内存块,运行时直接从池里取,用完归还。避免运行时频繁分配释放。
  • 使用 slab cache:对于固定大小的结构体,用 slab cache 管理,减少对伙伴系统的直接操作。
  • 避免分配大块连续内存:如果硬件支持分散-聚集(SG)DMA,尽量用 sg_alloc_table 分配不连续的物理页,让硬件自己处理。
  • 使用 CMA(Contiguous Memory Allocator):在系统启动时预留一块连续内存区域,专门用于大块连续内存分配。
  • 监控碎片化程度:通过 /proc/buddyinfo 查看伙伴系统的空闲页分布。如果高阶 order 的空闲页很少,说明碎片化严重。

2.5 知识结构图

下面这张图总结了本章的核心知识点和它们之间的关系。

Linux内存管理核心结构图 Linux内存管理核心:分配器与碎片化 驱动内存分配接口 kmalloc / kzalloc vmalloc / vzalloc Slab分配器(小对象) 伙伴系统(物理页管理) 物理内存页框(Page Frame) 内存碎片化问题 外碎片化 内碎片化 应对策略:mempool / slab / CMA / SG-DMA 核心关系 kmalloc → Slab分配器 → 伙伴系统 → 物理页框 vmalloc → 伙伴系统(多页)→ 建立页表映射 → 虚拟地址连续

2.6 小结

这一章我们聊了Linux内存管理的三大核心:伙伴系统管大块物理页,Slab分配器管小对象,kmalloc和vmalloc是驱动开发最常用的两个接口。碎片化是嵌入式系统的顽疾,理解它的成因,才能在设计驱动时提前规避。