vm_area_struct:进程地址空间的合法区间表

vm_area_struct:进程地址空间的合法区间表

第一篇里说过,/proc/self/maps 看到的不是页表,而是一段段虚拟地址区间。每一段区间背后,在内核里通常对应一个 vm_area_struct,简称 VMA。

这篇专门把 VMA 拆开看:

一个进程访问某个虚拟地址时,Linux 怎么先判断"这段地址是不是你的"?

这个问题不能直接交给页表回答。页表只关心一页虚拟地址当前有没有被翻译到某个物理页框;但很多合法地址在第一次访问前,页表里本来就没有 present 映射。内核需要先有一张"合法区间表",记录哪些范围可以访问、权限是什么、背后是匿名内存还是文件。

这张表的核心结构就是 vm_area_struct

一、mm_struct 是进程地址空间的总入口

Linux 里,每个普通用户进程都有一个地址空间描述符:mm_struct。它挂在进程的 task_struct 上:

复制代码
   task_struct
        │
        └─ mm
            │
            ▼
        mm_struct

mm_struct 不是一段用户内存,也不是页表本身。它是内核描述一个用户地址空间的总控结构,里面会记录:

  • VMA 集合:这个进程有哪些合法虚拟地址区间。
  • 页表根:例如 mm_struct->pgd,内核用它记录这个地址空间的顶级页表。
  • 代码段、数据段、堆等地址范围:例如 start_codeend_codestart_brkbrk;以及栈相关参考点 start_stack
  • 内存统计和限制:例如虚拟内存大小、锁定页数量、RSS 相关计数。

抽象图如下:

markdown 复制代码
   task_struct
        │
        ▼
   mm_struct
        ├─ VMA 集合:合法虚拟地址区间
        ├─ pgd:软件层面的顶级页表指针
        ├─ start_code / end_code
        ├─ start_data / end_data
        ├─ start_brk / brk
        └─ start_stack:栈相关参考点,不是完整栈边界

这里没有对称的 end_stack,不能理解成"栈下边界就是堆的 brk"。用户栈通常从较高地址向较低地址增长,但它和堆之间不是紧挨着的,中间往往有 mmap 区、动态库、匿名映射、地址空间空洞和 ASLR 留出的随机化距离。

真正描述"当前哪些栈地址合法"的,仍然是栈对应的 VMA:

arduino 复制代码
   stack VMA
      ├─ vm_start:当前已纳入栈 VMA 的低地址边界
      ├─ vm_end:当前栈 VMA 的高地址边界
      └─ vm_flags:通常带有 growsdown 语义

栈向下增长时,缺页地址如果落在允许扩展的范围内,内核可以扩展这个 stack VMA 的 vm_start;如果越过限制或保护间隔,就会变成非法访问。start_stack 更多是内核记录初始用户栈位置的字段,不能把它当成栈 VMA 的唯一边界。

这里要把 pgd 和硬件寄存器分清楚。

pgd 是 Linux 内核的页表层级抽象。mm_struct->pgd 保存的是这个进程地址空间的顶级页表位置,内核查页表、切换地址空间时会用到它。它不是 CPU 里的寄存器。

以 x86 为例,CPU 真正做地址翻译时使用的页表根寄存器是 CR3。当内核切换到某个进程的地址空间时,会把 mm_struct->pgd 指向的顶级页表的物理基址装进 CR3,之后硬件 page walk 才能从这个根开始查页表。

关系可以这样看:

rust 复制代码
   mm_struct->pgd
        │
        │  Linux 内核保存的顶级页表位置
        ▼
   顶级页表页
        ▲
        │
        │  x86 上运行该地址空间时,CR3 指向它的物理基址
        │
   CPU CR3 寄存器

换句话说:mm_struct->pgd 是内核管理地址空间时保存的"账本入口",CR3 是 x86 硬件当前实际使用的"翻译入口"。在 x86-64 硬件术语里,4 级分页的顶级常叫 PML4,5 级分页还会有 PML5;Linux 在源码层面仍用 pgd 作为跨架构抽象名。

同一个进程里的多个线程通常共享同一个 mm_struct。这意味着它们共享用户地址空间、VMA 和页表;但每个线程仍然有自己的用户栈和内核栈。线程和进程的完整布局会放到第七篇集中讲。

