引言

内存管理是 Linux 内核最核心、最复杂的子系统之一。它直接决定了系统在多核 NUMA 服务器、嵌入式设备和云计算场景下的性能上限。本文将从源码级别逐层剖析 Linux 内存管理的完整架构:物理页框分配器(Buddy System)、对象缓存分配器(Slab/SLUB)、虚拟内存管理器(VMA/mmap),并配合生产级故障排查与性能调优案例。

一、物理页框与 struct page

Linux 将物理内存建模为 NUMA Node → Zone → Page Frame 三级结构。每个物理页框对应一个 struct page 描述符,系统维护一个全局 mem_map 数组。

// 每个物理页的管理结构(简化)
struct page {
    unsigned long flags;        // 脏页/锁定/引用等标志
    atomic_t _refcount;         // 引用计数
    atomic_t _mapcount;         // 映射计数(多少进程在使用)
    unsigned long private;      // 类型私有数据
    struct address_space *mapping; // 关联的地址空间
    pgoff_t index;              // 在 mapping 中的偏移
    struct list_head lru;       // LRU 链表节点
};

在 64 位系统中,x86_64 通常定义 4KB 页,struct page 约占 64 字节。1TB 物理内存约对应 2.5 亿个 page 结构,仅 mem_map 就需约 1.5GB 内存。这也是为什么内核引入了 稀疏内存模型(SPARSEMEM) 和 vmemmap 动态映射来优化。

二、伙伴系统(Buddy System)

2.1 二进制伙伴算法原理

Buddy System 是 Linux 物理页分配的核心算法。它将可用内存划分为 11 个 free_area 链表,管理 order 0~10 的连续页块(2^n 个页面):

//mm/page_alloc.c
struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long nr_free;
};

// order=0: 1页(4KB), order=1: 2页(8KB), ..., order=10: 1022页(4MB)

分配流程:从目标 order 开始向上查找合适大小的块;若找到上层大块,则分裂(expand())至目标 order,一半放入空闲链表,另一半交给调用者。

释放流程:释放页块时检查其伙伴(buddy)是否也在空闲链表中。若伙伴空闲则合并(__free_one_page)为更大的块,递归向上合并。

2.2 页面迁移类型(Migrate Types)

为提高抗碎片化能力,内核将页面按可移动性划分为多种迁移类型分池管理:

迁移类型用途可移动性
MIGRATE_UNMOVABLE内核对象、内核栈不可移动
MIGRATE_MOVABLE用户态页面、缓存可移动
MIGRATE_RECLAIMABLE可回收的内核缓存不可直接移动,但有回收路径
MIGRATE_ISOLATE热插拔/离线专用完全隔离

2017 年内核引入的 Anti-Fragmentation 机制 确保不可移动的对象不会占用可移动页池,这对 THP(透明大页)的合并成功与否至关重要。

2.3 ZONE 分区策略

x86_64 通常设三个 ZONE:

  • ZONE_DMA32(0~4GB):兼容 32 位 DMA 设备
  • ZONE_NORMAL(线性映射区):直接映射内核代码和数据
  • ZONE_MOVABLE:可移动页面池,支持内存热插拔

分配时优先使用 NORMAL 区,仅在 DMA 请求时分配 DMA 区,回退策略(fallback)通过 zone 的 zonelist 实现。

三、per-cpu 页面缓存

NUMA 系统中 Buddy 的锁竞争是性能瓶颈。内核在每个 CPU 上维护了 Hot/Cold 两个 per-cpu 缓存:

// 从 per-cpu 缓存分配单页(order=0)
static struct page *get_pcp_page(zone, order, gfp) {
    // 优先从 pcp->lists[MIGRATE_PCPTYPES] 取出
    // 不足时从 Buddy 批量补充(batch=64 页)
}

Hot页直接用于分配,Cold页作为储备。当 Hot 页不足且 Cold 页超过阈值时,将 Cold 页归还给 Buddy。这种设计大幅减少了 zone->lock 自旋锁的争用。

