那套假地址,到底怎么变成真内存的

4 阅读12分钟

那套假地址,到底怎么变成真内存的

前两篇我们一路打印地址:Vec 的牌子在栈上某个 0x16b9...,buffer 在堆上某个 0x1044...。我们默认这些数字就是内存条上一个个格子的真实门牌号——0x1044e8 就是内存里第 0x1044e8 号那个字节。

这个默认,是错的。你打印出来的那串地址,是假的。

不是比喻意义上的假,是物理意义上的假:内存条上压根没有一个叫 0x1044e8 的格子被你这个程序独占。这篇就干一件事——拿一段真能跑的代码先把"假"砸实,再一层层看这套假地址到底怎么、在哪一刻,变成真内存的。

一、先上铁证:同一个地址,两份内存

不绕弯子,先看一段会让人愣一下的代码。它做的事很简单:定义一个全局变量 counter = 100,然后 fork() 把自己分裂成两个进程(父和子),子进程把 counter 改成 200,父子各自打印 counter地址

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int counter = 100;

int main(void) {
    printf("fork 前 &counter = %p, counter = %d\n", (void*)&counter, counter);
    fflush(stdout);

    pid_t pid = fork();              // 一分为二
    if (pid == 0) {
        counter = 200;               // 子进程:改成 200
        printf("[子] &counter = %p   counter = %d\n", (void*)&counter, counter);
        return 0;
    } else {
        wait(NULL);                  // 父进程:等子跑完再打印
        printf("[父] &counter = %p   counter = %d\n", (void*)&counter, counter);
    }
    return 0;
}

编译跑起来(macOS arm64,本文所有输出都是真跑的,复制即可复现):

clang vm.c -o vm && ./vm

输出:

fork 前 &counter = 0x102838000, counter = 100
[子] &counter = 0x102838000   counter = 200
[父] &counter = 0x102838000   counter = 100

盯着这三行看十秒。父进程和子进程打印出的 &counter 完全相同,都是 0x102838000。可子进程明明把它改成了 200,父进程读出来却还是 100。

同一个地址,两个不同的值。

如果 0x102838000 真是内存条上某个固定格子的门牌号,这不可能发生——一个格子里不可能同时装着 100 和 200。唯一的解释是:父子俩说的 0x102838000 根本不是同一个物理格子。 同一串数字,背后是两份互不相干的真内存。

地址是假的。这就是铁证。

二、那为什么程序没乱套——地址是进程的私人坐标系

假地址会引出一个立刻的疑问:如果地址是假的,那两个进程(甚至几百个进程)同时在跑,大家都用着 0x102838000 这种地址,凭什么不会互相踩到对方的内存?

答案是:每个进程都活在自己的一套坐标系里,地址只在它自己这套坐标系内有意义。

你程序里写的、打印出来的地址,有个正式名字叫虚拟地址——"虚拟"就是"假"的客气说法。内存条上真实格子的门牌号,叫物理地址。两者之间隔着一张"翻译表":每个进程一张,专属于它。

   进程 A 的虚拟地址            进程 A 的翻译表            物理内存(真的内存条)
   0x102838000      ───────────►  翻到  ───────────►   真实位置 #5
                                                        ┌──────────────┐
   进程 B 的虚拟地址            进程 B 的翻译表           │   ...        │
   0x102838000      ───────────►  翻到  ───────────►    真实位置 #9
                                                        └──────────────┘

   同样一个假地址 0x102838000,经过两张不同的表,翻到了两个不同的真实位置。

这下第一节那个谜解开了一半:fork 出来的父子是两个进程,各有各的翻译表。它俩嘴里都念 0x102838000,但 A 的表把它翻到物理位置 #5,B 的表翻到 #9——读写的是两块完全不同的真内存。门牌号一样,房子不是同一栋。

所以虚拟地址根本不是"内存里的位置",它是进程的私人坐标系里的一个坐标。离开了这个进程自己的翻译表,这串数字什么也不是。

顺带验证一下"假"还能更彻底:同一个程序连跑两次,连虚拟地址本身都会变。

第一次:&counter = 0x102838000
第二次:&counter = 0x1008e8000

同一行代码、同一个全局变量,两次启动印出的地址不一样。这叫地址随机化(ASLR),是操作系统故意每次启动随机挪一下布局、给攻击者添堵的安全措施。它顺便又证明了一遍:地址是每次运行临时编出来的,不是刻在内存条上的。

三、这张表不是一格一格记的——分页

现在问题来了:这张"虚拟→物理"的翻译表,到底长什么样?

最天真的想法是:每个字节记一条,虚拟地址 0x102838000 对应物理地址 #5、0x102838001 对应 #6……但这行不通。64 位系统的虚拟地址空间大到天文数字(理论上 2 的 64 次方个地址),逐字节记映射,光这张表本身就能把全世界的内存撑爆。

操作系统的解法是:别一个字节一个字节翻,按"页"成块翻。

把虚拟地址空间切成一块块固定大小的(page),物理内存也切成同样大小的块(叫页框)。翻译表只需记"第几号虚拟页 → 第几号物理页框",一条记录管一整页。这张按页记录映射的表,就叫页表(page table)。

这页有多大?正好能验证——回头看第一节那几个地址:

0x102838000      ← 末尾三个十六进制 0
0x1008e8000      ← 末尾三个十六进制 0

