虚拟地址不是内存:Linux 如何切开一个进程的地址空间

虚拟地址不是内存:Linux 如何切开一个进程的地址空间

我们平时写 C 程序时,指针看起来像是"内存地址":

c 复制代码
int *p = malloc(4096);
printf("%p\n", p);

但这个地址并不是内存条上的物理位置。它首先是进程看到的虚拟地址 。CPU 真正访问内存前,还要通过页表把虚拟地址翻译成物理页框;翻译不到,就会触发缺页异常,让内核决定是补上映射,还是给进程发 SIGSEGV

所以理解 Linux 内存管理的第一步,不是去看物理内存有多少,也不是去看 buddy 怎么分配页,而是先把这个问题拆开:

一个进程看到的地址空间,到底被 Linux 切成了哪些区域?这些区域和真实物理内存是什么关系?

一、先分清三种地址

内存管理里最容易混的是这三种东西:

  • 虚拟地址:进程指令里使用的地址,也是指针打印出来的值。
  • 物理地址:内存控制器、总线和物理页框层面的地址。
  • 内核虚拟地址:内核代码访问内存时使用的虚拟地址,其中一部分会线性映射到物理内存。

用户程序平时只直接接触第一种。比如 malloc 返回的地址、局部变量的地址、mmap 返回的地址,全都是虚拟地址。它们是不是已经有物理页 backing,取决于页表里有没有 present 的映射。

markdown 复制代码
   用户程序
      │
      │  指针值:0x7f3a_1234_5000
      ▼
   CPU / MMU
      │
      │  查 TLB / 页表
      ▼
   物理页框:PFN n

这意味着一句很重要的话:

虚拟地址空间可以很大,但物理内存只会在真正需要时逐页兑现。

malloc(1GB),进程的虚拟地址空间可能多出一大片合法范围;但如果只写了第一个字节,内核通常只需要给被触碰的那一页分配物理页。剩下那些地址在"法律上"属于你,在"物理上"还没有落地。

二、每个进程都有自己的用户地址空间

Linux 里,每个普通进程都有一套自己的用户态虚拟地址空间。两个进程即使打印出相同的指针值,也不表示它们在访问同一块物理内存。

css 复制代码
   进程 A 的虚拟地址 0x5555_4000
        │
        └─ A 的页表 ──► 物理页框 X

   进程 B 的虚拟地址 0x5555_4000
        │
        └─ B 的页表 ──► 物理页框 Y

虚拟地址相同,只说明两个进程的地址空间布局相似。真正翻译到哪里,要看当前进程的页表。进程切换时,内核会切换页表根,让同一个虚拟地址在不同进程里得到不同解释。

这就是虚拟内存最基本的隔离能力:一个进程不能因为猜到了另一个进程的指针值,就直接读写对方的内存。

三、用户地址和内核地址怎么分

在 64 位 Linux 上,一个进程的虚拟地址空间通常被切成两大半:低地址给用户态,高地址给内核态。具体边界和地址形式与架构、内核配置有关,不能把某个机器上的数值当成所有 Linux 的固定结论。

抽象图更重要:

arduino 复制代码
   低地址
   0x0000_0000_0000_0000
        │
        │  用户态虚拟地址空间(user space)
        │
        │  程序代码 / 只读数据
        │  data / bss
        │  heap
        │  mmap 区:动态库、文件映射、匿名映射
        │  user stack
        │
   ─────┼──────────────── user / kernel 分界
        │
        │  内核态虚拟地址空间(kernel space)
        │
        │  direct map
        │  vmalloc
        │  modules
        │  fixmap
        │  kernel text / data
        │
   高地址

用户态代码只能访问用户地址范围。它如果直接去碰内核地址,会因为权限不允许而触发异常。内核态代码则能访问内核地址范围,也能在受控路径上访问当前进程的用户地址。

一个容易误解的点是:内核地址空间通常映射在每个进程页表的高地址部分。这不是说每个进程都有一份完整的内核拷贝,而是为了让 CPU 陷入内核后,内核代码、内核数据和 direct map 等区域仍然能被同一套地址翻译机制访问。