四、Slab / SLUB / SLOB 分配器

4.1 设计动机

内核态分配通常需要小于整页的内存(如 task_struct 约 7KB)。直接从 Buddy 分配 4KB 页再分裂有大量内碎片。Slab 分配器将一页或数页分割成固定大小的对象缓存,每个对象即为一个内核结构。

4.2 SLUB 核心数据结构

SLUB 是当前默认的 Slab 分配器,核心结构为 kmem_cache:

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;  // per-cpu 活跃 slab
    unsigned long flags;
    unsigned int size;         // 对象大小(含对齐)
    unsigned int object_size;  // 请求的原始大小
    unsigned int offset;       // 空闲链表指针偏移
    struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 远端 slab
};

每个 slab 页面内含若干相同大小的对象,通过 free list(内嵌指针链接)管理可用对象。SLUB 的创新在于取消了单独的 slab 管理页,将所有元数据嵌入到 struct page 的 union 中(仅在 slab 使用时生效)。

4.3 kmalloc 三步分配

kmalloc(size, flags) 是内核最常见的内存分配接口,其实现为三步查找:

  1. Size 到 Cache 映射:kmalloc 维护 13 个固定尺寸的通用缓存池(8B~8KB),size 向上对齐到最近缓存。若 size > 8KB,转到 vmalloc。
  2. Allocator 选择:优先从 kmem_cache_cpu->freelist 分配热路径(per-cpu partial hit → node partial → new page from buddy)。
  3. NUMA 感知:从请求 CPU 的本地 node 分配,本地无可用对象时再访问远端 node。

若 size 超过两个页面(8KB on 4KB page),kmalloc 转调 kmalloc_large() 走 Buddy 直接分配,并标记为 __GFP_COMP compound page。

五、虚拟地址空间与 mmap

5.1 进程地址空间结构

每个进程的 48 位虚拟地址空间(配置 5 级页表时为 57 位)由多个 struct vm_area_struct(VMA)组成,按地址排序组织在红黑树中:

struct mm_struct {
    pgd_t *pgd;                    // 页全局目录
    struct rb_root mm_rb;          // VMA 红黑树根
    struct vm_area_struct *mmap;   // VMA 链表头
    unsigned long start_code, end_code;
    unsigned long start_brk, brk;  // 堆边界
    unsigned long mmap_base;       // mmap 起始地址
    // ...19 个字段
};

5.2 mmap 分配路径

用户态 mmap 通过 ELF loader / malloc (大块) / 直接 syscall 触发。内核使用 bottom-up 策略 在 mmap_base 以下向上扫描空闲 gap(通过 VMA 红黑树),或通过 top-down 策略(ASLR 启用时)从栈顶向下扫描。找到合适 gap 后创建新 VMA 并入树。

5.3 缺页中断(Page Fault)

物理页不在内存时触发缺页,缺页路径决定了 mmap 的按需调页特性:

// arch/x86/mm/fault.c:do_page_fault()
1. 查找 VMA:find_vma(mm, address) —— O(log n) 红黑树查找
2. 权限校验:VMA 有对应 r/w/x 权限
3. 匿名页分配:do_anonymous_page() — 分配零页写入
4. 文件页加载:do_fault() — 从磁盘/PageCache 调入
5. COW 分裂:do_wp_page() — fork 后写时复制

对于 mmap 映射的文件,内核首先检查 Page Cache(radix_tree_lookup);若已在 cache 则直接映射,否则启动预读(page_cache_sync_readahead)将磁盘数据加载进内存。

六、页面回收与交换

6.1 LRU 链表

内核将页面按活跃度维护在 4 条 LRU 链表上:active_file / inactive_file / active_anon / inactive_anon。页面首次加入时放在 inactive 尾部,被访问时 mark_page_accessed() 提升至 active。回收时扫描 inactive 最久未访问的页面予以驱逐。

