3. Linux内核内存管理机制

伙伴系统,说白了就是内核管理物理内存的那套核心算法。你写驱动时调用的 kmalloc、__get_free_pages,底层最终都会落到它头上。我刚开始啃这部分时也觉得挺绕,但搞懂了之后,很多内存相关的坑你一眼就能看穿。

3.1 算法原理:把内存切成“伙伴”

伙伴系统的核心思想很简单:把内存按 2 的幂次方分成块。每个块的大小是 2^order 个连续物理页框(page frame)。

系统维护一个数组 free_area[MAX_ORDER],每个元素对应一个链表,挂载着对应大小的空闲块。

order 块大小(页框数) 典型用途
0 1 页(4KB) 小块分配,如 slab 缓存
1 2 页(8KB) 小型 DMA 缓冲区
2 4 页(16KB) 中等大小分配
3 8 页(32KB) 网络收发缓冲区
... ... ...
10 1024 页(4MB) 大块连续内存

分配时,如果对应 order 的链表有空闲块,直接取走。如果没有,就去更高 order 的链表里借一块,然后一分为二。这两块就是“伙伴”。

伙伴关系的关键条件:

  • 两个块大小相同
  • 物理地址连续
  • 从同一个大块分裂而来
  • 低地址块的起始地址必须是 2^(order+1) 的整数倍

释放时,系统会检查它的伙伴是否也是空闲的。如果是,就把两块合并回更大的块,继续向上检查。这个过程叫合并(coalescing)。

3.2 内存分配:从分裂到返回

分配函数的核心路径是 __alloc_pages_nodemask()。我简化一下关键步骤:

  1. 快速路径:在目标 zone 的 free_area[order] 链表里找。有就直接取。
  2. 慢速路径:如果链表为空,调用 expand() 从更高 order 分裂。
  3. 回收尝试:如果还是不够,触发内存回收(kswapd 或 direct reclaim)。
  4. 终极手段:调用 OOM killer 杀掉进程。

分裂过程用代码表示大概是这样:

// 从 order = 2 的块中分裂出 order = 1 的块
struct page *expand(struct zone *zone, struct page *page,
                    int low, int high, struct free_area *area)
{
    unsigned long size = 1 << high;

    while (high > low) {
        area--;
        high--;
        size >>= 1;
        // 把后半块挂到低一级的 free_area 链表
        list_add(&page[size].lru, &area->free_list);
        area->nr_free++;
    }
    return page;
}

嗯,这里要注意:page[size] 就是伙伴块的起始页描述符。内核通过 page->lru 链表头把它们串起来。

我的经验:在嵌入式设备上,我建议尽量使用 kmalloc 而不是直接 __get_free_pages。因为 kmalloc 背后有 slab 缓存,分配小对象时不会频繁触发伙伴系统的分裂合并,性能好很多。

3.3 内存释放:合并的艺术

释放函数 __free_pages() 的核心是 __free_one_page()。它做三件事:

  1. 把页放回 free_area[order] 链表
  2. 检查伙伴是否空闲(通过 __find_buddy_index() 计算伙伴页框号)
  3. 如果伙伴空闲,合并并递归向上检查

伙伴页框号的计算公式:

buddy_pfn = page_pfn ^ (1 << order)

这个异或操作很巧妙。举个例子:

  • 假设 order=1,块大小 2 页
  • 块 A 起始页框号 0,块 B 起始页框号 2
  • 0 ^ (1 << 1) = 2,正好是伙伴的起始页框号
  • 2 ^ (1 << 1) = 0,反过来也成立

我曾经在调试一个 DMA 驱动时,发现释放内存后系统内存越用越少。查了半天,原来是驱动在释放时传错了 order 值,导致伙伴系统无法正确合并,内存慢慢碎成了渣。

避坑指南:释放时传入的 order 必须和分配时完全一致。多 1 或少 1 都会导致伙伴系统状态错乱,轻则内存泄漏,重则内核崩溃。

3.4 碎片化问题:伙伴系统的软肋

伙伴系统最大的痛点是外部碎片。你想想看,系统运行久了,各种大小的分配释放交替进行,内存块会变得支离破碎。

举个例子:

  • 系统有 16 个连续页框(order=4)
  • 分配 1 页(order=0),从中间取走第 8 页
  • 分配 2 页(order=1),从剩余空间取走第 0-1 页
  • 释放第 8 页
  • 现在空闲页框分布:2-7、8、10-15

虽然空闲页框总数不少,但最大连续块只有 6 页(order=2 都凑不出来)。这就是碎片。

碎片化的后果很直接:

场景 影响
DMA 分配大块连续内存 失败,驱动报错
网络设备分配 RX 环形缓冲区 分配耗时增加,丢包
文件系统预读缓存 性能下降

内核的应对策略:

  • 内存规整(compaction):把可移动页迁移到一起,腾出连续空间
  • 反碎片(anti-fragmentation):通过 __GFP_RECLAIMABLE 等标志位,把不同生命周期的页分类存放
  • 虚拟可移动域(ZONE_MOVABLE):专门存放可迁移的用户态页面

我个人习惯在驱动初始化时,一次性分配好所有大块内存,避免运行时频繁触发伙伴系统的分裂合并。如果实在需要动态分配,我会用 alloc_pages_exact() 配合 __GFP_NORETRY,分配失败就降级处理,而不是死等。

3.5 伙伴系统核心流程总览

下面这张图把分配和释放的完整路径画出来了,你对照着看会更清楚:

伙伴系统核心流程 伙伴系统分配与释放核心流程 分配路径 释放路径 请求 order 块 free_area[order] 有空? 取出块,返回 无 向 order+1 借块 分裂,挂回低 order 释放 order 块 伙伴也空闲? 合并,order+1 递归向上合并 否 挂回链表 碎片化问题:频繁分裂合并 → 最大连续块变小 → 大块分配失败 内核对策:内存规整(compaction)+ 反碎片分类 + ZONE_MOVABLE

从这张图你能看到,分配路径的核心是“分裂”,释放路径的核心是“合并”。碎片化问题就藏在这两个过程的反复交替中。

我的建议:在写驱动时,尽量用 devm_kmalloc() 这类设备资源管理函数。它们会在驱动卸载时自动释放内存,避免你忘记释放导致伙伴系统状态混乱。我在一个网络驱动里吃过这个亏,后来全部改成 devm 系列,省心多了。