第 7 天:两个进程都在用 `0x1234`,为什么不会改到同一块内存?
昨天我们说,父子进程不能靠修改普通变量传话,因为它们各有自己的地址空间。“各有一份”究竟是什么意思?难道 fork 时,操作系统立刻把父进程用过的内存全部复制了一遍?
先做个实验。
相同的地址,不同的值
把下面代码保存为 address.c,在 Linux 或 WSL 中运行:
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int number = 10;
int main(void)
{
printf("fork 前:PID=%ld,地址=%p,值=%d\n",
(long)getpid(), (void *)&number, number);
fflush(stdout);
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
number = 20;
printf("子进程:PID=%ld,地址=%p,值=%d\n",
(long)getpid(), (void *)&number, number);
return 0;
}
if (waitpid(pid, NULL, 0) == -1) {
perror("waitpid");
return 1;
}
printf("父进程:PID=%ld,地址=%p,值=%d\n",
(long)getpid(), (void *)&number, number);
return 0;
}
gcc -Wall -Wextra -o address address.c
./address
地址的具体数字每次可能变化,但一次运行中,你会看到类似结果:
fork 前:PID=4100,地址=0x55a0...,值=10
子进程:PID=4101,地址=0x55a0...,值=20
父进程:PID=4100,地址=0x55a0...,值=10
父子进程打印的 &number 看起来是同一个地址。子进程把值改成 20,父进程读到的却仍是 10。
因为程序打印的是虚拟地址,不是内存条上的物理位置。一个地址数字要结合“它属于哪个进程”,才能知道它最终指向哪里。
地址怎样找到真正的数据?
现代操作系统通常把虚拟地址空间和物理内存都分成固定大小的小块,分别叫页和页框。常见的页大小是 4 KiB,不过具体大小可以因系统和映射方式而异。
假设页大小是 0x1000 字节,也就是 4096 字节。程序访问虚拟地址 0x1234,可以拆成两部分:
0x1234 = 第 1 个虚拟页 + 页内偏移 0x234
进程的页表记录虚拟页对应哪个物理页框。假设进程 A 的第 1 页对应物理页框 0x9,那么:
虚拟地址 0x1234
→ 虚拟页 0x1,页内偏移 0x234
→ 物理页框 0x9,页内偏移仍是 0x234
→ 物理地址 0x9234
进程 B 也可以访问虚拟地址 0x1234,但它的页表可能把第 1 页指向物理页框 0xB,结果就是 0xB234。
进程 A:0x1234 ──A 的映射──→ 0x9234
进程 B:0x1234 ──B 的映射──→ 0xB234
页内偏移不变,变的是“这一页对应哪个页框”。程序不必知道数据最终落在内存条的什么位置。
这也是为什么两个程序可以各自放心地使用自己的栈、堆和全局变量。**虚拟地址相同,不代表它们一定指向同一份物理数据。**反过来,不同进程也可以通过共享内存等机制,让各自的虚拟地址映射到同一份物理数据;昨天提到的共享内存,就是利用了这种能力。
fork 为什么没有立刻复制全部内存?
回到开头的实验。fork 后,父子进程起初看到的 number 都是 10。在 Linux 中,内核通常先让它们共享相应的物理页,并把这些页设成暂时不能直接写入。
子进程执行 number = 20 时,内核才为需要修改的页准备独立副本,让子进程写自己的那份。父进程原来的值仍是 10。这种“等到写入时再复制”的办法,叫写时复制。
注意,复制通常按页发生,不是只复制 number 这四个字节。它避免了 fork 一开始就复制大量可能根本不会被修改的内存。Shell 创建子进程后马上用 exec 运行别的程序时,这尤其有用:旧程序的许多数据压根用不着复制。
开头实验能证明父子进程的修改彼此隔离;它打印不出物理地址,也不能单靠那几行输出观察到写时复制发生在哪一个页上。
每次访问内存都要问操作系统吗?
不用。如果每读一次变量都要调用一次内核,程序会慢得难以使用。
CPU 中负责地址转换的硬件会按照当前进程的页表工作。为了不在每次访问时都重复查找页表,处理器还使用 TLB 保存近期的地址转换结果。可以把 TLB 理解为地址转换专用的小缓存。
操作系统负责建立和维护映射规则,并在切换进程等时候让硬件使用合适的地址空间;正常的内存读写由硬件按规则完成。只有碰到尚未建立有效映射、权限不允许等情况,才需要通过缺页异常进入内核处理。
页表里也不只有“对应哪个页框”,还包含访问权限。例如一页可以允许读取却不允许写入。程序试图写入不该写的页时,硬件会阻止这次访问。至于缺页异常什么时候是正常现象、什么时候意味着程序出错,是明天的重点。
看看自己的地址空间
在 Linux 或 WSL 的终端运行:
cat /proc/$$/maps
$$ 是当前 Shell 的进程号。输出会列出它的部分虚拟地址区域,以及这些区域的读、写、执行权限。你可能看到程序代码、共享库、堆和栈对应的映射。
别把 maps 中的地址范围相加,就当成“实际占用了这么多内存”。它展示的是虚拟地址如何划分和映射;哪些页此刻真的在物理内存里,是另一个问题。
今天只需把这件事想透:**程序手里的地址,是在自己的地址空间里找位置;操作系统和硬件再决定这个位置怎样对应到物理内存。**虚拟内存也不等于“内存不够时拿硬盘凑数”——即使没有交换空间,程序仍在使用虚拟地址。
明天继续追这个地址转换过程:如果程序访问的一页还没放进物理内存,电脑会怎样让它继续运行?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)