页分配器:伙伴系统原理、gfp_mask标志、alloc_pages与free_pages

内存管理,说白了就是给内核里的各种“住户”分配房间。谁要住?进程、驱动、DMA缓冲区,还有内核自己。而页分配器,就是最底层那个管“标准间”的——每次至少给一页(通常是4KB)。

我刚开始接触内核内存管理时,觉得这玩意儿挺简单:要内存就申请,用完就释放呗。后来踩了几个坑才发现,这里面的门道深着呢。尤其是伙伴系统,它决定了内存碎片化程度,直接影响系统长期运行的稳定性。

伙伴系统:内存的“拼图游戏”

伙伴系统的核心思想,我总结成一句话:把内存按2的幂次方分块,合并时找“另一半”

你想想看,物理内存是一整块连续的区域。如果随意分配和释放,很快就会变得支离破碎——就像拼图缺了关键几块,想拼个大图就难了。伙伴系统就是为了解决这个问题的。

它的工作方式是这样的:

  • 内存被分成不同大小的块,每个块的大小是2^order页
  • order从0到MAX_ORDER-1(通常MAX_ORDER=11,所以最大块是2^10=1024页,即4MB)
  • 每个order维护一个空闲链表
  • 分配时,从满足需求的最小order开始找;如果该order没空闲块,就去更大order里拆
  • 释放时,检查它的“伙伴”是否也空闲,如果是就合并成更大的块

什么叫“伙伴”?两个大小相同、地址连续的块,且它们是由同一个大块拆分出来的。举个例子:

假设有一个order=2的块(16KB,地址0~0x3FFF)
拆分成两个order=1的块:
  - 块A:地址0~0x1FFF
  - 块B:地址0x2000~0x3FFF
A和B就是伙伴关系

如果再拆A:
  - 块A0:地址0~0x0FFF
  - 块A1:地址0x1000~0x1FFF
A0和A1是伙伴,但A0和B不是伙伴(大小不同)

我在项目中遇到过一个问题:一个网络设备驱动频繁申请和释放order=0的页,系统跑了几天后,发现分配order=4的连续内存总是失败。用/proc/buddyinfo一看,order=4的链表全空了,但order=0的碎片页一大堆。这就是典型的“外碎片”问题——每个小块都有人占着,凑不成大块。

核心要点:伙伴系统的最大优势是反碎片化。它通过合并机制,让空闲内存尽量保持在大块状态。但代价是内部碎片——你申请1字节,它也得给你一页(4KB)。

gfp_mask:分配行为的“遥控器”

光有伙伴系统还不够。内核里不同场景对内存的需求千差万别:

  • 进程上下文可以睡眠等待
  • 中断上下文绝对不能阻塞
  • DMA需要特定地址范围的内存
  • 有些内存必须从特定区域分配

这时候就需要gfp_mask(Get Free Pages mask)了。它本质上是一个位掩码,告诉页分配器“该怎么干”。

我个人习惯把gfp_mask分成三类来看:

1. 行为修饰符(怎么分配)

标志含义我的经验
__GFP_WAIT允许睡眠等待进程上下文必备,中断里千万别用
__GFP_IO允许发起磁盘I/O文件系统场景常用
__GFP_FS允许调用VFS操作小心递归锁!我吃过亏
__GFP_NOWARN分配失败不告警试探性分配时用,省得刷屏
__GFP_ZERO返回零初始化内存安全敏感场景推荐

2. 区域修饰符(从哪分配)

标志含义说明
__GFP_DMA从DMA区域分配ZONE_DMA,通常是16MB以下
__GFP_HIGHMEM从高端内存分配32位系统上才有意义
__GFP_DMA32从DMA32区域分配ZONE_DMA32,4GB以下
__GFP_MOVABLE可迁移的内存有助于内存规整,我强烈建议加上

3. 常用组合(类型标志)

内核预定义了一些常用组合,直接用就行:

// 最常用:进程上下文,允许阻塞
GFP_KERNEL = __GFP_WAIT | __GFP_IO | __GFP_FS

// 中断上下文:绝对不能睡眠
GFP_ATOMIC = __GFP_HIGH

// DMA缓冲区:必须从DMA区域分配
GFP_DMA = __GFP_DMA

