页分配器:伙伴系统原理、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计算,省心不少。
伙伴系统的可视化
说了这么多,不如一张图来得直观。下面是我画的伙伴系统工作流程:
避坑指南:我踩过的那些坑
最后分享几个实战中容易翻车的地方:
- 中断上下文用GFP_KERNEL:这会导致内核睡眠,直接panic。记住:中断里只能用GFP_ATOMIC。
- 忘记检查返回值:alloc_pages可能返回NULL。我见过太多代码直接拿返回值就用,结果NULL指针解引用崩了。
- order不匹配:分配和释放的order必须一致。建议用同一个变量或宏。
- DMA内存没加__GFP_DMA:有些DMA控制器只能访问低16MB内存。不加这个标志,分配的内存可能不在DMA可达范围内。
- 频繁分配释放小页:这会导致伙伴系统碎片化。如果频繁分配小内存,考虑用slab分配器。
嗯,页分配器这块内容就这些。说白了就是:伙伴系统管怎么分和合,gfp_mask管怎么分,alloc_pages/free_pages是具体干活的人。理解了这三者的关系,内核内存管理就算入门了。