第一次接触 Linux 内核内存管理时,很多人会陷入一个误区:认为理解了虚拟内存、分页机制这些概念就等于掌握了内核内存管理的精髓。但真正深入内核源码后才发现,物理内存的管理远比想象中复杂——它不仅要处理从硬件启动时的内存布局探测,到伙伴系统、SLUB 分配器的层层抽象,还要应对 NUMA 架构、内存回收、碎片整理等工程难题。

更关键的是,内核内存管理不是一个孤立模块,它贯穿了从系统启动到进程调度、从驱动分配到网络缓存的每个环节。如果你只停留在理论层面,当遇到实际性能问题或内存泄漏时,往往会无从下手。这篇文章将带你从物理内存的硬件基础开始,逐步拆解内核如何组织、分配、回收物理内存,并给出可实操的排查方法和优化思路。

1. 从硬件到内核:物理内存的探测与初始化

理解内核内存管理,首先要回答一个基本问题:内核如何知道系统有多少物理内存,以及它们的布局是怎样的?这个问题在 x86 架构上尤其重要,因为 BIOS 提供的 e820 内存映射表描述了系统中可用的、保留的、甚至是存在缺陷的内存区域。

1.1 启动阶段的内存布局探测

在 x86 系统启动时,BIOS 会通过 e820 调用提供内存映射信息。内核在 arch/x86/kernel/e820.c 中处理这些信息,将其转换为 struct e820_entry 数组。这个过程发生在实模式到保护模式的转换期间,此时内核还没有建立完整的页表映射。

// 简化版 e820 条目结构
struct e820_entry {
    u64 addr;    // 内存区域起始地址
    u64 size;    // 区域大小
    u32 type;    // 类型:E820_RAM、E820_RESERVED 等
};

关键的是,e820 表显示的内存布局通常不是连续的。可能存在多个可用的 RAM 区域,中间被保留区域(如 BIOS 代码、硬件映射等)隔开。这就引出了内核需要解决的第一个问题:如何管理这些非连续的内存区域?

1.2 物理内存的抽象:从 e820 到 memblock

在完全建立页表之前,内核使用 memblock 来管理物理内存。memblock 是一个早期内存分配器,它将 e820 信息转换为两个核心数组:

  • memory :记录所有可用的物理内存区域
  • reserved :记录已分配或保留的内存区域

你可以通过 /sys/kernel/debug/memblock/memory 查看系统内存区域信息:

# 查看内存区域布局
cat /sys/kernel/debug/memblock/memory

memblock 的初始化在 init/main.c 的 setup_arch() 阶段完成。这个阶段内核还没有启用 slab 分配器,所以 memblock 使用最简单的位图管理方式。

注意:在排查内存问题时,如果怀疑是早期内存分配异常,可以检查 memblock 的调试信息。但生产环境通常不开启 memblock debug,需要在编译内核时配置 CONFIG_DEBUG_FS 。

1.3 内核页表的建立与内存映射

内核启动过程中最关键的步骤之一是从实模式切换到保护模式,再启用分页机制。这个过程涉及多级页表的建立:

  1. 临时页表 :在 arch/x86/kernel/head_64.S 中建立最初的 4 级页表,映射内核代码和数据区域
  2. 最终页表 :在 arch/x86/mm/init_64.c 中建立完整的直接映射区域(direct mapping)

x86_64 架构下,内核空间的虚拟地址与物理地址存在固定的偏移关系(通常是 0xffff888000000000)。这种直接映射简化了内核访问物理内存的过程,但也意味着内核虚拟地址空间的大小受限于物理内存容量。

// 物理地址到内核虚拟地址的转换
#define __va(x) ((void *)((unsigned long)(x) + PAGE_OFFSET))
#define __pa(x) ((unsigned long)(x) - PAGE_OFFSET)

这种设计带来的一个实际影响是:在内存大于 64TB 的系统上,可能需要调整内核配置来支持更大的直接映射区域。

2. 伙伴系统:大块物理内存的管理核心

当内核完成基本初始化后,memblock 会将内存控制权交给伙伴系统(Buddy System)。伙伴系统是物理内存管理的基石,它负责以页为单位(通常是 4KB)分配和释放连续物理内存。