现代内核还会受到 KPTI、地址随机化、不同架构页表格式等影响,细节会变。但对这个系列来说,先记住职责边界就够了:

  • 用户地址空间描述"这个进程自己能看到什么"。
  • 内核地址空间描述"内核运行时能看到什么"。
  • 页表把这些虚拟地址翻译到具体物理页框,或者标记为当前还没有映射。

四、/proc/self/maps 看到的是 VMA,不是页表

观察一个进程地址空间,最直接的入口是:

bash 复制代码
cat /proc/self/maps

你会看到类似这样的行:

text 复制代码
55b0f7d2a000-55b0f7d2e000 r--p 00000000 08:01 12345 /usr/bin/cat
55b0f7d2e000-55b0f7d33000 r-xp 00004000 08:01 12345 /usr/bin/cat
55b0f8c80000-55b0f8ca1000 rw-p 00000000 00:00 0     [heap]
7f36c5200000-7f36c53a0000 r-xp 00000000 08:01 67890 /usr/lib/libc.so.6
7ffc8a9d0000-7ffc8a9f1000 rw-p 00000000 00:00 0     [stack]

每一行表示一段连续的虚拟地址区间,内核里对应一个 vm_area_struct,通常简称 VMA

一行里最关键的信息有这些:

  • 起止地址:这段虚拟地址从哪里到哪里。
  • 权限:r 可读,w 可写,x 可执行。
  • 私有/共享:p 是 private,s 是 shared。
  • 文件来源:如果是文件映射,会显示路径;匿名映射通常没有路径。
  • 特殊区域:例如 [heap][stack][vdso]

但一定要注意:maps 不是页表转储。

它告诉你"这些虚拟地址区间是否合法、权限是什么、背后是什么对象",不告诉你"每一页现在是否已经有物理页"。一个 1GB 的匿名 VMA 可能只有少数几页真的分配了物理内存;maps 仍然只显示一行大区间。

rust 复制代码
   /proc/self/maps 看到的是:

   0x40000000 - 0x80000000  rw-p anonymous
        │
        │  这段地址合法,允许读写
        ▼
   页表可能是:

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

这就是后面反复要用的区分:

VMA 记录"这段地址是否合法";页表记录"这一页是否已经兑现成物理映射"。

五、堆、栈、动态库和 mmap 区分别在哪里

从用户态看,一个普通进程常见区域大致是这样:

arduino 复制代码
   低地址
        │
        │  程序代码段:r-x
        │  只读数据:r--
        │  data / bss:rw-
        │
        │  heap:brk 扩展出来的堆
        │
        │  mmap 区:
        │    - 动态库
        │    - mmap 文件
        │    - 大块匿名映射
        │    - 线程栈
        │
        │  stack:主线程用户栈
        │
        │  vdso
        │
   高地址(仍在用户态范围内)

几个区域的来源不同:

  • 程序代码段来自可执行文件,通常可读可执行,不可写。
  • data / bss保存全局变量、静态变量,通常可读写。
  • heap 通常由 brk/sbrk 调整边界,小块 malloc 常从这里切。
  • mmap 区mmap 系统调用创建,大块 malloc、动态库、文件映射都可能出现在这里。
  • stack是用户栈,函数调用、局部变量、返回地址等都在这里增长。
  • vdso是内核映射给用户态的一小段辅助代码,用来加速某些系统调用相关操作。

这里不要把 malloc 简化成"等于系统调用"。用户态 malloc 首先是 libc 的分配器行为:小分配可能只在已有 arena 里切一块,不进内核;需要扩展地址空间时才会用 brkmmap。即使进了内核,也通常只是创建或扩大 VMA,不代表立刻分配所有物理页。

六、CPU 访问一个虚拟地址时发生了什么

把静态布局和动态访问连起来,一次内存访问大致走这条路:

bash 复制代码
   CPU 执行 load/store/fetch,拿到虚拟地址
        │
        ├─ TLB 命中
        │     └─ 直接得到物理页框,继续执行
        │
        └─ TLB 未命中
              │
              ▼
           page walk 查页表
              │
              ├─ PTE present
              │     └─ 建 TLB,继续执行
              │
              └─ PTE not present / 权限不符
                    └─ 缺页异常,陷入内核