// 用户空间内存:可迁移,可回收
GFP_HIGHUSER = __GFP_WAIT | __GFP_IO | __GFP_FS | __GFP_HIGHMEM | __GFP_MOVABLE

我的建议:能用GFP_KERNEL就别用GFP_ATOMIC。GFP_ATOMIC分配失败率更高,因为它不能等待内存回收。我在一个项目中把某个路径从GFP_ATOMIC改成GFP_KERNEL后,内存分配失败率从5%降到了0.01%。

alloc_pages与free_pages:实战操作

理论说完了,咱们看看实际怎么用。核心API就两个:分配和释放。

分配页:alloc_pages

// 函数原型
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);

// 示例:分配2页(8KB)连续内存
struct page *page = alloc_pages(GFP_KERNEL, 1);
if (!page) {
    pr_err("分配失败,内存不足\n");
    return -ENOMEM;
}

// 获取虚拟地址
void *addr = page_address(page);
// 现在可以用addr操作内存了
memset(addr, 0, 2 * PAGE_SIZE);

这里有个细节:order是2的幂次指数。order=0就是1页,order=1是2页,order=2是4页,以此类推。最大order通常是10(1024页,即4MB)。

注意:alloc_pages返回的是struct page *,不是虚拟地址!你需要用page_address()转换。但有个坑:高端内存(HIGHMEM)的页没有直接映射,page_address可能返回NULL。这时候要用kmap()临时映射。

释放页:free_pages

// 函数原型
void free_pages(unsigned long addr, unsigned int order);

// 示例:释放之前分配的2页
free_pages((unsigned long)addr, 1);

// 或者用page版本
void __free_pages(struct page *page, unsigned int order);
__free_pages(page, 1);

释放时有个关键点:order必须和分配时一致。你分配了order=1(2页),释放时也得传order=1。传错了会出大事——内核会把内存管理结构搞乱,轻则内存泄漏,重则系统崩溃。

我曾经犯过这个错:分配时用了order=2,释放时手抖写成order=1。结果系统跑了几个小时突然panic,查了两天才定位到。从那以后,我写代码都会把order定义成宏或者变量,确保分配和释放用同一个值。

更常用的封装:alloc_pages_exact

说实话,我平时很少直接用alloc_pages。因为大多数时候我需要的是指定字节数的内存,而不是2的幂次方页。这时候用alloc_pages_exact更方便:

// 分配正好10KB的内存
unsigned long size = 10 * 1024;
struct page *page = alloc_pages_exact(size, GFP_KERNEL);
if (!page)
    return -ENOMEM;

void *addr = page_address(page);
// ... 使用内存 ...

// 释放时也要用对应的函数
free_pages_exact(addr, size);

这个函数内部会帮你处理对齐和order计算,省心不少。

伙伴系统的可视化

说了这么多,不如一张图来得直观。下面是我画的伙伴系统工作流程:

伙伴系统内存分配与合并流程 初始空闲块:order=4(16页,64KB) 分配order=2(4页) 已分配:order=2(4页) 空闲:order=2(4页) 释放order=2 检查伙伴:地址相邻且空闲? 空闲:order=2(4页) 伙伴空闲,合并! 合并后:order=4(16页,64KB)完整空闲块 关键规则: 1. 两个块大小必须相同(相同order) 2. 物理地址必须连续 3. 必须由同一个父块拆分而来 4. 合并后进入上一级order的空闲链表

避坑指南:我踩过的那些坑

最后分享几个实战中容易翻车的地方:

  1. 中断上下文用GFP_KERNEL:这会导致内核睡眠,直接panic。记住:中断里只能用GFP_ATOMIC。
  2. 忘记检查返回值:alloc_pages可能返回NULL。我见过太多代码直接拿返回值就用,结果NULL指针解引用崩了。
  3. order不匹配:分配和释放的order必须一致。建议用同一个变量或宏。
  4. DMA内存没加__GFP_DMA:有些DMA控制器只能访问低16MB内存。不加这个标志,分配的内存可能不在DMA可达范围内。
  5. 频繁分配释放小页:这会导致伙伴系统碎片化。如果频繁分配小内存,考虑用slab分配器。

嗯,页分配器这块内容就这些。说白了就是:伙伴系统管怎么分和合,gfp_mask管怎么分,alloc_pages/free_pages是具体干活的人。理解了这三者的关系,内核内存管理就算入门了。