文章目录
一、前言
嘿,小伙伴们!还记得咱们之前聊过的 fork
函数吗?当时说它会返回两次,而且在讲变量为啥在父子进程里会有不同的值时,提到了进程独立性和写时拷贝。不过,为啥同一个变量名,父子进程看到的内容却不一样呢?这背后藏着一个超有意思的概念 —— 程序地址空间!今天,就让我们一起揭开它神秘的面纱,一探究竟吧!
二、编程语言视角下的地址空间
学 C/C++ 的时候,肯定见过这张经典的地址空间图(以 32 位机为例哈)。32 位地址线就像一群有超能力的小精灵,它们能组合出 2 32 2^{32} 232 种状态,也就是 4G 个地址。每个地址对应一个字节,所以整个地址空间就是 4G 大小啦!这里面的内核空间是操作系统的 “专属领地”,进程的各种数据结构都存放在这里哦。
2.1 动手验证一下
咱们用代码来实际感受感受地址空间的分布,先看这段代码:
#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
值不一样,这倒是符合我们之前说的进程独立性。可奇怪的是,打印出来的地址明明一样,为啥数据不同呢?真相只有一个 —— 这个地址根本不是真实的物理地址,而是虚拟地址!真实的物理地址可存不了两个不同的数据哦。
3.1 揭开神秘面纱 —— 地址空间概念
之前咱们说进程就是 task_struct + 代码和数据
,其实没那么简单!进程一创建,操作系统就像个贴心的 “大管家”,不仅给它创建 PCB,还会专门打造一个 “私人空间”—— 进程地址空间。我们写代码用的地址就来自这里。进程地址空间其实是内核创建的一个结构体,PCB 里有个指针指向它。虚拟地址和物理地址之间靠页表 “牵线搭桥”,每个进程都有自己的页表哦。
3.2 深入剖析神奇现象
每个进程都有自己独立的 PCB、进程地址空间和页表,子进程的这些东西大多是照着父进程 “复制粘贴” 来的。就拿全局变量 g_val
来说,物理内存里它只有一份,在父进程里它有个虚拟地址 0X601054
,子进程创建的时候,也给 g_val
分配了同样的虚拟地址 0X601054
,页表的映射关系也是从父进程继承来的,所以父子进程打印 g_val
的地址是一样的,共享代码也是这个原理。
但当子进程要修改 g_val
时,为了保证父子进程数据独立,就会触发写时拷贝。操作系统就像个 “裁判”,看到子进程要改共享数据,马上喊 “暂停”!然后给子进程在物理内存里重新开辟一块空间,把 g_val
放进去,再修改页表的映射关系。这个过程完全是操作系统自动完成的,子进程还蒙在鼓里呢!就好比你朋友要来家里,你觉得家里乱,让他等会儿,你收拾屋子的时候他根本不知道。而虚拟地址就像个 “淡定哥”,根本不关心底层的这些操作,一点不受影响!
四、细节解释
4.1 地址空间究竟是什么?
核心概念
地址空间(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; // 栈的起始地址(通常是高地址)
// 其他字段...
};
解读细节
- start_code 和 end_code: 表示可执行代码段(text segment)的范围。这部分存储程序的机器指令,是只读的,不能被修改。
- start_data 和 end_data: 表示全局变量和静态变量的数据段。这部分可以被修改,包括已初始化和未初始化的数据。
- start_brk 和 brk: 表示堆(heap)的范围,用于动态分配内存,比如通过
malloc
或new
进行的分配。 - start_stack: 表示栈(stack)的起始地址,通常在地址空间的高端,用于函数调用和局部变量。
这些字段定义了地址空间的逻辑布局,也为进程的内存管理提供了一个清晰的结构化描述。
重要补充
每个进程的地址空间中,“每一个最小单位地址”都可以通过虚拟内存机制映射到物理内存中。地址空间的划分和管理是为了让进程可以安全、高效地访问内存,而不需要关心底层的实际物理地址。
4.2 为什么要有地址空间?
地址空间的存在解决了多个实际问题,同时带来了许多优势。主要原因有以下几点:
原因一:让进程以统一的视角看待内
- 问题:
如果没有地址空间,进程需要直接操作物理内存中的数据。这会带来以下困难:- 物理内存的地址可能在程序运行期间发生变化,例如当进程被挂起或重新加载到内存中时。
- 程序的代码和数据可能分散在物理内存的不同位置,进程很难有序地访问它们。
- 如果程序需要动态分配内存,会因为物理内存的不连续性而导致碎片化和复杂的管理。
- 解决: 地址空间将虚拟地址映射到物理地址,提供了一种逻辑上的“连续性”。进程只需按虚拟地址的顺序访问内存,而无需关心实际的物理分布。
- 好处:
- 简化了程序设计,让程序员可以假设整个内存是连续且独占的。
- 操作系统可以根据需要动态调整物理内存分配,提高了内存利用率。
原因二:保护物理内存
- 问题:
如果进程直接访问物理内存,很容易因为程序错误(例如指针越界、非法写入)而破坏其他进程或内核的数据。 - 解决: 地址空间和虚拟内存机制为每个进程创建了一个隔离的逻辑视图。进程的所有内存访问请求会先经过内存管理单元(MMU),MMU 会根据页表将虚拟地址映射到物理地址。在这个过程中:
- 系统可以拦截非法访问(如越界访问、修改只读段),并触发保护机制(如触发
segmentation fault
信号)。 - 内核可以保证不同进程之间的内存隔离,避免了恶意或错误进程干扰其他进程。
- 系统可以拦截非法访问(如越界访问、修改只读段),并触发保护机制(如触发
- 好处:
- 提高系统安全性和稳定性。
- 防止物理内存被错误或恶意修改。
原因三:模块解耦合
- 问题: 如果没有地址空间,进程的内存管理逻辑和操作系统的物理内存管理将强耦合在一起。这会导致设计复杂度增加,并且难以扩展。
- 解决: 地址空间和页表的存在将进程管理模块与内存管理模块解耦合:
- 进程只需管理自己的虚拟地址空间,而不需要关心底层的物理内存分布。
- 操作系统负责将虚拟地址动态映射到物理地址,统一管理物理内存。
- 好处:
- 简化了操作系统的设计。
- 提高了系统的可扩展性和灵活性。
4.3 页表
CR3寄存器
- 什么是CR3寄存器? 在 x86 架构的 CPU 中,CR3寄存器用于存储当前运行进程的页表首地址。这是进程切换时保存和加载的重要硬件上下文之一,确保每个进程都能够独立管理自己的地址空间。
- 作用:
- 当 CPU 执行指令并需要访问内存时,CR3 寄存器中的页表地址被用来查询虚拟地址和物理地址之间的映射。
- 当操作系统切换进程时(上下文切换),CR3 寄存器的值也会随之切换,指向新进程的页表。
- CR3 中存储的是物理地址,而不是虚拟地址,因为页表本身需要直接操作物理内存。
- 硬件上下文: 当前进程被切换时,操作系统会保存与之相关的所有硬件上下文,包括 CR3 的内容。当进程被重新调度时,这些上下文会被加载回来。
页表是由页表项组成的
- 页表项(Page Table Entry, PTE): 页表本质上是一个数据结构,它包含了多个页表项。每个页表项描述一个虚拟页到物理页的映射关系,同时存储一些权限和状态信息。
- 页表项的主要字段:
- Present 位(存在位):
- 表示该页是否在物理内存中。
- 如果为
1
,表示该页存在于物理内存,可以正常访问。 - 如果为
0
,则可能引发缺页中断,操作系统会将所需的页加载到物理内存。
- Read/Write 位(读/写位):
- 控制该页是否允许写操作。
- 如果设置为只读,则尝试写入会引发保护异常(保护内存安全)。
- 一些代码段(如只读的常量字符串)会设置为只读,以防止意外修改。
- User/Supervisor 位(用户/超级用户位):
- 控制该页是否允许用户态程序访问。
- 如果为
1
,表示用户态程序可以访问; - 如果为
0
,则仅内核态程序可以访问。
- Present 位(存在位):
-
权限管理的意义:
页表项通过硬件(如 CPU)检查权限位来确保内存访问的合法性:
- 遇到非法访问(如用户态尝试修改内核态数据),会触发异常(如段错误)。
- 这种机制提高了系统的安全性和稳定性。
小提示:
物理内存本身没有“权限”这个概念,所有物理内存都是可读可写的。页表项的权限(如只读)是由 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
,编译器会在编译阶段报错,从而避免运行时错误。
缺页中断
- 惰性加载(Lazy Loading): 操作系统并不会一次性将整个程序的所有内容加载到物理内存中,而是按需加载(即使用时再加载)。
- 比如,大型游戏可能有几十 GB 数据,而物理内存只有 8 GB 或 16 GB。通过分批加载的方式,可以在有限内存中运行大型程序。
- 缺页中断的触发:
- 当进程访问某个虚拟地址时,CPU 首先通过页表检查对应页是否在物理内存中。
- 如果页表项的 Present 位为
0
(页面不存在于物理内存),则会触发缺页中断(Page Fault)。 - 操作系统接管控制,按照以下步骤处理:
- 在磁盘上找到对应的页内容(如可执行程序的一部分)。
- 选择一个空闲的物理页,或在内存不足时,通过页面置换算法(如 LRU)释放一个物理页。
- 将磁盘上的页加载到物理内存。
- 更新页表项,将新加载的物理地址填入,并将 Present 位设置为
1
。
- 控制返回用户态程序,重新执行引发缺页的指令。
- 缺页中断的意义:
- 实现虚拟内存的高效利用,避免物理内存不足的问题。
- 通过分批加载程序和数据,显著降低内存占用。
与进程创建的关系
- 进程创建时:
- 操作系统会为其分配必要的内核数据结构,如 PCB(进程控制块)、地址空间(
struct mm_struct
)、页表等。 - 页表的初始状态是空的,所有页表项的 Present 位都设置为
0
。
- 操作系统会为其分配必要的内核数据结构,如 PCB(进程控制块)、地址空间(
- 进程运行时:
- 当进程尝试访问某些代码或数据时,会触发缺页中断,操作系统动态加载需要的内容。
- 挂起进程时:
- 操作系统会将进程的代码和数据从内存清理出去(释放物理内存)。
- 对应页表项的 Present 位设置为
0
。
结语
到这里,我们的程序地址空间探秘之旅就要告一段落啦!从编程语言视角下的地址空间布局,到虚拟地址的神奇奥秘,再到页表、写时拷贝这些底层机制,每一步都充满了惊喜与挑战。原来,我们编写的每一行代码、定义的每一个变量,背后都有如此精妙的内存魔法在默默运行。
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!