缺页异常不是天然等于错误。它只是说明硬件当前没法完成这次翻译,必须让内核判断原因:

  • 地址根本不在任何 VMA 里:非法访问,SIGSEGV
  • 地址在 VMA 里,但操作权限不匹配:保护错误,SIGSEGV
  • 地址合法,只是页还没分配:分配物理页,填页表,重试指令。
  • 文件映射页不在内存:从 page cache 或磁盘读入。
  • 写时复制页被写:复制一页新的。
  • 页被换出到 swap:从交换区换回。

这篇先不展开缺页处理,只保留最关键的分工:

复制代码
   VMA:判断地址是否合法、权限是否允许、背后是什么
   页表:判断这一页当前有没有真实映射
   物理页:真正存放数据的页框

七、可以自己看的几个入口

想把这篇的概念落到本机上,可以从这些命令开始:

bash 复制代码
cat /proc/self/maps
cat /proc/self/smaps
pmap $$

maps 看 VMA 的地址区间和权限;smaps 会进一步显示每个 VMA 的内存统计;pmap $$ 用更紧凑的方式看当前 shell 进程的地址空间。

这里的几个词先简单解释一下:

  • 每段映射 :就是 maps 里的每一行,也就是一个 VMA。比如代码段、heap、某个动态库映射、stack,都会分别统计。
  • RSS:resident set size,表示这段 VMA 当前有多少页真的在物理内存里。
  • PSS:proportional set size,表示按比例分摊后的 RSS。共享库这类页面可能被多个进程共享,PSS 会把共享页按进程数分摊,而不是每个进程都算一整份。
  • 匿名页 :没有文件背书的页,典型来源是 heap、stack、匿名 mmap。这类页如果要被回收,脏数据通常只能写到 swap。
  • page cache :内核用物理内存缓存磁盘文件内容的机制。比如读文件、执行程序、加载动态库、访问文件 mmap 时,文件内容进入内存后通常就由 page cache 管着;下次再读同一段文件,可以直接从内存拿,不必重新读磁盘。
  • 文件页 :背后对应磁盘文件的页,典型来源是程序代码段、动态库、文件 mmap。文件页在内存里通常属于 page cache。干净文件页可以直接丢掉,需要时再从文件读回来。

如果你在不同机器上看到地址差异,不必惊讶。ASLR、架构、页大小、内核配置、动态链接器版本都会影响具体数值。真正稳定的是结构关系:

markdown 复制代码
   进程看到虚拟地址
        │
        ▼
   VMA 判断这段地址是否合法
        │
        ▼
   页表记录已兑现的虚拟页 -> 物理页映射
        │
        ▼
   物理页框真正存放数据

下一篇就从这条链路继续往下走:虚拟地址最终翻译到物理页框以后,Linux 如何组织那一大片物理内存?为什么不是简单维护一个"空闲 RAM 列表",而是要有 node、zone、PFN 和 struct page

相关推荐
饼干哥哥2 天前
ChatGPT会员掉了,代充黑幕藏不住了
人工智能·操作系统·产品
小宇子2B2 天前
五、内核里的 GS / swapgs,与现代 TSS
操作系统
小宇子2B3 天前
四、x86-64 的简化:段机制基本退场,FS/GS 为什么留下
操作系统
小宇子2B3 天前
二、保护模式的段:选择子、GDT,与那张 64 位的段描述符
操作系统
小宇子2B6 天前
三、内核入口 el0_svc / entry_SYSCALL_64 的汇编做了什么——从异常向量到 C 函数
操作系统
小宇子2B7 天前
四、从 write(1, "hello", 5) 到 ksys_write() —— sys_call_table 怎么路由的
操作系统
小宇子2B7 天前
一、printf("hello") 怎么变成 write(1, "hello", 5) —— libc 的 stdout 缓冲机制
操作系统
小宇子2B12 天前
一个 pthread_mutex_lock() 到底锁了什么——从用户态 CAS 到内核调度
操作系统
小宇子2B13 天前
多线程 malloc 为什么会变慢——glibc 的 arena 到 bins 全景
操作系统