title: Linux内核内存管理深度实战:从伙伴系统到SLUB分配器的工程剖析 channel_id: 44 tags: Linux内核,内存管理,伙伴系统,SLUB,性能调优


Linux内核内存管理深度实战:从伙伴系统到SLUB分配器的工程剖析

内核内存管理是整个操作系统的基石。理解伙伴系统如何解决外部碎片、SLUB如何高效服务小内存分配请求,对于系统调优、驱动开发和性能分析至关重要。本文从源码级别剖析两者协作机制,结合真实内核代码(基于 Linux 6.x)讲解核心算法与工程实践。

一、物理内存的组织:从节点到区域到页

Linux内核将物理内存组织为一个多层次的树形结构。理解这个结构是理解后续分配器的前提。

1.1 NUMA 节点与 pglist_data

在NUMA系统中,每个节点由 struct pglist_data(定义在 include/linux/mmzone.h)描述:

typedef struct pglist_data {
    struct node_zones node_zones[MAX_NR_ZONES];    // 内存区域
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
    int nr_zones;                                   // 该节点区域数
    struct page *node_mem_map;                      // 页描述符数组
    unsigned long node_start_pfn;                   // 起始页帧号
    unsigned long node_present_pages;               // 总页数
    unsigned long node_spanned_pages;               // 包含空洞的总页数
    ...
} pg_data_t;

每个节点包含多个Zone,常见的区域类型包括:

Zone类型 说明 x86_64典型范围
ZONE_DMA 16MB以下,用于老旧DMA 0-16MB
ZONE_DMA32 4GB以下,32位DMA 16MB-4GB
ZONE_NORMAL 线性映射到内核空间 16MB-896MB
ZONE_MOVABLE 可迁移页,减少碎片 动态

1.2 页帧与 struct page

系统中每个物理页帧都对应一个 struct page 描述符(约64字节)。4GB内存就需要约64MB的 mem_map 数组来存储描述符本身——这是内核内存开销的重要来源。

struct page {
    unsigned long flags;        // 状态标志(PG_slab, PG_head等)
    union {
        struct address_space *mapping;
        void *virtual;          // 内核虚拟地址(若映射)
        atomic_t _mapcount;
    };
    atomic_t _refcount;        // 引用计数
    ...
    union {
        struct list_head lru;      // LRU/Partial列表
        structSlabList { ... };    // SLAB链表用
        struct {                   // SLUB用
            struct page *next;     // 空闲链表指针(union匿名)
            int pages;             // 复合页阶
            int pobjects;          // 空闲对象计数
        };
    };
};

关键细节:在SLUB中,page->next 和 page->pobjects 复用了 lru 和 _mapcount 的存储位置(union机制),因为这些字段在 slab 页面上不会同时使用。

二、伙伴系统(Buddy System):物理页分配的主引擎

2.1 核心思想与数据结构

伙伴系统管理以页(或页组——"阶"为单位进行分配。每个Zone维护11个空闲区域数组(free_area[0..10]),每个数组包含一个按页组大小(2^n页 = 2^n × 4KB)组织的双向链表。

struct free_area {
    struct list_head free_list[MIGRATETYPE_TYPES]; // 按迁移类型分链表
    unsigned long nr_free;                          // 该阶空闲页数
};

struct zone {
    struct free_area free_area[MAX_ORDER]; // MAX_ORDER = 11
    ...
};

分配阶n的含义: - 阶0 = 1页 = 4KB - 阶2 = 4页 = 16KB
- 阶8 = 256页 = 1MB - 阶10 = 1024页 = 4MB(最大值)

2.2 分配与分裂

当请求2^n页时,内核首先在 free_area[n] 的空闲链表中查找。若该阶无空闲块,则向上阶(n+1, n+2, ...)查找,找到后反复"分裂":

分配请求:阶0 (1页)
free_area[0]: 空
free_area[1]: [A(2页), B(2页)]
                        ↓ 分配B,分裂B
free_area[0]: [b2(1页)]    ← 剩余半块
free_area[1]: [A(2页)]
                        ↓ b1(1页) 给用户
返回 b1

源码中的分裂逻辑(__rmqueue_smallest):

static __always_inline
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
                                int migratetype)
{
    unsigned int current_order;
    struct free_area *area;
    struct page *page;

    for (current_order = order; current_order < MAX_ORDER; ++current_order) {
        area = &(zone->free_area[current_order]);
        page = get_page_from_free_area(area, migratetype);
        if (!page)
            continue;
        expand(zone, page, order, current_order, migratetype, alloc);
        set_pcppage_migratetype(page, migratetype);
        return page;
    }
    return NULL;
}

