那套假地址,到底怎么变成真内存的
前两篇我们一路打印地址: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)——每一个都是单独一篇的料。这篇先到"地址是按页翻译的虚拟坐标,翻不动就缺页"为止。下一篇再往里走一层。