第一章:内存管理的哲学——碎片化与二进制
1.1 外部碎片:物理内存不可逾越的鸿沟
在嵌入式系统的生命周期中,内存碎片(Fragmentation)是一个幽灵,它悄无声息地吞噬着系统的确定性。作为一名嵌入式工程师,你可能遇到过这样的困境:系统显示还有 100MB 的空闲内存,但当你尝试通过驱动申请一个 2MB 的连续 DMA 缓冲区时,内核却无情地返回了 NULL。
这种现象被称为外部碎片。它的产生源于内存分配的随机性与生命周期的不一致性。想象内存是一条长长的胶带,不同的进程像是在胶带上随意粘贴的小标签。有的标签撕掉得早,有的撕掉得晚。随着时间的推移,胶带上留下了一连串细小的、互不相连的空隙。虽然空隙的总面积很大,但再也无法贴上一张完整的大标签。
在 Linux 内核演进的早期,开发者们尝试过多种分配算法:
首次适应算法(First Fit):简单,但会导致内存起始端布满小碎片。
最佳适应算法(Best Fit):试图寻找最匹配的空隙,但计算开销巨大。
这些算法在处理频繁的、大小不一的分配请求时,最终都无法逃脱碎片化的惩罚。对于内核这种需要长期运行(Uptime 往往以年计算)的系统,必须有一种机制能够不仅能“分出去”,更要能“合回来”。这就是伙伴系统(Buddy System)登台的历史背景。它不仅仅是一个算法,更是一种关于“秩序”的工程哲学。
1.2 二进制之美:为什么是 $2^n$?
伙伴系统的核心思想极其纯粹:将内存块划分为不同等级(Order),每个等级的大小都是 $2^{\text{order}}$ 个页(Page Frame)。这种基于二进制幂次的设计,是计算机科学中最优雅的权衡之一。
为什么不采用更灵活的大小?原因在于**伙伴(Buddy)**的判定逻辑。在二进制体系下,一个大小为 $2^k$ 的内存块,其“伙伴”块的地址可以通过简单的位运算(XOR)瞬间计算出来。这种极高的计算效率,使得内存在释放时能够以 $O(1)$ 的时间复杂度进行合并尝试。
在 Linux 内核中,典型的 MAX_ORDER 为 11。这意味着它管理着从 1 页(4KB)到 $2^{10}$ 页(4MB)不等的 11 种块大小。这种分层管理带来的好处是多维度的:
分配对齐:天然满足硬件对地址对齐(Alignment)的要求,这对 DMA 传输至关重要。
查找效率:通过位图和多级链表,内核可以迅速定位到最合适的空闲块。
内聚性:它将相似大小的请求归类,减少了“大块被小分配切碎”的风险。
然而,伙伴系统也带来了一个副作用:内部碎片。如果你申请 5KB 的内存,伙伴系统会给你一个 8KB($2^1$ 个 Order)的块,多出的 3KB 将被浪费。但内核设计者认为,为了消除致命的外部碎片并换取极致的分配速度,这种少量的内部浪费是完全可以接受的权衡。
第二章:伙伴系统的架构版图——从 Zone 到 Page
2.1 管理区(Zone)与节点(Node):内存的等级制度
在解析代码之前,我们必须理解物理内存在 Linux 内核中的“户籍制度”。在现代高性能嵌入式 SoC(如具备多通道内存或异构存储的设备)中,内存并不是一块连续的扁平空间,而是呈现出 NUMA(非统一内存访问) 架构。
每一个 NUMA 节点被称为一个 pg_data_t 结构。而在每个节点内部,内存又被划分为不同的管理区(Zone):
ZONE_DMA:通常指内存起始的 16MB,专为老旧硬件的 DMA 寻址预留。
ZONE_DMA32:针对只能寻址 32 位地址(4GB 以下)的设备。
ZONE_NORMAL:内核最常用的线性映射区。
ZONE_HIGHMEM:高端内存,用于处理 32 位系统无法直接映射的物理空间。
伙伴系统并不是全局唯一的,它是基于 Zone 独立运行的。每个 Zone 都有自己的一套 free_area 数组。这种隔离设计极大地减少了多核(SMP)环境下的锁竞争。当 CPU0 在 ZONE_NORMAL 分配内存时,CPU1 可以并行地在 ZONE_DMA32 进行操作,互不干扰。
对于嵌入式工程师来说,理解 Zone 的划分至关重要。当你通过 kmalloc(GFP_DMA) 申请内存时,你实际上是在命令伙伴系统跳过默认的 ZONE_NORMAL,转而去 ZONE_DMA 的 free_area 链表中寻找空闲块。如果该 Zone 枯竭,即使其他 Zone 满载空闲内存,分配依然会失败。
2.2 free_area 与 struct page:数据的毛细血管
伙伴系统的灵魂存储在 struct zone 结构体的 free_area[MAX_ORDER] 数组中。
每一个 free_area 元素代表一个特定 Order 的空闲资源池。它不仅仅是一个简单的双向链表头,还包含了该 Order 下不同**迁移类型(Migrate Types)**的分组。
在更微观的层面,每一个 4KB 的物理页框都由一个 struct page 来描述。为了节省宝贵的内存,这个结构体被设计得极其精简且采用了大量的 union 技巧。在伙伴系统中,page->private 存储了该页所属的 Order 值,而 page->lru 则作为链表节点连接到对应的 free_area 中。
这里有一个容易被忽视的细节:伙伴系统管理的是“原始页框”。当一个块被伙伴系统分配出去后,它就从 free_area 链表中消失了,其 struct page 标志位会被标记为“已使用”。直到它被释放,它才重新回归伙伴系统的怀抱。这种从链表到页框的转换,构成了内核最基础的 I/O 循环。
第三章:分配逻辑的精密流程——拆分(Splitting)
3.1 查找与命中的最优路径
当内核通过 __alloc_pages_nodemask() 发起分配请求时,伙伴系统启动了一场寻找“最合适块”的狩猎。这个过程分为“快速路径”和“慢速路径”。
在快速路径中,内核执行以下逻辑:
计算目标 Order:根据请求字节数向上取整到 $2^n$。
直接命中检查:检查对应 Order 的 free_area 链表是否为空。
成功返回:如果链表有块,直接摘除,分配结束。
这是最理想的情况,时间复杂度为 $O(1)$。这种高效性使得内核在处理像网卡接收数据包(sk_buff)这样高频的内存需求时,能够保持极低的延迟。
3.2 递归拆分:化整为零的演变
真正的挑战在于:如果目标 Order 的链表是空的怎么办?这时,伙伴系统展现了它最核心的动态调整能力——拆分(Splitting)。
逻辑如下:
向上检索:查找 Order + 1 的链表。如果依然为空,继续查找 Order + 2,直到 MAX_ORDER。
定位源块:假设我们在 Order + 2 找到了一个空闲块。
一级拆分:将该块平分为两个 Order + 1 的块。其中一个放入 Order + 1 的空闲链表,另一个继续参与下一级拆分。
二级拆分:将剩余的块平分为两个 Order 的块。一个分配给请求者,另一个放入 Order 的空闲链表。
这种拆分过程是原子性的,且受到 Zone 锁的保护。它巧妙地解决了“大内存块如何按需供应”的问题。对于嵌入式设备中常见的连续大块内存需求,这种拆分机制保证了系统能够灵活地从大块中“抠”出小块,而不会造成无法挽回的浪费
接续前文,我们已经深入探讨了伙伴系统的架构基础与拆分逻辑。现在,我们将进入伙伴系统最具“生命力”的部分——内存的回收合并、反碎片机制的实战演练,以及内核如何通过牺牲局部公平性来换取极致的分配速度。
第四章:破镜重圆的艺术——合并(Merging)与伙伴判定
4.1 伙伴判定的数学本质:位运算的魔力
在伙伴系统中,释放内存并不是简单地把页框丢回链表,而是一个充满“社交尝试”的过程。每当一个内存块准备回归 free_area 时,它都会首先询问:“我的伙伴(Buddy)在哪里?它现在有空吗?”
那么,内核是如何在浩如烟海的物理地址中,瞬间锁定谁才是某个页块的“亲兄弟”呢?这里体现了二进制设计的精妙。对于一个起始地址为 $Addr$、大小为 $2^{Order}$ 的内存块,其伙伴块的地址 $Buddy\_Addr$ 满足一个极其简洁的数学公式:
$$Buddy\_Addr = Addr \oplus (2^{Order} \times \text{PAGE\_SIZE})$$
这个公式背后隐藏着两个关键约束:
大小相等:两个块必须处于相同的 Order。
地址对齐:伙伴块必须是连续的。更重要的是,这两个块合并后的新块,其起始地址必须对齐到 $2^{Order+1}$ 的边界上。
在 C 语言实现的内核源码中,这种判定通过位运算在纳秒级完成。内核会检查目标地址对应的 struct page 结构。如果该页的 PageBuddy 标志位被置位,且其存储的 order 与当前块完全一致,那么重逢的时刻就到了。这种基于位运算的判定机制,规避了复杂的链表遍历或树形结构搜索,使得伙伴系统在频繁释放内存时依然能保持 $O(1)$ 的时间复杂度。
对于嵌入式工程师来说,理解这一点能帮你解释为什么有些内存申请明明大小合适却无法对齐。伙伴系统对“对齐”的偏执,本质上是为了在释放时能以最低代价完成合并。如果地址不对齐,合并逻辑就会失效,从而导致内存碎片像干涸的土地一样产生裂纹,再也无法拼接成大块。
4.2 递归合并:从沙砾回归磐石
合并过程是一个递归的“滚雪球”过程。当两个 Order 为 $k$ 的伙伴块重逢并合并后,它们会变成一个 Order 为 $k+1$ 的新块。此时,内核并不会止步于此,而是会带着这个新生成的 $k+1$ 块,继续向上寻找它的 $k+1$ 级伙伴。
这个过程会一直持续,直到满足以下任一条件:
找不到伙伴块(对方正在被使用)。
伙伴块的 Order 不匹配。
已经达到了系统的 MAX_ORDER(通常是 4MB 的块)。
这种递归合并机制是对抗外部碎片的核心武器。在长期运行的嵌入式系统中,内存会不断地被拆分用于处理网络包、存储传感器数据或运行短生命周期的进程。如果没有高效的递归合并,物理内存很快就会变成一地鸡毛。
然而,合并也是有代价的。频繁的合并与拆分会带来昂贵的 CPU 开销和锁竞争。在多核(SMP)系统中,为了合并一个大块,可能需要长时间持有 Zone 锁。这引出了我们后面要讨论的缓存优化。但在宏观层面,递归合并保证了系统在经历高负载波动后,依然有能力恢复出大块连续空间,为下一次大宗内存申请做好准备。
第五章:战术防御——反碎片(Anti-Fragmentation)机制
5.1 迁移类型(Migrate Types):给内存打上标签
虽然伙伴系统通过合并缓解了碎片,但它无法解决一个根本性问题:如果一个“不可移动”的小块(如内核堆栈)不幸被分配到了一个巨大的连续块中间,那么即使周围所有的页面都释放了,由于这个小块无法移动,合并过程也会被拦腰截断。
为了解决这个问题,Linux 内核引入了**迁移类型(Migrate Types)**的概念。它将物理页框分为三大核心阵营:
MIGRATE_UNMOVABLE(不可移动):地址固定,不能为了合并大块而随意搬家。内核的大部分核心数据结构都属于此类。
MIGRATE_RECLAIMABLE(可回收):主要是文件缓存(Page Cache)。虽然它们不能随便移动,但内核可以在需要时直接把它们“踢”出内存,从而腾出空间。
MIGRATE_MOVABLE(可移动):用户态进程的内存。内核可以通过修改页表映射,在物理地址空间中给它们“搬家”。
在伙伴系统的 free_area 链表中,其实为每一种迁移类型都维护了独立的链表。通过这种物理上的“隔离居住”,内核确保了不可移动的小块会被集中管理,从而避免它们像害群之马一样散落在原本属于“可移动”阵营的大块区域中。
5.2 备选列表与“污染”治理:Fallback 策略
理想很丰满,现实很骨感。如果 MIGRATE_UNMOVABLE 链表的内存用完了,内核会怎么办?为了不让申请请求失败,内核允许进行“借用”。
这就是 Fallback(备选)策略。当一个请求在所属的迁移类型链表中找不到空闲块时,它会按照预设的优先级去其他链表“偷”一块内存。
在高性能嵌入式场景中,内存“污染”是系统运行数天后性能下降的主因。内核为此设计了复杂的抢占逻辑:如果必须从其他链表偷内存,它倾向于偷一个最大的块(如 Order 10)。这样做的逻辑是:既然已经污染了,不如干脆把这一整块大区域都划拨给该迁移类型,减少未来继续跨界污染的概率。对于开发者而言,监控 /proc/pagetypeinfo 是诊断系统长周期稳定性最直接的手段。
第六章:速度与激情的权衡——Per-CPU Pageset (PCP)
6.1 Zone 锁的瓶颈与 PCP 的诞生
在多核处理器日益普及的今天,伙伴系统的全局 Zone 锁成为了性能瓶颈。想象一下,如果有 8 个 CPU 核心同时在申请 4KB 的内存,它们必须排队竞争同一个 zone->lock。这种锁竞争在高并发网络转发或实时视频处理中是不可接受的。
为了突破这一瓶颈,内核在伙伴系统之上构建了一层轻量级缓存——Per-CPU Pageset (PCP)。
每个 CPU 核心都拥有一份私有的、无需加锁的单页缓存。当进程申请 Order 0(单页)的内存时,内核首先会去当前 CPU 的 PCP 链表中查找。如果命中,整个过程只需极简的指针操作,效率极高。
6.2 批量分配与冷热页优化:Bulk 操作的智慧
PCP 不仅仅是一个简单的缓存,它还包含了一套精妙的“补货”与“卸货”机制:
批量获取(Refill):当 CPU 的 PCP 缓存空了,它会一次性从伙伴系统中申请一批页面(如 32 个),而不是一个一个拿。这样只需加一次 Zone 锁,就能大幅降低锁竞争的频率。
热页(Hot Pages)与冷页(Cold Pages):PCP 将页面分为热页和冷页。刚刚释放回 PCP 的页通常是“热”的,因为它们的数据很可能还在 CPU 的 L1/L2 Cache 中。内核优先分配这些热页,可以极大地提升缓存命中率,减少昂贵的内存访问延迟。
对于处理大规模数据流的应用(如您正在进行的多路相机采集),PCP 机制保证了中断上下文中频繁的小内存申请能够瞬间完成。然而,PCP 也有隐患:如果大量内存被扣留在各个 CPU 的私有缓存中,会导致系统总体的空闲内存水位(Watermark)出现误判。因此,内核在内存紧张时,会通过 drain_all_pages() 强制刷新所有 PCP 缓存回伙伴系统。
接续前文,我们已经完成了伙伴系统的静态架构、动态拆分、递归合并以及基于 PCP 和迁移类型的防御性设计。
第七章:内存紧缩(Compaction)——伙伴系统的主动出击
7.1 碎片化的终结者:双指针扫描机制
尽管迁移类型(Migrate Types)通过“分区治理”延缓了碎片的产生,但在一个长期运行的嵌入式系统中,内存依然会不可避免地趋于破碎。当伙伴系统(Buddy System)在所有迁移类型的链表中都找不到足够大的连续块(如 Order 4 以上),而总空闲内存依然充足时,内核不会坐以待毙,而是会触发内存紧缩(Compaction)。
内存紧缩的哲学非常直观:既然大房子被隔断墙拆碎了,那我们就把租客们往一头赶,把另一头的隔断墙拆掉,重新拼出一间大房子。在内核代码 mm/compaction.c 中,这一过程由两个“扫描器”共同完成:
迁移扫描器(Migration Scanner):它从管理区(Zone)的底部(低地址)开始向上扫描,寻找那些“可移动”的页面(Movable Pages)。一旦发现这些承载着进程数据的页面,它就把它们记录下来,准备搬家。
空闲扫描器(Free Scanner):它从管理区的顶部(高地址)反向向下扫描,寻找纯粹的空闲页框(Free Pages)。这些空闲位点就是“新家”。
当两个扫描器在管理区中间相遇时,紧缩过程进入高潮:内核将“迁移扫描器”发现的进程数据,原封不动地拷贝到“空闲扫描器”预留的空闲页框中。随后,更新进程的页表指向新地址,并释放旧的物理页。这时,原本散落在各处的空闲页就会在管理区底部汇聚,伙伴系统的合并逻辑(Merging)会被自动触发,瞬间产生出大量的高阶(High-order)内存块。这种算法的精妙之处在于它不仅解决了碎片,还通过局部搬迁规避了全量扫描带来的极致开销。
7.2 同步与异步的博弈:紧缩对实时性的冲击
在嵌入式实时系统中,内存紧缩是一把威力巨大的双刃剑。根据触发时机的不同,紧缩分为同步紧缩(Direct Compaction)和异步紧缩(kcompactd)。
异步紧缩(kcompactd):这是内核的后台管家。当 kswapd 发现内存水位(Watermark)偏低但碎片化严重时,会唤醒 kcompactd 在后台默默进行搬迁。这种方式对应用层几乎透明,是系统维持长期确定性的关键。
同步紧缩(Direct Compaction):这是最危险的。当一个进程(例如您的视频采集线程)申请大块内存失败,且后台回收不足以解决问题时,该进程会被强行拉入紧缩流程。这意味着你的业务线程必须停下来,去帮内核搬运其他进程的内存页。
为了防御这种不确定性,内核引入了 compaction_proactiveness 参数。通过提高这个值,可以让内核在碎片化尚不严重时就更积极地进行后台紧缩,从而减少业务线程被拉入“泥潭”的概率。此外,对于不可移动页(Unmovable Pages)导致的紧缩失败,内核设计了“逃生机制”,避免在无法紧缩的区域浪费过多 CPU。作为开发者,监控 /proc/vmstat 中的 compact_stall 计数器,是判断系统卡顿是否由伙伴系统紧缩引起的最直接手段。
第八章:调优
8.1 CMA 与大页的协同演战
在 2026 年的主流嵌入式 SoC 上,处理多路 USB 摄像头或 MIPI 接口的视频录制任务,最稳健的策略并不是完全依赖伙伴系统的动态分配,而是使用连续内存分配器(CMA, Contiguous Memory Allocator)。
CMA 的本质是伙伴系统的一个“特区”。在系统启动阶段,内核划出一块巨大的连续内存。在平时,伙伴系统可以把这块区域借给“可移动”页使用;但当驱动程序需要进行大块 DMA 传输时,CMA 会通过紧缩机制迅速清空该区域。
针对视觉任务的配置建议:
CMA 尺寸调优:根据Buffer 需求,在 Bootloader 中准确预留。
透明大页(THP)的抉择:虽然大页(Hugepages)能减少 TLB Miss,但在内存受限的嵌入式设备上,频繁的大页拆分和合并会加剧伙伴系统的负担。建议对视频处理这类大内存块应用使用显式大页(Static Hugepages),而关闭系统全局的透明大页。
这种预留机制不仅保护了伙伴系统免受高阶请求的冲击,还通过减少动态紧缩,确保了视频采集链路的确定性。当物理内存不再处于“拆东墙补西墙”的焦虑状态时,系统的整体功耗和响应延迟都会显著改善。
8.2 指标监测与内核参数的终极微调
作为一个内核级工程师,手中必须有两把尺子:一把是监控指标,一把是调优参数。在伙伴系统的管理中,这几处设置至关重要:
/proc/buddyinfo 的深度解读:
这是伙伴系统的实时“体检报告”。从 Order 0 到 Order 10,每个数字代表空闲块的数量。如果你发现低 Order 数字极大而高 Order 几乎为 0,说明系统已经病入膏肓,正处于碎片化的极端状态。
vm.min_free_kbytes:
这是调整伙伴系统“危机感”的旋钮。提高这个值,可以让内核更早地唤醒 kswapd 进行回收和合并。对于 4GB 内存的嵌入式设备,将其设为 64MB 到 128MB 通常能有效缓解视频采集瞬间的内存短缺。
vm.extfrag_threshold:
该值决定了内核何时认为碎片化达到了“不可容忍”的阈值。调低该值会让内核更频繁地触发紧缩,有助于保持内存的整洁。
伙伴系统不仅是 Linux 内核物理内存管理的终点,也是所有高性能应用的起点。从 $2^n$ 的二进制哲学,到多核 PCP 的速度平衡,再到迁移类型和紧缩机制的碎片化防御,它展现了一套近乎完美的工程平衡艺术。