如果你写过内核模块,一定被页面分配失败折磨过。无论是kmalloc、dma_alloc_coherent还是mmap的后端,底层都绕不开 Linux 内核的“伙伴系统”(Buddy System)。每次调试时oom、page allocation failure或者Bad page state,追根溯源多半也和伙伴系统 API 用错有关。写驱动的人如果不懂这套机制,遇到 order 连续内存分配失败往往只能靠运气。这篇文章我想把伙伴系统的原理和常用内核 API 一次性讲透,结合真实的模块代码、/proc/buddyinfo观测方法和排障经验,给内核学习者、驱动开发者和性能优化爱好者一份能直接上手的参考资料。
刚开始研究内核内存时,我最头疼的是一堆抽象概念:zone、order、buddy pair、migrate type、GFP mask……每一个名词背后都对应一整套设计。但把这些东西拆开之后会发现,伙伴系统的思路其实特别朴素:把物理内存按 2 的幂次切成块,分配时给大块,释放时合并小块。困难的是它和 CPU 架构、并发锁、内存碎片、设备驱动约束全都纠缠在一起。接下来我会从设计思路讲到核心 API,再用一个可运行的内核模块演示实操,最后把常见的坑和排查手段列出来。
1. 为什么“伙伴”这个词这么形象
1.1 从一次驱动内存分配失败说起
有次我帮一个同事排查某自研网卡驱动的报文重排缓存问题。驱动需要一次分配 32 个连续物理页(order=5),用于 DMA 环形缓冲区。系统空闲内存还有 30 多 GB,但驱动每次加载稳定必现一次分配失败,日志里抛page allocation failure: order:5, mode:0x100c02。当时第一反应是“内存不够”,但空闲这么多,显然不是容量问题。后来看/proc/buddyinfo,发现高阶 block 几乎全被拆成零散页,根本凑不出 32 个物理连续的页。这就是典型的外部碎片问题,也就是伙伴系统最核心的痛点之一。
这个案例说明,理解伙伴系统 API,不能只看函数签名,还要知道它背后维护的是什么样的物理页形态。order不是一个简单的“大小参数”,它代表 2 的幂次个连续物理页。分配 order=5,是要求内存中存在一段由 32 个物理连续的页组成的块,而不是总剩余 32 页即可。
1.2 伙伴系统和内核内存方案的“分工”
内核里管理物理内存的框架常被提成三块:伙伴系统(Buddy Allocator)、slab 分配器、vmalloc 区域。它们分工不同。
- 伙伴系统是“页级”分配器,管理整块的物理页,动辄给出去 4KB、8KB、16KB 乃至更大的连续物理内存。
- slab 分配器(现在多数是 SLUB)面向“字节级”小对象,比如
task_struct、inode、sk_buff。它自己也要从伙伴系统申请一批页作为 slab 页。 - vmalloc 负责虚拟地址连续但物理页不必连续的大块区域,底层依然可能通过伙伴系统获取单页。
也就是说,伙伴系统是绝大多数内存分配的“总闸”。搞懂它的 API,等于掌握了内核内存的入口。
2. 伙伴系统的底层设计思路与数据结构
2.1 页面、order 和“伙伴”的定义
物理内存在内核里被分成固定大小的“页框”,一页大小一般是PAGE_SIZE,x86_64 上是 4096 字节。每个物理页对应一个struct page,内核用 PFN(Page Frame Number)表示物理页的序号。
伙伴系统的最小分配单位是页,但它不是只做“每次给一页”这件事。为了能高效分配连续页块,它引入了一个关键概念:order。一个 order 为 n 的块,包含 2^n 个物理连续的页。
- order=0:1 页,4KB
- order=1:2 页,8KB
- order=2:4 页,16KB
- order=3:8 页,32KB
- ……
- order=10:1024 页,4MB(这是大多数 x86_64 内核默认的
MAX_ORDER-1)
每个 zone(内存区域)里有一个数组free_area[NR_PAGE_ORDERS],下标就是 order。free_area[order]里挂着一个链表free_list[migratetype],用来存放当前 order 的空闲块。分配时如果对应 order 的链表有货,直接摘下来;没货就往更高阶找,找到高阶块再“分裂”。
“伙伴”这个词具体指什么?物理地址相邻、order 相同的两个空闲块,互为伙伴。比如两个 order=2 的块,如果它们物理上连续,合并起来就是一个 order=3 的块。伙伴定位在代码里很精彩:给定一个页的 PFN,它在 order n 级别的伙伴 PFN 就是pfn ^ (1 << n)。异或一位,因为伙伴之间的地址只差一个块大小。这种位运算特性让伙伴系统能极快地判断是否可以合并。
2.2 分配与合并路径:拆分和重组如何运作
先看分配。调用alloc_pages(gfp, order)后,内核会从指定 zone 的free_area[order]找有没有空闲块。如果有,直接把这块从链表上摘下来给调用者;如果没有,就向free_area[order+1]甚至更高阶找。假设请求 order=2,系统里只有一个 order=4 的空闲块,分配器会把它拿下来,然后一层层分裂成两个 order=3、四个 order=2,其中一块交给调用者,另外三块分别挂到对应的 order=3 和 order=2 链表上。
这个“从高到低拆”的过程在实现上很讲究。它保证任何时刻,物理页块都保持 2 的幂次对齐,并且分裂产生的残余块不会浪费。释放时则反着来:把页块放回链表,然后看它的伙伴是不是也空闲,如果伙伴空闲且同样 order,就合并成高一个 order 的块,再继续看更高一级的伙伴是否可以合并。这一合并操作可以逐级向上,直到无法合并或到达最大 order。
所以伙伴系统天然适合“频繁分配、频繁释放”的场景,因为它总能尽量把空闲内存拼成更大的连续块。但问题也出在这里:如果内存中有很多不可移动的页,这些页会像钉子一样钉住周围区域,导致高阶块无法合并。这就是前面提到的高阶分配失败的根源。
2.3 迁移类型:解决“碎片固化”问题的关键设计
为了对抗碎片,伙伴系统没有只分成“空闲”和“非空闲”,而是给每个页块定义了迁移类型(migratetype)。常见的有:
MIGRATE_UNMOVABLE:页框不可移动,例如内核代码段、某些驱动分配的物理地址受限页。MIGRATE_RECLAIMABLE:可回收但不可迁移,比如某些文件系统元数据缓存页。MIGRATE_MOVABLE:可迁移用户态页,比如用户进程的匿名页、page cache。MIGRATE_CMA:CMA 区域预留页,可以由伙伴系统分配,但优先级较低。MIGRATE_ISOLATE:隔离页,通常是内存压缩、离线等操作临时用。
为什么要分这么细?因为如果不可移动的页到处乱插,再多的 MOVABLE 页也拼不出连续块。把不可移动页集中在某些页块里,把可移动页放在另一些页块里,内存压缩(compaction)才能把可移动页迁走,腾出连续块。伙伴系统的空闲链表实际是free_area[order]->free_list[migratetype],即每个 order 下按迁移类型再分多个链表。
当某个迁移类型的空闲块不够时,分配器会查一个 fallback 表,按优先级从其他迁移类型借用块。借用带有代价,所以内核会尽量将新分出的页按原来的迁移类型归位,维持长期布局。看cat /proc/pagetypeinfo能看到每个 zone、每个 order、每种迁移类型的空闲块数,这对分析碎片非常有价值。
2.4 并发与性能:锁、per-CPU 缓存与快速路径
物理页分配是整个内核最高频的操作之一,任何系统的系统调用都有可能触碰伙伴系统。如果每次分配都锁住 zone 的全局锁,性能会立刻崩掉。因此内核引入了 per-CPU 页列表(PCP,per-cpu pages)。
PCP 主要为 order=0 的单页分配服务。每个 CPU 上维护一个小型缓存,里面装着一批页。分配单页时优先从本 CPU 的 PCP 拿,不够再从伙伴系统批量搬运一批;释放单页时也先放进 PCP,攒到一定数量再还给伙伴系统。这样大多数单页分配根本不会碰 zone 全局锁。这也是为什么你分配一堆 order=0 的页然后释放,/proc/buddyinfo可能看不出明显变化——很多页还留在 PCP 缓存里没真正归还。
对于高 order 分配,一般直接操作free_area,但要区分硬中断、软中断和普通进程上下文,内核用local_lock配合 zone lock 来保证安全。理解这些机制,有助于解释很多“奇怪”的现象:比如一个 CPU 上频繁分配释放,另一个 CPU 却感觉内存不足。
3. 伙伴系统内核 API 逐个解析
3.1 入口:alloc_pages 家族
伙伴系统最核心的分配接口是:
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);它返回一个struct page *,指向一段连续物理页的起始页结构体。申请失败时返回 NULL。这里的order是 2 的幂指数,不是字节数。alloc_pages(GFP_KERNEL, 0)就是分配一页,等价于:
struct page *alloc_page(gfp_t gfp_mask);如果要指定 NUMA 节点,可以用:
struct page *alloc_pages_node(int nid, gfp_t gfp_mask, unsigned int order);内核里还有大量封装,例如alloc_pages_current()、alloc_pages_vma()等。底层最终都会走到__alloc_pages_nodemask()。你日常写驱动,记住最常用的alloc_pages()和alloc_page()就够了。
拿到struct page *之后,还需要转换成内核虚拟地址或者物理地址。对低端内存(直接映射区)可以直接用:
void *page_address(struct page *page); unsigned long page_to_pfn(struct page *page); phys_addr_t page_to_phys(struct page *page);注意page_address()对 HIGHMEM 页可能返回 NULL,常规驱动做 DMA 时通常用物理地址,不依赖这个。反过来,由虚拟地址找struct page *用virt_to_page(addr)。
3.2 返回虚拟地址的便捷接口:__get_free_pages
alloc_pages返回的是页结构体指针,很多驱动代码想直接得到一个内核线性虚拟地址,于是常用:
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);这个函数内部先调用alloc_pages,再通过page_address转成虚拟地址。它返回一个unsigned long,可以强转成指针使用。对于 order=0 的情况:
unsigned long get_zeroed_page(gfp_t gfp_mask);get_zeroed_page分配一页并清零,非常适合需要干净缓冲区的场景。
在使用这些返回虚拟地址的接口时要留意:它们依赖直接映射区,不能用于 HIGHMEM。如果你分配页之后要传给 DMA 引擎,通常还是用alloc_pages+dma_map_page,因为 DMA 关心的是物理地址和一致性,不是返回的虚拟地址。
3.3 释放接口与配对铁律
释放对应关系很简单:
void __free_pages(struct page *page, unsigned int order); void free_pages(unsigned long addr, unsigned int order);free_page(addr)是 order=0 的便捷封装,__free_page(page)同样。这里一定要记住一条铁律:分配的 order 和释放的 order 必须完全一致。比如你用__get_free_pages(GFP_KERNEL, 2)申请了 4 页,释放时必须是free_pages(addr, 2)。如果你释放时填了 order=3,等于告诉内核“这是一块 8 页大小的块”,它会拿一个不存在的伙伴去合并,轻则破坏伙伴链表,重则导致panic: Bad page state甚至系统内存彻底错乱。
我见过有人把所有释放都写成free_page(addr),也就是按 order=0 释放一块 4 页的内存。这种错误非常隐蔽,表面上可能只是偶尔的内存泄漏,但在某些时机下会和其他页的释放发生错误合并,最终造成不可预测的内存踩踏。
3.4 gfp_mask:决定生死的选择
伙伴系统 API 里最容易被忽视的是gfp_t gfp_mask。它不只是给 allocator 提个“优先级建议”,而是规定了这个分配在内存紧张时能做什么、不能做什么。
常用组合:
| 标志 | 含义 | 典型使用场景 |
|---|---|---|
GFP_KERNEL | 普通进程上下文分配,允许睡眠、允许回收、允许 I/O、允许操作文件系统 | 驱动中普通缓冲区、内核数据结构 |
GFP_ATOMIC | 原子上下文分配,不能睡眠,不能做直接回收,但可以使用紧急保留内存 | 中断处理、自旋锁保护区间、softirq |
GFP_NOWAIT | 不等待、不睡眠,只从现存空闲块分配 | 快速路径,可容忍失败 |
GFP_NOFS | 允许回收和 I/O,但不操作文件系统 | 文件系统内部代码,避免死锁 |
GFP_NOIO | 允许回收,但不触发块设备 I/O | 位于 I/O 路径底层时防止递归 |
GFP_USER | 用户态映射分配,通常含 HIGHMEM,可回收 | 用户页表页、用户缓冲区 |
GFP_HIGHUSER_MOVABLE | 高内存、可迁移、允许回收 | 用户态 page cache、匿名页 |
底层的__GFP_*位还需要关注几个:
__GFP_ZERO:要求分配后把页清零。注意这不等同于get_zeroed_page只能用在 order=0,__GFP_ZERO可以和alloc_pages配合用于任何 order。__GFP_HIGH:允许使用系统紧急保留空间(min watermark 之下的保留页),但必须慎用,耗尽保留空间会导致系统无法正常回收。__GFP_NOWARN:失败时不打印 warning。对于高频试探性分配很实用。__GFP_MOVABLE:标记页可迁移,有利于碎片整理。__GFP_DMA/__GFP_DMA32:限制从 DMA 专用内存区域分配,老设备驱动常用。__GFP_HIGHMEM:允许从高端内存区域分配,但要配合返回struct page *的接口。
选择GFP_*时有一条底线:可能会睡眠的GFP_KERNEL绝不能在中断上下文、硬中断、软中断、持有 spinlock 的临界区里用。否则系统可能在分配内存时睡眠,造成不可预期死锁。遇到这种情况要么改用GFP_ATOMIC,要么调整架构在非原子阶段预分配。
4. 实操:用内核模块观测伙伴系统 API 的行为
4.1 准备工作与模块骨架
下面这段代码是一个完全自包含的内核模块。它提供两个模块参数:order和count,加载时分配count块大小为2^order的连续物理页,打印每块的 PFN、虚拟地址和物理地址,并检查相邻块是否物理连续。模块卸载时释放全部页面。
#include <linux/module.h> #include <linux/kernel.h> #include <linux/gfp.h> #include <linux/mm.h> #define MAX_PAGES 32 static unsigned int order = 2; static unsigned int count = 4; module_param(order, uint, 0644); module_param(count, uint, 0644); static struct page *pages[MAX_PAGES]; static unsigned long vaddrs[MAX_PAGES]; static unsigned int nr_pages; static int __init demo_init(void) { int i; gfp_t flags = GFP_KERNEL | __GFP_ZERO; if (order >= MAX_ORDER) { pr_err("order too large, max is %d\n", MAX_ORDER - 1); return -EINVAL; } if (count > MAX_PAGES) { pr_warn("count capped to %d\n", MAX_PAGES); count = MAX_PAGES; } pr_info("buddy demo: order=%u, count=%u, each block=%lu pages\n", order, count, 1UL << order); for (i = 0; i < count; i++) { pages[i] = alloc_pages(flags, order); if (!pages[i]) { pr_warn("alloc_pages failed at i=%d, stop allocating\n", i); break; } vaddrs[i] = (unsigned long)page_address(pages[i]); pr_info("page[%d] pfn=0x%lx virt=%px phys=0x%llx\n", i, page_to_pfn(pages[i]), pages[i], (unsigned long long)page_to_phys(pages[i])); if (i > 0) { unsigned long prev = page_to_pfn(pages[i - 1]); unsigned long cur = page_to_pfn(pages[i]); if (cur == prev + (1UL << order)) pr_info("page[%d] and page[%d] are contiguous\n", i - 1, i); else pr_info("page[%d] and page[%d] NOT contiguous, diff=%ld\n", i - 1, i, (long)cur - (long)prev); } } nr_pages = i; return 0; } static void __exit demo_exit(void) { int i; for (i = 0; i < nr_pages; i++) __free_pages(pages[i], order); pr_info("freed %d blocks of order %u\n", nr_pages, order); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("Buddy system API demo module");把代码存成buddy_demo.c,写一个简单 Makefile:
obj-m := buddy_demo.o KDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean在测试环境执行make,然后用 root 加载。
4.2 分配 order 页并观察 buddyinfo 变化
先记录初始状态:
cat /proc/buddyinfox86_64 上输出通常是每个内存节点、每个 zone 一行,然后是 order 0 到 order 10 的空闲块数量。比如某列数字为 12,就表示这个 order 的块有 12 个空闲块。
然后加载模块,分配 4 块 order=2 的内存:
insmod buddy_demo.ko order=2 count=4查看内核日志:
dmesg | tail -n 20能明显看到每块的 pfn、物理地址,以及相邻块是否连续。如果运气好,两块连续页块再释放后会合并成一个 order=3 的块。此时再次cat /proc/buddyinfo,由于模块还持有这 4 块内存,order=2 列会比之前少。卸载模块之后再cat,order 数量通常会回升;如果释放的两块正好伙伴齐全,高阶列还会增加。
如果想验证连续分配和合并,可以多加载几次,比如:
insmod buddy_demo.ko order=3 count=8分配 8 块 order=3 内存,每块 8 页,共 64 页连续物理内存。观察 dmesg 输出的 pfn 差是否等于 8。连续块越多,释放后合并成高阶块的概率越大。
4.3 观察连续页的合并过程
这个模块最重要的价值之一,是让你直观看到 buddy 合并现象。你可以在demo_exit里做一点额外处理:释放前记录一下/proc/buddyinfo中高阶列的值,释放后再读一遍。但内核模块里直接读文件不方便,更简单的做法是:
- 模块加载前记录
cat /proc/buddyinfo。 - 加载模块分配 8 块相邻的 order=2。
- 再开一个 shell 观察 dmesg,确认它们是否连续。
- 卸载模块后立刻
cat /proc/buddyinfo。
这时你会发现,原本 order=2 同一位置附近可能冒出多个 order=3、order=4 的空闲块。这其实就是伙伴合并的现场:连续的小块在释放时被组合成了更大的连续块。理解这个现象,你就能明白为什么频繁释放“随机大小”的内存会让高阶分配越来越难。
4.4 其他有用的观测节点
除了/proc/buddyinfo,还有几个值得配合使用的节点:
/proc/pagetypeinfo:按迁移类型和 order 展示空闲块。查看这个文件可以判断可移动页和不可移动页分别占了多大空间。/proc/zoneinfo:展示每个 zone 的 watermark、nr_free、nr_inactive 等详细信息。/proc/pagetypeinfo里还能看到Page block order和Pages per block,这是伙伴系统执行迁移类型分组的基本块大小。
如果系统开启了页迁移,还可以用echo 1 > /proc/sys/vm/compact_memory强制内存压缩,然后再看/proc/buddyinfo,高阶空闲块数通常会有改善。这个操作生产环境慎用,但实验室验证碎片问题时很直观。
5. 常见问题与排查实录
5.1 为什么连续内存分配失败而剩余内存充足
这是伙伴系统最经典的问题。系统剩余几百 MB,但你alloc_pages(GFP_KERNEL, 10)要 4MB 连续物理页却失败,因为可用空闲页被拆成碎片,没有 1024 个连续物理页。日志里的page allocation failure: order:10, mode:...会附带各 zone 的空闲块统计,基本一眼就能看出高阶列大面积是 0。
具体排查建议三步走:
- 看
cat /proc/buddyinfo,统计 order 10 列是不是基本为零。 - 看
cat /proc/pagetypeinfo,确认不可移动页是不是占据了几乎所有 region。如果高 order 空闲块全在MIGRATE_UNMOVABLE链表里,说明不可移动页太少、太散,导致可移动页被卡住。 - 确认调用时机和并发情况。即使内存刚压缩过,如果大量进程在短时间内同时申请高 order 块,也会瞬间耗尽。应用层和驱动层要做得更保守:启动阶段预分配、使用内存池、把大块请求拆分。
5.2 释放错了 order 会怎么样
开发中最危险的错误是释放参数不匹配。可能表现出的症状有三种:
- 内核直接
panic,报Bad page state或BUG: unable to handle page fault。 - 内存泄漏,系统空闲内存慢慢减少,但找不到谁在泄漏。
- 偶发的内存数据错乱,因为伙伴链表被错误合并,同一个页同时被两个子系统持有。
排查这类问题很难,最好从代码审查入手。可以打开内核的DEBUG_PAGEALLOC、PAGE_OWNER等配置,再配合 page owner 的调用栈查看哪些页被错误标记了。但我还是强调:释放函数和分配函数的 order 必须一字不差。分配时用什么 order,释放时也要原样传回去。
5.3 怎么判断碎片程度
手工看/proc/buddyinfo不够直观,可以写一个简单脚本统计最大连续块数量。例如想算每个 zone 的可分配“最大连续页数”,看最后一列大于 0 时的 order 值即可。order 越高,说明最大连续块越大。
如果希望量化碎片指数,可以用外部碎片指数公式:
Extern Frag Index = 1 - (sum_i free_pages_i * 2^i) / (total_free_pages * max_order_block_pages)粗暴一点的做法是只统计order >= 3的空闲块总数。做嵌入式系统内存调优时,这个值越低越危险。
实际生产环境中,还要结合内存压缩的触发次数看。如果cat /proc/vmstat里compact_success远小于compact_stall,说明碎片问题经常触发但压缩效果有限,这时候就需要从应用侧减少高 order 分配频率。
5.4 调试工具与经验
伙伴系统本身有 tracepoint,排查高效分配失败时很有用:
cd /sys/kernel/tracing echo 1 > events/kmem/mm_page_alloc/enable echo 1 > events/kmem/mm_page_alloc_extfrag/enable echo 1 > tracing_on cat trace_pipemm_page_alloc_extfrag事件会告诉你分配是从哪个迁移类型的链表 fallback 过来的,频率高说明迁移类型布局不健康。
另外,内核启动参数里如果加了page_owner=on,挂载 debugfs 后可以看每个物理页的分配调用栈:
mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/page_owner > /tmp/page_owner.txt虽然输出量大,但定位“谁长期占用不可移动页”时非常有用。排查内存泄漏时,我也写过一个小工具定期抓取 page owner 输出做 diff,能很快找出异常增长的调用栈。
还有一个容易被忽略的点:很多时候高 order 分配失败不是伙伴系统的问题,而是 zone 的 watermark 没达标。日志里会有Node 0 Normal: free=xxxx min=yyyy low=zzzz,如果 free 已经低于 min,正常GFP_KERNEL分配即使有碎片也优先失败,因为系统要保留一部分页给紧急路径。这种情况下,真正的压力不是碎片,而是内存总量接近极限,优先考虑回收和重启大内存进程。
6. 一些实操体会与后续可以深入的方向
用伙伴系统 API 这几年,我最大的感受是:它真的像一套“分蛋糕、合并蛋糕”的规则。你只要不按规则操作,蛋糕就会越切越碎,最后谁也拿不到大块。日常开发里,能少用高 order 就少用;必须用的时候,尽量在启动阶段或业务低峰期预分配;分配失败一定要有降级方案,而不是在调用栈里硬等。
我自己的一个典型习惯是:驱动需要 DMA 缓冲区时,不再每次都alloc_pages(order=...),而是在模块初始化时用mempool或者自建缓冲区池,固定分配好若干块,之后只做“借出/归还”,绝不让释放合并的时机变得不可控。这既提高了性能,也大幅降低了碎片概率。
如果你想把这块知识学得更深,下一步可以去看伙伴系统的这些分支:内存压缩(compaction)如何把 MOVABLE 页搬走、CMA 怎么在伙伴系统之上预留区域、kswapd怎么根据 watermark 进行回收、SLUB 怎么从伙伴系统拿一整块页来做本地对象缓存。全部串起来之后,你会发现内核里每一次malloc级调用背后,都是一台精密的“蛋糕分切机”在运转。