这一篇先记住一条主线:

markdown 复制代码
   当前线程
      │
      ▼
   task_struct
      │
      ▼
   mm_struct
      │
      ├─ 找 VMA:判断地址是否合法
      └─ 找页表:判断这一页是否已经有映射

二、VMA 描述一段连续虚拟地址区间

一个 vm_area_struct 表示一段连续的虚拟地址范围。它不是"一个页",也不是"一块物理内存"。它更像一条规则:

text 复制代码
从 vm_start 到 vm_end 这段虚拟地址,
允许按 vm_flags 指定的方式访问,
背后可能对应某个文件,也可能是匿名内存。

简化后可以这样看:

arduino 复制代码
   vm_area_struct
      ├─ vm_start:起始虚拟地址,包含
      ├─ vm_end:结束虚拟地址,不包含
      ├─ vm_flags:读、写、执行、共享、向下增长等标志
      ├─ vm_file:文件映射对应的 struct file,匿名映射为空
      ├─ vm_pgoff:映射从文件的哪个页偏移开始
      ├─ vm_ops:这类映射的操作函数
      └─ anon_vma:匿名页反向映射相关结构

注意 vm_end 通常是不包含的。也就是说,一个 VMA 覆盖的是:

text 复制代码
[vm_start, vm_end)

这和很多 C 代码里的区间习惯一致,判断地址是否落在 VMA 内部时,大致是:

text 复制代码
vm_start <= addr < vm_end

如果你看 /proc/self/maps,每一行的地址范围就对应这种半开区间:

text 复制代码
55b0f7d2e000-55b0f7d33000 r-xp 00004000 08:01 12345 /usr/bin/cat

可以近似理解成:

ini 复制代码
   vm_start = 0x55b0f7d2e000
   vm_end   = 0x55b0f7d33000
   flags    = readable + executable + private
   file     = /usr/bin/cat
   pgoff    = 0x4000 / PAGE_SIZE

maps 里的权限列很重要:

  • r:允许读。
  • w:允许写。
  • x:允许执行。
  • p:private,私有映射,写入时通常触发写时复制。
  • s:shared,共享映射,修改可能对其他映射者可见。

这些信息并不等于页表项里的硬件权限位,但它们会影响内核后续如何建立页表项、如何处理缺页、是否允许某次访问继续下去。

三、VMA 不是页表

VMA 和页表最容易混在一起,因为它们都和虚拟地址有关。但它们回答的问题不同。

VMA 回答:

text 复制代码
这段虚拟地址范围属于当前进程吗?
允许读、写、执行吗?
背后是匿名内存、文件,还是特殊映射?

页表回答:

text 复制代码
这个虚拟页现在有没有映射到物理页框?
如果有,PFN 是多少?
硬件访问权限和状态位是什么?

所以一个 VMA 可以覆盖很大的范围,而页表只为已经需要兑现的页逐步补上映射:

rust 复制代码
   VMA:

   0x40000000 ───────────────────────── 0x80000000
                rw-p anonymous

   页表:

   0x40000000 -> present, PFN A
   0x40001000 -> not present
   0x40002000 -> not present
   0x40003000 -> present, PFN B
   ...

这就是为什么 mmap 一大片匿名内存之后,maps 里能立刻看到一段新范围,但 RSS 不一定马上增加同样大小。VMA 先建立"合法性承诺",物理页通常等第一次访问触发缺页时再分配。

可以把关系收束成一句话:

VMA 是地址空间的合同,页表是已经履约的页级映射。

这个比喻的边界也要清楚:VMA 不是用户可见的语言层对象,而是内核内部结构;页表也不是 VMA 的缓存,而是硬件参与使用的地址翻译结构。

四、匿名映射和文件映射的 VMA 有什么不同

VMA 背后不一定有文件。按来源看,常见 VMA 大致可以分成两类:匿名映射和文件映射。

匿名映射没有普通磁盘文件作为 backing。典型来源是:

  • heap,也就是 brk 扩展出来的堆。
  • 用户栈。
  • mmap(MAP_ANONYMOUS) 创建的匿名映射。
  • 大块 malloc 可能向 libc 触发的匿名 mmap

