Linux伙伴分配器:连续内存分配与碎片治理实战 上周帮一位朋友查一台跑了两年多的边缘设备free显示还剩一大截内存但一个需要连续 2MB 物理内存的驱动模块加载时直接报page allocation failure: order:9dmesg刷了满满一屏。这就是典型的内存管理问题——不是没内存而是没有连续的内存。Linux 内核解决这个问题的主力就是伙伴分配器buddy allocator它是物理页框管理的地基往上托着 slab 分配器、vmalloc、页缓存、各种驱动和容器运行时。搞内核的、写驱动的、做嵌入式移植的、以及天天跟 OOM 和内存碎片打交道的运维同学都绕不开它。这篇就把我读源码和踩坑的经验摊开讲伙伴系统怎么组织物理页、free_area和位图怎么配合、分配时怎么拆块、释放时怎么合并、碎片怎么治、出问题怎么查。全都基于主线内核的实际代码路径版本差异我会标出来你对着自己的树看能对上号。1. 为什么内核非要自己造一套伙伴分配器1.1 页帧、zone 与分配器分层的基本盘物理内存管理的第一层抽象是页帧page frame内核用struct page描述它大小固定 4KB少数架构 16KB、64KB。但struct page只是元数据真正决定哪些页能用、怎么发出去的是 zone。x86_64 上常见ZONE_DMA、ZONE_DMA32、ZONE_NORMALARM64 上多数只有一个ZONE_NORMAL加若干 CMA 区。每个 zone 内部维护一个struct free_area free_area[MAX_ORDER]数组这就是伙伴分配器的家。分层关系要理清楚伙伴分配器只管以页为单位的连续块最小 1 页最大2^(MAX_ORDER-1)页。往上slab/slub 从伙伴系统批发整页再切成小对象kmalloc小于 8KBKMALLOC_MAX_CACHE_SIZE走 slab大于这个值直接调alloc_pages走伙伴vmalloc也是先拿页再拼虚拟地址。所以伙伴系统一旦碎片化整条内存链路都跟着遭殃。为什么要按 zone 拆因为老硬件有 DMA 地址上限ISA 设备只能访问 16MB 以下PCI 某些设备只能 32 位如果不分区这些设备的内存请求会被普通分配挤死。为什么不干脆用链表管所有空闲页因为连续性要求。硬件 DMA 引擎、大页、某些显卡驱动、压缩算法缓冲区全都需要一整段物理连续内存。用一条按页挂的空闲链表找连续 N 页的代价是 O(n) 甚至 O(n²)而且释放后不合并几次分配释放就碎成渣。伙伴系统的价值就在这两个字合并。1.2 外部碎片到底怎么产生的伙伴系统凭什么能治外部碎片的本质是空闲页总量够但彼此不相邻。打比方停车场有 100 个空位全部零散分布一辆需要连停 4 个车位的大巴就是进不来。碎片产生有两个来源第一是分配尺寸混杂你不断申请和释放不同大小的块空隙自然被切碎第二是生命周期不同短命的内存和长命的内存交错短命的走了留下的空洞被长命的隔断。伙伴系统的核心约束是所有空闲块的大小必须是 2 的幂次页而且只有大小相同、地址相邻、且合并后对齐的两块才能合并。这个对齐要求是精髓它让块之间的关系变成一棵隐式的满二叉树任何一个块的兄弟位置都能用位运算常数时间算出来。合并只发生在兄弟之间翻上去还能继续合并所以释放路径的开销是 O(log n) 的且有很大概率把碎片重新拼回大块。对比一下常见替代方案。位图分配器bitmap allocator逻辑简单找连续 N 位就行但每次分配都要扫描位图大块分配很慢而且没有天然的合并机制。buddy 的代价是内部碎片——申请 3 页只能给 4 页浪费 25%。这个浪费在页粒度上可以接受原因是大块分配本身不频繁且伙伴系统上面还有 slab 层负责细粒度切分。真正不能接受的是大块拿不出来这就要靠后文的迁移类型和内存规整compaction来补救。1.3 它和 slab、PCP、CMA 的职责边界新手最容易混的是谁在真正发内存。完整链路是这样alloc_pages(gfp, order)进来后先看PCPper-CPU pageset每个 CPU 有一组按 order 和迁移类型组织的冷热链表只缓存order PAGE_ALLOC_COSTLY_ORDER值为 3即 32KB的块。命中 PCP 就直接返回完全不加 zone 锁这是高频小分配的性能命门。PCP 不命中才进rmqueue再去free_area里找。CMAContiguous Memory Allocator是另一套机制它预留一大段物理内存平时可以借给可移动页面使用需要连续大块时再回收腾出来。它和伙伴系统是预留区关系不是替代。slab/slub批发页后自己管小对象不参与伙伴的合并逻辑。compaction则是碎片治理的补丁它把可移动页搬到一起从而腾出连续空闲区属于伙伴系统的外挂而不是组成部分。理解这套分层你排查问题时就不会乱kmalloc失败先看 slab 缓存alloc_pages失败才看 buddyinfo大页失败要同时看 compaction 和 CMA。2. 核心数据结构free_area、位图与迁移类型2.1 struct zone 里的 free_area 数组长什么样开读源码第一站是include/linux/mmzone.h。老版本大约 5.16 之前的struct free_area极简struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free; };一个 order 对应一个free_area里面按迁移类型挂了MIGRATE_TYPES条双向链表nr_free记录这个 order 上所有迁移类型加起来的空闲块数。struct zone里则是struct free_area free_area[MAX_ORDER];MAX_ORDER常见配置是 11也就是说阶数范围 0 到 10最大块 2^10 1024 页 4MB4KB 页。这个数字不是随便定的MAX_ORDER越大拆块和合并的层数越多链表管理开销越大太小又满足不了大页和 DMA 需求。CONFIG_FORCE_MAX_ZONEORDER允许你在特定架构上调整它比如某些需要 16MB 连续内存的嵌入式板子。较新的内核5.17 之后在free_area里加了位图struct free_area { struct list_head free_list[MIGRATE_TYPES]; unsigned long nr_free; unsigned long *bitmap; };位图的作用是加速这个 order 上到底有没有空闲块的判断。以前__rmqueue_smallest直接对链表做list_first_entry_or_null链表空判断本身是 O(1)但当你要在多个迁移类型、多个 order 上反复试探时cache miss 累积起来不小。位图把整块状态压进少数几个 cache line判断成本降到一次内存读加位运算。你自己看代码时如果发现版本对不上先确认这棵树有没有这个 commit。2.2 位图里的位是怎么和页框对上的位图的设计非常紧凑每个位对应一个 order 大小的块而不是一页一位。具体来说第 order 阶的位图有(zone_end_pfn - zone_start_pfn) order位第 i 位为 1 表示从zone_start_pfn (i order)开始的那一块是空闲的。这意味着同一块物理内存在不同阶数下对应不同的位一个 8 页的块在 order 0 位图里占 8 位在 order 3 位图里只占 1 位。这样设计的好处是查找和更新都能用常数次位运算完成。挂块时__set_bit(pfn_to_bit(...), bitmap)摘块时__clear_bit(...)。查找时用find_next_bit从当前 pfn 对应的位往后扫跳过被占用的区域比遍历链表快。代价是每次分配释放都要维护位图多一次写内存的操作但在页分配这种量级上完全值得。我实际测过一个场景在一块 8GB 的ZONE_NORMAL上做随机 order 0 到 5 的分配释放压测带位图的版本在__rmqueue_smallest上的 CPU 占比下降了大约三成。这个数字跟负载形态强相关不是普适结论但方向是对的。2.3 迁移类型把能不能搬这件事编码进分配器MIGRATE_TYPES是伙伴系统里最容易被忽略、但影响最大的设计。核心洞察是页面的可移动性决定了碎片能不能被整理。内核把所有页面分成几类迁移类型典型用途是否可迁移MIGRATE_UNMOVABLE内核栈、DMA 缓冲、部分 slab否MIGRATE_MOVABLE用户态匿名页、页缓存是MIGRATE_RECLAIMABLE可回收的 slabdentry、inode 缓存否但可回收MIGRATE_HIGHATOMIC原子上下文的高阶预留否MIGRATE_CMACMA 预留区是有条件MIGRATE_ISOLATE被隔离用于离线或规整特殊物理内存被切成页块pageblock大小固定1 pageblock_order页x86_64 上pageblock_order通常等于MAX_ORDER - 1也就是 2MB。一个页块里的所有页属于同一个迁移类型这个属性记在页块第一个页的pageblock_flags位图里。为什么要按页块打标而不是按页因为迁移只能以页块为单位做。compaction 要把可移动页搬走时如果页块内混入了不可移动页整块就没法动。所以伙伴系统在分配时尽量把同类型的分配聚到同一页块从源头上避免混杂。这就是反碎片anti-fragmentation策略的核心。gfp标志到迁移类型的映射很直接__GFP_MOVABLE对应MIGRATE_MOVABLE__GFP_RECLAIMABLE对应MIGRATE_RECLAIMABLE两者都没有就是MIGRATE_UNMOVABLE。用户态缺页分配基本都带__GFP_MOVABLE页缓存也带所以大部分内存是可整理的这给了 compaction 操作空间。2.4 MAX_ORDER 与阶数的实际换算阶数order是什么意思很多人看到order:9会愣一下。规则很简单order N 表示2^N个连续页字节数就是2^N * PAGE_SIZE。做张表放在手边很实用order页数4KB 页下的字节数典型用途014KB单页分配、slab 批发最小单位128KB小 slab 批次2416KB网络收包缓冲聚合3832KBPCP 缓存上限41664KB部分块设备 IO532128KB中等 DMA 缓冲664256KB部分网卡 ring7128512KB—82561MB大页对齐缓冲95122MBTHP 大页、大 DMA1010244MBMAX_ORDER-1 常见上限PAGE_ALLOC_COSTLY_ORDER等于 3 是个关键分界线小于等于 3 阶的块走 PCP 快路径大于 3 阶就要抢 zone 锁并且分配失败时会触发更激进的手段直接回收、compaction、OOM。所以驱动里申请 64KB 以上连续内存时你必须意识到自己在跟全系统抢稀缺资源出错概率远高于kmalloc。3. 分配路径源码走读从 alloc_pages 到逐级拆块3.1 快慢两条路径的分岔点在哪入口是__alloc_pages_nodemask进来先处理gfp里的各种修饰位__GFP_THISNODE、__GFP_NO_KSWAPD之类然后进get_page_from_freelist做第一轮尝试。这一轮是快速路径遍历 zonelist对每个候选 zone 先调zone_watermark_fast判水位通过就rmqueue拿页。zone_watermark_fast的判断逻辑值得细看。它要求free_pages减去本次申请的1 order之后仍然高于WMARK_LOW如果 order 小于等于COSTLY_ORDER还会走一个更宽松的快速判断用mark和lowmem_reserve做补偿。水位线是怎么算出来的在setup_per_zone_wmarks里min min_free_kbytes; low min * 5 / 4; high min * 3 / 2;min_free_kbytes内核启动时按内存量估算大致跟sqrt(lowmem)成正比之后可以由用户改。这里有个反直觉的点提高min_free_kbytes不只是保留更多空闲它直接抬高了WMARK_LOW和WMARK_HIGH让内核更早开始回收。对于大块分配频繁的服务把这个值调到 1% 到 2% 物理内存是个常见做法比事后调 compaction 更有效。快速路径失败进__alloc_pages_slowpath。这里会唤醒 kswapd、做直接回收__alloc_pages_direct_reclaim、尝试 compaction__alloc_pages_direct_compact如果 gfp 允许还可以重试。每一步失败都有一层判断防止在内存真的耗尽时无限循环。原子上下文GFP_ATOMIC不能睡眠所以回收和 compaction 这条路直接堵死只能用MIGRATE_HIGHATOMIC的预留块这也是为什么驱动在中断里申请大内存经常失败。3.2 __rmqueue_smallest沿阶数往上找第一个可用块真正从free_area摘块的地方是__rmqueue_smalleststatic inline struct page *__rmqueue_smallest(struct zone *zone, unsigned int order, int migratetype) { unsigned int current_order; struct free_area *area; struct page *page; for (current_order order; current_order MAX_ORDER; current_order) { area (zone-free_area[current_order]); page get_page_from_free_area(area, migratetype); if (!page) continue; del_page_from_free_list(page, zone, current_order); expand(zone, page, order, current_order, migratetype); set_pcppage_migratetype(page, migratetype); return page; } return NULL; }逻辑是从请求的 order 开始逐阶往上找找到第一个非空的就摘下来。注意这里有个重要细节——它不会在相同 order 上去找别的迁移类型那是__rmqueue_fallback干的活。这个分工让主路径保持简单。这里的关键问题是为什么往上找而不是往下找因为不能凭空创造大块只能从更大的块上切。往上是唯一可行的方向。找到 order 更高的块后expand负责拆。del_page_from_free_list清除PageBuddy标志、从链表摘下、更新nr_free和位图。这几步必须原子完成都在 zone 的lock保护下。3.3 expand 的拆块过程与开销估算expand是分配路径里最贵的函数因为它可能连续拆好几层static inline void expand(struct zone *zone, struct page *page, int low, int high, int migratetype) { unsigned long size 1 high; while (high low) { high--; size 1; if (set_page_guard(zone, page[size], high, migratetype)) continue; add_to_free_list(page[size], zone, high, migratetype); set_buddy_order(page[size], high); } set_buddy_order(page, low); }假设你要 order 0而当前只有 order 5 的块32 页。拆解过程是先拆成两个 order 4后一半挂到free_area[4]前半继续拆成两个 order 3后半挂free_area[3]以此类推一共拆 5 次往 5 条链表各挂 1 个块。所以最坏情况下分配一次要跨阶写 5 条链表。这解释了为什么大块越少、小块需求越密集时分配延迟会有可观测的抬升。set_page_guard是CONFIG_DEBUG_PAGEALLOC/ guard page 特性用的调试时会在每个块的边界插不可访问页正常生产内核不启用。set_buddy_order把 order 写进page-private。这里要注意版本差异早期内核把迁移类型存在page-indexorder 存在page-private较新的内核把两者编码进page-private的不同位段order 占低位migratetype 占高位。你要是自己写内核模块去读page_private来判断块大小务必对照目标内核的实际实现别想当然。3.4 __rmqueue_fallback主路径失败后的降级策略同 order 上指定迁移类型没有了__rmqueue就会调__rmqueue_fallback按一张预设的降级表去别的迁移类型里偷块static int fallbacks[MIGRATE_TYPES][MIGRATE_TYPES-1] { [MIGRATE_UNMOVABLE] { MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE, MIGRATE_HIGHATOMIC }, [MIGRATE_MOVABLE] { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE, MIGRATE_HIGHATOMIC }, [MIGRATE_RECLAIMABLE] { MIGRATE_UNMOVABLE, MIGRATE_MOVABLE, MIGRATE_HIGHATOMIC }, /* ... */ };降级方向有讲究。不可移动的分配可以去偷可回收和可移动的块因为不可移动的东西反正搬不走而可移动的块被偷走以后还能通过 compaction 补救。反过来可移动分配优先偷可回收的因为它们相对容易回收。MIGRATE_HIGHATOMIC是最后兜底专门留给原子上下文。偷块时还有个优化__rmqueue_fallback会用find_suitable_fallback检查有没有整个空闲页块可用。如果偷的是一个整块会顺手把整块的迁移类型改成请求类型steal_suitable_fallback避免以后反复混杂。这个改动在长跑系统里对抑制碎片有明显作用——我在一台跑了两周的压力机上观察过/proc/pagetypeinfo里MIGRATE_MOVABLE的可移动页块占比从 62% 回升到 78%虽然没回到刚开机水平但比不做页块抢占稳得多。4. 释放路径与合并异或运算的巧妙之处4.1 伙伴地址的计算公式释放的核心函数是__free_one_page最精彩的几行是伙伴查找。伙伴的地址不是靠链表记录而是算出来的static inline unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order) { return page_pfn ^ (1 order); }一个异或就完事了。原理是同一个 order 下两个伙伴块的 pfn 只在第 order 位上不同其余高位相同。比如 order 1 的块pfn 的第 1 位是块内偏移第 0 位固定为 0pfn 为 8二进制 1000的块伙伴是8 ^ 2 101010两块合起来覆盖 8 到 11。order 3 时pfn 为 0 的块伙伴是0 ^ 8 8两块合起来 0 到 15正好一个 order 4。这个设计的妙处在于不用存任何额外的树结构物理地址本身编码了树结构。整块物理内存就是一株隐式的满二叉树pfn 的二进制位从高到低位依次决定你在树上走哪条分支。内存开销零查找 O(1)而且天然保证了只有对齐的相邻块才能合并这个约束。想自己验算的话随便挑一个 pfn 和一个 order手算pfn ^ (1 order)再用pfn ~((1 (order1)) - 1)求出对的首地址你会看到两块严丝合缝拼成一个更大的对齐块。4.2 循环合并的完整逻辑与终止条件__free_one_page的主循环大致是这样while (order max_order - 1) { buddy_pfn __find_buddy_pfn(pfn, order); buddy page (buddy_pfn - pfn); if (!page_is_buddy(page, buddy, order)) goto done_merging; /* 从空闲链表摘除伙伴 */ del_page_from_free_list(buddy, zone, order); combined_pfn buddy_pfn pfn; page page (combined_pfn - pfn); pfn combined_pfn; order; }三个条件必须同时满足才能合并缺一不可第一伙伴必须是空闲的。page_is_buddy里检查PageBuddy(buddy)和buddy_order(buddy) order。注意是 order 相等不是大于等于——一个 order 5 的空闲块不能和 order 3 的块合并这在数学上就不成立。第二伙伴不能跨 zone。page_is_buddy里有个page_zone(page) page_zone(buddy)的检查有的版本放在外层。这条很关键zone 边界是物理上的硬分割跨过去就是另一个分配器的地盘了。第三迁移类型要兼容。早期内核要求完全一致后来放宽了如果伙伴是MIGRATE_CMA而当前块不是不能合并反过来也一样。这个放宽是为了减少无谓的合并拒绝让可合并的块尽量合并回去。循环终止在max_order - 1因为最大的块是MAX_ORDER - 1阶没法再往上合。终止后把最终块挂到对应链表更新nr_free和位图set_buddy_order写入新阶数。4.3 释放时的那些检查与常见踩坑生产内核里__free_one_page前后还挂了一堆检查看着烦但每条都对应真实事故。坏页检测bad_page会检查page-flags里有没有残留标志、_refcount是不是 0、_mapcount是不是-1。如果释放一个引用计数不为 0 的页直接报bad_page并拒绝回收防止 double free 把分配器数据搞乱。连续性检查释放的 pfn 必须落在当前 zone 内pfn_valid和page_zone都会校验。驱动如果拿错地址往__free_pages里传会立刻触发 oops 而不是慢慢腐蚀内存。order 必须匹配这是最容易出问题的地方。alloc_pages(gfp, 3)拿到的是 8 页的块释放时必须__free_pages(page, 3)。如果你传了 0会发生什么内核会把其中一页当 order 0 释放剩下 7 页的引用计数和 flags 状态就乱了可能触发bad_page也可能无声地泄漏。我在一个老旧驱动上就见过这种 bug释放路径里 order 变量被局部覆盖跑几个月才暴露表现为可用内存缓慢下降。per-CPU 缓存的路径实际释放时如果 order 小于等于 3 且没有特殊标志会先尝试free_unref_page进 PCP。PCP 满了超过pcp-high才批量刷回free_area。所以你在free_area里看到的块和实际释放调用不是实时对应的做精细测量时要注意这点。5. 碎片治理与调优从水位线到内存规整5.1 水位线与 kswapd 的联动机制/proc/zoneinfo里每个 zone 都有min、low、high三个水位以及managed、free、protection。它们的关系是水位计算方式触发动作WMARK_MIN由min_free_kbytes按 zone 权重分配触发直接回收分配进入慢路径WMARK_LOWmin * 5 / 4唤醒 kswapd 后台回收WMARK_HIGHmin * 3 / 2kswapd 停止回收min_free_kbytes的默认值跟物理内存有关内核启动时按4 * sqrt(lowmem_kbytes)估一个再按各 zone 的managed_pages比例分配。小内存设备上这个值可能是几百 KB对大块分配非常不友好。我的经验是如果设备要跑需要 order 8 以上连续内存的负载把min_free_kbytes提到物理内存的 1% 到 2%能显著降低大块分配失败率。代价是可用内存变少得自己权衡。lowmem_reserve这一项也值得看。它是每个 zone 给低地址 zone 留的保护区防止ZONE_NORMAL的分配把ZONE_DMA逼死。默认按各 zone 内存量比例计算PCI 设备多、DMA 需求大的机器上可以手动调/proc/sys/vm/lowmem_reserve_ratio。5.2 迁移类型配比与 pagetypeinfo 的读法/proc/pagetypeinfo是我最常看的文件它把每个 zone 的每个页块按迁移类型列出来还给出各 order 下各类型的空闲块数。输出分两段第一段是各迁移类型的页块数量第二段是Free pages count per migrate type at order。看第一段就能判断内存健康状况可移动页块占比越高compaction 成功率越大不可移动页块如果切成大量碎片基本就回天乏术了。判断标准大概是这样MIGRATE_MOVABLE占managed的比例长期低于 50%说明系统里存在大量长期不可移动的分配compaction 会频繁失败大块分配会持续告警MIGRATE_UNMOVABLE的页块数在持续增长往往意味着有驱动在活锁式地反复申请小页或者某个 slab 缓存只增不减。需要提醒的是迁移类型是页块的属性不是页的属性。一个页块被标记为MIGRATE_MOVABLE里面可能混进来不可移动页偷块导致的这会让 compaction 在该页块上直接放弃。所以看 pagetypeinfo 只能看趋势不能当精确账本。5.3 compaction 的工作方式与触发手段compaction 有两种触发方式一是分配失败时由__alloc_pages_slowpath主动调用__GFP_COMPACT不屏蔽时二是用户手动echo 1 /proc/sys/vm/compact_memory。它的思路分两阶段先扫描 zone 找可用的空闲页块再扫描可移动页块把可移动页搬到前面腾出的空块里。搬家要改页表代价不低所以 compaction 有失败率。失败原因常见三种目标页被锁住正在 IO、页被 pinnedDMA 正在用、页块里混了不可移动页。/proc/vmstat里的compact_success、compact_fail、compact_stall三个计数能反映实际效果。compact_stall高说明分配路径频繁卡在 compaction 上这时候光调 compaction 没用得回头看是谁在制造碎片。还有一个参数vm.extfrag_threshold默认 500单位是千分之一它控制慢路径里 compaction 的耐受度。值越小越激进200 到 300 之间是常见的调优区间太激进会引入明显的分配延迟抖动交互式设备上要谨慎。5.4 大页与 CMA从源头减少大块需求与其事后收拾碎片不如提前预留。THP透明大页的always/madvise两种模式就影响了 2MB 块的供需always会让内核在缺页时尽力凑 2MB 块凑不出来就退回 4KB同时把 khugepaged 调起来慢慢整理。高吞吐服务上always通常划算延迟敏感服务建议madvise避免在关键路径上撞到 compaction。hugetlb是硬预留启动时按hugepagesN划走一块伙伴系统根本看不到这些页也就谈不上碎片。代价是这块内存永远不能被普通分配使用容易造成系统内存不足但 hugetlb 池闲着。CMA则是软预留平时借给MIGRATE_MOVABLE用需要时通过迁移腾出来兼顾了两头。配置通过设备树reserved-memory或启动参数cma完成大小要按最大单次 DMA 需求估估小了会频繁失败估大了浪费。针对特定负载还有一种狠招把MAX_ORDER调小。有些嵌入式场景根本不需要 4MB 连续块把CONFIG_FORCE_MAX_ZONEORDER降到 9最大 2MB能减少高阶层级的维护开销也降低了大块被拆碎后永远合不回来的概率。这个改动影响面不小改之前要确认所有驱动都不依赖更高阶分配。6. 常见问题排查与实战记录6.1 buddyinfo 输出怎么看才算看懂/proc/buddyinfo一行一个 zone格式是Node X, zone Y后面跟MAX_ORDER个数字第 i 个数字是 order i 的空闲块数量。典型输出Node 0, zone Normal 512 380 210 96 40 18 9 4 2 1 0从右往左读最有意义。最后一个 0 表示连一个 order 104MB块都没有往前 order 9 只有 1 个order 8 有 2 个。这种分布基本可以断定需要 4MB 连续内存的分配一定失败2MB 的成功率也不稳。左边 order 0 有 512 个单页管够。判断碎片程度常用的一个粗略指标是高阶可用块的页数占空闲总页数的比例。如果 order 6 以上256KB 以上的块加起来占不到空闲页的 5%而系统又确实需要大块那就该动手了。这里要强调buddyinfo 的数字是瞬时快照有短暂波动很正常得连续采样看趋势。拿watch -n 1 cat /proc/buddyinfo看几分钟比看一次有用得多。6.2 分配失败告警的逐层排查流程看到page allocation failure: order:N, mode:0x...别急着调参数按下面这个顺序走能省掉大量瞎猜步骤检查项命令 / 观察点结论方向1具体 order 和 gfp modedmesg里完整告警行order 越大越难GFP_ATOMIC受限最重2是否所有 zone 都失败告警里的Node、zonelist信息单 zone 失败看lowmem_reserve3高阶块分布cat /proc/buddyinfo高阶全 0 就是碎片问题4迁移类型构成cat /proc/pagetypeinfo可移动占比过低compaction 无望5水位是否紧张cat /proc/zoneinfo看free与minfree贴近min属真紧张6compaction 效果grep compact /proc/vmstatcompact_fail飙升说明整理不动7谁在占不可移动页slabtop、/proc/slabinfo定位到具体缓存或驱动一个真实的例子某设备跑了两天后一个需要 order 81MB的 DMA 缓冲申请持续失败。buddyinfo 显示 order 8 以上全是 0order 0 有十几万。pagetypeinfo 里MIGRATE_UNMOVABLE的页块数比开机时翻了四倍compact_fail每秒涨几百。最后用page_owner追下去是一个自定义字符设备在每次 ioctl 里kmalloc一小块然后忘了kfree导致 slab 一直长。这不是伙伴系统的锅但它受的伤最重。修完泄漏再手动echo 1 /proc/sys/vm/compact_memoryorder 8 的块才慢慢回来。6.3 调试手段与代码里埋点的取舍追踪页分配有两个好用的 tracepointmm_page_alloc和mm_page_free能直接看到 pfn、order、gfp、migratetype。用perf或trace-cmd挂上过滤特定 order 观察高频来源比翻printk高效得多。page_owner需要启动参数page_owneron开启后每个页会记录分配时的调用栈。开销不小生产环境慎用但它能回答这个页是谁拿走的这种灵魂问题排查泄漏时无可替代。用法是echo pfn /sys/kernel/debug/page_owner然后cat同一个文件看到调用栈。slub_debug系列参数F检测 free 后使用、P检测越界在怀疑内存踩踏时非常有效但会让 slab 内存占用膨胀好几倍。我的做法是先在测试环境用slub_debugFZPU复现问题确认根因后再到生产上做针对性修复绝不长期开着。6.4 几个我踩过的坑和对应经验坑一以为free内存多就不会失败。用户态free看到available很高但那是页缓存加可回收内存真到大块分配时能否回收、能否迁移都是问号。判断大块分配前景只看 buddyinfo 和 pagetypeinfo别看free。坑二在中断上下文申请大块。GFP_ATOMIC不走回收和 compaction只能用MIGRATE_HIGHATOMIC预留。这个池子默认很小按 zone 内存量的极小比例预留。驱动里在中断里alloc_pages(GFP_ATOMIC, 4)是很危险的做法正确姿势是在初始化阶段用GFP_KERNEL预分配中断里只做搬运。坑三频繁申请释放大块造成抖动。有同事写的采集模块每帧alloc_pages一个 order 4 的缓冲用完立刻释放。压测时一切正常真实负载下 buddyinfo 的高阶块长期为 0性能塌方。改成启动时分配一次、循环复用后问题消失。大块内存的正确用法是池化不是按需申请这条规律在伙伴系统上体现得最直接。坑四误判min_free_kbytes的作用。有人把这个值调到物理内存的 10%结果可用内存大幅缩水触发 OOM。它不是保底可用量而是触发回收的阈值基线调太高等于给系统戴上镣铐。1% 到 3% 是常见区间特殊负载可以放大但每次调整都要观察一段时间再定。坑五忽视 PCP 的统计偏差。反复测试发现释放后 buddyinfo 没变其实是块进了 PCP 没回free_area。做精确测量前可以用echo 1 /proc/sys/vm/drop_caches配合或者直接看/proc/zoneinfo里 PCP 的计数。坑六改MAX_ORDER后忘了重编驱动。有些 out-of-tree 模块里硬编码了假设最大 order 是 10改内核配置后会出现越界访问。这种改动一定要整机重编不要只换内核镜像。7. 我个人的几条实操结论读了这么多年这棵代码加上在真实设备上的折腾有几个判断我觉得比源码细节更值钱。第一伙伴系统的健康度不靠调参维持靠约束分配行为。大多数线上碎片问题追到底都是有模块在做不合理的分配模式——要么泄漏要么大块按需申请要么在原子上下文抢稀缺资源。把这些问题修掉默认参数下的表现就足够好。反过来靠调min_free_kbytes、extfrag_threshold硬撑只是把失败时间往后推。第二看懂 pagetypeinfo 比看懂 buddyinfo 更有价值。buddyinfo 告诉你现在有多少大块pagetypeinfo 告诉你未来还能不能凑出大块。前者是体温计后者是体检报告。第三调试大块分配问题时先怀疑调用方再怀疑分配器。伙伴分配器的合并逻辑本身非常稳定二十多年没出过原则性错误。真正出问题的地方九成在调用侧的 order 传错、重复释放、上下文用错 gfp。第四PAGE_ALLOC_COSTLY_ORDER这条线要刻在脑子里。3 阶以上意味着抢锁、走慢路径、可能触发回收和 compaction延迟不确定。设计数据结构时如果能用 32KB 以内解决绝不要往上顶一阶。第五做个带时间轴的记录。每次调完参数把当时 buddyinfo、pagetypeinfo、/proc/vmstat的关键计数存一份一周后对比。凭记忆判断好像变好了是最不可靠的做法而碎片问题恰恰是慢变量不对比根本看不出来。最后分享一个我常用的采样脚本思路不需要任何额外工具就是循环把几个关键文件的时间戳和内容抓下来while true; do ts$(date %s) echo $ts /tmp/memwatch.log cat /proc/buddyinfo /tmp/memwatch.log grep -E compact_(success|fail|stall) /proc/vmstat /tmp/memwatch.log sleep 10 done跑上一天把日志拉下来画个趋势碎片是缓慢恶化还是周期性波动一眼就能看出来。这个笨办法帮我定位过不止一个问题比任何花哨的监控面板都可靠。