2.1 伙伴系统的工作原理与数据结构

伙伴系统的核心思想是将物理内存划分为多个阶(order)的链表,每个阶对应 2^order 个连续页面。当请求分配内存时,系统会从最适合的阶开始查找,如果该阶没有空闲块,就向更高阶分裂。

关键数据结构在 include/linux/mmzone.h 中定义:

struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long nr_free;
};

struct zone {
    // ... 其他字段
    struct free_area free_area[MAX_ORDER];
    // ...
};

MAX_ORDER 通常定义为 11,意味着伙伴系统最多可以分配 2^10 = 1024 个页面(4MB)的连续内存。这也是为什么需要其他机制(如 CMA、vmalloc)来分配更大块内存的原因。

2.2 内存迁移类型与反碎片机制

Linux 2.6.24 引入了迁移类型来减少内存碎片。系统将页面分为以下几种类型:

  • MIGRATE_UNMOVABLE :不可移动页面(内核数据结构等)
  • MIGRATE_MOVABLE :可移动页面(用户进程数据)
  • MIGRATE_RECLAIMABLE :可回收页面(文件缓存等)
  • MIGRATE_CMA :连续内存分配器专用

你可以通过 /proc/buddyinfo 查看各迁移类型的空闲页面分布:

# 查看伙伴系统状态
cat /proc/buddyinfo

输出示例:

Node 0, zone      DMA      1      0      1      0      2      1      1      0      1      1      3
Node 0, zone    DMA32   1043   5278   1283    892    467    228    118     48     30     15     20

这个信息对于诊断内存碎片问题非常有用。如果高阶(右侧)的空闲块很少,说明系统可能存在碎片。

2.3 实际应用:诊断内存碎片问题

当驱动程序申请大块连续内存失败时,伙伴系统往往是问题的根源。排查步骤应该是:

  1. 检查当前碎片状态 :

    cat /proc/buddyinfo
    cat /proc/pagetypeinfo
    
  2. 确认内存申请大小 :计算需要的阶数, order = ilog2(size/PAGE_SIZE)

  3. 分析申请路径 :使用 ftrace 或 perf 跟踪 __alloc_pages 调用链

  4. 考虑解决方案 :

    • 调整迁移类型配置(通过 /proc/sys/vm/ 相关参数)
    • 使用 CMA 预留连续内存
    • 修改驱动使用多段映射而非单段连续内存

经验:在嵌入式或需要大量连续内存的场景中,应该在系统启动早期通过内核参数预留内存,而不是依赖运行时的伙伴系统。

3. SLUB 分配器:小块内存的高效管理

伙伴系统以页为单位管理内存,但对于几十到几百字节的小块内存,直接使用伙伴系统会产生巨大浪费。SLUB 是当前内核默认的小块内存分配器,它构建在伙伴系统之上,为内核对象提供高效的内存分配。

3.1 SLUB 的三层架构设计

SLUB 分配器采用巧妙的三层设计:

  1. kmem_cache :每个对象类型对应一个缓存(如 task_struct、inode_cache)
  2. SLUB 页面 :每个 CPU 有本地的 slab 页面,用于快速分配
  3. 伙伴系统 :当 slab 不足时,向伙伴系统申请整页内存

这种设计的优势在于:

  • per-CPU 缓存减少锁竞争
  • 对象复用减少初始化开销
  • 自然缓存对齐提高访问效率

查看系统所有 kmem_cache 的状态:

# 查看 SLUB 分配器统计
cat /proc/slabinfo

3.2 实际性能优化:调整 SLUB 参数

在生产环境中,SLUB 分配器的配置对性能有显著影响。特别是网络、文件系统等高频分配路径:

# 调整特定 kmem_cache 的批量分配数量
echo "16" > /sys/kernel/slab/kmalloc-128/cpu_partial

# 限制每个 slab 的最大对象数,减少内存浪费
echo "16" > /sys/kernel/slab/kmalloc-512/objects