匿名 VMA 通常长这样:

ini 复制代码
   vm_area_struct
      ├─ vm_start / vm_end
      ├─ vm_flags:VM_READ / VM_WRITE / VM_MAYWRITE ...
      ├─ vm_file = NULL
      └─ anon_vma:用于匿名页反向映射

匿名映射刚创建时,通常只是多了一个 VMA,页表里还没有对应的 present PTE。也就是说,虚拟地址范围已经合法,但具体到每个虚拟页,页表项可能仍然是空的或 not present。

第一次访问时要分读和写:

ini 复制代码
   mmap anonymous 后
      │
      └─ VMA 存在,普通页表项通常还没有 present 映射

   第一次读
      │
      ├─ 可能映射到内核共享的只读零页
      └─ 页表项形式:present + read-only + PFN=zero page

   第一次写
      │
      ├─ 分配新的私有物理页
      ├─ 清零
      └─ 页表项形式:present + writable + PFN=新分配页

这里的"页表项形式"说的是最低层 PTE 的核心状态。真实 PTE 里还会有 user/supervisor、accessed、dirty、NX、soft-dirty、架构保留位等标志;不同架构和内核配置会有差异。对这篇来说,抓住三种状态就够了:还没有 present 映射、映射只读共享零页、映射可写私有物理页。

如果第一次是读,内核不一定马上给这个进程分配真实私有物理页,因为读一个全零的匿名页可以用共享零页满足。等后续真的写入时,由于零页是只读的,会再次触发缺页,内核再分配新页、清零、复制或直接建立私有可写映射。

以后如果内存压力很大,匿名页因为没有原始文件可以重新读取,所以要回收已经承载进程私有数据的匿名页,通常需要先把内容写到 swap。

文件映射背后有一个文件。典型来源是:

  • 可执行文件的代码段和数据段。
  • 动态库。
  • 用户显式 mmap 某个文件。
  • vdso 这类特殊映射可以有自己的内核提供逻辑。

文件映射 VMA 会记录 vm_filevm_pgoff

rust 复制代码
   vm_area_struct
      ├─ vm_start / vm_end
      ├─ vm_flags:VM_READ / VM_EXEC / VM_SHARED / VM_PRIVATE ...
      ├─ vm_file -> struct file
      ├─ vm_pgoff:文件页偏移
      └─ vm_ops:文件映射相关 fault 操作

文件映射缺页时,内核会根据 VMA 里的文件和偏移,去 page cache 找对应文件页;page cache 没有时,再从存储设备读入。

抽象对比如下:

markdown 复制代码
   匿名 VMA
      │
      └─ 缺页时分配新物理页 / 使用零页 / 处理 COW

   文件 VMA
      │
      └─ 缺页时根据 vm_file + vm_pgoff 找 page cache

MAP_PRIVATEMAP_SHARED 又会继续影响写入语义:

  • MAP_PRIVATE:私有映射。写入时通常走写时复制,当前进程得到自己的匿名页副本。
  • MAP_SHARED:共享映射。多个映射者可能看到同一份修改;如果背后是普通文件,脏页后续可以写回这个文件。

这也是 maps 权限列里 ps 的来源。

五、VMA 集合怎么被组织起来

一个进程可能有很多 VMA:程序段、动态库、heap、stack、匿名映射、文件映射、线程栈、JIT 区域等都可能各占一段。内核必须能高效回答这个问题:

text 复制代码
给定一个虚拟地址 addr,找到覆盖它的 VMA。

老资料里经常会说 mm_struct 里有 VMA 链表和红黑树。这个说法对很多旧内核是对的:链表方便顺序遍历,红黑树方便按地址查找。

但现代 Linux 已经把 VMA 的核心索引切换成了 maple tree。所以现在读资料时要注意版本差异:

markdown 复制代码
   较老内核:
      mm_struct
         ├─ mmap:VMA 链表
         └─ mm_rb:VMA 红黑树

   现代内核:
      mm_struct
         └─ mm_mt:maple tree 管理 VMA