expand 函数执行大块分裂,将多余的低地址部分放回低阶链表。

2.3 释放与合并(Coalescing)

伙伴系统释放的核心在于"伙伴合并"——当两个相邻的同大小空闲块互为"伙伴"时,合并为一个两倍大小的块。

判断伙伴的关键条件(同一阶、物理连续、同属一个Zone、同迁移类型):

static inline bool
page_is_buddy(const struct page *page, const struct page *buddy,
              unsigned int order)
{
    if (!pfn_valid_within(page_pfn(buddy)))
        return false;
    if (page_zone_id(page) != page_zone_id(buddy))
        return false;
    if (page_to_pfn(page) & ((1 << order) - 1))  // 地址对齐检查
        return false;
    return true;
}

合并过程中有一个精妙设计:通过 page_to_pfn(page) & (1 << order) 的奇偶性计算出"伙伴"页的地址——无需遍历即可O(1)定位伙伴。

2.4 伙伴系统的外部碎片问题

伙伴系统无法解决"外部碎片"问题——即虽然总空闲页很多,但没有足够大的连续物理页。经典场景:一个长期运行的数据库服务器,经历了大量非连续的小块分配释放后,虽然空闲了50%内存,但大多为阶0的孤立页框。

Linux通过以下机制应对:

  1. 可回收页的页面迁移(/proc/sys/vm/compact_memory)
  2. 按迁移类型分组(MIGRATE_UNMOVABLE, MIGRATE_MOVABLE, MIGRATE_RECLAIMABLE)
  3. 主动内存规整(kcompactd 内核线程)

2.5 迁移类型分组:防止碎片的工程智慧

伙伴系统在每个阶中按迁移类型进一步分区:

enum migratetype {
    MIGRATE_UNMOVABLE,    // 内核对象、页表等(不可移动)
    MIGRATE_MOVABLE,      // 用户空间页面、可轻松迁移
    MIGRATE_RECLAIMABLE,  // 缓存页、无引用可回收
    MIGRATE_PCPTYPES,     // Per-CPU热页缓存
    MIGRATE_ISOLATE,      // 隔离态
    MIGRATE_TYPES
};

核心策略:将不可移动页和可移动页分离。UNMOVABLE页(如 kmalloc 分配的结构体)只占据特定迁移类型的区域,不会散布在MOVABLE区域中阻止后续合并。

三、SLUB分配器:小内存分配的高速通道

3.1 为什么需要 SLAB/SLUB

伙伴系统以页(4KB)为最小分配单位,而内核中大量请求远小于一页(task_struct 约1.7KB,inode 约580B)。多次内核启动阶段可能分配数万个小对象。若每次小分配都消耗一页,物理内存会严重浪费(内部碎片),且频繁的伙伴系统操作带来大量TLB pressure。

SLAB分配器(Solaris首创,Linus引入内核)通过"内核对象缓存"解决这个问题:

  • 从伙伴系统预分配整页或连续页
  • 将页面切割为固定大小的对象槽位
  • 维护空闲对象的快速分配/释放

SLUB是SLAB的简化重写,自2.6.23成为默认分配器。

3.2 SLUB 核心数据结构

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;   // Per-CPU快速路径
    slab_flags_t flags;
    unsigned long min_partial;                   // node partial最小保留数
    unsigned int size;                           // 对象实际大小
    unsigned int object_size;                    // 用户请求的大小
    struct reciprocal_value reciprocal_size;      // size的倒数
    unsigned int offset;                         // 空闲链表指针偏移
    unsigned int cpu_partial;                    // cpu partial阈值
    void (*ctor)(void *);                        // 对象构造函数

    struct kmem_cache_node *node[MAX_NUMNODES];  // NUMA节点级partial列表
    struct kmem_cache_order_objects oo;          // 最佳order计算
    struct kmem_cache_order_objects max;
    struct kmem_cache_order_objects min;
    ...
};

Per-CPU缓存结构(快速路径,无锁):

struct kmem_cache_cpu {
    void **freelist;         // 空闲对象链表头
    unsigned long tid;       // 全局事务ID(用于慢路径同步)
    struct page *page;       // 当前活动的 slab 页面
    struct page *partial;    // Per-CPU partial slab链表
};

Node级管理结构:

struct kmem_cache_node {
    spinlock_t list_lock;
    unsigned long nr_partial;
    struct list_head partial;    // 半空闲slab列表
};

3.3 SLUB 分配流程详解

快速路径(无锁)

static __always_inline void *__slab_alloc(struct kmem_cache *s,
                gfp_t gfpflags, unsigned long addr, struct kmem_cache_cpu *c)
{
    void *p;

    // 1. 尝试从 Per-CPU freelist 直接取出
    p = c->freelist;
    if (unlikely(!p))
        goto new_slab;

    c->freelist = get_freepointer(s, p); // 下一个对象地址
    return p;

new_slab:
    // 2. 当前slab无空闲,尝试从 CPU partial链取一个
    if (slab_partial(c)) {
        p = c->page->freelist;
        c->page = c->partial;      // 替换当前active slab
        c->freelist = p;
        c->partial = c->partial->next;
        return p;
    }

    // 3. 进入慢路径:需要从伙伴系统分配新页面
    return ___slab_alloc(s, gfpflags, addr, c);
}

慢路径(加锁分配新slab)

static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags,
                          unsigned long addr, struct kmem_cache_cpu *c)
{
    struct page *page;

    // 1. 先查Node级partial列表
    page = get_partial(s, gfpflags, addr);

    if (!page) {
        // 2. 从伙伴系统分配新页面
        page = new_slab(s, gfpflags, node);
        if (!page)
            return NULL;
        inc_slabs_node(s, page_to_nid(page), page->objects);
    }

    // 3. 初始化新slab,构建空闲链表,安装到Per-CPU缓存
    c->page = page;
    c->freelist = page->freelist;
    page->freelist = NULL;     // 标记为active slab

    return __slab_alloc(s, gfpflags, addr, c); // 重试快速路径
}

3.4 SLUB 释放空闲对象

static __always_inline void do_slab_free(struct kmem_cache *s,
                void *head, void *tail, unsigned long tid)
{
    struct page *head_page = virt_to_head_page(head);
    struct kmem_cache_cpu *c;

    c = raw_cpu_ptr(s->cpu_slab);

    // 快速路径:直接挂回 freelist
    set_freepointer(s, object, c->freelist);
    c->freelist = object;
    c->tid = next_tid(tid);

    // 检查是否从 fully-used 变为部分空闲
    if (unlikely(slab_was_frozen(head_page))) {
        // 页面之前已无空闲,现在变成partial
        // 需要调度到特定的释放逻辑
        ...
    }

    // 如果Per-CPU partial超过阈值,把partial slab回退到Node级
    if (slab_free_exceeds_cpu_partial(s, head_page))
        flush_cpu_slab(s);
}

释放逻辑中,当页面从"全满"变"有1个空闲"或从"部分空闲"变"完全空闲"时,有不同的处理路径:

  • 从全满 → 部分空闲:页面被添加到Per-CPU partial链表
  • 从部分空闲 → 完全空闲:页面返还给Node级partial列表(若Node级也满了则返还给伙伴系统)

3.5 空闲链表与 Red Zone 保护

SLUB将空闲对象本身用作链表存储——空闲内存的起始地址存放"下一个空闲对象"指针(指针侵入技术),零额外内存开销。

当开启 CONFIG_DEBUG_SLUB 时,SLUB在对象前后插入Red Zone marker检测越界写:

┌──────────────────────────────────────────┐
│ RED ZONE │    user object    │ RED ZONE │
│  64 bits │   (requested)     │  64 bits │
└──────────────────────────────────────────┘

检测值为 0x12a8a00010020100(RED_ACTIVE)与 0x99a8a00010020101(RED_INACTIVE),被踩后触发内核告警。

3.6 SLUB vs SLAB vs SLOB

特性 SLAB (legacy) SLUB (default) SLOB (tiny)
复杂度 高(多种缓存链表) 极低 极简
元数据开销 高(每slab64B管理数据) 复用 struct page 极低
NUMA感知 完善(per-node cpucache) 好(per-node partial) 无
调试能力 强 强 弱
适用场景 已废弃 通用(默认) 嵌入式(<64MB RAM)

SLUB简化策略的核心:将"空闲链表"直接嵌入 struct page,消除了SLAB中独立的 kmem_bufctl_t 数组(每对象4B开销)。

四、实战篇:内存分析与调优

4.1 查看分配器状态

slabtop 实时显示各缓存使用情况:

$ slabtop -o
 Active / Total Objects (% used)    : 231759 / 232952 (99.5%)
 Active / Total Slabs (% used)      : 6206 / 6206 (100.0%)
 Active / Total Caches (% used)     : 88 / 133 (66.2%)
 Active / Total Size (% used)       : 103.91M / 104.44M (99.5%)

  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
 12328  12328 100%   0.19K    587       21      9.39K dentry
 26794  26794 100%   0.10K    687       39      5.49K buffer_head
  5568   5121  91%   0.63K    232       24      9.28K proc_inode_cache
 11214  10914  97%   0.25K    342       33      5.47K vm_area_struct
 38106  35689  93%   0.13K    635       60      5.08K radix_tree_node
 18490  16303  88%   0.57K    331       56      8.34K inode_cache

各缓存的"OBJ SIZE"正是SLUB按大小分桶的结果。

4.2 /proc/slabinfo 深度分析

$ cat /proc/slabinfo | grep -E "kmalloc-|task_struct"
kmalloc-8k        64     64    8192    4    8 : tunables ...
kmalloc-4k       256    230    4096    8    8 : tunables ...
kmalloc-2k       128    117    2048   16    8 : tunables ...
kmalloc-1k       256    199    1024   32    8 : tunables ...
kmalloc-512      384    352     512   32    8 : tunables ...
kmalloc-256      512    481     256   32    8 : tunables ...
task_struct      494    420    2240   20   18 : tunables ...

字段说明:active_objs / active_slabs / objsize / objs_per_slab / pages_per_slab

高 active_slabs 但低 active_objs/objs_per_slab 比值说明 slab 利用率低,可能存在碎片化问题。

4.3 SLUB 性能调优参数

每个SLUB缓存都有可变参数的 sysfs 接口:

/sys/kernel/slab/<cache_name>/
├── cpu_partial     # CPU partial阈值
├── min_partial     # node partial最少保留数
├── order           # slab阶数
├── objects         # 总缓存对象数检测
├── object_size     # 对象大小
├── partial         # partial slab数
├── poison          # 毒化已释放对象 (0/1)
├── red_zone        # Red Zone保护 (0/1)
└── store_user      # 记录分配/释放调用者 (0/1)

典型调优场景:

# 1. 全局开启SLUB DEBUG(调试内存踩踏)
# 启动参数加 "slub_debug=FZPU"

# 2. 对特定缓存开启用户追踪
echo 1 > /sys/kernel/slab/kmalloc-256/store_user

# 3. 分析非活跃partial slab
slabtop -s c  # 按Cache Size排序

4.4 调试实战:追踪内存越界

案例:某驱动出现随机 kmalloc-256 内存损坏。

# 步骤1: 开启 poison 和 red_zone
echo 1 > /sys/kernel/slab/kmalloc-256/poison
echo 1 > /sys/kernel/slab/kmalloc-256/red_zone

# 步骤2: 开启用户追踪记录调用栈
echo 1 > /sys/kernel/slab/kmalloc-256/store_user

# 步骤3: 重启测试用例,等待bug触发
# 步骤4: 从 dmesg 获取调用栈
「BUG: Object kmalloc-256 @ffff88xxxx corrupted」
「 slab_alloc+0x45/0x210 [call trace]」

SLUB的 CONFIG_SLUB_DEBUG 结合 slub_debug=U 还能检测 use-after-free(释放后用0x6B填充对象内容,分配时校验前8字节是否为0x6B)。

4.5 内存碎片排查

# 查看buddy系统当前状态
cat /proc/buddyinfo

Node 0, zone   Normal  12  15  8  4  2  1  1  0  1  0  0

# 每列分别为 free_area[0]..[10] 的空闲页数
# 上例中高阶(>=5阶=64页=256KB)空闲块极少,说明存在碎片

# 查看页迁移类型分布
cat /proc/pagetypeinfo

手动触发主动规整:

# 触发内存规整
echo 1 > /proc/sys/vm/compact_memory

# 查看规整结果
grep -i compact /proc/vmstat

五、进阶话题:内存分配的硬核场景

5.1 Per-CPU Hot Pages:伙伴系统的"一级缓存"

在物理页分配路径上,伙伴系统自身还有一层 Per-CPU 热页缓存(pageset),避免每个CPU频繁在zone锁上争抢:

struct per_cpu_pages {
    int count;          // pcplist中的页数
    int high;           // 高于此值放回伙伴系统
    int batch;          // 一次批量移动页数
    struct list_head lists[MIGRATYPE_TYPES]; // 头插法的freelist
};

struct zone {
    struct per_cpu_pageset __percpu *per_cpu_pageset;
    ...
};

当代码调用 alloc_pages(GFP_KERNEL) 时,若 order == 0(只请求1页),直接从 Per-CPU pageset 中弹出一页,只有pageset空了才会进入真正的伙伴系统慢路径。这是热路径优化的一大关键。

5.2 Kmalloc 的通用缓存系列

kmalloc(size, flags) 并未创建专属缓存,而是从预建的幂次对齐缓存中选择:

static struct kmem_cache *kmalloc_caches[NR_KMALLOC_TYPES][KMALLOC_SHIFT_HIGH + 1];

关键大小分桶:

索引 大小 实际slab对象大小
6 64B 64B (含元数据对齐)
7 128B 128B
8 256B 256B
9 512B 512B
10 1024B 1K
11 2048B 2K
12 4096B 4K
13 8192B 8K

请求250B → 实际从 kmalloc-256 取出一个256B槽位(浪费6B内部碎片)。请求3000B → 分配 kmalloc-4k一页(对于4K页这是内部碎片率25%)。

对于超过8KB的请求,kmalloc 直接退化为 alloc_pages + page_address。

5.3 GFP 标志的影响

GFP标志深刻影响伙伴系统和SLUB的行为:

GFP标志 效应
__GFP_RECLAIM 允许kswapd回收页/规整
__GFP_HIGH 允许访问紧急保留池
__GFP_IO 允许发起I/O(写回脏页)
__GFP_FS 允许调用文件系统操作
__GFP_ZERO 返回清零内存
__GFP_NOFAIL 永不失败(可能死循环!)
GFP_NOWAIT 不回收不阻塞
GFP_ATOMIC 最高优先级,仅从热页池取

在硬中断上下文或持有自旋锁时,必须使用 GFP_ATOMIC,因为它不会入睡、不会触发回收。若此时 Per-CPU pageset 已空且伙伴系统无可分配页,GFP_ATOMIC 可能直接返回NULL。

5.4 Kmemleak:检测内存泄漏

CONFIG_DEBUG_KMEMLEAK 可以追踪所有未配对的 kmalloc/kmem_cache_alloc 调用:

# 触发一次全局扫描
echo scan > /sys/kernel/debug/kmemleak

# 查看潜在泄漏
cat /sys/kernel/debug/kmemleak

输出格式:

unreferenced object 0xffff88002f4b7d00 (size 256):
  comm "insmod", pid 1234, jiffies 4294937301
  backtrace:
    [<ffffffff81012345>] create_the_leak+0x15/0x18 [my_module]
    [<ffffffff81023456>] my_module_init+0x56/0x78 [my_module]

原理:kmemleak 在每次内存分配时记录调用栈,周期性扫描内存(堆栈、数据段、寄存器)中是否存在指向该对象的引用。未被引报为"潜在泄漏"(可能是误报如全局指针)。

六、总结

Linux内核的内存管理是一个精巧的多层体系:

  1. 伙伴系统 — 以页为粒度管理物理内存,通过分裂/合并提高效率,按迁移类型分组缓解外部碎片
  2. Per-CPU Pageset — 热页缓存,消除单页分配时的zone锁竞争
  3. SLUB分配器 — 构建在伙伴系统之上,提供小对象(B~8KB)的高速分配,Per-CPU freelist实现无锁热路径
  4. kmalloc 缓存系列 — 幂次分桶的通用缓存机制

理解这四层结构,就掌握了从 kmalloc 到 alloc_pages 完整调用链的工程语义。当面对内存泄漏、碎片问题、分配器性能瓶颈时,/proc/buddyinfo、/proc/slabinfo、slabtop、slub_debug、kmemleak 这些工具能提供足够深入的洞察力。

在云原生场景中,容器内高密部署时每个Pod多出几十MB的slab开销不容忽视;在高性能网络(DPDK/RDMA)场景下,hugepage 绕过伙伴系统直接分配 2MB/1GB 页框的策略又是一条完全不同的工程路径。内存管理——永远是系统工程师绕不开的战场。


参考代码基于 Linux 6.6+,部分结构体字段可能因内核版本不同而有调整。建议读者结合自己的内核源码阅读验证。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部