调整原则应该是:

  • 对于高频小对象,增加 per-CPU 缓存大小
  • 对于大对象或低频对象,减少 slab 页面数量以节省内存
  • 根据实际负载使用 slabinfo 工具分析热点

3.3 内存泄漏检测与调试

SLUB 提供了强大的调试功能,可以在编译内核时开启:

  • CONFIG_SLUB_DEBUG=y :启用基本调试功能
  • CONFIG_SLUB_DEBUG_ON=y :默认开启所有 slab 的调试

调试功能开启后,可以检测:

  • 使用未初始化内存
  • 释放后使用(use-after-free)
  • 越界访问

实际操作中,更常用的方法是使用 kmemleak :

# 启用 kmemleak
echo scan > /sys/kernel/debug/kmemleak

# 获取检测报告
cat /sys/kernel/debug/kmemleak

4. NUMA 架构下的内存管理挑战

在多处理器系统中,NUMA(Non-Uniform Memory Access)架构带来了新的内存管理复杂度。每个 CPU 节点有本地内存,访问远程节点内存会有额外延迟。

4.1 NUMA 拓扑识别与内存分配策略

内核在启动时通过 ACPI SRAT 表或硬件特定方法识别 NUMA 拓扑。查看系统 NUMA 信息:

# 查看 NUMA 节点布局
numactl --hardware

# 查看进程的 NUMA 内存分配
cat /proc/<pid>/numa_maps

内核提供了多种 NUMA 分配策略:

  • MPOL_DEFAULT :使用进程启动节点的策略
  • MPOL_BIND :绑定到特定节点
  • MPOL_PREFERRED :优先使用指定节点
  • MPOL_INTERLEAVE :在多个节点间交错分配

4.2 实际性能调优:NUMA 平衡与绑定

对于性能敏感的应用,错误的 NUMA 策略可能导致性能下降 30% 以上。优化建议:

  1. 分析当前状态 :

    # 查看每个节点的内存使用
    numastat
    
    # 查看进程的内存节点分布
    numastat -p <pid>
    
  2. 绑定内存分配 :

    # 将进程绑定到节点 0 运行,内存也从节点 0 分配
    numactl --membind=0 --cpunodebind=0 <command>
    
  3. 监控 NUMA 平衡器效果 :

    # 查看自动平衡统计
    cat /proc/vmstat | grep numa
    

注意:NUMA 平衡器(自动迁移页面到访问节点)会带来一定开销。在已知工作负载特性的情况下,手动绑定通常能获得更好性能。

4.3 跨节点分配的内存回收挑战

NUMA 环境下的内存回收比 UMA 系统复杂得多。当某个节点内存不足时,回收器需要决定:

  • 回收本地节点的内存(可能较少)
  • 回收远程节点的内存(访问成本高)
  • 迁移页面到访问节点(平衡长期效益与短期开销)

内核的 zone_reclaim_mode 参数控制这一行为:

# 启用积极的本地回收
echo 1 > /proc/sys/vm/zone_reclaim_mode

在实际调优中,需要根据应用的内存访问模式选择合适的策略。数据库等连续访问大量数据的工作负载适合绑定策略,而多线程科学计算可能更适合交错分配。

5. 内存回收机制:从 LRU 到页面交换

物理内存是有限资源,当内存紧张时,内核需要回收部分页面来满足新的分配请求。理解回收机制对于诊断系统卡顿、OOM 杀进程等问题至关重要。

5.1 LRU 链表与页面分类

内核维护多个 LRU(Least Recently Used)链表来跟踪页面使用情况:

  • 活跃匿名页面 :进程堆栈、堆数据
  • 非活跃匿名页面 :待换出的匿名内存
  • 活跃文件页面 :正在使用的文件缓存
  • 非活跃文件页面 :可回收的文件缓存

查看当前 LRU 状态:

# 查看各 LRU 链表大小
cat /proc/meminfo | grep -i active
cat /proc/meminfo | grep -i inactive

5.2 内存回收触发条件与流程

内存回收主要通过以下条件触发:

  1. 直接回收 :分配内存时发现空闲页面不足
  2. kswapd 后台回收 :当空闲页面低于水位线时启动
  3. 内存压力通知 :cgroup 内存压力达到阈值

