物理内存管理:从节点到页帧的完整图景
物理内存管理,说白了就是操作系统怎么管好那一堆内存颗粒。我刚开始接触Linux内存子系统时,觉得这玩意儿不就是malloc和free吗?后来在嵌入式项目上踩了坑才明白——物理内存的组织方式,直接决定了系统能不能稳定跑起来。
咱们先看一张整体架构图,把今天要聊的几个概念串起来:
内存节点:NUMA时代的产物
先说说内存节点。这个概念在UMA(统一内存访问)架构下其实没啥存在感——整个系统就一个节点,所有CPU访问内存的延迟都一样。但到了NUMA(非统一内存访问)时代,情况就变了。
我调试过一个48核的ARM服务器,每个CPU簇有自己的内存控制器。如果进程跑在CPU0上却分配了CPU1的内存,性能直接掉20%。这就是内存节点存在的意义——让系统知道"哪块内存离哪个CPU最近"。
每个节点对应一个 pglist_data 结构体,里面装着:
- 节点内的内存域列表
- 空闲页的统计信息
- 页面回收相关的参数
- kswapd内核线程(每个节点一个)
关键点:在设备树或ACPI中,内存节点信息由固件提供。我见过一个案例,固件把两个内存条描述成同一个节点,结果NUMA优化完全失效。排查了三天才发现是BIOS的SRAT表配错了。
内存域:把内存按用途分类
每个节点内部,内存又被划分成不同的域(zone)。为什么要分?因为不同硬件对内存地址有不同要求。
| 内存域 | 用途 | 典型地址范围 |
|---|---|---|
ZONE_DMA |
老式ISA设备DMA(16MB以下) | 0 - 16MB |
ZONE_DMA32 |
32位设备DMA(4GB以下) | 0 - 4GB |
ZONE_NORMAL |
常规内存,内核直接映射 | 16MB - 896MB(32位) |
ZONE_HIGHMEM |
高端内存,需临时映射(32位) | 896MB以上 |
ZONE_MOVABLE |
可迁移页面,用于内存热插拔 | 由内核参数指定 |
嗯,这里要注意:64位系统基本没有ZONE_HIGHMEM了,因为虚拟地址空间够大。但ZONE_DMA和ZONE_DMA32依然存在——我遇到过一块FPGA加速卡,它的DMA引擎只能访问32位地址空间,分配内存时必须指定GFP_DMA32标志。
页帧:物理内存的最小单元
页帧(page frame)就是物理内存的"最小管理单位",通常是4KB。每个页帧对应一个 struct page 结构体,这个结构体大概64字节,记录着:
- 页面的引用计数
- 页面标志位(脏页、锁定、正在回写等)
- 映射到的虚拟地址(匿名页或文件页)
- LRU链表节点(用于页面回收)
我算过一笔账:一台256GB内存的机器,4KB页帧需要约4GB的 struct page 数组。这可不是小开销!所以内核在启动时会用 memmap 参数预留这块空间。
调试技巧:用 cat /proc/zoneinfo 可以查看每个内存域的页帧使用情况。我曾经靠这个命令发现某个域的 nr_free_pages 持续下降,最终定位到一个内核模块的内存泄漏。
伙伴系统算法:内核的"内存批发商"
伙伴系统是物理内存分配的核心算法。它的思路很简单:把空闲页按2的幂次(order)分组管理。
举个例子:
- order=0:1页(4KB)
- order=1:2页(8KB)
- order=2:4页(16KB)
- ……
- order=10:1024页(4MB)
分配时,如果请求order=3(8页),系统先找order=3的空闲链表。没有就找order=4,拆成两个order=3的块,一个分配出去,另一个挂回order=3链表。释放时反过来,检查相邻块是否空闲,是就合并。
核心API就两个:
// 分配2^order个连续物理页,返回第一个页的struct page指针
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);
// 释放页面
void __free_pages(struct page *page, unsigned int order);
我踩过的一个坑:在中断上下文调用 alloc_pages 时没加 GFP_ATOMIC 标志,结果内核直接睡眠了——中断上下文不能睡眠,系统就卡死了。这个bug排查了两天,最后靠打开 CONFIG_DEBUG_ATOMIC_SLEEP 才抓到。
内存碎片化:伙伴系统的天敌
伙伴系统有个致命弱点——碎片化。你想想看,系统运行久了,各种大小的分配和释放交替进行,空闲页就变得支离破碎。
我遇到过最夸张的情况:系统还有200MB空闲内存,但想分配一个8MB的连续块(order=11)却失败了。这就是典型的外碎片问题。
内核对付碎片化有这几招:
- 反碎片化(Anti-fragmentation):把可移动和不可移动的页面分到不同域,避免不可移动页把空闲空间打碎
- 内存规整(Compaction):通过页面迁移把分散的空闲页合并成大块
- 虚拟可移动域(ZONE_MOVABLE):专门放可迁移页面,规整时直接搬走
注意:内存规整不是免费的。我在一个实时系统上做过测试,触发一次全量规整耗时约50ms,这对延迟敏感的应用是灾难。所以实时系统通常要调小 extfrag_threshold 或者干脆关掉规整。
CMA:为大块连续内存而生
CMA(Contiguous Memory Allocator)是内核解决大块连续内存分配的利器。它的设计很巧妙:在系统启动时预留一大块内存,平时允许可移动页面占用它,当有设备(如GPU、Camera)需要大块连续内存时,再把占用的页面迁走。
配置CMA的方式:
// 内核启动参数
cma=64M
// 或者设备树
reserved-memory {
linux,cma {
compatible = "shared-dma-pool";
size = <0x04000000>;
reusable;
};
};
使用CMA的典型流程:
// 分配CMA内存
struct page *cma_alloc(struct cma *cma, unsigned int count,
unsigned int align, bool no_warn);
// 释放CMA内存
bool cma_release(struct cma *cma, const struct page *pages,
unsigned int count);
我调试过一个摄像头驱动,它需要4MB连续内存做ISP缓冲区。一开始用 alloc_pages 分配order=10,系统跑几天就分配失败了。改成CMA后,再也没出过问题。
经验之谈:CMA的大小不是越大越好。我见过有人给一个2GB内存的系统配了1GB CMA,结果普通应用内存不够,频繁触发OOM Killer。一般建议CMA大小不超过总内存的20%。
连续内存分配器:伙伴系统的补充
除了CMA,内核还有几个专用的连续内存分配器:
| 分配器 | 特点 | 适用场景 |
|---|---|---|
kmalloc |
基于slab,最大4MB连续 | 内核小对象分配 |
vmalloc |
虚拟地址连续,物理页可不连续 | 大块内存但不需要物理连续 |
dma_alloc_coherent |
分配一致DMA缓冲区,保证cache一致性 | DMA设备驱动 |
gen_pool |
通用内存池,可自定义分配策略 | 特定硬件的内存管理 |
这里有个容易混淆的点:dma_alloc_coherent 分配的内存一定是物理连续的,而 vmalloc 分配的内存物理上可能不连续。我见过有人用 vmalloc 给DMA设备分配缓冲区,结果DMA传输数据错乱——因为DMA需要物理连续地址。
总结一下:物理内存管理是个层层递进的体系。节点解决NUMA亲和性,域解决设备兼容性,伙伴系统解决通用分配,CMA和连续分配器解决特殊需求。理解这个层次,你写驱动时就知道该用哪个API了。