maple tree 是 Linux 内核里一种面向范围索引的数据结构,适合保存"地址区间 -> 对象"这种映射。VMA 正好是按虚拟地址范围组织的对象,所以用它可以更好地支持查找、插入、删除和遍历。

对理解内存管理来说,不必一开始就钻进 maple tree 的内部节点布局。关键是知道:

VMA 不是散落在 mm_struct 里的数组,而是按虚拟地址范围组织起来,供缺页、mmapmunmapmprotect 等路径快速查找和修改。

查找时,内核大致要做的事情是:

markdown 复制代码
   addr
     │
     ▼
   在 mm_struct 的 VMA 索引里查找
     │
     ├─ 找不到覆盖 addr 的 VMA
     │     └─ 地址非法
     │
     └─ 找到 vma
           ├─ addr 在 [vm_start, vm_end) 内
           └─ 继续检查访问权限

这一步在缺页路径里非常关键。页表没映射并不自动意味着错误;只有找不到合法 VMA,或者访问类型和 VMA 权限不匹配,才会变成用户态能看到的段错误。

六、mmapbrkmprotect 会改变 VMA

VMA 是动态变化的。程序运行过程中,很多系统调用都会创建、删除、拆分或合并 VMA。

brk:调整堆边界

小块 malloc 常常先从 libc 已有 arena 里切内存;当 arena 不够时,libc 可能用 brk 扩展堆。内核侧看到的是堆对应 VMA 的结束地址变化:

yaml 复制代码
   原 heap VMA:

   [start_brk, brk)

   brk 增大后:

   [start_brk, new_brk)

如果扩展区域和原堆 VMA 权限、属性连续,内核可以直接扩大原 VMA,而不是创建一堆碎片化 VMA。

mmap:创建新映射

mmap 可以创建匿名映射,也可以映射文件:

ini 复制代码
   mmap anonymous
        │
        └─ 新建一个 vm_file = NULL 的 VMA

   mmap file
        │
        └─ 新建一个 vm_file 指向目标文件的 VMA

调用成功后,用户态马上拿到一个虚拟地址。但这不代表所有页都已经有物理页。很多情况下,内核只是把 VMA 插入当前进程的 VMA 集合里,真正的物理页等访问时再处理。

munmap:删除或切开 VMA

munmap 释放一段虚拟地址范围。如果释放范围刚好覆盖整个 VMA,内核可以删除它。如果只释放中间一段,原 VMA 可能被切成两段:

yaml 复制代码
   原 VMA:

   [A,                         D)

   munmap [B, C) 后:

   [A, B)                 [C, D)

这也是为什么一个进程的 VMA 数量会随运行行为增长。

mprotect:修改权限,必要时拆分 VMA

mprotect 修改某段虚拟地址的权限。如果它只覆盖原 VMA 的一部分,内核也可能拆分 VMA:

lua 复制代码
   原 VMA:

   [A,                         D)  rw-

   mprotect [B, C) 为 r-- 后:

   [A, B) rw-   [B, C) r--   [C, D) rw-

这说明 VMA 的边界不是天然等于"代码段、堆、栈"这种大概念。它的边界由地址范围和属性共同决定:一旦相邻区域权限、文件、偏移、操作等属性不同,就必须分成不同 VMA。

七、缺页时,VMA 是第一道判断

现在把 VMA 放回动态路径里。CPU 访问某个虚拟地址,如果页表无法完成翻译,就会触发缺页异常。进入内核后,内核不能立刻分配页;它要先问:

text 复制代码
这个地址是不是当前进程合法拥有的地址?
这次访问是读、写还是取指?
VMA 允许这种访问吗?

简化流程如下:

markdown 复制代码
   CPU 访问虚拟地址 addr
        │
        ▼
   页表没有 present 映射 / 权限不满足
        │
        ▼
   缺页异常,进入内核
        │
        ▼
   在 mm_struct 中查找 VMA
        │
        ├─ 找不到覆盖 addr 的 VMA
        │     └─ SIGSEGV
        │
        ├─ 找到了,但权限不匹配
        │     └─ SIGSEGV
        │
        └─ 找到了,权限允许
              │
              ▼
          handle_mm_fault
              │
              ├─ 匿名映射:分配页 / 零页 / COW
              ├─ 文件映射:page cache / 读文件
              ├─ swap entry:换入
              └─ 建立或更新页表项

