嘿,小伙伴们!还记得咱们之前聊过的 fork 函数吗?当时说它会返回两次,而且在讲变量为啥在父子进程里会有不同的值时,提到了进程独立性和写时拷贝。不过,为啥同一个变量名,父子进程看到的内容却不一样呢?这背后藏着一个超有意思的概念 —— 程序地址空间!今天,就让我们一起揭开它神秘的面纱,一探究竟吧!
学 C/C++ 的时候,肯定见过这张经典的地址空间图(以 32 位机为例哈)。32 位地址线就像一群有超能力的小精灵,它们能组合出
种状态,也就是 4G 个地址。每个地址对应一个字节,所以整个地址空间就是 4G 大小啦!这里面的内核空间是操作系统的 “专属领地”,进程的各种数据结构都存放在这里哦。

咱们用代码来实际感受感受地址空间的分布,先看这段代码:
#include <stdio.h>
#include <stdlib.h>
int g_val_1; // 一个没初始化的全局变量,就像一个空盒子
int g_val_2 = 100; // 初始化好的全局变量,里面装着数字100
int main(int argc, char* argv[], char* env[])
{
printf("code addr:%p\n", main);// 打印main函数地址,看看代码区在哪
const char *str = "Hello word";// 定义一个字符串常量,像一句固定的台词
printf("read only string addr:%p\n", str);// 打印字符常量区地址
printf("init global value addr:%p\n", &g_val_2);// 已初始化全局变量地址
printf("uninit global value addr:%p\n", &g_val_1); // 未初始化全局变量地址
char* men1 = (char*)malloc(100);
printf("heap addr-men1:%p\n", men1);// 堆区地址,像一个动态仓库
printf("stack addr-str:%p\n", &str); // 栈区地址
static int a = 10;// 静态局部变量,有点特别哦
printf("static a add:%p\n", &a); // 静态局部变量地址
int i = 0;
for(; argv[i]; i++)
printf("argv[%d],addr:%p\n", i, argv[i]);// 打印命令行参数地址
for(i = 0; env[i]; i++)
printf("env[%d],addr:%p\n", i, env[i]);// 打印环境变量地址
return 0;
}可能的运行结果(示例):
code addr:0x555555554810
read only string addr:0x555555556014
init global value addr:0x555555556020
uninit global value addr:0x55555555601c
heap addr-men1:0x555555758780
stack addr-str:0x7ffc406769c8
static a add:0x555555556018
argv[0],addr:0x555555554770
env[0],addr:0x7ffc40676e78
env[1],addr:0x7ffc40676e88
env[2],addr:0x7ffc40676e98
... // 省略更多环境变量的输出这里还有两个超有趣的小知识:
int main()
{
int a;
int b;
int c;
int d;
printf("stack addr:%p\n", &a);
printf("stack addr:%p\n", &b);
printf("stack addr:%p\n", &c);
printf("stack addr:%p\n", &d);
} 可能的运行结果(示例):
stack addr:0x7ffd4612376c
stack addr:0x7ffd46123768
stack addr:0x7ffd46123764
stack addr:0x7ffd46123760可以看到栈区地址是由高向低增长的。
int main()
{
char* mem1 = (char*)malloc(100);
char* mem2 = (char*)malloc(100);
char* mem3 = (char*)malloc(100);
char* mem4 = (char*)malloc(100);
printf("Heap addr:%p\n", mem1);
printf("Heap addr:%p\n", mem2);
printf("Heap addr:%p\n", mem3);
printf("Heap addr:%p\n", mem4);
return 0;
}可能的运行结果(示例):
Heap addr:0x555555758780
Heap addr:0x555555758800
Heap addr:0x555555758880
Heap addr:0x555555758900可以看到堆区地址是由低向高增长的。
还有哦,静态变量也很有意思,看这段代码:
int g_val_1;
int g_val_2 = 100;
int main()
{
printf("code addr:%p\n", main);
const char *str = "Hello word";
printf("read only string addr:%p\n", str);
printf("init global value addr:%p\n", &g_val_2);
printf("uninit global value addr:%p\n", &g_val_1);
char* men1 = (char*)malloc(100);
printf("heap addr-men1:%p\n", men1);
printf("stack addr-str:%p\n", &str);
static int a = 10;
printf("static a add:%p\n", &a);
return 0;
}code addr:0x56205750d169
read only string addr:0x56205750d288
init global value addr:0x56205770e010
uninit global value addr:0x56205770e00c
heap addr-men1:0x562057b0f740
stack addr-str:0x7ffd78d26858
static a add:0x56205770e008从上述模拟结果可以看到,静态局部变量 a 的地址 0x56205770e008 与已初始化全局变量 g_val_2 的地址 0x56205770e010 以及未初始化全局变量 g_val_1 的地址 0x56205770e00c 是比较接近的,验证了 static 修饰的局部变量在编译时被放置到了全局数据区这一特点。不过要注意哦,它只是延长了 “寿命”,作用域还是只在 main 函数里。而且这些代码都是在 Linux 系统下验证的,要是放到 Windows 的 VS 里,结果可能就不一样啦!
接下来,咱们通过一个超酷的例子来认识虚拟地址!看代码:
int g_val = 100;
int main()
{
pid_t pid = fork();
if(pid == 0)
{
int cnt = 5;
// 子进程的“表演时间”
while(1)
{
printf("I am child, pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
if(cnt)
{
cnt--;
}
else
{
g_val = 200;
}
}
}
else
{
// 父进程也来“凑热闹”
while(1)
{
printf("I am parent, pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}可能的运行结果(示例),这里假设子进程的 PID 为 12345,父进程的 PID 为 12344(实际会根据系统分配有所不同):
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:200, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:200, &g_val:0x555555556020
... // 持续打印,父子进程g_val值不同这段代码创建了一个子进程,还定义了全局变量 g_val 初始值是 100。父子进程都去访问 g_val,子进程里还有个局部变量 cnt,一开始是 5,每次循环就减 1,减到 0 的时候把 g_val 改成 200。
运行结果超神奇!子进程修改 g_val 后,父子进程读到的 g_val 值不一样,这倒是符合我们之前说的进程独立性。可奇怪的是,打印出来的地址明明一样,为啥数据不同呢?真相只有一个 —— 这个地址根本不是真实的物理地址,而是虚拟地址!真实的物理地址可存不了两个不同的数据哦。
之前咱们说进程就是 task_struct + 代码和数据,其实没那么简单!进程一创建,操作系统就像个贴心的 “大管家”,不仅给它创建 PCB,还会专门打造一个 “私人空间”—— 进程地址空间。我们写代码用的地址就来自这里。进程地址空间其实是内核创建的一个结构体,PCB 里有个指针指向它。虚拟地址和物理地址之间靠页表 “牵线搭桥”,每个进程都有自己的页表哦。
每个进程都有自己独立的 PCB、进程地址空间和页表,子进程的这些东西大多是照着父进程 “复制粘贴” 来的。就拿全局变量 g_val 来说,物理内存里它只有一份,在父进程里它有个虚拟地址 0X601054,子进程创建的时候,也给 g_val 分配了同样的虚拟地址 0X601054,页表的映射关系也是从父进程继承来的,所以父子进程打印 g_val 的地址是一样的,共享代码也是这个原理。
但当子进程要修改 g_val 时,为了保证父子进程数据独立,就会触发写时拷贝。操作系统就像个 “裁判”,看到子进程要改共享数据,马上喊 “暂停”!然后给子进程在物理内存里重新开辟一块空间,把 g_val 放进去,再修改页表的映射关系。这个过程完全是操作系统自动完成的,子进程还蒙在鼓里呢!就好比你朋友要来家里,你觉得家里乱,让他等会儿,你收拾屋子的时候他根本不知道。而虚拟地址就像个 “淡定哥”,根本不关心底层的这些操作,一点不受影响!
地址空间(Address Space)是操作系统分配给每个进程的一个独立的逻辑内存范围,用于存储程序的代码、数据、堆、栈等内容。每个进程的地址空间都是相互隔离的,并且与物理内存的实际分布无关。它为进程提供了一种逻辑上的“统一视角”,让进程感觉自己独占整个内存。
在 Linux 中,地址空间是由内核用结构体 struct mm_struct 描述的。这个数据结构存储了进程地址空间的范围(起始地址和结束地址)以及内存的划分方式。类似于 PCB(进程控制块)描述进程的状态信息,struct mm_struct 是描述进程地址空间的核心结构。
struct mm_struct)struct mm_struct
{
unsigned long start_code; // 代码段的起始地址
unsigned long end_code; // 代码段的结束地址
unsigned long start_data; // 数据段的起始地址
unsigned long end_data; // 数据段的结束地址
unsigned long start_brk; // 堆的起始地址(用于动态分配)
unsigned long brk; // 堆的当前结束地址
unsigned long start_stack; // 栈的起始地址(通常是高地址)
// 其他字段...
};malloc 或 new 进行的分配。这些字段定义了地址空间的逻辑布局,也为进程的内存管理提供了一个清晰的结构化描述。
每个进程的地址空间中,“每一个最小单位地址”都可以通过虚拟内存机制映射到物理内存中。地址空间的划分和管理是为了让进程可以安全、高效地访问内存,而不需要关心底层的实际物理地址。
地址空间的存在解决了多个实际问题,同时带来了许多优势。主要原因有以下几点:
segmentation fault 信号)。1,表示该页存在于物理内存,可以正常访问。0,则可能引发缺页中断,操作系统会将所需的页加载到物理内存。1,表示用户态程序可以访问;0,则仅内核态程序可以访问。小提示:
物理内存本身没有“权限”这个概念,所有物理内存都是可读可写的。页表项的权限(如只读)是由 CPU 的内存管理单元(MMU)强制执行的。例如,字符常量区(如 "Hello World")被定义为只读,是因为页表中对应的物理页被设置了只读权限。
#include <stdio.h>
int main()
{
char* str = "Hello World";
*str = 'B'; // 试图修改只读内存
return 0;
}"Hello World"。*str = 'B'; 时,CPU 会检查页表项的权限位,发现这是只读页,触发保护异常(如段错误 Segmentation Fault)。str 时加上 const,编译器会在编译阶段报错,从而避免运行时错误。0(页面不存在于物理内存),则会触发缺页中断(Page Fault)。1。struct mm_struct)、页表等。0。0。到这里,我们的程序地址空间探秘之旅就要告一段落啦!从编程语言视角下的地址空间布局,到虚拟地址的神奇奥秘,再到页表、写时拷贝这些底层机制,每一步都充满了惊喜与挑战。原来,我们编写的每一行代码、定义的每一个变量,背后都有如此精妙的内存魔法在默默运行。