Linux 进程虚拟地址空间详解

一、先抛出核心疑问:同一个地址,为什么父子进程值不一样?

看下面这段现象:

复制代码
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) ──────────────┤ 向下增长,局部变量、函数帧、返回地址
├──────────── 命令行参数、环境变量 ────┤
└──────────── 内核空间 ────────────────┤ 高地址,所有进程共享同一份内核,用户态无法直接访问
高地址

重点说明:

  1. 静态变量 / 全局变量:生命周期和进程绑定,只要进程存在,就存在于数据段 /.bss,不是栈变量。

  2. 字符串常量 :放在代码段,只读。

    char *s = "hello world";
    *s = 'H'; // 运行崩溃!试图修改只读区域

"hello world" 存放在只读常量区,指针s只是保存这个只读区域的虚拟地址。 如果写成char s[]="hello world";,字符串会拷贝到栈上,就可以修改。

  1. 栈:函数局部变量,函数调用结束自动回收,栈向下生长。

  2. 堆:malloc申请,手动管理,堆向上生长。

四、页表 & MMU:虚拟地址 → 物理地址翻译

进程拿到虚拟地址,CPU 访问内存时,MMU 硬件自动查询当前进程的页表,完成地址翻译:

  • 页表:内核为每个进程维护的映射表,记录:虚拟页号 → 物理页框号 + 权限位

  • 权限位:标记该页可读、可写、可执行;

  • 如果虚拟地址没有映射到物理内存,或者权限非法,触发缺页异常 / 段错误。

虚拟地址空间是连续的,但对应的物理内存可以离散分布 。OS 只在真正访问的时候,才分配物理内存(缺页中断),这就是延迟分配 。 malloc(1024) 只是在虚拟地址空间标记一段区域,不一定立刻分配物理内存,第一次写入的时候才触发缺页,分配物理页。

五、fork 写时拷贝(Copy On Write)

fork() 创建子进程时:

  1. 并不会立刻复制父进程所有物理内存;

  2. 父子进程共享同一份物理内存 ,页表标记为只读;

  3. 父子进程的虚拟地址空间各自独立,虚拟地址完全一样;

  4. 当任意一方尝试修改内存内容时:

    1. 触发缺页异常;

    2. OS 才拷贝对应的那一页物理内存;

    3. 修改方的页表指向新拷贝的物理页;

    4. 父子从此各自独立。

👉 这就解释前面的现象: fork 之后,父子共享g_val对应的物理页;子进程执行g_val=105写操作,触发写时拷贝,单独复制一页,所以虚拟地址不变,物理地址变成两份,父子读到不同的值。

结论:fork 本质是创建独立的虚拟地址空间 + 复制页表,物理内存延迟拷贝。代码段只读,父子可以永久共享,不需要拷贝。

六、为什么需要虚拟地址空间?三大意义

  1. 内存保护,隔离进程 每个进程只能访问自己地址空间映射的内存。访问非法地址、修改只读页面直接触发段错误,A 进程不能随意读写 B 进程内存,保护物理内存。

  2. 把离散的物理内存包装成连续的虚拟地址 物理内存碎片化,但是每个进程看到的虚拟地址是一整块连续空间,写程序不用关心物理内存分布。

  3. 解耦进程管理和物理内存管理 进程只操作虚拟地址,内核单独管理物理内存,方便实现内存置换、缺页加载、延迟分配,让物理内存利用率更高。

七、常见坑总结

  1. 指针打印出来的地址永远是虚拟地址,不是物理地址,用户层拿不到物理地址;

  2. 栈变量生命周期随函数,全局 /static 变量生命周期随进程;

  3. 字符串常量在只读段,不可修改;

  4. malloc 只是申请虚拟地址,物理内存写的时候才分配;

  5. fork 不复制内存,写的时候才拷贝页面。

八、思维导图对应知识点回顾

  • 进程具有独立性,根源就是独立虚拟地址空间 + 独立页表;

  • mm_struct 描述整个虚拟地址空间;

  • 页表是虚拟地址到物理地址的桥梁,MMU 硬件完成翻译;

  • 内核空间在地址最高处,所有进程共享内核。

相关推荐
如何原谅奋力过但无声1 小时前
【灵神高频面试题合集21-25】动态规划(下)
算法·动态规划
硅基手札1 小时前
【Linux内核专栏 12】VFS 与文件系统
linux
释厄6231 小时前
学术引用本体论——WorkBuddy 学术科学文化三违反
网络·人工智能·算法
梦帮科技1 小时前
【3.0修订版】 RNS 代币架构:ERC20 五件套扩展与六钱包分配
数据结构·后端·算法·架构·node.js·区块链·php
水饺编程1 小时前
第1章:开发环境搭建,在 Windows 中安装 VS 2019
linux·c语言·汇编·visual studio
supabc1231 小时前
Celium:连接 Windows、Mac、Linux 与 Android,让远程访问和设备管理更简单
android·linux·windows·macos·远程访问·网络管理·celium
薛定e的猫咪2 小时前
(AISTATS 2023)BaCaDI:未知干预下的贝叶斯因果发现
人工智能·深度学习·算法·机器学习
ShineWinsu2 小时前
对于Redis:RDB持久化的解析
linux·数据库·redis·缓存·面试·持久化·rdb
漂流瓶jz3 小时前
UVA-11491 奖品的价值 题解答案代码 算法竞赛入门经典第二版
c++·算法·图论·题解·aoapc·算法竞赛入门经典·uva