它们末尾都是 000。在 macOS arm64 上 getpagesize() 返回 16384(16KB),而这两个地址对 16384 取模都正好等于 0——也就是说它们都精确落在页的边界上。这不是巧合:页是翻译的最小单位,一整页一起映射,所以一块内存区域的起点总是对齐到页边界。

   虚拟地址空间(按页切块)                物理内存(按页框切块)
   ┌─────────────┐ 虚拟页 0                ┌─────────────┐ 页框 0
   ├─────────────┤ 虚拟页 1  ──┐           ├─────────────┤ 页框 1
   ├─────────────┤ 虚拟页 2    │  页表里    ├─────────────┤ 页框 2
   ├─────────────┤ 虚拟页 3    └─记一条──►  ├─────────────┤ 页框 3
   ├─────────────┤  ...        映射         ├─────────────┤  ...
   └─────────────┘                          └─────────────┘

   一条页表记录 = 一整页(16KB)的映射,不必逐字节记。

所以更准确地说:CPU 每次拿着一个虚拟地址去访问内存,背后都有一步翻译——查这个进程的页表,把虚拟页号换成物理页框号,页内的偏移原样保留,拼出真正的物理地址,再去内存条上取数。这步翻译快到你完全察觉不到,但它每次访问都在发生。

四、要是页表里没这一页呢——缺页中断

按页翻译听起来很顺,但有个绕不开的洞:如果 CPU 拿着一个虚拟地址去查页表,表里压根没有这一页的映射,怎么办?

这时 CPU 会停下当前指令,"陷入"操作系统内核,触发一个叫缺页中断(page fault)的事件——意思是"这页我翻不出来,内核你来处理"。内核接手后,看情况分两种下场。

下场一:这地址根本不该被你访问 → 段错误。

最干脆的例子,随手编一个地址去读:

int *wild = (int*)0x1234;   // 我瞎编的一个地址
int x = *wild;              // 去读它

跑起来:

我要去读 0x1234 ...
[1]    12345 segmentation fault  ./segv      ← 退出码 139

0x1234 这一页不在你进程的页表里,内核一查:"你从没申请过这块地方,非法访问。" 直接把进程杀掉,就是我们熟悉的段错误(segmentation fault,退出码 139 = 128 + 信号 11/SIGSEGV)。

所以地址虽然是假的,但不是你想用哪个就能用哪个——只有页表里登记过的页才翻得动,没登记的一碰就崩。这也反过来说明页表的另一个作用:它顺便就是一道围栏,把每个进程圈在自己合法的地盘里。

下场二:这地址合法,只是还没真给你内存 → 内核现场补一页。

这种更有意思。很多时候内核答应给你一块内存(虚拟地址有了、页表里标着"这页归你"),但还没真从内存条上拨一块物理页框出来——反正你还没用到,先记个账,等你真访问的那一刻再说。等 CPU 真去访问、触发缺页中断,内核才现场找一个空闲页框、填进页表、然后让那条指令重来一次。这次翻译就通了。这叫按需分配:内存是用到的瞬间才真正落地的,不是申请时就备好的。

现在回到第一节那个谜,把它彻底闭环。

fork 出父子两个进程时,操作系统并没有真把父进程的内存整个复制一份给子进程(那太慢太浪费)。它做的是:让父子的页表指向同一批物理页框,先共享,并悄悄把这些页标记成"只读"。

于是:

  • 父子的 counter 此刻确实在同一块物理内存上,值都是 100;
  • 它俩的虚拟地址 0x102838000 也都映射到这同一块——所以打印出来地址一样、值一样;
  • 直到子进程执行 counter = 200 这个操作。一写"只读"页,立刻触发缺页中断,内核接手:"哦,有人要改共享页了,该分家了。" 它现场复制一份新的物理页框,把子进程的页表改指到新副本上,再让那条写指令重来——这次写进的是子进程私有的那份。
   fork 刚结束(共享,只读)              子进程写 counter=200(触发缺页→复制)
   父 0x102838000 ┐                       父 0x102838000 ──► 物理页框#5 (counter=100)
                  ├─► 物理页框#5 (=100)
   子 0x102838000 ┘                       子 0x102838000 ──► 物理页框#9 (counter=200)
                                                              ↑ 内核现场复制出来的新页
   地址都一样,先共享一份                  一写就分家:地址还一样,物理页框已是两个

这套"先共享、谁写谁才复制"的把戏,叫写时复制(copy-on-write)。第一节那个"同址两值"之所以成立,正是因为:父子的虚拟地址 0x102838000 始终没变,变的是它在各自页表里指向的物理页框——子进程那一写,把它从共享页悄悄换成了私有的新页。

地址一样,是因为虚拟地址没动;值不一样,是因为底下的物理页框在写的那一刻被内核掉了包。

五、收尾

把这篇串起来:

  • 你打印出来的地址全是虚拟地址,是进程私人坐标系里的坐标,不是内存条上的真实位置;
  • 每个进程一张页表,按(arm64 上 16KB)成块地把虚拟地址翻译成物理地址,CPU 每次访问内存都在背后做这步翻译;
  • 翻不出来就触发缺页中断:要么是非法访问被内核杀掉(段错误),要么是内核现场补一页真内存再放行(按需分配、写时复制都是这么发生的);
  • 所以"假地址变成真内存"不是在程序启动时一次性发生的,而是分散在每一次访问、查页表的那一瞬——很多页是你真用到的那一刻,内核才把物理内存补上的。

回到最开头那个 0x1044e8。现在再看它,它不是内存条上第 0x1044e8 号格子,而是你这个进程私有地图上的一个坐标,等着每次访问时被页表翻译成某个此刻才确定的物理位置。

而这张页表本身怎么组织、为什么不是一张大平表、CPU 反复查表为什么不慢(靠一个叫 TLB 的缓存)、物理内存真的不够用时旧页怎么被踢到磁盘上(swap)——每一个都是单独一篇的料。这篇先到"地址是按页翻译的虚拟坐标,翻不动就缺页"为止。下一篇再往里走一层。