2. 进程地址空间 mm_struct 深度解析

在 Linux 内核中,每个进程都拥有一个独立的虚拟地址空间,而 mm_struct 正是描述这个地址空间的核心数据结构。它记录了进程内存的布局、映射关系、权限控制以及物理内存的映射入口。理解 mm_struct 是掌握内存管理全栈的第一步,也是后续分析页表、缺页中断和 VMA 操作的基础。

关键认知: mm_struct 是进程的“内存身份证”。内核通过它来管理进程的代码段、数据段、堆、栈、内存映射文件以及页表层次结构。每个进程(除内核线程外)都拥有唯一的 mm_struct 实例。

2.1 mm_struct 核心字段全景

下表列出了 mm_struct 中最重要的字段及其功能分类,这些字段直接参与地址空间的构建、查找和权限控制。

字段名 类型 功能描述 所属类别
pgd pgd_t * 指向进程页全局目录(Page Global Directory)的指针,是页表遍历的根入口 页表管理
mmap struct vm_area_struct * 虚拟内存区域(VMA)链表头,按虚拟地址升序链接所有 VMA VMA 管理
mm_rb struct rb_root VMA 红黑树根节点,用于快速查找指定虚拟地址所属的 VMA VMA 管理
arg_start / arg_end unsigned long 命令行参数(argv)在用户空间栈中的起始和结束地址 进程参数
env_start / env_end unsigned long 环境变量(envp)在用户空间栈中的起始和结束地址 进程参数
start_code / end_code unsigned long 代码段的起始和结束虚拟地址 代码段
start_data / end_data unsigned long 初始化数据段(data segment)的起始和结束虚拟地址 数据段
start_brk / brk unsigned long 堆(heap)的起始地址和当前堆顶地址,brk 用于系统调用 brk() 调整堆大小 堆管理
start_stack unsigned long 用户栈的起始地址(栈顶附近) 栈管理
map_count atomic_t 当前进程中 VMA 的数量 统计
total_vm unsigned long 进程虚拟内存页的总数(以 PAGE_SIZE 为单位) 统计
实战要点: mmap 链表用于遍历所有 VMA,而 mm_rb 红黑树用于快速查找。内核在查找 VMA 时优先使用红黑树(O(log n) 复杂度),只有在需要顺序遍历或合并相邻 VMA 时才使用链表。

2.2 pgd:页表遍历的根入口

pgd 是 mm_struct 中指向页全局目录的指针。在 x86-64 架构下,页表采用四级页表结构:PGD → P4D → PUD → PMD → PTE。进程切换时,内核会将当前进程的 pgd 值写入 CPU 的 CR3 寄存器,从而激活该进程的地址空间。

下面通过 SVG 图展示 pgd 在页表层次中的位置以及虚拟地址到物理地址的转换路径。

pgd 在四级页表层次中的位置 虚拟地址(48位) PGD P4D PUD PMD PTE 物理页帧 mm_struct->pgd 指向 PGD 表基地址,CR3 寄存器加载该值后启动地址翻译
注意: 在进程创建(fork)时,子进程会复制父进程的 mm_struct,但 pgd 指向的页表会被写时复制(Copy-on-Write)。这意味着父子进程最初共享同一套页表,只有当一方尝试写入时才会创建新的页表副本。

2.3 mmap 与 mm_rb:VMA 的双重组织

虚拟内存区域(VMA)是进程地址空间的基本管理单元。每个 VMA 描述一段连续的虚拟地址区间及其属性(权限、文件映射、匿名等)。mm_struct 通过两种数据结构组织 VMA:

  • mmap 链表: 按虚拟地址升序链接所有 VMA,用于顺序遍历和相邻 VMA 的合并操作。
  • mm_rb 红黑树: 以 VMA 的起始地址为键值构建平衡二叉搜索树,用于快速查找指定虚拟地址所属的 VMA。

下面的 SVG 图展示了 mmap 链表和 mm_rb 红黑树如何共同管理进程的 VMA 集合。

mmap 链表与 mm_rb 红黑树协同管理 VMA mm_struct mmap / mm_rb VMA 1 (0x0-0x1000) VMA 2 (0x1000-0x2000) VMA 3 (0x2000-0x3000) NULL mm_rb 红黑树结构 VMA 2 (0x1000) VMA 1 (0x0) VMA 3 (0x2000) mmap 链表:按地址顺序链接,用于遍历和合并 mm_rb 红黑树:按起始地址快速查找,时间复杂度 O(log n) 两者通过 vm_next / vm_prev 和 vm_rb 字段共享同一组 VMA 对象
内核源码路径: VMA 的查找函数 find_vma() 首先尝试从 mm_rb 红黑树中查找,如果找到则返回;如果未找到,则通过 mmap 链表进行边界检查。这种双重结构兼顾了查找效率和遍历灵活性。

2.4 arg_start/arg_end 与 env_start/env_end:进程参数与环境的边界

当用户执行一个程序时,内核会将命令行参数和环境变量放置在用户栈的顶部附近。arg_start 和 arg_end 标记了参数列表(argv)在栈中的地址范围,而 env_start 和 env_end 标记了环境变量(envp)的地址范围。这些字段对于调试器(如 GDB)和 /proc/<pid>/maps 的解析至关重要。

