内核里物理内存管理最经典的一块,就是buddy(伙伴)系统分配器。我最初接触“分配page”这个概念时,以为无非是从一个空闲链表里摘一个节点下来,后来被一次连续内存分配失败的问题按在地上摩擦,才意识到这个看似简单的操作背后,牵连着水位判断、迁移类型、per-cpu缓存、慢路径回收一整串逻辑。这篇想跟你聊透一件事:buddy系统分配器到底怎么把一个物理page交到申请者手上。无论你是刚准备翻内核内存源码,还是正在为设备上某个驱动频繁分配大块连续内存头疼,这篇文章都适用;认真读完,你会明白“分配page”为什么远没有听上去那么轻松。
我之前也犯过一个典型错误:看到一段代码里调了分配接口,就以为系统会立刻从伙伴系统拿到一页。实际跑起来才发现,大量order为0的页面先从per-cpu列表里拿,伙伴系统的空闲链表根本没动;而一旦申请的是高阶连续页,又可能触发内存回收和碎片整理。与其被各种边界情况搞懵,不如把设计意图、主路径、兜底路径一次看清,接下来要带着你走的正是这条路线。
1. buddy系统分配器:从设计思路看“为什么要按2的幂管理page”
1.1 连续物理页是稀缺资源:外部碎片问题
内存分配器面对的核心矛盾,不只是“内存总量不够”,更是“连续内存块不够”。内核里很多场景必须拿连续物理页:DMA缓冲区、frame buffer、部分设备驱动,以及huge page。如果系统跑一段时间后,空闲页被切得七零八落,明明总空闲内存还有很多,却连一个稍大的连续区间都凑不出来,这种浪费就是外部碎片。
伙伴系统解决外部碎片的基本思路,是把空闲内存按2的幂次拆成块,每个块包含2的order次方个连续物理页,order相同的块大小相同。申请者需要多少连续页,就向上取整到最接近的2的幂次,比如要5个连续页就按order等于3分配,要1页就order等于0。空闲块只存在有限几种大小里,内存被切成整齐的块序列,尽可能避免像用户态堆那样出现一堆大小完全随机的空洞。
但这种整齐也付出代价:如果某次申请order等于3的8个连续页,而空闲列表里没有恰好8页的块,系统必须把一个更大块拆开,拆出来的剩余部分挂到更小的order链表里。频繁拆块会把大块切碎,所以伙伴系统配套设计了一套合并机制:释放页时,如果旁边恰好也存在空闲的相同大小块,就尝试把两者拼回更大的块。有拆有并,整个系统才不至于一路碎到底。
1.2 伙伴(buddy)的定义与合并条件
“伙伴”是这个分配器名字的来源,也是合并逻辑的基础。两个空闲块要成为伙伴,必须满足三个条件:大小完全相同;物理地址连续;两块拼起来后的起始地址,正好符合更大一级块的边界对齐。
简化理解:假设order为o,那么一个块包含2的o次方个连续page。两个这样的块互为伙伴,前提是低地址块的起始物理页号,除以2的o次方后商是偶数。换个等价说法,低地址块起始pfn的第o个bit必须是0。比如order等于1时,pfn 0-1这一块和pfn 2-3这一块是伙伴,因为低块起始pfn 0的第1bit为0;pfn 2-3和pfn 4-5不能合并成order等于2的块,因为低块pfn 2的第1bit是1,两者拼接后的起始地址没有按4对齐。
判断这个条件时,典型的实现会看pfn是否满足:pfn >> order 之后低一位是否为0,或者说 (pfn >> order) & 1 等于0。合并时,两块拼成的新块起始地址永远落到低地址那块上,同时把新块的order记录下来,以便继续和更高一级的块尝试合并。
这块逻辑当年让我栽过跟头:我写测试模块时,刻意分配两个相邻的order等于2块,然后释放第一个,再释放第二个,期望它们自动合并成order等于3的块。结果发现没有合并,一查才发现这两块虽然pfn连续,但起始pfn并不满足order等于2的对齐条件,它们本来就不是伙伴,只是邻居。伙伴不等于邻居,这个区分非常关键。
1.3 核心数据结构:free_area、zone与page状态
伙伴系统在内核里不是一张孤零零的链表,而是挂在内存节点和zone层级之下。每个zone都维护一个free_area数组,数组下标就是order,从0一直到大块order。每个free_area里再按不同的迁移类型分出若干空闲链表,同一个order的空闲页块都挂在对应链表上。
页块之间的关系靠struct page上的链表指针串联。但系统还需要知道一个空闲块当前属于哪个order,这个信息存在page的private字段里。只有当一个page被标记为PG_buddy(空闲状态)时,private字段才有效,存放该块当前的order值。这个设计很省内存,却很容易被误用:一旦页面被分配出去,private字段的order含义就失效了,不能再拿它去判断任何排序关系。
每个空闲页块只通过链表头和私有的order标记维护状态,因此伙伴合并时操作的都是链表节点和page标志位,不涉及具体内存内容。这也意味着,对伙伴系统的并发访问必须靠zone锁保护,否则两个核同时拿同一个块或者同时释放相邻块,链表就会乱。order为0的页面分配可以走per-cpu列表绕开这个锁,后文会专门展开。
2. 分配page的完整路径:从入口到拿到页
2.1 分配入口与快路径决策
一个普通的内核模块申请页面,最常见的入口是以alloc_pages为代表的接口族。调用者传两个信息:内存分配掩码GFP和需要连续页的order。分配掩码决定了很多行为,包括是否允许回收、是否允许睡眠、是否必须满足某些移动性要求。order则直接决定要拿多大的连续块。
入口往下,核心分配函数会先走“快路径”。快路径做的事非常直白:遍历当前进程允许访问的zone列表(zonelist),从本地node开始,找到一个水位条件满足的zone,然后调用rmqueue从伙伴系统中摘页。
这里说的“水位条件”指的是zone的watermark。内核给每个zone维护了min、low、high三档水位。快路径一般要求分配后剩余空闲内存不能低于low水位,如果低到min以下,普通分配请求会被拒绝,避免系统把自己拖到彻底没有应急内存的境地。实际判断时,还会考虑这次分配能不能从低水位预留内存里拿,以及是否为原子请求。不同GFP掩码会有不同容忍度。
我第一次追这段代码时,非常不理解为什么分配前要搞这么多次水位判断,直接看看有没有空闲块不就行了吗?后来才意识到,空闲块不代表安全,系统必须保证回收路径、原子上下文、关键驱动随时还有内存可用。水位就是提前划好的安全线,宁可让一次普通分配慢一点,也不能让系统在内存耗尽时彻底动弹不得。
2.2 rmqueue:拆块与per-cpu页的取舍
rmqueue是真正从伙伴系统摘页的核心函数。如果是order等于0的请求,它会先检查当前CPU的per-cpu page list,也就是PCP缓存。每个CPU都维护了一批已经拿出来、随时可以单页分配的页面。只要PCP列表不为空,就在本地拿一页,完全不用碰zone锁,也不用动伙伴系统的全局链表,速度极快。操作系统里绝大多数页面分配都是order等于0的小分配,这一层优化对整体性能的意义非常大。
PCP列表不是无限大的,有high和low两个阈值。当它低于low时,下一次order等于0分配会触发批量补充:一次从zone的伙伴系统里取一批页补进PCP;当它高过high时,后续释放可能把多余的页还给伙伴系统。这种批量流动大大减少了全局锁竞争。
如果order大于0,或者PCP路径不可用,rmqueue就会调用更底层的__rmqueue逻辑。规则很直接:先到对应order的链表里找,如果有某个迁移类型的空闲块达到要求,就直接摘取。如果没有,就往更高order的链表里找。找到一个高阶空闲块后,把它从高阶链表摘下,一路拆到目标order:每拆一级,把剩下的“兄弟半块”挂回低一级的链表。这个过程中,每个新产生的空闲块都要更新page的private字段和PG_buddy标志,否则后续释放和再分配时,系统不知道该按什么order归还。
拆块的顺序有讲究。理论上可以先拆高阶块的低地址半部分,也可以拆高地址半部分。常见实现会优先选择保持迁移类型和缓存温度更友好的那一半,同时尽量让拆出来的剩余块在后续分配中可用。拆分结束后,被选中的目标块会进入准备状态,清掉PG_buddy标志、初始化引用计数和页属性,这一步如果漏掉,分配给调用者后又误把private当作order处理,调试时会非常难查。
2.3 慢路径:回收、内存整理与OOM的介入
快路径失败不代表系统真的没内存了。很多时候是水位不达标,或者目标迁移类型的空闲块不足。这时内核会进入慢路径,也就是一套逐级升级的“救援机制”。
慢路线的第一步通常是唤醒后台内存回收线程,让kswapd异步地回收可回收页,同时当前线程可能直接参与回收(direct reclaim)。回收对象主要是文件缓存、可回收的slab对象等。如果页是干净的文件缓存,直接释放;如果是脏页,还得先写回才能释放,这就会产生明显延迟。
回收只能增加空闲页总量,却未必能增加大块连续内存。如果系统里空闲页都是碎片化的小块,回收再多也凑不出order较高的连续块。因此慢路径里还有内存整理(compaction)机制:把某些区域的页搬得更紧凑,腾出大块连续空间。这依赖于迁移类型的设计——可移动页可以搬,不可移动页不能随便搬。后面会展开。
如果所有手段都试完,仍然无法满足一次关键分配,最后才会走向OOM决策。OOM会选择合适进程释放内存,但这不是百试百灵:有些分配是原子上下文,无法睡眠去回收,只能直接失败返回NULL。所以写驱动程序时,永远要处理分配失败分支,而不是假设大块连续内存一定拿得到。
我之前遇到一个框架代码,假设order等于10的分配一定能成功,结果在长时间运行的系统上一夜之间积累了大量碎片,最终分配返回NULL,驱动直接崩溃。从那以后我养成了一个习惯:凡是大order分配,必须考虑失败后的降级路径,宁可多写几个fallback分支。
3. 迁移类型、watermark与防碎片策略
3.1 迁移类型隔离:把不同类型页分流
伙伴系统单靠order链表,无法阻止外部碎片恶化。原因在于不同类型的页对“能否移动”的要求不同。内核分配的很多页是不可移动的,比如内核栈、某些驱动专用页;用户进程页、部分文件缓存页则是可移动的。如果这两种页交错散布在物理内存里,一旦不可移动页占住某个位置,它周围的可移动页即使能搬走,也无法把整个区间拼成一个足够大的连续空洞。
所以伙伴系统在同一个order下,按迁移类型再分组。常见类型包括不可移动、可回收、可移动、CMA等。分配时优先从本类型链表里取。这样做的结果是,物理内存会在使用中逐渐形成“同类页扎堆”的格局:可移动页聚在一起,不可移动页聚在另一片。将来需要大块连续内存时,只需要挪走可移动页,就能腾出连续区域。
这个思路很像仓库管理:把“随时要挪走的纸箱”和“不能挪动的货架”分区存放。如果当初图省事混着放,后期想腾一整排位置,难度会大很多。伙伴系统的迁移类型隔离,就是提前为未来可能的页面迁移扫清障碍。
3.2 fallback借块机制:有原则的妥协
理想是每种迁移类型都自给自足,现实却不可能。某个迁移类型的页可能已经被用完,但另一个迁移类型还有大量空闲。为了不让分配直接失败,伙伴系统准备了一套fallback顺序:目标迁移类型没有空闲块时,依次尝试其他迁移类型的链表。
这个顺序不是随意定的。不可移动页的需求最“挑剔”,如果它拿不到自己的页,会先向可回收类型借;可移动类型则相对宽容,可以在必要时向不可移动以外的类型借。分配借贷时还有一个“steal”逻辑:如果从别的迁移类型链表中找到了合适的大块,系统可能会整块拿过来,并把这块内存标记成当前请求的类型。这样一来,以后这块内存就归新类型管理。
这里的风险是,如果高order分配频繁从可移动类型链表中“抢走”大块,会把特意聚拢的可移动区域破坏掉,碎片防治又会失效。所以具体实现里对steal行为做了很多限制,比如低order请求可以整块steal,高order请求则更谨慎,避免一次性破坏大片可移动页。调优时如果发现某类碎片特别严重,可以先想想是不是哪类设备驱动总在抢其他类型的页。
3.3 watermark水位:分配前的“安全阀”
水位机制在快路径里已经提到,这里单独拎出来说,是因为它和伙伴系统的分配成功率直接相关。水位不是固定值,而是根据zone大小动态算出来的:min是最低预留,low是正常分配的下限,high是后台回收希望达到的目标。系统并不希望每个zone里空闲内存完全用完,宁可让部分内存“闲置”着,也要保证应对突发分配的能力。
慢路径中,如果分配扫描到远端node,水位要求会更宽松,否则调度器不会轻易把内存操作跨node迁移。这也是为什么numa环境下,看似本地node内存不够时,系统可能跑到远端node分配,代价是访问延迟上升。
调试内存紧张问题时,我经常先看水位和水位因子。有些系统明明memfree挺高,分配却一直触发回收,很可能是低水位被调得太高,或者内存被新加的预留机制占用了。调整水位并不是越大越好,太激进地压水位,换来的只是短暂的可分配内存,代价是系统更容易在突发负载下卡死。
4. 实操:观测buddy状态与定位分配page异常
4.1 从/proc/buddyinfo读空闲块分布
真遇到问题,我不会只抱着源码看,而是先打开系统接口拿第一手数据。最常用的观测入口就是buddyinfo。它按node和zone,打印每个order对应的空闲块数量。
示例输出大致长这样:
Node 0, zone DMA 1 1 1 1 2 1 1 1 1 1 1 Node 0, zone DMA32 1024 512 256 128 64 32 16 8 4 2 1后面的数字从左到右依次是order 0到order 10的空闲块数量。注意这是“块数”,不是页数。想算某个order对应的总空闲页数,用块数乘以2的order次方。比如order为6那一列值是16,表示有16个64页的连续块,合计1024页,分布在4MB区间里。
读buddyinfo的心得:先看高阶列。如果order 8、9、10长期为0,但order 0-3都很大,说明内存碎片化已经很严重。严格说,碎片化要看“高阶相对于低阶是否太少”,因为正常系统也会不断消耗高阶块。另一个技巧是连续抓多份快照观察变化趋势:如果高阶块数量持续下降且没有回升,说明系统里存在反复申请和释放大内存的过程,或者碎片在累积。
4.2 用压测模块验证分配行为
为了验证伙伴系统的行为,我经常在调试内核里挂一个简单模块,固定分配不同order的页,并把拿到页的物理页号打印出来。下面是一段精简示例,实际编译时还需要补上许可证声明和模块出口:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/gfp.h> #include <linux/mm.h> static int alloc_order = 8; module_param(alloc_order, int, 0644); static int __init test_init(void) { int i; for (i = 0; i < 5; i++) { struct page *p = alloc_pages(GFP_KERNEL, alloc_order); if (!p) { pr_info("order=%d allocation failed at round %d\n", alloc_order, i); break; } pr_info("got pfn=%lu, order=%d\n", page_to_pfn(p), alloc_order); __free_pages(p, alloc_order); } return 0; } module_init(test_init); module_exit(test_exit); MODULE_LICENSE("GPL");实际操作时,我用order 0跑了很多轮,发现buddyinfo几乎不变,原因是这些页都从PCP列表流动,伙伴系统的全局链表没有频繁改动。改成order大于0后,buddyinfo里对应order列的数值会立刻减少,释放后又恢复。这个测试对理解“哪些分配会真正影响伙伴系统布局”很有帮助。
建议把压测模块的参数设计成可以用命令行传入order和次数,方便自动跑脚本观察。每次分配后记录pfn,还能用来分析拿到了哪些区域。如果有连续order分配需求,可以记录相邻分配之间是否经常差2的order次方位移,这有助于判断系统是否真的分配到了物理相邻的页。
4.3 用tracepoint跟踪分配事件
代码里断点不好打,特别是在高频分配路径上打log会直接拖垮系统。推荐用内核tracepoint观测页面分配事件。常见事件路径在kmem事件组下,例如页面分配事件会记录order、gfp标志、迁移类型和最终拿到的pfn。
开启方法一般是这样:
# 进入tracefs目录 cd /sys/kernel/tracing # 只开启页面分配相关事件,避免刷爆ring buffer echo 1 > events/kmem/mm_page_alloc/enable echo 1 > tracing_on # 跑一段业务后关闭 echo 0 > tracing_on # 查看记录 cat trace | head -200trace输出里如果看到大量order很高的分配持续失败,需要配合buddyinfo判断失败原因;如果看到分配接口反复进入慢路径,但最终还能成功,说明系统在靠回收硬撑,长期来看性能会抖动。另一个实用技巧是把gfp_flags解码后过滤,只看带特定标志的分配。有的trace工具还支持按进程过滤,用来揪出哪个进程在持续消耗高阶连续页。
5. 常见问题与排查技巧实录
5.1 内存充足却分配不到连续页
最典型的现象:free命令显示还有很多内存剩余,但是驱动申请order等于9的512页连续内存返回NULL。抓buddyinfo发现高阶列全是0,低阶列却堆着大量小块。这是非常标准的外部碎片问题,不是内存总量不够。
遇到这种情况,先看这些小块属于什么迁移类型。用page type信息或直接看zone状态,如果碎片主要是可移动页造成的,可以尝试触发内存整理;如果碎片由不可移动页造成,整理也未必有效,只能靠预留机制或调整分配时机避开。更实际的方案是让驱动在系统刚启动、内存还没碎片化时就预留好所需的内存块,或者改成用CMA区域申请可移动连续内存。
5.2 慢路径卡顿与延迟抖动
有时候分配没有失败,但调用耗时突然飙高,触发栈上看到回收和内存整理的影子。比如一台业务设备运行数小时后,每几百毫秒出现一次分配延迟抖动,查出来是某个模块周期性order等于5分配,而系统当时的low空余内存紧张,每次都要先唤醒回收线程。
处理方向有三类:减少大order分配的频率;调整水位让系统提前回收;把分配时机错开业务高峰。最忌讳的是在中断或原子上下文里尝试大order分配,因为这类路径睡眠受限,一旦需要回收会直接失败,或者导致更严重的延迟。
5.3 释放后伙伴合并失败的隐秘原因
如果你观察到释放内存后,对应空闲块数量没有按预期增加,很可能是合并条件不满足。第一检查pfn是否满足伙伴对齐;第二检查相邻块是否真的处于空闲状态,也就是PG_buddy标志还在不在;第三检查释放前private字段是否被其他代码污染了。
我自己踩过最深的坑是有人无意中复用了page的private字段存私有数据,但没清掉PG_buddy标志。页面在伙伴系统里还被当成空闲块,private存的却是私有数据,合并时拿到错误的order信息,链表直接乱掉。开启page owner和debug pagealloc后,这类脏数据问题才会比较快地暴露出来。
5.4 常见问题速查表
| 症状 | 排查切入点 | 常用处理方向 |
|---|---|---|
| 内存总量充足但大块分配失败 | buddyinfo高阶块是否为0 | 触发内存整理、调整分配时机、预留内存或使用CMA |
| 分配延迟抖动明显 | trace中频繁慢路径回收 | 调整水位、减少大order高频分配、检查回收瓶颈 |
| 释放后空闲块不增加 | pfn对齐、PG_buddy、private字段 | 开启page owner/debug pagealloc排查 |
| order 0分配频繁全局锁竞争 | PCP列表长期为空或过小 | 调整batch参数、加大per-cpu缓存 |
| 特定迁移类型耗尽 | pagetype信息中某类型块持续为0 | 分析steal来源、调整分配类型、预留区域 |
这张表不能替代实际分析,但能在你半夜被内存问题叫醒时,快速找到大概方向。
6. 写到最后说点个人体会
折腾buddy系统分配page的这段经历,让我最大的感受是:真正难的不是链表操作和位运算,而是各种机制之间的耦合。水位决定能不能分配,迁移类型决定会从哪里拿页,per-cpu缓存决定有多少分配根本不经过伙伴系统,慢路径又在背后兜底。只看单一函数很难理解系统行为,必须把观测数据放进来一起看。
我现在接到内存相关任务,第一件事永远是先确认观测手段:打开tracepoint、记录buddyinfo快照、确认之后再做改动。如果哪次排查到最后发现是碎片问题,我会顺手把分配调用来源和迁移类型一起打印出来,这样下次再遇到还能对照历史数据。
如果你想深入这个方向,我建议不要一上来啃所有细节,而是先做到三件事:能解释order和伙伴关系、能说出快路径和慢路径的区别、会读buddyinfo。把这三件事练熟,再去看代码实现会顺畅得多。希望这篇关于伙伴系统分配page的拆解,能帮你少走点弯路。