引言

内存管理是 Linux 内核最核心的子系统之一。无论是用户态的 malloc() 背后的 brk()/mmap(),还是内核态的 kmalloc()/vmalloc(),亦或页面回收、OOM killer、KSM 去重、CMA 连续内存分配——整个系统的性能、稳定性和响应速度都与内存管理紧密相关。而且更现实的是:驱动开发、网络栈优化、容器密度调优,几乎每一个方向都离不开对内存管理的理解。

本文将从物理内存的 NUMA 节点模型出发,深入伙伴系统(Buddy System)的分配与合并算法、Slab 缓存层的设计与实现(SLAB/SLUB/SLOB)、虚拟内存分配(vmalloc)与物理连续分配(kmalloc)的本质区别、内存碎片与反碎片机制、CMA 连续内存分配器、以及 struct page 的管理与内存热插拔支持。目标是让你在遇到内存相关性能问题或内核 panic 时,不仅能定位根因,更能从源码层面理解解决方案的原理。


一、物理内存模型:NUMA 与内存区域

1.1 从 UMA 到 NUMA

Linux 内核早期假设所有内存对 CPU 的访问延迟一致——UMA(Uniform Memory Access)。但现代多路服务器普遍采用 NUMA(Non-Uniform Memory Access)架构,CPU 访问本地节点的 DDR 内存远快于跨节点访问。内核用 pg_data_t 描述每个 NUMA 节点:


typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];       // 内存区域数组
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
    int nr_zones;                                // 节点中区域的数量
    struct page *node_mem_map;                   // 节点内所有 page 数组
    unsigned long node_start_pfn;                // 起始物理页号
    unsigned long node_present_pages;            // 实际存在页数
    unsigned long node_spanned_pages;            // 跨度页数(含空洞)
    int node_id;                                 // NUMA 节点 ID
    // ...
} pg_data_t;

每个 NUMA 节点被划分为多个"内存区域"(Zone),不同区域的用途截然不同。

1.2 Zone 类型与边界

Zone 类型 典型范围 用途
ZONE_DMA 0-16MB ISA DMA 设备(已很少使用)
ZONE_DMA32 16MB-4GB 32-bit DMA 设备
ZONE_NORMAL 4GB-上限 内核直接映射区(线性映射)
ZONE_HIGHMEM >896MB(32位) 高端内存(64位已废弃)
ZONE_MOVABLE 用户配置 可迁移页面区(用于内存热插拔)

64位系统中,ZONE_HIGHMEM 已经消失——64位地址空间足够直接将全部物理内存线性映射到内核虚拟地址空间。

1.3 水位线机制(Watermark)

每个 Zone 维护三条水位线,决定页面回收的触发时机:

  • WMARK_MIN:紧急水位,低于此值分配器进入直接同步回收(direct reclaim)
  • WMARK_LOW:低水位,后台 kswapd 唤醒
  • WMARK_HIGH:高水位,kswapd 停止回收

struct zone {
    unsigned long _watermark[NR_WMARK];  // [MIN, LOW, HIGH]
    unsigned long watermark_boost;       // 短时突发内存提升
    // ...
};

水位线由 vm.min_free_kbytes 参数调节,过大会浪费内存,过小容易触发 OOM。


二、伙伴系统(Buddy System):物理页分配的基石

2.1 伙伴系统核心思想

伙伴系统是物理页面分配的核心算法。它将空闲页面按 2 的幂次分组(order-0 到 order-10,对应 1 页到 1024 页即 4KB 到 4MB),分配时按最小满足原则拆分,释放时合并相邻的"伙伴"块。


struct zone {
    struct free_area	free_area[MAX_ORDER];  // 11 个空闲区域
    // ...
};

struct free_area {
    struct free_list free_list[MIGRATE_TYPES]; // 多个迁移类型队列
    unsigned long		nr_free;                  // 空闲块总数
};

