在 Linux 学习中,进程地址空间是虚拟内存的核心基础。很多人容易混淆程序地址空间与进程地址空间,也分不清虚拟地址和物理地址。我们现在结合几段 C 语言实验代码,直观演示地址空间布局、写时拷贝、地址保护等机制,理解这套操作系统为进程打造的内存抽象。
目录
[一、程序地址空间 vs 进程地址空间](#一、程序地址空间 vs 进程地址空间)
[三、虚拟地址 和 物理地址 的关系](#三、虚拟地址 和 物理地址 的关系)
一、程序地址空间 vs 进程地址空间
程序是磁盘上的静态文件,进程是运行起来的实例。
程序地址空间:存放在磁盘上的可执行文件(ELF 文件),编译器、链接器规划好的逻辑布局,只是一张 "蓝图",没有加载到内存,不占用物理内存。
进程地址空间 :程序被 OS 加载、创建进程之后,给这个进程分配的虚拟地址空间,是操作系统给进程画出来的 "独立内存假象",也就是我们常说的进程地址空间。
程序地址空间是静态蓝图,进程地址空间是进程运行时,操作系统维护的虚拟地址布局。
二、进程地址空间是什么
64 位 Linux 地址空间布局逻辑一致,只是地址范围极大,不再是 4GB 限制。
重点:这个地址全部都是虚拟地址,不是物理内存地址。 进程眼里自己独占一整个巨大的连续地址空间;实际上 OS 通过页表,把虚拟地址映射到零散的物理内存,甚至一部分数据还在磁盘 swap 里。
三、虚拟地址 和 物理地址 的关系

- CPU 执行指令,拿到的地址永远是虚拟地址;
- MMU(内存管理单元)+ 页表,自动把虚拟地址翻译成物理地址;
- 进程全程看不到真实物理地址,也不能直接访问物理内存;
- 不同进程,可以拥有相同的虚拟地址,但是页表映射到不同的物理内存,互不干扰。
四、为什么需要进程地址空间(虚拟地址空间)
- 地址隔离,进程保护 每个进程以为自己独占内存,不能直接读写别的进程的地址;越界访问直接触发段错误 SIGSEGV,防止一个进程搞垮整个系统。
- 简化链接、编译 编译器不需要关心这个程序将来会被加载到内存哪个位置,统一使用逻辑虚拟地址,也就是位置无关代码 PIC。
- 内存利用率更高(虚拟内存) 不用把整个程序全部一次性载入物理内存;用的时候才加载(缺页中断),闲置数据可以换出到 swap 磁盘,物理内存可以同时跑更多程序。
- 方便内存共享 比如 C 标准库 shturl.,只在物理内存存一份,多个进程的虚拟地址空间同时映射到这一块物理内存,节省内存。
- 方便进程隔离、权限控制 代码段设置只读,防止程序指令被恶意修改;栈、堆设置可读可写,内核空间用户态不可访问。
五、验证进程地址空间分布
编译运行环境:gcc + Linux
cpp
#include <stdio.h>
#include <stdlib.h>
// 初始化全局变量 -> .data段
int g_val1 = 100;
// 未初始化全局变量 -> .bss段
int g_val2;
void test_func()
{
// 局部变量,在栈上
int stack_a = 10;
printf("【栈 stack】局部变量 stack_a 地址:%p\n", &stack_a);
}
int main()
{
static int static_val = 200; // 静态局部变量,.data段
int local = 1; // main函数局部变量,栈
int* p = (int*)malloc(sizeof(int)); // 堆heap
*p = 666;
printf("【代码段 .text】函数test_func地址:%p\n", test_func);
printf("【.data段】已初始化全局变量 g_val1:%p\n", &g_val1);
printf("【.data段】静态变量 static_val:%p\n", &static_val);
printf("【.bss段】未初始化全局变量 g_val2:%p\n", &g_val2);
printf("【堆 heap】malloc开辟的p指向地址:%p\n", p);
printf("【栈 stack】main局部变量 local:%p\n", &local);
test_func();
free(p);
return 0;
}
预期观察现象

- 函数地址(代码段)地址最低;
- 然后是.data、.bss;
- 堆地址比 bss 高;
- 栈地址远远高于堆,靠近高地址;
- 多次运行,这些变量的虚拟地址几乎不变 (ASLR 地址随机化开启会变化,可以
echo 0 > /proc/sys/kernel/randomize_va_space临时关闭测试)
六、证明:写时拷贝
这个是面试高频考点,最能证明:虚拟地址≠物理地址!
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
int num = 100;
pid_t pid = fork();
if (pid < 0)
{
perror("fork fail");
return 1;
}
else if (pid == 0)
{
// 子进程
printf("=====子进程=====\n");
printf("子进程修改前 num = %d, &num = %p\n", num, &num);
num = 200;
printf("子进程修改后 num = %d, &num = %p\n", num, &num);
}
else
{
// 父进程
sleep(1); // 等子进程先打印
printf("=====父进程=====\n");
printf("父进程 num = %d, &num = %p\n", num, &num);
}
return 0;
}
运行结果

现象:父子的 num 虚拟地址一模一样,但是变量值互不影响。 原理:
- fork 之后,一开始父子页表映射到同一块物理内存,只读共享;
- 任意一方尝试修改内容,触发写时复制(COW):OS 开辟一块新物理内存,复制数据,修改进程的页表映射;
- 虚拟地址不变,但是现在映射到两块不同物理内存,互相隔离。
这就是进程地址空间的魔力:相同虚拟地址,可以对应不同物理内存。
七、验证野指针访问,验证地址保护
cpp
#include <stdio.h>
int main()
{
int *p = (int*)0x12345678; // 随便写一个非法虚拟地址
*p = 10;
return 0;
}
编译不会报错,运行直接 Segmentation fault(段错误) 原因:这个虚拟地址不在当前进程地址空间合法范围内,MMU + 页表检测到非法访问,内核发送信号杀死进程,保护系统。
八、总结
-
进程地址空间本质:一套虚拟地址的管理布局,是操作系统给每个进程的抽象假象;
-
分段:代码段、数据段、BSS、堆、栈、共享区、内核区;
-
核心硬件支撑:MMU + 页表,完成虚拟地址→物理地址翻译;
-
关键机制:写时复制 COW、缺页中断、权限检查;
-
两个核心代码证明:①各段地址分布;②fork 父子同虚拟地址但是独立。
-
虚拟内存管理:在进程地址空间基础上,页面置换、缺页异常、swap;
-
虚拟地址的意义:上面已经覆盖,隔离、解耦、内存超配。