这里能看出 VMA 和页表的配合关系:

复制代码
   VMA 说:这个地址范围可以这样用。
   页表说:这个具体虚拟页现在有没有物理映射。
   缺页处理说:如果 VMA 允许,但页表还没兑现,我来补上。

如果访问的是空指针附近地址,通常找不到合法 VMA,于是 SIGSEGV。如果对只读代码段写入,可能找得到 VMA,但写权限不允许,也会 SIGSEGV。如果访问的是刚 mmap 出来的匿名页,VMA 合法、权限允许,只是页表还没有映射,内核就可以分配物理页并重试指令。

八、可以自己做的小实验

下面这个程序会制造几种不同 VMA:用 sbrk 明确扩展 brk/heap 区间,创建匿名 mmap 和文件 mmap,再用 mprotect 改变匿名映射中一页的权限。

c 复制代码
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void)
{
    size_t page = (size_t)sysconf(_SC_PAGESIZE);

    void *heap = sbrk((intptr_t)page * 8);
    if (heap == (void *)-1) {
        perror("sbrk");
        return 1;
    }
    memset(heap, 0x11, page);

    void *anon = mmap(NULL, page * 4, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (anon == MAP_FAILED) {
        perror("mmap anonymous");
        return 1;
    }

    int fd = open("/tmp/vma-demo-file", O_CREAT | O_RDWR | O_TRUNC, 0600);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    if (ftruncate(fd, (off_t)page * 4) != 0) {
        perror("ftruncate");
        return 1;
    }

    void *file = mmap(NULL, page * 4, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);
    if (file == MAP_FAILED) {
        perror("mmap file");
        return 1;
    }

    if (mprotect((char *)anon + page, page, PROT_READ) != 0) {
        perror("mprotect");
        return 1;
    }

    printf("pid  = %d\n", getpid());
    printf("heap = %p\n", heap);
    printf("anon = %p\n", anon);
    printf("file = %p\n", file);
    printf("press Enter to exit\n");
    getchar();

    return 0;
}

编译运行:

bash 复制代码
gcc -O0 -g vma-demo.c -o vma-demo
./vma-demo

另开一个终端观察:

bash 复制代码
cat /proc/<pid>/maps
cat /proc/<pid>/smaps

下面是一份真实输出,来自 Docker 里的 x86_64 Linux 环境:

bash 复制代码
docker run --rm --platform linux/amd64 gcc:13 ...

程序打印的地址:

text 复制代码
arch = x86_64
pid  = 17
heap = 0x405000
anon = 0x7fffff7c6000
file = 0x7fffff5d9000

截取 /proc/17/maps 中和实验相关的几行:

text 复制代码
00400000-00401000 r--p 00000000 00:4e 35982568  /tmp/vma-demo
00401000-00402000 r-xp 00001000 00:4e 35982568  /tmp/vma-demo
00402000-00403000 r--p 00002000 00:4e 35982568  /tmp/vma-demo
00403000-00404000 r--p 00002000 00:4e 35982568  /tmp/vma-demo
00404000-00405000 rw-p 00003000 00:4e 35982568  /tmp/vma-demo
00405000-0042e000 rw-p 00000000 00:00 0
7fffff5d9000-7fffff5dd000 rw-s 00000000 00:4e 35982564  /tmp/vma-demo-file
7fffff7c6000-7fffff7c7000 rw-p 00000000 00:00 0
7fffff7c7000-7fffff7c8000 r--p 00000000 00:00 0
7fffff7c8000-7fffff7ca000 rw-p 00000000 00:00 0
ffffe9a9b000-ffffe9abc000 rw-p 00000000 00:00 0          [stack]

这里可以直接对上几个点:

  • heap = 0x405000 落在 00405000-0042e000 rw-p 这段匿名 VMA 里,这就是这次 sbrk 扩出来的 brk/heap 区间。这个容器环境没有把它标成 [heap],所以不要只依赖名字。
  • /tmp/vma-demo-file 是文件共享映射,权限列是 rw-s,对应 MAP_SHARED
  • anon = 0x7fffff7c6000 原本是 4 页连续匿名映射;mprotect 把第二页改成只读后,它被拆成了三段:rw-pr--prw-p
  • [stack] 对应用户栈 VMA,smaps 里通常能看到 gd 这类 growsdown 标志。

再截取 /proc/17/smaps 中几段统计:

text 复制代码
7fffff5d9000-7fffff5dd000 rw-s ... /tmp/vma-demo-file
Size:                 16 kB
Rss:                   0 kB
Shared_Dirty:          0 kB
Private_Dirty:         0 kB
VmFlags: rd wr sh mr mw me ms

ffffe9a9b000-ffffe9abc000 rw-p ... [stack]
Size:                132 kB
Rss:                  16 kB
Shared_Dirty:          0 kB
Private_Dirty:        16 kB
VmFlags: rd wr mr mw me gd ac

文件映射那段 Size 是 16KB,因为程序 mmap 了 4 个 4KB 页;但 Rss 是 0KB,因为程序还没有真正读写这段文件映射。VMA 已经存在,不代表页表已经为每一页建立了真实物理页映射。

你可以重点看几件事:

  • heap/brk 区间是否因为 sbrk 和触碰内存而出现或扩大;有的环境会显示 [heap],有的只显示一段匿名 rw-p
  • 匿名 mmap 对应的无文件路径 VMA。
  • /tmp/vma-demo-file 对应的文件映射 VMA。
  • mprotect 是否把原本连续的匿名映射拆成权限不同的多段。
  • maps 中的虚拟范围可能很大,但 smaps 里的 RSS 不一定等于整个范围大小。

不同 libc、内核版本、ASLR 设置会影响具体地址和 VMA 数量。实验重点不是背某个输出,而是观察 VMA 如何随着系统调用变化。

九、把这一篇收束成一张图

yaml 复制代码
   task_struct
        │
        ▼
   mm_struct
        │
        ├─ VMA 集合
        │    │
        │    ├─ vm_area_struct: code    r-xp file
        │    ├─ vm_area_struct: heap    rw-p anonymous
        │    ├─ vm_area_struct: mmap    rw-p anonymous
        │    ├─ vm_area_struct: libc    r-xp file
        │    └─ vm_area_struct: stack   rw-p anonymous / growsdown
        │
        └─ pgd:内核记录的顶级页表
             │
             └─ 已经兑现的虚拟页 -> 物理页框

这一篇要留下的核心分工是:

arduino 复制代码
   mm_struct:一个进程用户地址空间的总入口
   vm_area_struct:一段合法虚拟地址区间及其属性
   页表:已经建立的页级虚拟地址到物理页框映射
   struct page:物理页框在内核里的元数据

到这里,前面几篇的链路已经能接起来了:

arduino 复制代码
   用户访问虚拟地址
        │
        ▼
   VMA 判断地址是否合法
        │
        ▼
   页表判断这一页是否已有映射
        │
        ▼
   需要物理页时,从 zone / buddy 拿 page frame
        │
        ▼
   struct page 描述这个物理页

下一篇就继续沿着缺页异常往下走:当 VMA 说"这个地址合法",但页表里还没有 present 映射时,Linux 如何真正分配物理页、建立页表项,并让出错的指令重新执行。

相关推荐
星马梦缘1 天前
操作系统计算题专项练习(15题)
操作系统·os·银行家算法·段页式
小宇子2B2 天前
物理内存的地图:node、zone、page frame 和 struct page
操作系统
阿昭L2 天前
操作系统复习(九)
操作系统
阿昭L2 天前
操作系统复习(七)
操作系统
小宇子2B3 天前
虚拟地址不是内存:Linux 如何切开一个进程的地址空间
操作系统
饼干哥哥5 天前
ChatGPT会员掉了,代充黑幕藏不住了
人工智能·操作系统·产品
小宇子2B5 天前
五、内核里的 GS / swapgs,与现代 TSS
操作系统
小宇子2B6 天前
四、x86-64 的简化:段机制基本退场,FS/GS 为什么留下
操作系统
小宇子2B6 天前
二、保护模式的段:选择子、GDT,与那张 64 位的段描述符
操作系统