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 最大的区别。
分配流程:
- 假设你要申请 8KB(order=1)的连续物理内存。
- 系统先到 order=1 的空闲链表里找。如果有,直接分配。
- 如果没有,就去 order=2(16KB)的链表里找。找到一块16KB的空闲块,把它一分为二(分裂),两块都是8KB。
- 一块分配给你,另一块挂到 order=1 的空闲链表里。
- 如果 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 由一个或多个连续的物理页组成。这些页面被划分成若干个固定大小的对象。
- 对象:就是你要分配的实际数据结构实例。
工作流程:
- 创建 cache:
kmem_cache_create("my_struct_cache", sizeof(struct my_struct), 0, 0, NULL); - 分配对象:
struct my_struct *p = kmem_cache_alloc(my_cache, GFP_KERNEL); - 释放对象:
kmem_cache_free(my_cache, p); - 销毁 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字节就是内碎片。
碎片化产生的原因:
- 频繁分配和释放不同大小的内存块。
- 长时间运行,小内存块散布在物理地址空间各处,阻碍大块内存的合并。
- 某些内存被长期占用(比如内核代码段、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 知识结构图
下面这张图总结了本章的核心知识点和它们之间的关系。
2.6 小结
这一章我们聊了Linux内存管理的三大核心:伙伴系统管大块物理页,Slab分配器管小对象,kmalloc和vmalloc是驱动开发最常用的两个接口。碎片化是嵌入式系统的顽疾,理解它的成因,才能在设计驱动时提前规避。