1. 项目概述Buddy系统分配器到底在解决什么问题物理内存是计算机里最宝贵的资源之一而操作系统内核每天要做的事情就是把这些内存块高效地分配给各个进程、内核模块、设备驱动使用。如果你写过或者看过内核内存管理相关代码一定绕不开一个名字Buddy System伙伴系统。我这次要聊的Buddy系统分配器之分配page说白了就是Linux内核里一套物理页分配机制的核心实现。它负责回答一个看似简单、实则极其复杂的问题当一个进程或内核子系统向系统申请若干物理页时分配器怎么给得又快又准同时在释放时又能把零散的内存重新拼成大块。这个模块的适用场景非常广。嵌入式Linux移植、驱动开发、虚拟化内存管理、数据库性能调优甚至你在排查一次应用报错Out of Memory时底层追到最后都会看到Buddy分配器的工作痕迹。对内核开发者而言理解分配page的整个流程不只是搞懂几个函数调用而是真正看懂物理内存从空闲到被使用的完整生命周期。对做系统调优的工程师而言分配器的行为直接决定了内存碎片化程度、分配延迟和OOM触发的概率这些指标最终都会反馈到业务稳定性和性能表现上。我最初接触这块内容时也一度被各种术语绕晕order、migratetype、watermark、zone、free_area……听起来像天书。但真正把分配page的主路径从入口函数到链表操作逐行走了一遍之后我发现Buddy分配器的设计其实非常优雅核心思路也就是把一个复杂的资源管理问题通过按大小分桶 拆分与合并这套组合拳给化解掉了。这篇文章就打算用我理解的方式把这套机制掰开揉碎讲清楚重点放在分配路径上顺带把背后的设计逻辑、常见坑点和排障手段也一并分享出来。2. 整体设计思路为什么内核选了伙伴这套玩法2.1 从碎片化说起连续内存的稀缺性先讲一个背景。物理内存分配有一个天然痛点内存页是4KB大小的最小单位但系统里不同的使用者对连续性的要求差异极大。普通进程的数据页分散在哪儿都行但DMA缓冲区、内核栈、页表等场景必须拿到物理上连续的一段内存。如果分配器只会一个页一个页地发那么时间一长物理内存上就会布满洞大块连续内存被大量零星页切割得支离破碎。这种碎片化一旦严重哪怕系统明明还有几百MB总空闲内存也凑不出一块2MB或更大的连续区域直接导致分配失败或者触发OOM。Buddy分配器的出现就是为了对抗碎片化。它的核心策略是先尽量分配大小合适的内存块避免把大块拆成小块释放时则主动把相邻的小块合并回大块让空闲内存始终保持既有小块应急也有大块兜底的状态。这套策略翻译成数据结构就是我在下文里要详述的free_area数组加链表体系。2.2 核心数据结构一览分配page这套机制贯穿了几个关键数据结构。首先是节点、内存域、页框这三层模型内核把物理内存按访问速度和硬件拓扑划分为若干node节点每个node再细分为多个zone内存域比如ZONE_DMA、ZONE_NORMAL、ZONE_MOVABLE。Buddy分配器主要工作在zone这一层每个zone都维护着自己的空闲页集合避免跨zone的锁竞争和访存开销。每个zone里最关键的就是struct free_area数组它把空闲页按order阶数分成最多11个链表。order是一个以2为底的指数order为0表示1个页order为1表示2个连续页order为10表示1024个连续页也就是通常说的4MB大小块。每个free_area链表里挂的都是该order大小的空闲连续块。为什么用2的幂因为拆分和合并时只要判断伙伴是否存在就可以在位图或链表层面快速完成二进制天然适合这种操作。除了free_area分配路径上还会频繁碰到的数据结构包括管理区的watermark数组水印阈值、pcpper-cpu页表缓存列表、page结构的flags字段以及用于统计的vm_stat。理解了这些结构之间的关系再去看分配page的代码就不会觉得是一团浆糊了。2.3 为啥拆分合并这招这么好使我说个生活化的类比。设想一个仓库里存放了好多标准货箱容量分别是1、2、4、8个单位每种规格的箱子都放在对应的货架上。有人来申请3个单位的货品时管理员不会去1号货架东拼西凑而是直接从4单位货箱里拆出一份把用不完的1个单位放回1货架等货品归还后管理员再检查旁边的箱子如果同规格且相邻就两两合并放回更大的货架。清点始终高效空间也不容易被零碎占用。Buddy分配器的哲学就是这个。它的优势是多方面的。第一分配和释放的时间复杂度可控不是去扫描全内存找合适空间而是从对应order的链表直接取块。第二合并逻辑判断准确可靠两个order相同的空闲块只要物理地址连续且互为伙伴就能直接升级为大块无需遍历验证。第三配合迁移类型机制后还能把可移动页和不可移动页分类管理进一步延后碎片化恶化。这几点叠加起来让Buddy分配器在绝大多数场景下都能维持稳定表现。2.4 分配路径的两层结构快速路径与慢速路径从代码角度看分配page并不是一个孤立函数而是一套有回退机制的流程。快速路径会尝试在当前zone的free_area和per-cpu缓存中直接获取页面这个路径没有复杂的回收或压缩操作延迟极低。如果水位条件不满足或者快速路径拿到不合适的迁移类型分配器会滑入慢速路径唤醒内核回收线程、触发直接内存回收、尝试内存规整甚至调用OOM killer来腾出空间。这两个路径的分界点就是我在调试时最关注的逻辑之一。系统CPU使用率突增往往不是业务计算导致的而是慢路径里的直接回收或规整在消耗CPU。理解了这两个路径的差异和触发条件排障时就能快速锁定方向。3. 分配page之前的准备order、迁移类型与水位线详解3.1 order参数到底怎么选调用Buddy分配器的入口函数很多比如alloc_pages、__get_free_pages、alloc_page等但它们最终都会携带一个关键参数——order。order为0最常见的用途是匿名页、文件缓存页这种单页分配order大于0则意味着申请者需要连续多页常见的场景包括网络收发环形缓冲区、SLUB分配器的页块、以及一些驱动直接申请的连续DMA内存。这里有一个容易被忽略的细节order不是随便选的因为伙伴系统的块大小完全由order唯一确定申请方必须按实际需求取最合适的order。比如需要3页通常就申请order为24页多出来的一页由伙伴系统内部处理。内核不会提供order为0.5这种选项也没办法只拆出3页。这一点上设计者是在用一定的内部碎片浪费换取分配算法的简洁和高效。我在实际调试中就遇到过有人为了省内存强行把所有分配都改成order0的循环结果性能反而下降。因为零散页会造成页表项增加、TLB命中率降低最终得不偿失。该用连续页的场景老老实实申请order反而更划算。3.2 迁移类型给页打上可迁移标签迁移类型migratetype是Buddy分配器对抗碎片化的另一个重要支柱。内核把内存页分成了若干类别例如不可移动页、可回收页、可移动页、CMA页等每种类型在zone内都有自己的free_area链表。这样做的好处是不可移动的页集中在一处可移动的页集中在另一处内核做内存规整时就可以优先迁移那些可移动页腾出连续区域给不可移动的高阶分配需求而不是把全zone的内存全部搬一遍。选迁移类型的原则也值得展开。默认情况下页面会被归入MIGRATE_MOVABLE或MIGRATE_RECLAIMABLE等类别具体由页面的用途来决定。比如进程的匿名页、用户态文件映射页大部分标记为可移动内核自身的vmalloc区域、某些驱动分配的内存则标记为不可移动。分配器在free_area里找块时会优先匹配同类迁移类型如果找不到允许通过pageblock的fallback机制借用其他类型的空闲块。这里要留意fallback的顺序它直接影响了碎片化的演进方向分配器总是优先借用相邻分类的块尽量避免动到不可移动但大块集中的区域。3.3 水位线分配器的“红绿灯”水位线是Buddy分配器里一个地位极其关键的机制。每个zone的watermark数组里存着三个阈值WMARK_MIN、WMARK_LOW、WMARK_HIGH。简单理解空闲页数量高于HIGH时内存充裕处于LOW和HIGH之间属于正常波动低于LOW内核后台的kswapd线程会被唤醒开始回收页低于MIN说明内存压力很大分配路径可能直接进入慢速回收甚至OOM。这三个值是动态计算的基准上会参考min_free_kbytes这个内核参数再按zone内存大小比例放大。实际项目里最常见的改动就是调高min_free_kbytes为的是让系统更早进入回收状态、保留更多应急空闲页。但它不是越大越好因为min_free_kbytes设太高会导致可用内存减少、缓存压低业务吞吐反而下滑。这个参数的平衡推荐结合监控数据来做不要照抄网上的“推荐值”。3.4 从gfp_mask开始的设计串联真正进入内存分配之前内核还需要拿到gfp_mask分配掩码它决定了这次分配的行为取向。掩码里携带了大量关键决策是否允许睡眠、允许使用哪些zone、是否可以进行磁盘I/O回收、是否允许规整、是否属于高优先级分配等。我见过不少新手在写驱动时分配GFP_KERNEL用得很顺但到了原子上下文里还是用GFP_KERNEL直接触发睡眠并导致内核栈报错。正确做法是原子上下文至少要改用GFP_ATOMIC。理解了gfp_mask的作用就能理解为什么分配page的路径上会有那么多分支判断所有判断最终都是为了让“这次分配”在“当前上下文”下用最合理的方式拿到内存。4. 核心分配流程把page从伙伴系统里抠出来的每一步4.1 入口get_page_from_freelist的成功定律虽然分配路径有快慢之分但最终真正从zone里取页的操作收敛在一个核心函数上get_page_from_freelist。这个函数的核心逻辑并不复杂遍历允许分配的zone列表逐个检查水印条件然后从zone里取页。它做的事有点像你去取款机取钱先看哪台机器在服务范围内再看机器余额够不够最后才真正出钞。在遍历zone的时候分配器会结合gfp_mask里设置的zone限制。比如GFP_DMA标记只允许从ZONE_DMA里分配普通GFP_KERNEL则首选ZONE_NORMAL。每到一个zone先比较当前空闲页数与水印阈值空闲不够就跳过或进入慢路径够了就调用rmqueue执行实际取页。这里有一个很容易忽视的细节即使zone的水印条件暂时满足如果分配的是高阶order还需要检查当前zone的碎片化指数判断这块zone是否还有足够大的连续块。如果一个zone已经被碎片塞满大块分配会直接失败尽管它总空闲页并不少。4.2 rmqueue与分配拆分的细节操作rmqueue是分配页的另一个重头戏。它会根据order是否超过阈值决定走per-cpu页表缓存还是直接操作free_area链表。order为0的分配大多走pcp路径因为单页分配频率极高如果每次都去拿zone的锁、动链表锁竞争和cache miss会成为严重的性能瓶颈。per-cpu列表相当于给每个CPU准备了一个“小仓库”先把一批单页批量搬进来分配时直接本地拿既快又稳。而order大于等于1的高阶分配则会走free_area路径。这个操作里最核心的是buddy拆分的实现。以申请order24页为例系统先看当前order2的链表是否还有空闲块。如果没有就向上找order3、order4乃至更高的链表找到后把大块逐级对半拆开拆分出来的“剩余半块”挂回下一级链表目标半块继续往下拆直到拆出所需order的块。这个过程有一个专门的辅助函数expand通过循环处理buddy的下半部分效率非常高。反过来释放页面合并时系统会检查“伙伴”页是否空闲是的话就两两合并并升级order一路向上直到无法合并。我看了很多讲解但真正把expand函数一步步走下来才明白原来拆分的顺序不是随便定的而是特意把保留块放在低位地址、把剩余块放回空闲列表这样会让后续的合并判断特别干净。内核代码里处处都是这种细节智慧。4.3 释放路径里隐藏的“反向分配”虽然这篇文章的主题是分配page但不讲释放就没法理解合并策略的意义。释放路径的入口是free_unref_page或__free_page最终落到__free_one_page这个函数里。它做的事正好是分配拆分的逆操作拿到释放页后先看它的伙伴页是否也是空闲且order相同如果是就两页合并order加一继续检查更高阶伙伴直到没有可合并的伙伴为止。这里有个关键限制伙伴页的判断不是看物理地址差多少而是通过buddy_index之类的位运算直接算出伙伴页的物理地址再去free_area链表里查。这个算法非常聪明它不需要扫描所有空闲块每次合并操作都是常数级的判断。我在调试中曾试着打印伙伴页地址手工验证过几个case确实跟理论完全一致。如果你也想验证最直接的手段就是打开页表的debug信息或者用Ftrace跟踪释放路径的调用序列。4.4 从分配器到真实页page的flags与引用计数从free_area链表里取下struct page之后分配器还需要完成最后的初始化工作把page的引用计数置为1清除一些旧的标志位设置新的映射信息。这些操作一半是为了安全防止刚分配的页还带着上一个使用者的数据或状态一半是为了记账让系统的内存统计保持正确。我早期排查内存泄漏时经常忽略page的_refcount字段。有些驱动在申请页之后没有正确维护引用计数导致页面永远无法释放回伙伴系统最终表现为某个模块的内存持续增长。Buddy分配器能给管理员提供的调试入口之一就是page结构的flags和_refcount字段配合/proc/kpageflags这类接口可以逐渐定位到泄漏页面到底被谁引用着。5. 内存水印不足时的自救慢路径、回收与规整5.1 水印跌破之后发生了什么在快速路径里如果所有zone的空闲页都低于水印系统并不会直接宣告分配失败而是会进入__alloc_pages_slowpath这条慢路径。这个路径设计得很像一个急救流程先尝试异步回收不行再来同步回收回收后如果水位还是不够就尝试内存规整如果连规整都凑不出足够的大块并且这次分配不可失败才最终触发OOM。慢路径有几个值得注意的细节。比如它会先把分配标志位里的__GFP_DIRECT_RECLAIM去掉再重试避免回收过程陷入死循环它还会调用wake_all_kswapd来唤醒各个node的回收线程。很多系统管理员发现CPU使用率毛刺配合sar看到kswapd在跑通常就是因为某个瞬间内存压力变大、watermark被突破了。这种毛刺一般不用过度紧张但要是频繁出现就要排查是不是内存泄漏或者page cache设置过大。5.2 kswapd是怎么被调度的kswapd是内核里最重要的内存回收守护线程之一每个node有一个它号称“后台清道夫”。它的触发条件是zone的空闲页面数量低于WMARK_LOW而不是低于MINID。之所以留出LOW和MIN之间的缓冲区间是为了让kswapd还有时间慢慢回收避免分配线程因为等内存而直接卡死。一旦kswapd把zone空闲页补回WMARK_HIGH以上它就会休眠等待下一次内存压力信号。这里有个调优经验当你在做低延迟业务时可以适当地把min_free_kbytes调大一些让kswapd提前开始工作避免业务线程在分配高峰亲自下场回收。但注意别弄过头因为min_free_kbytes调大等于强制系统保留更多空闲页会挤占page cache的空间磁盘缓存命中率可能下降。我见过某系统因为这个参数调大后读写性能反而下降了10%左右最后又调了回来。5.3 内存规整让碎片重新连成片当内存碎片化严重、需要高阶连续块但找不到时分配器会尝试直接内存规整。规整的核心思路是把zone内可移动的页尽量搬到一起把腾出来的连续区域排布成空闲大块。你可以把它理解成磁盘碎片整理先把零散文件集中挪到一侧空出另一侧连续磁盘区域。规整过程中内核会遍历zone内的pageblock扫描可迁移页并逐一迁移。这一步比较耗时而且迁移本身需要复制页表、处理TLB失效所以分配器会优先尝试轻量的异步规整只有在万不得已时才做同步规整。我实测过一个场景一个内存压力很大的容器里反复申请order4以上的连续内存触发同步规整后分配延迟从几十微秒暴涨到几毫秒业务RT直接拉垮。这时候最有效的办法不是去优化规整本身而是从上层减少高阶连续内存的申请频率比如改用分散的缓冲区方案。5.4 OOM killer最后的裁决者如果回收和规整全都救不了场系统会启动OOM killer去杀掉一些“占用内存大户”的进程腾出空间。这个机制虽然听起来暴力但它保证了内核不会因为内存耗尽而全线崩溃。OOM的触发条件也跟水位线紧密相关分配请求无法获得满足同时zone的空闲页低于某个阈值且回收无法恢复。OOM killer选中的目标通常基于一个叫作oom_score的评分它会结合进程的内存占用、运行时间、优先级等因素综合排序。这部分我曾写过一个小工具通过读取/proc/*/oom_score来预判哪些进程可能成为牺牲者。不过调优时我建议优先堵住内存泄漏的源头而不是反复期待OOM killer帮你“准确执法”因为它的选择标准并不总是符合业务预期。6. 跨界联动Buddy分配器与PCP、CMA及外部模块6.1 PCP缓存为什么order0的分配这么快前面提过per-cpu页表缓存这里展开讲。PCP的设计初衷很简单order0的单页分配占到了分配总量的绝大多数如果每个CPU都频繁去抢zone的全局锁就算泡在cache里也会产生巨大的cacheline乒乓效应。于是Buddy分配器就直接给每个CPU分配了一个本地页“储备库”——PCP。PCP里按迁移类型再细分了链表批量从伙伴系统搬过来一批单页本地分配时基本无锁。当PCP空了再调用rmqueue_pcplist从伙伴系统一次性补充一批当PCP超出高水位也会主动释放部分页回伙伴系统。这个过程对上层使用者完全透明但你通过perf工具统计锁竞争时能明显看到引入了PCP后zone锁的争用大幅度下降。在很多性能敏感的内核模块里利用PCP的分配路径是一个刻意优化的方向。6.2 CMA区域给驱动准备的“弹性连续内存”另一个跟Buddy分配器关系密切的概念是CMAContiguous Memory Allocator。CMA的设计思路比较有意思它预留的内存区域平时可以被可移动页正常使用但当驱动需要大块连续内存时它会把区域内已有的可移动页迁走腾出连续空间给驱动用。这个机制让“连续内存的利用率”和“大块内存的可用性”这对矛盾得到了一定程度的调和。在Buddy分配器的实现里CMA区域同样以迁移类型MIGRATE_CMA的形式出现在free_area链表中。分配时普通页面请求通常不会去触碰CMA区域只有带特殊标志的分配请求才会优先选用CMA区域。正因为这种隔离设计CMA区域的快速回收和大块分配才成为可能。我在调嵌入式多媒体系统时就经常使用CMA给ISP或GPU预留连续缓冲区体验下来只要合理配置CMA大小和迁移类型基本不会影响常规内存的分配。6.3 与SLUB/SLAB的协作Buddy分配器并不是内核内存管理的终点。以内核对象分配为例SLUB分配器会从Buddy系统申请一些order较大的内存块然后切分成若干个小对象供内核频繁分配释放使用。换句话说Buddy系统处理的是“物理页”一级的粗粒度管理SLUB处理的是“对象”一级的细粒度管理。层层分工各有侧重。其实这也就解释了为什么有时候你在slabtop里看到某个内核缓存占用很大但free命令里系统内存依然够用。因为SLUB手里囤了一批Buddy给它的大块页这些页对Buddy来说已经被分配出去了但对SLUB来说可能只是闲置对象。排查内存占用时不能光看Buddy的free分布还要结合slab、page cache、进程内存等一起分析。7. 实测与调试如何观察伙伴系统的分配行为7.1 用buddyinfo和zoneinfo快速摸底接到一个问题现场时我通常会第一时间看几个proc文件。第一个是/proc/buddyinfo它按node和zone分别展示每个order链表的空闲块个数。通过它你一眼就能看出系统的物理内存碎片化到了什么程度如果order0、1的空闲块有很多但order4以上的块几乎没有同时系统总空闲内存又不低那么大概率是碎片化在作祟。第二个是/proc/zoneinfo它提供了每个zone的水印水位、free页数、各种统计计数等详细信息。我会尤其关注nr_free_pages、pageset、vm_stat中的pgalloc_success、pgalloc_dma等字段。如果看到pgalloc_high或pgalloc_normal统计持续增长但free却不断下降说明分配发生在哪个zone、哪类路径上能够迅速把问题范围缩小。7.2 用tracepoint跟到分配现场如果proc文件不够用下一步就是利用内核的tracepoint来做动态追踪。比如mm_page_alloc这个tracepoint会输出分配调用的order、gfp_flags和返回的page地址是分析分配路径最直接的“监听器”。mm_page_free则对应释放路径。配合ftrace的function_graph你甚至可以看到一个分配请求具体经过了哪几个函数从而判断它走的是快速路径还是慢速路径。我分享一个实际排查案例某平台出现周期性的分配延迟尖刺。通过ftrace抓取mm_page_alloc事件后发现大部分延迟尖刺都伴随着order3以上的高阶分配并且这些分配大多都在慢路径的compact过程中完成。顺着线索查到是一个网络驱动在收包时频繁申请高阶连续页调整驱动改用预分配缓冲池之后问题消失。整个过程如果你不懂Buddy分配路径只会以为网络驱动本身有bug白折腾好久。7.3 开启内核debug特性定位泄漏与越界内核本身也提供了一批给内存调试用的选项CONFIG_DEBUG_PAGEALLOC、CONFIG_PAGE_POISONING、CONFIG_PAGE_OWNER等。开启编译后释放的内存页会被填入特定字节分配时如果发现内容被改动基本就可以断定有越界或UAF。page_owner则能记录每个页面的分配调用栈配合工具可以定位到是哪个调用路径“占着茅坑不拉屎”。不过要说句实在话这些debug选项在线上环境开启的成本不低它们会显著增加内存和CPU开销。通常做法是在测试环境或者灰度环境开启收集足够信息后再关闭。我自己的习惯是遇到疑难杂症才开page_owner平时保持默认不开以免影响性能。7.4 常见问题速查表现象最可能原因排查建议order0分配正常高阶分配总失败碎片化严重 / 水印设置过低检查buddyinfo与zoneinfo考虑开启compact或调高min_free_kbytes分配延迟出现周期性毛刺慢路径回收或规整被频繁触发抓mm_page_alloc tracepoint定位高阶分配来源并优化内存缓慢耗尽无进程占用page cache或slab/驱动泄漏结合slabtop、/proc/meminfo、page_owner逐层定位内核报BUG: soft lockup慢路径里长时间遍历回收或规整检查内核日志中的回收线程栈重点看compact和shrink路径OOM killer频繁触发但free还有剩余水位设置不合理 / 存在不可回收的大块内存计算实际水位结合zoneinfo确认是否触顶8. 收尾我对Buddy分配器分配page的个人体会写到这里整套分配page的核心链路基本过了一遍。从我个人的实践经验来说理解Buddy分配器最好的方式不是背代码而是把“内存为什么分zone、为什么要按order组织、为什么要分迁移类型、为什么要设水位线”这四个为什么彻底想通。因为这四个问题背后对应着物理硬件差异、碎片化对抗、隔离性需求和内存压力预警每一个都是内核设计者实打实的工程权衡。另一个让我很受益的实操习惯是排内存问题永远先看数据再谈理论。先用buddyinfo和zoneinfo把现象量化再用tracepoint把路径验出来最后才根据路径去翻源码。这套方法论帮我在不少诡异问题上省下过大量时间强烈推荐你也试试。Buddy分配器里的很多细节比如expand函数为什么这样做、水印阈值怎么算、迁移类型fallback为什么排成那个顺序看起来平平无奇实际都是被无数线上问题打磨过的。真正读懂它们的时候你会对整个内核内存管理的工程美学产生一种真切的尊敬。