6.2 页面回收(KSwapd + Direct Reclaim)

KSwapd 作为后台守护线程,get_scan_count() 计算每条 LRU 的扫描窗口:匿名页扫描权重更高(因回收代价更大,需要写 swap)。当空闲页低于 wmark_low 时唤醒 kswapd,低至 wmark_min 时分配路径同步 direct reclaim。

Direct Reclaim 在内存分配时发生,会阻塞调用者直到回收足够页,对用户态延迟影响巨大("direct reclaim stall"是生产常见性能问题)。

6.3 KSM 与 Zswap

KSM(Kernel Samepage Merging) 通过红黑树扫描用户态页面(标记为 MADV_MERGEABLE),发现相同内容的页后合并为一页(COW 共享),为 KVM 虚拟机节省 30%~50% 内存。

Zswap 压缩被驱逐的匿名页,存入内存中的压缩缓存池(默认 zbud 分配器,压缩比通常 2:1),减少 swap I/O。

七、NUMA 内存策略

NUMA 系统中远端内存访问延迟可达本地 2~3 倍。内核通过以下机制优化:

  • 本地优先分配:alloc_pages_node() 从请求 CPU 所属 node 开始
  • Zone Reclaim:本地 node 不足时触发回收再回退远端
  • MPOL_BIND/INTERLEAVE:用户态 set_mempolicy() 显式绑定
  • AutoNUMA Balancing:内核 3.13+ 将访问频次最高的页面迁移到本地(扫描开销 per-thread 10ms/页)

八、实战:内核驱动内存分配示例

// 字符设备驱动的完整内存分配流程
#include 
#include 
#include 

struct my_dev {
    void *kbuf;        // kmalloc 分配(物理连续)
    void *vbuf;        // vmalloc 分配(虚拟连续)
    struct page **pages; // 用户态 mmap 用
};

static int dev_mmap(struct file *filp, struct vm_area_struct *vma)
{
    unsigned long offset = vma->vm_pgoff << PAGE xss=removed>private_data;

    /* 方法1:remap_pfn_range 物理映射 */
    // io_remap_pfn_range(vma, vma->vm_start,
    //     virt_to_phys(dev->kbuf) >> PAGE_SHIFT, size, vma->vm_page_prot);

    /* 方法2:fault 钩子(推荐,支持 COW 和按需调页) */
    vma->vm_ops = &dev_vm_ops;
    return 0;
}