下面的 SVG 图展示了进程地址空间的典型布局,并标注了这些关键边界字段的位置。

进程地址空间布局与 arg_start/arg_end 等字段 内核空间(高地址) 用户栈(向下增长) start_stack / arg_start / arg_end / env_start / env_end 向下增长 内存映射区域(mmap 文件、共享库) 堆(向上增长) start_brk / brk 向上增长 BSS 段(未初始化数据) 数据段(初始化数据) start_data / end_data 代码段(只读) start_code / end_code arg_start/arg_end 和 env_start/env_end 位于栈区域顶部,由 exec 系统调用设置
调试技巧: 通过 /proc/<pid>/maps 可以查看进程的完整地址空间布局,其中每一行对应一个 VMA。而 /proc/<pid>/stat 中包含了 arg_start、arg_end 等字段的数值,可用于分析进程参数和环境的内存占用。

2.5 其他关键字段速览

除了上述核心字段外,mm_struct 还包含以下重要成员:

  • start_brk / brk: 堆的起始地址和当前堆顶。系统调用 brk() 和 sbrk() 通过调整 brk 来分配或释放堆内存。
  • start_stack: 用户栈的起始地址(通常接近栈的最高地址)。栈从该地址向下增长。
  • map_count: 当前 VMA 的数量。当 map_count 超过 sysctl_max_map_count(默认 65530)时,mmap 系统调用会返回 ENOMEM。
  • total_vm: 进程虚拟内存页的总数,包括所有已映射和未映射的页面。该值可通过 top 或 ps 命令的 VSZ 字段查看。
  • def_flags: 默认的 VMA 标志,例如 VM_READ、VM_WRITE、VM_EXEC,新建 VMA 时会继承这些标志。
性能指标: 当进程的 map_count 超过 10000 时,VMA 的查找和遍历性能会显著下降。此时应考虑使用大页(Huge Pages)或调整应用程序的内存映射策略来减少 VMA 数量。

2.6 实战:通过 /proc 查看 mm_struct 字段

以下示例展示了如何通过 /proc 文件系统查看当前进程的 mm_struct 关键字段。代码使用 C 语言读取 /proc/self/stat 并解析相关字段。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    FILE *fp = fopen("/proc/self/stat", "r");
    if (!fp) {
        perror("fopen");
        return 1;
    }

    char buf[1024];
    if (!fgets(buf, sizeof(buf), fp)) {
        perror("fgets");
        fclose(fp);
        return 1;
    }
    fclose(fp);

    // /proc/self/stat 格式:pid comm state ppid ...
    // 第 26 个字段是 startcode,第 27 是 endcode
    // 第 28 是 startstack,第 29 是 arg_start,第 30 是 arg_end
    // 第 31 是 env_start,第 32 是 env_end
    unsigned long startcode, endcode, startstack;
    unsigned long arg_start, arg_end, env_start, env_end;

    // 跳过前 25 个字段
    char *token = strtok(buf, " ");
    for (int i = 1; i < 25; i++) {
        token = strtok(NULL, " ");
        if (!token) break;
    }

    if (token) {
        startcode = strtoul(token, NULL, 10);
        token = strtok(NULL, " "); endcode = strtoul(token, NULL, 10);
        token = strtok(NULL, " "); startstack = strtoul(token, NULL, 10);
        token = strtok(NULL, " "); arg_start = strtoul(token, NULL, 10);
        token = strtok(NULL, " "); arg_end = strtoul(token, NULL, 10);
        token = strtok(NULL, " "); env_start = strtoul(token, NULL, 10);
        token = strtok(NULL, " "); env_end = strtoul(token, NULL, 10);

        printf("startcode  = 0x%lx\n", startcode);
        printf("endcode    = 0x%lx\n", endcode);
        printf("startstack = 0x%lx\n", startstack);
        printf("arg_start  = 0x%lx\n", arg_start);
        printf("arg_end    = 0x%lx\n", arg_end);
        printf("env_start  = 0x%lx\n", env_start);
        printf("env_end    = 0x%lx\n", env_end);
    }

    return 0;
}
运行结果示例: 编译并运行上述程序,输出可能如下(具体地址因 ASLR 而异):
startcode  = 0x400000
endcode    = 0x401000
startstack = 0x7ffc8a000000
arg_start  = 0x7ffc8a001000
arg_end    = 0x7ffc8a001020
env_start  = 0x7ffc8a001020
env_end    = 0x7ffc8a001080

2.7 本章小结

mm_struct 是进程地址空间管理的总控结构,其核心字段覆盖了页表入口(pgd)、VMA 组织(mmap / mm_rb)、进程参数边界(arg_start / arg_end)以及代码/数据/堆/栈的地址范围。理解这些字段的语义和相互关系,是后续分析 VMA 操作、缺页异常处理和页表遍历的基础。

常见误区: pgd 指向的是页全局目录的虚拟地址,而非物理地址。内核在切换进程时,需要将 pgd 转换为物理地址后写入 CR3 寄存器。这一转换通过 __pa() 宏完成。