free_list[MIGRATE_TYPES] 是"反碎片"的关键——按页面迁移能力分为多个队列:

  • MIGRATE_UNMOVABLE:内核代码、页表等不可移动页面
  • MIGRATE_MOVABLE:用户空间页面、可移动匿名页
  • MIGRATE_RECLAIMABLE:缓存页面(可回收但不可迁移)
  • MIGRATE_HIGHATOMIC:高原子分配专用池
  • MIGRATE_ISOLATE:热插拔用(已隔离页面)

2.2 分配流程:alloc_pages()

分配路径:


alloc_pages()
  → alloc_pages_current()
    → __alloc_pages_nodemask()
      → get_page_from_freelist()      // 快速路径:直接分配
      → __alloc_pages_slowpath()      // 慢速路径
        → wake_all_kswapds()          // 唤醒后台回收
        → __alloc_pages_direct_reclaim() // 同步回收
        → __alloc_pages_may_oom()      // 尝试 OOM
        → out_of_memory()              // OOM killer

快速路径(fast path)直接从伙伴系统的空闲列表取出页面;慢速路径则涉及内存回收、压缩等复杂操作。

2.3 伙伴合并算法

释放页面时,内核尝试将当前空闲块(order-N)与其"伙伴"合并:


/*
 * 伙伴的 "buddy_pfn" 计算方式:
 * if (pfn & (1 << order))  → buddy_pfn = pfn - (1 << order)
 * else                      → buddy_pfn = pfn + (1 << order)
 */
unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
    return page_pfn ^ (1UL << order);
}

合并持续进行:order-N 空闲块成功与伙伴合并后,升到 order-(N+1),继续尝试合并,直到达到最高阶或不存在可合并的伙伴。

