目录
📖一、语言层面的地址空间
在学习 C/C++ 语言的时候,一定都见过下面这张图:
即使没全部见过,但也见过其中的大多数
接下来我们验证一下,内存是不是这么存储的
我们通过如下的代码进行验证->:
#include <stdio.h>
#include <stdlib.h>
int g_val_1; // 定义一个未初始化全局变量
int g_val_2 = 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;
}
可以发现,内存确实是如图存储的
小Tips:堆栈空间是相对“生长”的。即堆区是先使用低地址再使用高地址,而栈区是先使用高地址再使用低地址。
// 栈区地址由高向低增长
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);
}
// 堆区地址由低向高增长
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;
}
小Tips:这里还有一个小细节,堆栈地址其实相聚很远,原因是堆栈之间还有一块区域,这块区域在讲解动静态库的时候为大家讲解。
// 静态变量地址的验证
int g_val_1; // 定义一个未初始化全局变量
int g_val_2 = 100; // 定义一个已初始化全局变量
int main()
{
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);
return 0;
}
小Tips:从打印结果中可以看出,静态变量的地址和全局变量的地址十分接近。这是因为 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;
}
代码解释:上面这段代码中创建了一个子进程,并且定义了一个全局的变量 g_val,初始化为100。让父子进程同时去访问变量 g_val。在子进程要执行的代码片段中还定义了一个局部变量 cnt,初始化为5,每执行一次循环就让 cnt--,当 cnt 减到0的时候把 g_val 的值修改为200。
结果分析:从打印结果中可以看出,在子进程对 g_val 进行修改后,父子进程获取到的 g_val 的值并不一样,这符合我们的预期。因为父子进程相互独立,他们拥有各自的代码和数据,子进程在对 g_val 进行修改的时候会发生写时拷贝,这一点在前面的文章中已经讲过。但奇怪的是,为什么同一个地址,从该地址获取到的数据却不相同。那么真想就只有一个,这个打印出来的地址一定不是真实存在的物理地址,因为真实存在的物理地址中只能存放一个数据,不可能同时存储两个不同的数据。因此我们可以得出一个结论:我们代码中打印出来的地址不是物理地址,一般把这个地址叫做线性地址或者虚拟地址。
📖三、初步解释这种现象——引入地址空间的概念
之前在介绍进程的时候说过,一个进程就等于 task_struct + 代码和数据。但实际上事情并没有这么简单。一个进程一旦被创建出来,操作系统为了让该进程能够更好的运行,除了会为该进程创建对应的 PCB 对象之外,还会为这个进程创建一个地址空间(准确的叫法是进程地址空间)。我们平时在编码过程中使用的地址就是这个地址空间中的地址。
进程地址空间本质上是内核为该进程创建的一个结构体对象。进程的 PCB 中是有对应的指针,指向该地址空间。进程地址空间中的虚拟地址和真是的物理地址是通过页表建立联系的,因此每个进程也会有一张页表。
小Tips:进程 PCB、进程地址空间、页表、物理地址四者之间的关系如上图所示。进程相关的代码和数据一定是存储在物理地址上的。
再来粗略的理解上面的现象
根据进程独立性,每个进程都要有自己独立的 PCB、进程地址空间、页表。子进程的这些东西,它们大部分都是以父进程为模板创建出来的。
对于全局变量 g_val,在物理内存上它始终只有一份,在父进程中 g_val 有自己的虚拟地址0X601054,创建子进程的时候,子进程根据父进程的地址空间创建出自己的地址空间,此时 g_val 对应的虚拟地址仍然是0X601054。子进程的页表最初也是根据父进程的页表去创建的,因此子进程中 g_val 变量的虚拟地址和父进程中的一样,还是0X601054,并且子进程页表中的虚拟地址和物理地址的映射关系还是继承自父进程。
因此,在子进程和父进程中都能够访问到 g_val 这个变量,并且在子进程和父进程中打印出来的 g_val 的地址都是一样的。父子进程共享同一份代码也是根据这个原理来实现的。当子进程要修改 g_val 变量的时候,由于父子进程的数据是相互独立的,该独立性体现在,在子进程去修改 g_val 的值不能影响到父进程,即,在父进程中 g_val 本身的值是100,在子进程将 g_val 的值修改成200的时候,父进程中 g_val 的值仍然得保持100。
所以子进程在修改的时候会发生写时拷贝,其本质就是操作系统发现子进程要去修改父子进程所共享的数据,操作系统会说:“子进程你等会儿,先别改”。然后操作系统会为子进程在物理内存中开辟一块空间来存储 g_val 的值,最后修改页表中的虚拟地址0X601054所对应的物理地址。这就是为什么打印出来的是通过一个地址,但是却有两个不同的值。
小Tips:写时拷贝是操作系统自动完成的,子进程并不知情。这就相当于你有一个朋友要到家里来玩,但是家里有点乱,你让他等会再来,期间你收拾房子的这个过程你朋友并不知情。操作系统就相当于是你,子进程就相当于是你的朋友。重新开辟空间,但是在这个过程中,左侧的虚拟地址是0感知的,它不关心也不会影响它。
所以今天我们更新一下
进程=PCB+代码和数据+进程地址空间
📖四、细节解释
地址空间究竟是什么?
进程地址空间是操作系统给进程弄的虚拟地盘。它划分出不同区域放代码、数据等,就像房子不同房间。操作系统靠数据结构管着这个地盘,规定进程能看到和操作的范围。
在语言层面要描述一个事物只能通过结构体,因此地址空间本质是内核的一个数据结构对象,类似 PCB 一样。Linux 中描述地址空间的结构体是 struct mm_struct
,该结构体中通过定义 start
和 end
字段来确定地址空间的范围,以及进行区域划分。
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; // 进程栈的首地址
//...
};
小Tips:地址空间就相当于是操作系统给进程画的一个大饼。操作系统给每个进程划分的地空间范围都是0~4G(以32位操作系统为例)。每个进程都不知道对方进程的存在,都以为自己会独享这4G的空间。但实际并不是这样的,操作系统并不会一次性把这4G的空间给同一个进程,而是每个进程需要多少了就在物理内存申请多少。(也就是写时申请)
📖 五、页表
5.1 CR3寄存器
CPU 中有一个叫做 CR3 的寄存器(X86架构),该寄存器中保存的就是当前正在运行的进程的页表的地址,本质上属于当前进程的硬件上下文,当前进程被切换走了,是会把和该进程有关的所以硬件上下文数据带走的,当然就包括 CR3 寄存器中保存的该进程页表的首地址。该进程重新被 CPU 调度的时候,会把该这些硬件上下文数据重新加载到 CPU 里对应的寄存器中。CR3 寄存器中存的是物理地址。
5.2 页表是由页表项组成的
页表是有多个页表项组成的,一般的页表项有如下几个。
Present(存在位):表示该页是否存在于物理内存当中。如果存在数据才可以访问。如果不存在,可能会引发缺页异常。
Read/Write(读/写位):表示是否允许读取和写入。如果设置了“只读”,则只允许读取,写入会引发保护异常。代码段和字符常量区的数据所对应的页就是“只读”。
User/Supervisor(用户/超级用户位):用于指示是否允许用户态程序访问该页。如果设置了“用户态可访问”,则用户程序可以访问;否则,只有内核态可以访问。这些权限位通过硬件(如CPU)来执行,当程序尝试访问内存时,硬件会检查相应的页表项权限位,如果权限不符合要求,会触发相应的异常,例如页故障异常。这样可以确保对内存的合法访问,提高系统的安全性和稳定性。
小Tips:这里需要注意一下,单纯的物理内存是没有“只读”、“只写”等这些权限管理概念的,对于物理内存上的任何一块空间来说,都是可读可写的。所以我们在语言层面所说的代码区和字符常量区是只读的,本质上是因为存储代码和字符常量的物理内存所对应的页表中设置了“只读”权限。
我们可以看这样一段代码->:
#include <stdio.h>
int main()
{
char* str = "Hello World";
*str = 'B';
return 0;
}
我们在编译器上书写这段代码后,这段代码编译不会报错,而是运行时报错,为什么呢?
首先,"Hello World"是一个字符串常量,是不可以修改的,这里我们所知道的不可以修改,其实就是因为这段字符串常量所对应的物理内存所对应的页表中是只读的。
而我们这么写并没有语法错误,所以编译器检查不出来,自然也就不会编译报错。
而因为字符串常量的物理内存所对应的页表是只读权限的,而我们想要去修改它,那自然就不允许,也就会运行时报错
现代操作系统几乎不做任何浪费空间和时间的事情,操作系统对大文件可以实现分批加载。这个其实很好理解,我们平时玩的大型电脑游戏动辄就几十个G,而我们的内存一般就只有8个G或者16个G,所以我们在玩这种大型游戏的时候,一定没有把和该游戏有关的所有文件一次性加载到内存中,而是采用分批加载的策略。操作系统对可执行程序一般采用的是惰性加载机制,即操作系统承诺给进程分配4G的空间(虚拟内存的大小),但实际在物理内存上是用多少加载多少。
操作系统会通过页表中的Present(存在位)页表项去判断,去标记该页是否存在于物理内存中,如果不存在就会发生缺页中断
缺页中断触发
页表里有个存在位(Present ),用来标记对应页面在不在物理内存。要是进程想访问的页面不在物理内存(存在位标记不存在 ),就触发缺页中断。
缺页中断就是操作系统和硬件共同配合,将需要的代码和数据从磁盘加载到物理内存中
缺页中断加载的重要问题
- 内存申请:物理内存很大,触发缺页中断后,得决定申请哪块内存放要加载的页面。
- 加载内容:还得确定加载可执行程序的哪部分,不是一股脑全加载。
- 地址填写:加载完页面到物理内存后,要把物理地址填到页表,让虚拟地址能正确映射。
加载完后物理地址如何填到页表里呢?
这一系列和缺页中断相关的问题最终都是由操作系统中的内存管理模块来执行的。
整个缺页中断的过程对进程是不可见的
小Tips:进程在被创建的时候,一定是先创建内核数据结构(进程 PCB、地址空间、页表…),然后再加载对应的可执行程序。挂起状态就是将进程的代码和数据从内存中清出去,然后再将Present(存在位)标志位设置成不存在即可。
📖六、完结
创作不易,留下你的印记!为自己的努力点个赞吧!