回收流程的核心在 mm/vmscan.c 的 shrink_node 函数中,基本步骤:

  1. 计算需要回收的页面数量
  2. 先尝试回收文件页面(无代价,只需丢弃缓存)
  3. 如果文件页面不足,开始回收匿名页面(需要交换到磁盘)
  4. 根据 LRU 算法选择最近最少使用的页面
  5. 处理脏页回写和交换输出

5.3 交换机制与交换分区优化

当匿名页面需要被回收时,内核将其内容写入交换设备。交换策略对系统性能影响巨大:

交换分区配置建议 :

  • 对于桌面系统:交换空间 = 物理内存的 1.5-2 倍
  • 对于服务器:根据工作负载特性决定,可能完全禁用交换
  • 使用 SSD 作为交换设备能显著提高交换性能

监控交换活动 :

# 查看交换统计
vmstat 1

# 查看进程交换情况
cat /proc/<pid>/status | grep VmSwap

优化交换行为 :

# 调整交换倾向性(0-100,值越大越积极交换)
echo 10 > /proc/sys/vm/swappiness

# 控制脏页回写阈值
echo 20 > /proc/sys/vm/dirty_ratio

5.4 OOM Killer 机制与调优

当内存回收无法满足分配请求时,内核会触发 OOM Killer 选择进程终止。理解选择算法有助于预防重要进程被误杀:

查看 OOM 评分 :

# 查看进程的 OOM 评分
cat /proc/<pid>/oom_score

# 调整 OOM 调整值(-1000 到 1000)
echo -1000 > /proc/<pid>/oom_score_adj

OOM Killer 调优策略 :

  • 为关键进程设置 oom_score_adj = -1000
  • 使用 cgroup 内存限制隔离不同服务
  • 监控 /var/log/messages 中的 OOM 日志

6. 高级主题与性能优化实战

掌握了基本机制后,我们需要关注实际生产环境中的高级主题和优化技巧。

6.1 透明大页(THP)的性能影响

透明大页(2MB 或 1GB 页面)能减少 TLB 缺失,提高内存访问效率,但也可能增加内存碎片:

# 查看 THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled

# 控制 THP 行为
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

使用建议:

  • 内存密集型应用(数据库、科学计算)通常受益于 THP
  • 内存受限或碎片敏感的系统应该谨慎使用
  • 使用 madvise 模式,让应用显式请求大页

6.2 内存压缩与 KSM

内核页面合并(KSM)通过合并相同内容的页面节省内存,特别适合虚拟化环境:

# 查看 KSM 统计
cat /sys/kernel/mm/ksm/pages_shared
cat /sys/kernel/mm/ksm/pages_sharing

内存压缩(zswap、zram)在内存紧张时压缩页面内容,减少交换I/O:

# 启用 zswap
echo 1 > /sys/module/zswap/parameters/enabled

6.3 内存性能监控工具箱

建立完整的内存监控体系:

基础监控 :

# 实时内存状态
watch -n 1 'cat /proc/meminfo'

# 内存压力监控
cat /proc/pressure/memory

高级分析 :

# 使用 perf 分析内存访问模式
perf record -e cache-misses -ag -- sleep 5

# 使用 valgrind 进行用户空间内存分析
valgrind --tool=memcheck <program>

生产环境检查清单 :

  • 定期检查 /proc/buddyinfo 预防内存碎片
  • 监控 /proc/vmstat 中的页面回收统计
  • 设置 cgroup 内存限制防止单个服务耗尽内存
  • 配置合理的交换空间和 swappiness 参数

Linux 内核的物理内存管理是一个极其复杂的系统,从硬件抽象到高级优化策略,每个层面都有其设计哲学和实际考量。真正掌握内存管理不在于记住所有细节,而在于理解各个组件如何协同工作,以及当问题出现时如何系统性地排查和解决。

最有效的学习路径是从实际需求出发:先理解自己工作场景中的内存使用模式,然后有针对性地深入研究相关机制。无论是优化数据库性能、调试内核驱动内存泄漏,还是简单地为应用选择合适的内存配置,扎实的内存管理知识都是不可或缺的基础。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