一、先抛出核心疑问:同一个地址,为什么父子进程值不一样?
看下面这段现象:
int g_val = 100;
pid_t id = fork();
if(id == 0){
g_val = 105;
printf("子进程: g_val=%d, &g_val=%p\n", g_val, &g_val);
}else if(id>0){
printf("父进程: g_val=%d, &g_val=%p\n", g_val, &g_val);
}
运行结果:
父进程: g_val=100, &g_val=0x601054
子进程: g_val=105, &g_val=0x601054
同一个地址 0x601054 ,父进程读到 100,子进程读到 105? 👉 这不是物理地址!这是虚拟地址。用户代码里拿到的所有指针地址,全部都是虚拟地址,物理地址由操作系统 + MMU 硬件隐藏起来,用户不可直接看见。
二、什么是进程虚拟地址空间
虚拟地址空间本质:OS 为每一个进程画出来的一个独立、连续的地址假象。
-
每个进程都认为自己独占全部内存;
-
每个进程拥有一套独立的
struct mm_struct来描述自己的地址空间; -
虚拟地址≠物理内存地址,中间依靠页表 + MMU做地址转换。
struct mm_struct就是进程地址空间的管理结构体,里面记录了各个段的起止范围:
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 已初始化全局数据段
unsigned long start_brk, brk; // 堆区域(malloc在堆上申请)
unsigned long start_stack; // 用户栈起始
unsigned long arg_start, arg_end; // 命令行参数
unsigned long env_start, env_end; // 环境变量
类比理解:就像给每个人单独划分一块虚拟地盘,每个人看到的地址编号完全一样,但底层真实物理空间完全独立。
三、虚拟地址空间的分区(从低地址到高地址)
低地址
├──────────── 代码段(.text) ───────────┤ 存放编译后的机器指令,只读
├──────────── 已初始化数据段(.data) ──┤ 初始化全局变量、static静态变量
├──────────── 未初始化数据段(.bss) ───┤ 未初始化全局/static变量,程序加载时默认清零
├──────────── 堆(heap) ───────────────┤ 向上增长,malloc/free在这里申请释放
│ 堆的边界由brk指针控制
├──────────── 共享库/文件映射区 ───────┤ mmap区域,动态库加载位置
├──────────── 栈(stack) ──────────────┤ 向下增长,局部变量、函数帧、返回地址
├──────────── 命令行参数、环境变量 ────┤
└──────────── 内核空间 ────────────────┤ 高地址,所有进程共享同一份内核,用户态无法直接访问
高地址
重点说明:
-
静态变量 / 全局变量:生命周期和进程绑定,只要进程存在,就存在于数据段 /.bss,不是栈变量。
-
字符串常量 :放在代码段,只读。
char *s = "hello world";
*s = 'H'; // 运行崩溃!试图修改只读区域
"hello world"存放在只读常量区,指针s只是保存这个只读区域的虚拟地址。 如果写成char s[]="hello world";,字符串会拷贝到栈上,就可以修改。
-
栈:函数局部变量,函数调用结束自动回收,栈向下生长。
-
堆:
malloc申请,手动管理,堆向上生长。
四、页表 & MMU:虚拟地址 → 物理地址翻译
进程拿到虚拟地址,CPU 访问内存时,MMU 硬件自动查询当前进程的页表,完成地址翻译:
-
页表:内核为每个进程维护的映射表,记录:虚拟页号 → 物理页框号 + 权限位
-
权限位:标记该页可读、可写、可执行;
-
如果虚拟地址没有映射到物理内存,或者权限非法,触发缺页异常 / 段错误。
虚拟地址空间是连续的,但对应的物理内存可以离散分布 。OS 只在真正访问的时候,才分配物理内存(缺页中断),这就是延迟分配 。
malloc(1024)只是在虚拟地址空间标记一段区域,不一定立刻分配物理内存,第一次写入的时候才触发缺页,分配物理页。
五、fork 写时拷贝(Copy On Write)
fork() 创建子进程时:
-
并不会立刻复制父进程所有物理内存;
-
父子进程共享同一份物理内存 ,页表标记为只读;
-
父子进程的虚拟地址空间各自独立,虚拟地址完全一样;
-
当任意一方尝试修改内存内容时:
-
触发缺页异常;
-
OS 才拷贝对应的那一页物理内存;
-
修改方的页表指向新拷贝的物理页;
-
父子从此各自独立。
-
👉 这就解释前面的现象: fork 之后,父子共享g_val对应的物理页;子进程执行g_val=105写操作,触发写时拷贝,单独复制一页,所以虚拟地址不变,物理地址变成两份,父子读到不同的值。
结论:fork 本质是创建独立的虚拟地址空间 + 复制页表,物理内存延迟拷贝。代码段只读,父子可以永久共享,不需要拷贝。
六、为什么需要虚拟地址空间?三大意义
-
内存保护,隔离进程 每个进程只能访问自己地址空间映射的内存。访问非法地址、修改只读页面直接触发段错误,A 进程不能随意读写 B 进程内存,保护物理内存。
-
把离散的物理内存包装成连续的虚拟地址 物理内存碎片化,但是每个进程看到的虚拟地址是一整块连续空间,写程序不用关心物理内存分布。
-
解耦进程管理和物理内存管理 进程只操作虚拟地址,内核单独管理物理内存,方便实现内存置换、缺页加载、延迟分配,让物理内存利用率更高。
七、常见坑总结
-
指针打印出来的地址永远是虚拟地址,不是物理地址,用户层拿不到物理地址;
-
栈变量生命周期随函数,全局 /static 变量生命周期随进程;
-
字符串常量在只读段,不可修改;
-
malloc只是申请虚拟地址,物理内存写的时候才分配; -
fork 不复制内存,写的时候才拷贝页面。
八、思维导图对应知识点回顾
-
进程具有独立性,根源就是独立虚拟地址空间 + 独立页表;
-
mm_struct描述整个虚拟地址空间; -
页表是虚拟地址到物理地址的桥梁,MMU 硬件完成翻译;
-
内核空间在地址最高处,所有进程共享内核。