static vm_fault_t dev_fault(struct vm_fault *vmf)
{
    struct my_dev *dev = vmf->vma->vm_private_data;
    vmf->page = vmalloc_to_page(dev->vm.buf + (vmf->pgoff << PAGE>page);
    return 0;
}
// 分配与使用的典型模式
static long dev_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
    struct my_dev *dev = f->private_data;

    switch (cmd) {
    case MY_ALLOC:
        // 小对象(< 8KB>kbuf = kzalloc(SZ_4KM, GFP_KERNEL);
        if (!dev->kbuf) return -ENOMEM;

        // 大对象(可能 > 4MB): vmalloc(虚拟连续,不保证物理连续)
        dev->vbuf = vzalloc(SZ_8MB);
        if (!dev->vbuf) { kfree(dev->kbuf); return -ENOMEM; }

        // 用户态 mmap 映射准备
        dev->pages = kcalloc(n_pages, sizeof(struct page *), GFP_KERNEL);
        for (i = 0; i < n>pages[i] = alloc_page(GFP_KERNEL | __GFP_ZERO);
        break;
    }
    return 0;
}

九、调试与性能分析

9.1 从 /proc 与 /sys 获取状态

# 查看伙伴系统分布(每个 order 的连续空闲页数量)
$ cat /proc/buddyinfo
Node 0, zone   Normal  120 580 892 432 156 48 32 8 2 1 0

# 查看 slab 对象缓存分布(slabtop / /proc/slabinfo)
$ slabtop -o | head -20
 OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
 12600  12600 100%   0.49K    150      84     24K kmalloc-512
  8400   8400 100%   0.19K    100      84     16K kmalloc-192

# 查看 NUMA 节点内存分布
$ numastat -c
Node         MemTotal    MemFree   HugePages
node0         251884      86523         32
node1         252087     198401         64

# 查看页面回收统计
$ cat /proc/vmstat | grep -E "pswpin|pswpout|kswapd|compact"

9.2 perf 事件探查

# 监控 Buddy 分配失败(order >= 3 失败说明内存碎片化)
perf record -e 'buddy:buddy_alloc_failed' -a sleep 10

# 追踪 kmalloc/kfree 调用栈
trace-cmd start -e 'kmem:kmalloc' -e 'kmem:kfree'
trace-cmd show | head -50

# 统计 TLB 缺失率
perf stat -e iTLB-load-misses,dTLB-load-misses ./target_process

9.3 生产诊断案例

案例一:THP 分配失败导致数据库性能下降

现象:随机延迟尖峰,/proc/buddyinfo 显示 order≥9 计数长期为 0。原因:内核长期运行后内存碎片化,瞬时无足够 2M 连续页面。解决:通过 compact_memory 触发内存规整;或启用 1GB 大页静态分配(hugepagesz=1G),彻底绕过 Buddy order 5(2M)限制。

案例二:Direct Reclaim 导致 Redis 尾延迟飙升

现象:P99 延迟 99% 分位超过 50ms。原因:Redis 大 value 分配触发 Direct Reclaim,kswapd 来不及回收。解决:禁用 THP(echo never > /sys/kernel/mm/transparent_hugepage/enabled),调低 vm.vfs_cache_pressure,或使用 jemalloc 替代 glibc malloc 优化小对象路径。

案例三:NUMA 跨节点访问拖慢高并发服务

现象:80% 内存访问来自远端 node,IPC(每秒指令数)下降 30%。原因:numactl --interleave=all 未设置,线程和内存默认都在首次调度的 node。解决:绑定线程至本地 node,开启 AutoNUMA 并确保扫描周期合适(numabalancing_scan_delay_ms=1000)。

十、生产级最佳实践(8 条)

  1. 小对象分配优先 kmalloc(< 8KB> —— 三选一按场景匹配
  2. GFP 标志严格区分:GFP_NOWAIT(中断上下文不可睡眠)、GFP_NOFS(文件系统持有锁时防递归)、GFP_DMA32(硬件 32 位地址限制)
  3. 每 CPU 频繁数据使用 per_cpu() 结合 get_cpu()/put_cpu() —— 避免 false sharing 与 NUMA 跨节点访问
  4. 巨型分配(>4MB)考虑 CMA(Contiguous Memory Allocator) —— 预分空闲帧保障多媒体/DMA 需求
  5. NUMA 场景启用 AutoNUMA,配合 set_mempolicy() 显式绑定敏感线程 —— 减少远端访问延迟
  6. kswapd 调优:swappiness=0(非 swap 环境)、脏页比率 dirty_ratio=40 —— 减少 direct reclaim 概率
  7. 监控 /proc/buddyinfo 与 /proc/slabinfo:order≥7 持续为 0 立刻触发 compact_memory —— 防 THP 失败
  8. 频繁创建销毁同类型对象务必用 kmem_cache_create() —— slab 缓存局部性比 kmalloc 高 30%,加/减锁代价不可忽略

结语

从 Buddy System 的抗碎片化设计,到 SLUB 的 CPU 热路径优化,再到 VMA 红黑树的 O(log n) 缺页处理,Linux 内存管理是一组精密配合的子系统。理解这些机制,不仅能写出内存高效的内核驱动,更能在生产环境中快速定位内存泄漏、THP 与碎片化问题。下一篇文章我们将深入 Linux 内核的 进程调度器 CFS/EAS,探索多核负载均衡与实时性保障机制。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部