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()。我简化一下关键步骤:
- 快速路径:在目标 zone 的
free_area[order]链表里找。有就直接取。 - 慢速路径:如果链表为空,调用
expand()从更高 order 分裂。 - 回收尝试:如果还是不够,触发内存回收(kswapd 或 direct reclaim)。
- 终极手段:调用 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()。它做三件事:
- 把页放回
free_area[order]链表 - 检查伙伴是否空闲(通过
__find_buddy_index()计算伙伴页框号) - 如果伙伴空闲,合并并递归向上检查
伙伴页框号的计算公式:
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 伙伴系统核心流程总览
下面这张图把分配和释放的完整路径画出来了,你对照着看会更清楚:
从这张图你能看到,分配路径的核心是“分裂”,释放路径的核心是“合并”。碎片化问题就藏在这两个过程的反复交替中。
我的建议:在写驱动时,尽量用 devm_kmalloc() 这类设备资源管理函数。它们会在驱动卸载时自动释放内存,避免你忘记释放导致伙伴系统状态混乱。我在一个网络驱动里吃过这个亏,后来全部改成 devm 系列,省心多了。