2.4 伙伴系统的问题

  1. 外部碎片:长期运行后页面分散,无法满足高阶连续物理页请求
  2. 分配开销:连续大块内存分配(如 DMA 缓冲区)可能失败
  3. 需要额外层:频繁小对象分配直接走伙伴系统极其低效 → Slab 层应运而生

  4. 三、Slab 分配器:小对象分配的利器

    3.1 设计动机

    内核中频繁分配的小对象(如 task_struct、inode、dentry、file 等)通过伙伴系统分配有两个致命缺陷:

    • 内存浪费:40 字节的 task_struct 不足一页,却要占整个页帧
    • 碎片严重:反复分配释放小对象导致伙伴系统内部碎片化

    Slab 分配器在伙伴系统之上建立对象缓存层:预先分配一个或多个页(Slab),将其划分为固定大小的槽位(slot),直接从缓存中分配/回收对象。

    3.2 三种 Slab 实现

    Linux 内核提供了三种 Slab 实现,当前默认使用 SLUB:

    特性 SLAB SLUB SLOB
    出处 Solaris 移植 SLAB 简化版 嵌入式专用
    元数据开销 高(每页独立管理) 最低(利用 page->freelist) 最少
    可-debug 良好 优秀(CONFIG_DEBUG_KMEMLEAK) 无
    调试支持 full 完整 无
    默认启用 是(旧版本) 是(现代内核) 仅嵌入式
    NUMA 感知 强 强 无

    3.3 SLUB 核心数据结构

    SLUB 的最大创新是利用 struct page 自身存储元数据,避免额外的管理开销:

    
    struct kmem_cache {
        struct kmem_cache_cpu __percpu *cpu_slab;  // CPU 本地 Slab
        struct kmem_cache_node *node[MAX_NUMANODES]; // 节点级缓存
        unsigned int object_size;                  // 裸对象大小
        unsigned int size;                         // 对齐后大小
        unsigned int offset;                       // freefield 偏移
        struct kmem_cache_order_objects oo;        // min/low/high order
        slab_flags_t flags;                        // SLAB flags
        char name[KMALLOC_CNAME_LEN];              // 缓存名称
        // ...
    };
    
    struct kmem_cache_cpu {
        void **freelist;              // 空闲对象链表头
        unsigned long tid;            // 唯一事务 ID(防中断重入)
        struct page *page;            // 当前 CPU 本地页
        struct page *partial;         // 本地 partial 页链表
    };
    

    3.4 kmalloc 家族

    kmalloc() 是内核最常用的动态分配接口,底层基于大小分级的通用 Slab 缓存:

    
    // size <= 8B    → kmalloc-8
    // size <= 16B   → kmalloc-16
    // size <= 32B   → kmalloc-32
    // ...
    // size <= 8K    → kmalloc-8192
    // size > 8K     → vmalloc()(不保证物理连续)
    

    核心特点:

    • 物理连续:返回的内存在物理上连续,可直接用于 DMA
    • 大小限制:通常上限为 2 * PAGE_SIZE(约 8KB),大分配走 vmalloc()
    • GFP flags:控制分配行为,GFP_KERNEL 可睡眠,GFP_ATOMIC 不可睡眠(中断上下文用)

    GFP flags 常用组合:

    标志 含义
    GFP_KERNEL 标准分配,可睡眠,可触发 I/O 回收
    GFP_ATOMIC 原子分配,不可睡眠,使用紧急保留页
    GFP_NOIO 允许睡眠但不触发 I/O 回收
    GFP_NOFS 允许睡眠但不触发文件系统操作
    GFP_DMA 从 ZONE_DMA 分配
    GFP_ZERO 分配后清零
    GFP_ACCOUNT 计入内存 cgroup 统计

    3.5 kmem_cache:专用缓存

    对于频繁分配固定大小的结构体,建议创建专用缓存:

    
    static struct kmem_cache *my_obj_cache;
    
    // 模块初始化时创建
    my_obj_cache = kmem_cache_create("my_obj", sizeof(struct my_obj), 0,
                                      SLAB_HWCACHE_ALIGN, NULL);
    
    struct my_obj *p = kmem_cache_alloc(my_obj_cache, GFP_KERNEL);
    kmem_cache_free(my_obj_cache, p);
    
    // 模块退出时销毁
    kmem_cache_destroy(my_obj_cache);
    

    专用缓存的好处包括:

    • 完全免除伙伴系统开销(批量分配)
    • 可启用 debug 选项(red zoning、poisoning)
    • SLAB_HWCACHE_ALIGN 对齐到 L1 cache line(避免伪共享)
    • 支持 kmem_cache_shrink() 主动回收 partial 页

    3.6 kmemleak:内存泄漏检测

    kmem_cache_alloc 分配的缓存对象泄露无法被常规工具检测。内核的 kmemleak 子系统通过标记 + 定期扫描的方式追踪所有分配,报告未引用的孤立对象:

    
    # 查看 kmemleak 报告
    echo scan > /sys/kernel/debug/kmemleak
    cat /sys/kernel/debug/kmemleak
    

    四、vmalloc:虚拟连续的内存分配

    4.1 vmalloc 与 kmalloc 的本质区别

    当需要分配较大块的连续虚拟地址内存但不需要物理连续时,使用 vmalloc():

    属性 kmalloc vmalloc
    物理连续性 是 否
    映射方式 直接映射区(linear mapping) 非连续映射区(vmalloc area)
    分配上限 ~8KB(MAX_ORDER 决定) 很大(受限于 vmalloc 空间)
    分配延迟 低(直接空闲链表) 高(需修改页表)
    使用场景 DMA、低功耗循环 模块加载、大段 I/O 缓冲
    TLB 效率 高(连续,少 TLB 条目) 低(碎片化,多 TLB 条目)

    4.2 vmalloc 的内部实现

    vmalloc() 的工作方式:

    
    vmalloc(size)
      → __vmalloc_node_range()
        1. 在 vmalloc 区域分配连续虚拟地址
        2. 调用 alloc_pages() 分配足够的物理页(可能分散)
        3. 修改内核页表建立虚拟→物理映射
    
    
    // 内核定义的 vmalloc 区间(x86_64)
    #define VMALLOC_START    0xffffc90000000000
    #define VMALLOC_END      0xffffe8ffffffffff
    // 大小约 32TB,足够使用
    

    4.3 性能注意事项

    由于 vmalloc 分配的页面物理上分散,有以下性能代价:

    • TLB thrashing:访问分散的物理页时 TLB 频繁刷新
    • DMA 不友好:硬件 DMA 需要物理连续或 scatter-gather(IOMMU/SWIOTLB 可缓解)
    • 分配开销高:需要逐个建立页表映射(mprotect 式操作)

    在需要大量非连续内存做 DMA 的场景,应使用 dma_alloc_coherent() + IOMMU 或 scatter-list 机制。


    五、内存碎片与反碎片机制

    5.1 碎片的分类

    • 内部碎片(Internal Fragmentation):分配的块比请求的大(为了满足对齐/最小分配大小)
    • 外部碎片(External Fragmentation):足够的空闲内存分散在不连续的区域中

    5.2 伙伴系统的反碎片:迁移类型

    如 2.1 节所述,伙伴系统按页面迁移能力将空闲页分为不同队列(free_list[MIGRATE_TYPES]),使得日后移动页面合并碎片时知道哪些区域可以回收。

    迁移类型:

    • UNMOVABLE:内核代码区、持久页表、vmalloc 区域 → 基本不被移动
    • MOVABLE:用户匿名页、缓冲区、缓存 → compaction 时移动
    • RECLAIMABLE:共享库映射、缓冲区 → 可以释放后再读回

    5.3 内存规整(Memory Compaction)

    当伙伴系统无法提供期望阶次的连续页时,内核的 compaction 机制扫描所有 UNMOVABLE 页面,将其中的 MOVABLE 页面迁移到别处,整理出大块空闲区域:

    
    // compaction 核心路径
    compact_zone()
      → isolate_movable_pages()      // 选出可移动页面
      → migrate_pages()              // 迁移到目标区域
      → release_pages()              // 释放旧页面
    

    可以通过 /proc/sys/vm/compact_memory 触发整机规整。

    5.4 Transparent Huge Pages (THP)

    THP 是二阶策略:分配大页(2MB/1GB)减少 TLB miss 和 page table 深度:

    • madvise 模式:应用显式声明需要 THP
    • always 模式:全局启用(可能造成内存浪费)
    
    # 查看 THP 状态
    cat /sys/kernel/mm/transparent_hugepage/enabled
    
    # 统计大页命中
    grep -i huge /proc/meminfo
    

    六、CMA:连续内存分配器

    6.1 CMA 设计理念

    嵌入式/移动设备上的 GPU、VPU、Camera 等硬件需要大块连续物理内存做 DMA。由于伙伴系统的长期运行后产生外部碎片,纯伙伴系统可能无法满足需求。CMA(Contiguous Memory Allocator)采用"借一还一"策略:

    预先在系统启动时预留一片连续区域,正常时期允许普通 MOVABLE 页面使用这片区域(提高内存利用率),需要 DMA 硬件时将这些 MOVABLE 页面迁移走,归还给硬件。

    6.2 CMA 使用方式

    通过 DTS(设备树)定义 CMA 区域:

    
    reserved-memory {
        linux,cma {
            compatible = "shared-dma-pool";
            reusable;
            reg = <0x0 0x80000000 0x0 0x10000000>; // 256MB@0x80000000
            linux,cma-default;
        };
    };
    

    内核启动参数:

    
    cma=256M@0-4G  // 在 0-4G 段预留 256MB CMA
    

    6.3 CMA 工作流程

    
    ┌─────────────────────────────────┐    设备需要连续内存
    │      CMA 预留区域 (256MB)        │
    ├──────────────┬──────────────────┤
    │ MOVABLE页(被  │   MOVABLE页(被    │ ← 普通使用时空闲内存
    │ 进程占用)     │  内核缓存占用)    │ ← 被"借用"
    ├──────────────┴──────────────────�│
    │      migration → free           │ ← 设备请求时迁移全部页
    └─────────────────────────────────┘
    │  return to device driver        │ ← 设备独占使用
    

    6.4 CMA 与其他机制的互补

    机制 解决问题 适用场景
    伙伴系统 小页分配 通用 4KB 页分配
    Slab 小对象分配 频繁分配固定大小结构
    CMA 大块连续物理内存 GPU/VPU/Camera DMA
    IOMMU 设备视角"连续" 需要 IOMMU 支持的设备

    七、struct page:每页的元数据

    7.1 核心作用

    内核为系统中每一帧物理页维护一个 struct page 实例。对于 64GB 内存的服务器,大约需要 1600 万个 struct page(约 640MB 元数据开销!)。

    
    struct page {
        unsigned long flags;          // 页面状态(dirty/active/locked)
        union {
            struct address_space *mapping; // 文件页→地址空间
            void *s_mem;                     // Slab 页首个对象
            atomic_t compound_mapcount;      // 复合页映射计数
        };
        
        struct {
            union {
                pgoff_t index;       // 在映射中的偏移
                void *freelist;      // SLUB 空闲链表头
            };
            union {
                unsigned long counters;
                struct {
                    unsigned inuse:16;  // Slab 使用计数
                    unsigned objects:15;
                    unsigned frozen:1;
                };
            };
        };
        
        // ... 省略大量 union 成员
    };
    

    7.2 复合页(Compound Pages)

    超出 4KB 的大页(2MB THP / 1GB HugeTLB)在内存子系统中以"复合页"形式管理:

    
    // 判断是否为复合页
    static inline bool PageHead(struct page *page)
    {
        return test_bit(PG_head, &page->flags);
    }
    
    // 获取复合页的 order
    unsigned int compound_order(struct page *page)
    {
        if (!PageHead(page))
            return 0;
        return page[1].compound_order;
    }
    

    compound_head() 用于从任意成员页返回"头页"——引用计数的权威来源。

    7.3 内存热插拔

    当物理内存被热添加/移除时,内存子系统通过以下步骤处理:

    
    void online_pages(unsigned long pfn, unsigned long nr_pages)
    {
        // 1. 将页面从 buddy 的 offlined 状态 → P_online
        // 2. 设置 zone 水位线
        // 3. 刷新per-cpu pageset
        // 4. 初始化 kswapd
    }
    
    void offline_pages(unsigned long pfn, unsigned long nr_pages)
    {
        // 1. 标记 zone 不可分配
        // 2. 迁移所有页面到 ZONE_MOVABLE
        // 3. 从伙伴系统移除该区域页
    }
    

    八、性能调优与调试

    8.1 /proc 与 /sys 关键参数

    
    # 内存概览
    cat /proc/meminfo
    cat /proc/buddyinfo       # 每个 zone 的伙伴系统 order-N 空闲块数
    cat /proc/pagetypeinfo    # 按迁移类型的详细页面分布
    cat /proc/slabinfo        # 所有 kmem_cache 的分配统计
    cat /proc/vmallocinfo     # 所有 vmalloc 分配区域
    
    # 调优参数
    sysctl -w vm.min_free_kbytes=65536    # 提升最小空闲(防直接回收)
    sysctl -w vm.swappiness=10            # 减少 swap 倾向
    sysctl -w vm.dirty_ratio=40           # 脏页比例上限
    sysctl -w vm.dirty_background_ratio=10 # 后台刷盘触发点
    sysctl -w vm.vfs_cache_pressure=50    # inode/dentry 缓存回收倾向
    sysctl -w vm.zone_reclaim_mode=0      # NUMA 本地回收模式
    

    8.2 Slab 信息查看

    
    # 查看所有 slab 缓存
    cat /proc/slabinfo
    
    # slabtop 实时监控(类似 top)
    slabtop
    
    # 查看特定缓存详情
    cat /sys/kernel/slab/kmalloc-64/objects    # 总对象数
    cat /sys/kernel/slab/kmalloc-64/order      # 每 slab 的阶数
    

    8.3 伙伴系统碎片诊断

    Buddyinfo 的输出解读:

    
    cat /proc/buddyinfo
    # Node 0, zone   Normal  10  5  3  2  1  1  0  0  0  0  0
    #                   order: 0  1  2  3  4  5  6  7  8  9 10
    

    如果高 order(如 order-9/10)空闲块为 0,说明严重外部碎片化。此时可以:

    1. 触发手动规整:echo 1 > /proc/sys/vm/compact_memory
    2. 检查 MIGRATE_MOVABLE 是否太少(特定驱动让 page unmovable?)
    3. 考虑使用 CMA 满足大块需求
    4. 8.4 perf 监控内存子系统

      
      # 监控 page fault 事件
      perf stat -e page-faults,minor-faults,major-faults -a
      
      # 监控 TLB miss
      perf stat -e dTLB-load-misses,dTLB-store-misses -a
      
      # 监控直接回收事件
      perf stat -e vmscan:mm_vmscan_direct_reclaim_begin -a
      
      # 追踪 kmalloc
      perf probe --add 'kmalloc'
      perf record -e probe:kmalloc -ag
      

      九、常见问题 FAQ

      Q1:为什么 kmalloc 不能超过 ~8KB?

      A:伙伴系统默认最大连续物理内存为 MAX_ORDER-1 个页(通常 order-10,即 4MB)。但 kmalloc 使用的通用 Slab 缓存最大 size 为 KMALLOC_MAX_SIZE(2 * PAGE_SIZE = 8KB)。更大的分配应使用 vmalloc() 或专用大缓存。

      注意:KMALLOC_MAX_SIZE 可通过 CONFIG_MAX_ORDER 调高,极端配置下可达 64MB(但这会消耗伙伴系统的大部分内存用于元数据)。

      Q2:GFP_KERNEL 与 GFP_ATOMIC 的使用场景?

      A:在中断处理程序、softirq、spinlock 持有期间,必须使用 GFP_ATOMIC(不可睡眠)。其他上下文使用 GFP_KERNEL。使用 GFP_KERNEL 时可以被分配器挂起(sleep),等待内存回收完成再分配。

      Q3:vmalloc 能否用于 DMA?

      A:通常不能。大多数 DMA 设备需要物理连续的内存(除非设备支持 scatter-gather 或 IOMMU)。在 IOMMU 系统上,可用 dma_alloc_coherent() + IOMMU 映射让设备看到连续的"IO 虚拟地址",但底层物理页可以是分散的。

      Q4:如何确认某个内存泄露?

      A:分层排查:

      • 用户态:valgrind / jemalloc profiling
      • 内核 kmalloc/kmem_cache:kmemleak debugfs
      • 伙伴系统层面:cat /proc/buddyinfo + slabtop 监控趋势
      • page 级别:page_owner (CONFIG_PAGE_OWNER) 追踪每个 alloc_pages 的调用栈

      Q5:slab 缓存为什么要区分 per-cpu 和 node 两级?

      A:per-cpu slab(kmem_cache_cpu->freelist)消除同一 CPU 的竞争,是最快路径;当当前 CPU slab 耗尽时,从 node 级 partial 列表补充;当 node 也无可用 slab 时,向伙伴系统请求新页。这种分级设计兼顾了性能与内存利用率。


      十、总结

      Linux 内核内存管理是一个精心设计的分层系统:

      1. 伙伴系统:物理页框的分配与合并引擎,是最底层原语
      2. Slab/SLUB:在小对象场景下避免伙伴系统的内部碎片,提供 O(1) 分配
      3. vmalloc:用页表映射实现虚拟连续 / 物理分散,适合大块非 DMA 内存
      4. CMA:以"借用-归还"策略在移动设备上保障连续物理内存供给
      5. struct page:贯穿所有子系统的元数据基石,支撑大页、迁移、热插拔
      6. 在实际工程实践中,理解这些层次的设计意图和相互作用,比死记 API 更重要。当你的系统出现内存碎片、分配延迟抖动或 OOM 问题时,只要从伙伴系统状态(/proc/buddyinfo)、Slab 使用(slabtop)、vmalloc 用量(/proc/vmallocinfo)三个维度入手,往往能在几分钟内定位问题根源。


        延伸阅读:

        - Mel Gorman, Understanding the Linux Virtual Memory Manager(经典书籍)

        - /Documentation/mm/ — 内核文档

        - mm/page_alloc.c、mm/slub.c、mm/vmalloc.c — 源码三件套

        - LWN.net Memory Management 系列文章(持续跟进新特性)

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }