3深入理解Linux内核(第3版)

内存管理

内存分配

页框分配slab分配器内存映射

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第3章:内存管理(Memory Management)

一、导读

3.1 本章学习目标

第3章深入讲解Linux内核的内存管理机制。在理解了第2章的硬件寻址基础之后,本章将聚焦于内核如何管理和分配内存资源。Linux的内存管理是内核中最复杂的子系统之一,它需要同时满足多种需求:高效分配物理页框、管理虚拟地址空间、支持内存映射、处理内存不足等。

本章的核心学习目标包括:

  • 理解伙伴系统(Buddy System)的工作原理:如何管理物理页框的分配和释放,减少外部碎片
  • 掌握slab分配器的设计思想:如何高效分配小对象,避免内部碎片
  • 理解kmalloc和vmalloc的区别:连续物理内存 vs 连续虚拟内存
  • 掌握内存映射(mmap)的实现:如何将文件或设备映射到进程地址空间
  • 理解内存区域(Zone)的划分:ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM各自的用途
  • 了解写时复制(Copy-on-Write)机制:fork如何高效创建子进程

3.2 为什么本章如此重要

内存管理是操作系统的核心职能之一。程序的性能、系统的稳定性、甚至安全性,都与内存管理密切相关。内存泄漏会导致系统资源耗尽,内存碎片会降低系统性能,错误的内存访问会导致安全漏洞。

对于内核开发者而言,理解内存管理机制是编写驱动程序和内核模块的基础。不同的内存分配接口(kmalloc、vmalloc、kmem_cache_alloc)适用于不同的场景,选择不当会导致性能问题甚至系统崩溃。对于系统程序员而言,理解mmap、COW等机制有助于编写更高效的程序。

3.3 核心问题引导

在阅读本章时,请带着以下问题思考:

为什么内核需要多种内存分配接口?一种通用的分配器为什么不够?

伙伴系统如何解决外部碎片问题?它又引入了什么新问题?

slab分配器为什么比通用的分配器更适合分配内核数据结构?

kmalloc和vmalloc返回的内存有什么本质区别?为什么不能互换使用?

fork()创建子进程时,为什么不需要复制整个地址空间?

当系统物理内存不足时,内核如何决定回收哪些页面?


二、核心概念详解

2.1 内存区域(Zone)

Linux将物理内存划分为多个区域(Zone),每个区域具有不同的特性和用途。这种划分源于硬件的限制和需求。

c
/* 内存区域定义(include/linux/mmzone.h) */
enum zone_type {
    ZONE_DMA,       /* 0-16MB,ISA设备DMA */
    ZONE_DMA32,     /* 0-4GB,32位设备DMA */
    ZONE_NORMAL,    /* 4GB以上,正常内核映射 */
    ZONE_HIGHMEM,   /* 32位系统>896MB(64位不需要) */
    ZONE_MOVABLE,   /* 可迁移的页面 */
    MAX_NR_ZONES
};

/*
 * ZONE_DMA (0-16MB):
 *   早期的ISA设备只能寻址低16MB物理内存。
 *   现代系统中这个区域通常很小或不存在。
 *
 * ZONE_DMA32 (0-4GB):
 *   32位DMA设备只能寻址4GB以内的物理内存。
 *   在64位系统上,这个区域确保有足够内存供32位设备使用。
 *
 * ZONE_NORMAL (4GB+):
 *   内核正常使用的内存区域。在64位系统上,
 *   几乎所有内存都在这个区域。内核的直接映射区
 *   覆盖这个区域的所有页面。
 *
 * ZONE_HIGHMEM:
 *   仅存在于32位系统。超过896MB的物理内存
 *   无法被内核直接映射(因为内核虚拟地址空间只有1GB,
 *   其中128MB用于vmalloc等),需要临时映射才能访问。
 *   64位系统不需要HIGHMEM。
 */

每个区域由一个pglist_data(也称为pg_data_t)结构描述,系统中可能有多个pglist_data(NUMA系统中每个节点一个)。

c
/* pglist_data 结构(简化) */
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;              /* 页描述符数组 */
    unsigned long node_start_paddr;         /* 起始物理地址 */
    unsigned long node_spanned_pages;       /* 总页数(含空洞) */
    unsigned long node_present_pages;       /* 实际存在页数 */
    int node_id;                            /* NUMA节点ID */
    struct pglist_data *pgdat_next;         /* 链表指针 */
    spinlock_t node_size_lock;              /* 保护锁 */
} pg_data_t;

2.2 伙伴系统(Buddy System)

伙伴系统是Linux内核管理物理页框分配的核心算法。它的设计目标是:快速分配和释放连续的物理页框,同时尽量减少外部碎片。

2.2.1 基本原理

伙伴系统将空闲页框按大小分组,每组包含大小为 2^i 个连续页框的块(称为块(block)),其中 i 的范围是 0 到 MAX_ORDER-1(通常为11,即最大块为 1024 页 = 4MB)。

伙伴系统的空闲链表:
Order 0 (1页=4KB):   [free] → [free] → [free] → NULL
Order 1 (2页=8KB):   [free] → [free] → NULL
Order 2 (4页=16KB):  [free] → NULL
Order 3 (8页=32KB):  [free] → [free] → NULL
...
Order 10 (1024页=4MB): [free] → NULL

分配过程:当请求分配 2^i 个页框时:

查找 order i 的空闲链表

如果有空闲块,直接分配

如果没有,查找 order i+1 的空闲链表

将找到的块一分为二(split),一半分配,一半放入 order i 的空闲链表

重复直到找到可用块或返回失败

释放过程:当释放 2^i 个页框时:

找到该块的"伙伴"(buddy)——地址相邻、大小相同的块

如果伙伴也在空闲链表中,合并两者为 2^(i+1) 的块

重复合并直到伙伴不在空闲链表中或达到最大order

c
/* 伙伴系统的核心数据结构 */
struct free_area {
    struct list_head free_list[MIGRATE_TYPES];  /* 空闲块链表 */
    unsigned long nr_free;                       /* 空闲块数量 */
};

/* zone 中的伙伴系统 */
struct zone {
    /* ... */
    struct free_area free_area[MAX_ORDER];  /* 各order的空闲链表 */
    /* ... */
};

/* 分配页框的核心函数(简化) */
struct page *__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order,
                                     struct zonelist *zonelist,
                                     nodemask_t *nodemask) {
    struct page *page;
    struct zone *zone;
    int migratetype;

    /* 1. 确定迁移类型 */
    migratetype = gfpflags_to_migratetype(gfp_mask);

    /* 2. 从合适的zone中分配 */
    /* 先尝试在请求的zone中分配 */
    /* 如果失败,尝试其他zone */
    /* 如果还是失败,触发内存回收 */

    /* 3. 从伙伴系统获取页框 */
    page = __rmqueue(zone, order, migratetype);
    if (!page) {
        /* 内存回收后重试 */
        __alloc_pages_slowpath(gfp_mask, order, zonelist);
    }

    return page;
}

/* 伙伴系统的拆分操作 */
static struct page *__rmqueue(struct zone *zone, unsigned int order,
                               int migratetype) {
    struct free_area *area;
    struct page *page;
    unsigned int current_order;

    /* 从请求的order开始,向上查找 */
    for (current_order = order;
         current_order < MAX_ORDER;
         ++current_order) {
        area = &(zone->free_area[current_order]);
        if (list_empty(&area->free_list[migratetype]))
            continue;

        /* 找到一个空闲块 */
        page = list_entry(area->free_list[migratetype].next,
                          struct page, lru);
        list_del(&page->lru);
        area->nr_free--;

        /* 拆分到请求的order */
        while (current_order > order) {
            area--;
            current_order--;
            /* 将块一分为二,高半部分放入低order链表 */
            buddy = page + (1 << current_order);
            list_add(&buddy->lru, &area->free_list[migratetype]);
            area->nr_free++;
        }

        return page;
    }

    return NULL;  /* 分配失败 */
}

2.2.2 伙伴系统的优缺点

优点:

  • 分配和释放速度快(O(1)链表操作)
  • 有效减少外部碎片(通过合并伙伴块)
  • 实现相对简单

缺点:

  • 内部碎片:请求3个页框时,必须分配4个(2^2),浪费1个
  • 只能分配2的幂次个页框,不适合分配小对象

2.3 slab分配器

伙伴系统的最小分配单位是1个页框(4KB),但内核中大量使用的数据结构(如task_struct、inode、dentry)通常小于4KB。如果直接用伙伴系统分配这些小对象,会造成严重的内部碎片。

slab分配器就是为了解决这个问题而设计的。它为每种类型的内核对象创建一个专用缓存(slab cache),每个缓存中预分配了大量该类型的对象。

c
/* slab 缓存的结构 */
/*
 * kmem_cache 结构(简化)
 *
 * ┌────────────────────────────────────────┐
 * │           kmem_cache                    │
 * │  name: "task_struct"                   │
 * │  obj_size: 5888 bytes                  │
 * │  align: 64                             │
 * │  obj_per_slab: 4 (每slab 4个对象)      │
 * ├────────────────────────────────────────┤
 * │  slab 1 (16KB = 4页)                   │
 * │  ┌──────┬──────┬──────┬──────┐         │
 * │  │ obj  │ obj  │ obj  │ obj  │         │
 * │  │ used │ used │ free │ free │         │
 * │  └──────┴──────┴──────┴──────┘         │
 * ├────────────────────────────────────────┤
 * │  slab 2 (16KB)                         │
 * │  ┌──────┬──────┬──────┬──────┐         │
 * │  │ obj  │ obj  │ obj  │ obj  │         │
 * │  │ used │ free │ free │ free │         │
 * │  └──────┴──────┴──────┴──────┘         │
 * └────────────────────────────────────────┘
 */

/* 创建 slab 缓存 */
struct kmem_cache *kmem_cache_create(
    const char *name,        /* 缓存名称 */
    size_t size,             /* 对象大小 */
    size_t align,            /* 对齐要求 */
    unsigned long flags,     /* 标志 */
    void (*ctor)(void *)     /* 构造函数(可选) */
);

/* 从 slab 缓存分配对象 */
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);

/* 释放对象回 slab 缓存 */
void kmem_cache_free(struct kmem_cache *cachep, void *objp);

/* 销毁 slab 缓存 */
void kmem_cache_destroy(struct kmem_cache *cachep);

/* 使用示例:内核中 task_struct 的分配 */
/* kernel/fork.c */
static struct kmem_cache *task_struct_cachep;

void __init fork_init(void) {
    task_struct_cachep = kmem_cache_create("task_struct",
        sizeof(struct task_struct),
        ARCH_MIN_TASKALIGN,
        SLAB_PANIC | SLAB_NOTRACK,
        NULL);
}

/* 分配新的 task_struct */
struct task_struct *dup_task_struct(struct task_struct *orig) {
    struct task_struct *tsk;
    tsk = kmem_cache_alloc(task_struct_cachep, GFP_KERNEL);
    /* ... 初始化 tsk ... */
    return tsk;
}
bash
# 查看系统的 slab 缓存使用情况
cat /proc/slabinfo | head -20
# name    <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
dentry           45678      46080      192       21         1
inode_cache      12345      12480      608        6         1
task_struct        234        256     5888        5         8

2.4 kmalloc 与 vmalloc

Linux内核提供了两种主要的内存分配接口:kmalloc和vmalloc。它们的区别在于分配的内存是否在物理上连续。

c
/* kmalloc - 分配连续的物理内存 */
/* 底层使用 slab 分配器 */
void *kmalloc(size_t size, gfp_t flags);
void kfree(const void *objp);

/* vmalloc - 分配连续的虚拟内存(物理上不连续) */
/* 通过修改页表使虚拟地址连续,但物理页框可以分散 */
void *vmalloc(unsigned long size);
void vfree(const void *addr);

/*
 * 对比:
 *
 * kmalloc:
 *   虚拟地址: [连续]
 *   物理地址: [连续]
 *   适用场景: DMA操作、硬件驱动、需要物理连续的场景
 *   限制: 最大分配约 4MB(取决于伙伴系统MAX_ORDER)
 *   速度: 快(直接映射,无需修改页表)
 *
 * vmalloc:
 *   虚拟地址: [连续]
 *   物理地址: [不连续]
 *   适用场景: 大块内存、模块加载、不需要物理连续的场景
 *   限制: 可以分配很大的内存(受vmalloc空间限制)
 *   速度: 较慢(需要修改页表,可能产生TLB刷新)
 */
c
/* GFP 标志(Get Free Pages flags) */
/* 控制内存分配的行为 */

/* 常用标志 */
#define GFP_KERNEL    0x00CC  /* 内核正常分配,可睡眠等待 */
#define GFP_ATOMIC    0x00D0  /* 原子上下文,不可睡眠 */
#define GFP_USER      0x00C8  /* 用户进程分配 */
#define GFP_HIGHUSER  0x00D0  /* 用户进程高端内存分配 */
#define GFP_DMA       0x0001  /* 要求 ZONE_DMA 区域 */

/* 行为标志 */
#define __GFP_WAIT    0x0010  /* 可以睡眠等待 */
#define __GFP_HIGH    0x0020  /* 可以使用保留的内存 */
#define __GFP_IO      0x0040  /* 可以执行I/O操作来回收内存 */
#define __GFP_FS      0x0080  /* 可以执行文件系统操作来回收内存 */
#define __GFP_ZERO    0x8000  /* 清零返回的内存 */

2.5 内存映射(mmap)

mmap系统调用将文件或设备映射到进程的虚拟地址空间。映射后,对映射区域的读写操作会被转换为对底层文件的读写,由内核负责数据的换入换出。

c
/* mmap 系统调用 */
#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);
int munmap(void *addr, size_t length);

/* prot 参数: */
/* PROT_EXEC  - 可执行 */
/* PROT_READ  - 可读 */
/* PROT_WRITE - 可写 */
/* PROT_NONE  - 不可访问 */

/* flags 参数: */
/* MAP_SHARED  - 共享映射,修改对其他进程可见 */
/* MAP_PRIVATE - 私有映射,修改使用COW,不影响其他进程 */
/* MAP_ANONYMOUS - 匿名映射,不关联文件 */
/* MAP_FIXED   - 要求映射到指定地址 */
c
/* 使用 mmap 映射文件 */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>

int main() {
    int fd = open("data.txt", O_RDWR);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    struct stat st;
    fstat(fd, &st);
    size_t file_size = st.st_size;

    /* 将文件映射到内存 */
    char *mapped = mmap(NULL, file_size,
                        PROT_READ | PROT_WRITE,
                        MAP_SHARED, fd, 0);
    if (mapped == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return 1;
    }

    /* 直接通过内存指针读写文件 */
    mapped[0] = 'H';
    mapped[1] = 'e';
    mapped[2] = 'l';
    mapped[3] = 'l';
    mapped[4] = 'o';

    printf("File content: %.*s\n", (int)file_size, mapped);

    /* 同步到磁盘(可选,close时也会同步) */
    msync(mapped, file_size, MS_SYNC);

    munmap(mapped, file_size);
    close(fd);
    return 0;
}

2.6 写时复制(Copy-on-Write)

fork()系统调用创建子进程时,如果完整复制父进程的整个地址空间,开销巨大。Linux使用写时复制(COW)技术优化这一过程:父子进程最初共享相同的物理页面,只有当某一方尝试写入时,内核才复制该页面。

c
/* fork 的 COW 实现(简化) */
/* kernel/fork.c */
static unsigned long copy_page_range(struct mm_struct *dst_mm,
                                      struct mm_struct *src_mm,
                                      pgd_t *dst_pgd, pgd_t *src_pgd) {
    /* 遍历源进程的所有页表项 */
    /* 对每个页表项: */
    /*   1. 复制页表项到子进程 */
    /*   2. 将父子进程的页表项都设为只读 */
    /*   3. 增加物理页的引用计数 */
    /* 当任何一方写入时,触发缺页异常 */
    /* 缺页处理函数复制页面,并恢复写权限 */
}

/* 缺页异常中的 COW 处理(简化) */
/* mm/memory.c */
static int do_wp_page(struct vm_area_struct *vma, unsigned long address,
                       pte_t *page_table) {
    struct page *old_page = vm_normal_page(vma, address, *page_table);
    struct page *new_page;

    /* 如果只有一个引用,直接恢复写权限 */
    if (page_count(old_page) == 1) {
        /* 不需要复制,直接提升权限 */
        pte_mkdirty(*page_table);
        return VM_FAULT_WRITE;
    }

    /* 否则,分配新页面并复制内容 */
    new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address);
    copy_user_highpage(new_page, old_page, address, vma);

    /* 更新页表项指向新页面 */
    /* 减少旧页面的引用计数 */
    /* 新页面设为可写 */

    return VM_FAULT_WRITE;
}

三、实战案例

3.1 观察伙伴系统状态

bash
# 查看伙伴系统的空闲块分布
cat /proc/buddyinfo
# Node 0, zone   DMA      1    0    1    0    2    1    1    0    1    1    3
# Node 0, zone  Normal   56   32   18   10    5    3    2    1    0    0  128

# 解读:
# Normal zone, order 0: 56个空闲的1页块(56×4KB=224KB)
# Normal zone, order 1: 32个空闲的2页块(32×8KB=256KB)
# Normal zone, order 9: 0个空闲的512页块
# Normal zone, order 10: 128个空闲的1024页块(128×4MB=512MB)

# 触发内存分配后再次查看
# 观察各order的空闲块数量变化

3.2 比较 kmalloc 和 vmalloc 的性能

c
/* mem_bench.c - 内存分配性能对比 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/mman.h>
#include <fcntl.h>

#define SIZE (64 * 1024 * 1024)  /* 64MB */

int main() {
    struct timespec start, end;
    double elapsed;

    /* 测试 malloc(底层使用 brk/mmap) */
    clock_gettime(CLOCK_MONOTONIC, &start);
    void *p1 = malloc(SIZE);
    memset(p1, 0, SIZE);  /* 触发实际分配 */
    clock_gettime(CLOCK_MONOTONIC, &end);
    elapsed = (end.tv_sec - start.tv_sec) +
              (end.tv_nsec - start.tv_nsec) / 1e9;
    printf("malloc + memset: %.6f seconds\n", elapsed);
    free(p1);

    /* 测试 mmap 匿名映射 */
    clock_gettime(CLOCK_MONOTONIC, &start);
    void *p2 = mmap(NULL, SIZE, PROT_READ | PROT_WRITE,
                    MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    memset(p2, 0, SIZE);  /* 触发缺页异常 */
    clock_gettime(CLOCK_MONOTONIC, &end);
    elapsed = (end.tv_sec - start.tv_sec) +
              (end.tv_nsec - start.tv_nsec) / 1e9;
    printf("mmap + memset: %.6f seconds\n", elapsed);
    munmap(p2, SIZE);

    return 0;
}

3.3 使用 mmap 实现进程间共享内存

c
/* shm_producer.c - 共享内存生产者 */
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>

#define SHM_NAME "/my_shm"
#define SHM_SIZE 4096

int main() {
    int fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
    ftruncate(fd, SHM_SIZE);

    char *shared = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE,
                        MAP_SHARED, fd, 0);

    for (int i = 0; i < 10; i++) {
        sprintf(shared, "Message %d", i);
        printf("Produced: %s\n", shared);
        sleep(1);
    }

    munmap(shared, SHM_SIZE);
    close(fd);
    return 0;
}

/* shm_consumer.c - 共享内存消费者 */
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#define SHM_NAME "/my_shm"
#define SHM_SIZE 4096

int main() {
    int fd = shm_open(SHM_NAME, O_RDONLY, 0666);
    char *shared = mmap(NULL, SHM_SIZE, PROT_READ,
                        MAP_SHARED, fd, 0);

    for (int i = 0; i < 10; i++) {
        printf("Consumed: %s\n", shared);
        sleep(1);
    }

    munmap(shared, SHM_SIZE);
    close(fd);
    shm_unlink(SHM_NAME);
    return 0;
}

四、本章小结

本章深入讲解了Linux内核的内存管理机制。伙伴系统通过按2的幂次组织空闲页框,实现了快速的分配和释放,同时通过合并伙伴块减少外部碎片。slab分配器则为小对象提供了高效的分配方案,通过预分配和对象复用,避免了频繁创建和销毁的开销。

kmalloc和vmalloc分别适用于不同的场景:前者分配物理连续的内存,适合DMA等硬件操作;后者分配虚拟连续的内存,适合大块内存分配。mmap系统调用通过将文件映射到地址空间,提供了高效的文件I/O和进程间通信机制。写时复制(COW)技术使得fork()操作变得轻量级,只有在真正需要写入时才复制页面。

这些机制共同构成了Linux内存管理的完整体系,它们的设计体现了在性能、灵活性和复杂度之间的精妙平衡。


五、思考与练习

思考题

伙伴系统在长时间运行后,即使总空闲内存充足,也可能无法分配大块连续内存。这是什么问题?Linux如何缓解这个问题?

slab分配器中的"着色(coloring)"技术是什么?它解决了什么问题?

为什么在原子上下文(如中断处理函数)中不能使用GFP_KERNEL标志?

vfork和fork的区别是什么?在什么场景下应该使用vfork?

编程练习

编写一个程序,使用mmap映射一个大文件,然后使用mincore()系统调用检查哪些页面已经在物理内存中。

编写一个程序,对比顺序写入和随机写入对内存分配性能的影响,分析原因。

编写一个简单的内存分配器(实现malloc和free的基本功能),使用mmap作为底层内存来源。


3/10内存管理
0%