---------------------------------------------------------------------------------------------------------------------------------
每日鸡汤:每天多一点点的努力,不为别的,只为了日后能够多一些选择,选择自己说了算的生活,选择自己喜欢的人。早奥!
---------------------------------------------------------------------------------------------------------------------------------
目录
一:历史核心问题回顾
之前我们在进程创建进程时介绍了fork函数可以创建进程,并且执行fork函数时该函数返回了两个次,那么在解释一个变量为什么会有两个不同值的时候,当时我们讲的是因为进程的独立性,导致子进程在被创建时将父进程的数据也都拷贝了一份(通过写实拷贝的方法),本质上是两份空间,但是为什么同一个变量名,同一个地址,同时读取,却读取到了不同的内容。当时并没有过多的进行解释,因为这涉及到了进程地址空间的问题。这个问题将是我们更进一步地了解进程空间地概念,接下来让我们一探究竟吧。
二:语言层面的地址空间
2.1:了解
在学习C / C++语言的时候,相信大家都会了解看到这样的图:
该图是32位机器上的地址空间分布图,32位地址线(32个 0 / 1 序列)最多可以表示2^32个地址,即最终也就是只有4G个地址,因为一个地址对应着1个字节,所以也为2^32个字节,故此全部的地址空间大小也就是4G空间。上述的内核空间是给操作系统使用的,而用户空间是给我们程序员使用的。
2.2:验证
验证各个分区地址:
#include <stdio.h>
#include <stdlib.h>
int g_val_1; // 未初始化全局变量
int g_val_2 = 10; // 已初始化全局变量
int main(int argc, char* argv[], char* env[])
{
printf("code addr: %p\n", main); // 正文代码段
const char* str = "hello world";
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* mem = (char*)malloc(100);
printf("heap addr: %p\n", mem); // 堆区
printf("stack addr: %p\n", &str); // 栈区
printf("命令行参数:%p\n", argv[0]); // 命令行参数
printf("环境变量env:%p\n", env[0]); // 环境变量
return 0;
}
验证堆、栈、命令行参数、环境变量空间内地址变量分布趋势:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char* argv[], char* env[])
{
printf("-----------------------------------\n");
printf("验证堆:\n");
char* mem1 = (char*)malloc(100);
char* mem2 = (char*)malloc(100);
char* mem3 = (char*)malloc(100);
char* mem4 = (char*)malloc(100);
printf("heap addr1: %p\n", mem1);
printf("heap addr2: %p\n", mem2);
printf("heap addr3: %p\n", mem3);
printf("heap addr4: %p\n", mem4);
printf("-----------------------------------\n");
printf("验证栈:\n");
int a = 10;
int b = 10;
int c = 10;
int d = 10;
printf("stack addr_a: %p\n", &a);
printf("stack addr_b: %p\n", &b);
printf("stack addr_c: %p\n", &c);
printf("stack addr_d: %p\n", &d);
printf("-----------------------------------\n");
printf("验证命令行参数:\n");
for (size_t i = 0; argv[i]; i++)
{
printf("argv[%d]: %p\n", i, argv[i]);
}
printf("-----------------------------------\n");
printf("验证环境变量:\n");
for (size_t i = 0; env[i]; i++)
{
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}
发现:堆栈相向而生。堆,先使用低地址再使用高地址;栈,先使用高地址再使用低地址。命令行参数和环境变量。先命令行参数再环境变量,环境变量的使用地址和堆使用一样
小 Dis:static修饰的局部变量编译的时候已经被编译到全局数据区了。
三:系统层面的地址空间
通过一段例子来引入虚拟地址的概念:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
int id = fork();
if (id == 0)
{
// child
while (1)
{
printf("pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
g_val--;
sleep(1);
}
}
// parent
while (1)
{
printf("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 变量,子进程访问时对 g_val 进行修改--, 父进程每次只是打印 g_val 的值,查看现象。
结果分析:从结果中可知,在子进程对 g_val 值进行修改时,父子进程获取到的 g_val 的值是不一样的,这是很正常的,因为进程之间具有独立性,父子进程也不例外,它们拥有各自的代码和数据,且子进程修改 g_val 的时候会发生写时拷贝。但是有个疑惑,那就是为什么在同一个地址获得到的数据不一样呢,那么我们就有这样一个理解,会不会这个地址并不是真实的地址呢?因为如果变量的地址时物理地址,那就不可能存在上面的现象,所以绝不可能是物理地址,那这个地址是什么地址呢?我们一般将其叫做是线性地址或者虚拟地址!!!
3.1:引入新概念,初步理解这种现象——引入地址空间的概念
我们上次在介绍进程的时候,进程就是一个 task_struct 和它对应的代码和数据。但是实际上并不是这样。因为一个进程在被创建出来的时候,且操作系统会对该进程更好的进行管理让进程能够更好的运行,操作系统不仅仅会给该进程创建对应的 task_struct 结构,还会为这个进程创建对应的进程地址空间。进程的 task_struct 中有对应的指针,指向该地址空间。当然每个进程中还有对应的页表,使得进程地址空间中的虚拟地址和物理内存通过页表建立映射关系。
一个进程的 task_struct、地址空间、页表、物理内存之间的关系结构大致如下:
注意:一个进程的代码和数据一定是存储到物理内存上的!!!
3.2:加深粒度再来理解上面父子进程同意变量的现象
父进程 fork 创建子进程:
根据进程的独立性,每个进程都要有自己独立的 task_struct、地址空间、页表等,而子进程的这些结构都是以父进程为模板创建出来的,可以说是完全一样的。对于上述代码中的全局变量 g_val,在父进程的虚拟地址为 0x40405c,映射对应的物理地址是 0x11223344,而由于子进程是由父进程为模板创建出来的,所以在子进程中,全局变量 g_val 对应的页表虚拟地址为 0x40405c,物理地址最初也为 0x11223344,而我们的代码在显示器打印的地址是虚拟地址,即开始时父子进程的 g_val 的值相同,地址也相同。但是当子进程要修改全局变量 g_val 时,由于父子进程之间的数据是相互独立的,所以当子进程对数据进行修改时,不能影响到父进程的数据,即子进程的 g_val 修改为200,但是父进程的 g_val 为100不变。此时子进程在修改 g_val 数据的时候就会发生写时拷贝。本质就是操作系统在发现子进程修改物理内存上 g_val 的数据的时候,会先在物理内存上开辟一块大小为4字节(sizeof(g_val))的空间,来存储子进程的 g_val 的值(200),然后再将子进程的页表对应的物理地址修改为新的 g_val 的地址(0x44332211)。故此,这就是为什么对于相同的变量相同的地址却打印出了不同的值。
总结:当子进程修改变量时,先经过写时拷贝(由操作系统自动完成的),写时拷贝时,重新开辟空间,再这一过程中,左侧的虚拟地址是0感知的,即不会影响它,改变的是页表右侧的值,之后,将子进程的 “ 新 g_val ” 的值修改为200,即根据页表找到对应的物理地址(新开辟的新空间),再修改。
四:谈细节
4.1:地址空间究竟是什么
地址空间,本质就是一个描述进程的可视化范围的大小。即在32为计算机中,有32位的地址和数据总线(高低电平0 / 1组成),所以,地址空间就是地址总线排列组合形成的地址范围 [ 0, 2^32),在数学层面范围性的概念——“数据范围”。
地址空间内一定存在着各种区域的划分,以方便进程对数据进行特殊访问。又因为每一个进程都有一个地址空间,所以操作系统肯定要对地址空间进行管理起来。所以要管理起来,管理的本质就是先描述,再组织。在语言层面就是一个数据结构对象,类似PCB一样。Linux中描述地址空间的结构体是 struct mm_struct,该结构体通过定义 start 和 end 来对地址空间进行划分,同时也可以确定地址空间内各个不同区域的划分。
struct mm_struct
{
// 代码区
unsigned long code_start; // 起始
unsigned long code_end; // 末尾
// 只读区
unsigned long readonly_start; // 起始
unsigned long readonly_end; // 末尾
// 初始化数据区
unsigned long init_start; // 起始
unsigned long init_end; // 末尾
// 未初始化数据区
unsigned long uninit_start; // 起始
unsigned long uninit_end; // 末尾
// 堆区
unsigned long heap_start; // 起始
unsigned long heap_end; // 末尾
// 栈区
unsigned long stack_start; // 起始
unsigned long stack_end; // 末尾
// 。。。。
};
注意:我们不仅仅要看到 start 和 end 的区域划分外,更要看到在各个区域范围内的连续的空间中每一个最小单元都可以有地址,只要有了该地址,就可以对它直接使用。
4.2:为什么要有进程地址空间
- 让进程以统一的视角看待内存。假设没有地址空间和页表,进程就会直接访问物理内存上的空间,这是很麻烦并且很不安全的。这是因为首先进程需要将对印的数据和代码的物理地址保存在进程的 task_struct,然后当一个进程从挂起状态转变成运行状态的时候,该数据和代码会重新加载到内存上,此时物理地址是会发生变化的,非常不安全的。但地址空间会帮我们解决这些问题,我们可以对可执行程序的虚拟地址进行连续的编制,这样就可以程序在物理内存中是无序的,而在虚拟地址中是有序的。以后我们的进程就不需要再担心程序再内存的任何位置了。就可以让我们物理内存中的代码和数据由无序变得有序。
- 增加虚拟地址空间可以让我们访问内存的时候,增加一个转化过程,在这个转化过程中,可以对我们的寻址请求进行审核,所以一旦发生异常访问,直接拦截,这样该请求就不会到达物理内存,达到保护内存的目的。
- 因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。
4.3:页表
此时页表并不是我们图中画的那么简单,他是一个很复杂的结构,此处我们只做简单的介绍即可,更为详细的可看【进程地址空间第四弹】,下面对页表做一下简单的介绍:
4.3.1:CR3寄存器
CP3寄存器是CPU内部的一个寄存器,该寄存器内保存当前运行进程的页表的地址,本质就是属于进程的硬件上下文。比如当进程切换时,进程 task_struct 是会把所有的硬件上下文数据带走的,当然CR3寄存器内的保存该进程页表的首地址也不例外。当某个时刻该进程重新被调度的时候,会将这些上下文的数据重新加载到CPU内对应寄存器中
4.3.2:页表项
页表中有多个页表项,例如:
Present(存在位):1=页在物理内存中,0=触发缺页中断,表示该页是否存在物理内存中,如果Present位为1才可以访问,如果不在就会引发缺页中断。
Read/Write(读/写位):0=只读,1=可写,表示是否允许读取或者写入。
此外还有用户/管理位(U/S)、访问位(A)、写穿透位(PWT)等等。此处就不过多讲解了。
4.3.3:缺页中断
首先我们需要有一个共识:现代操作系统,几乎不做任何浪费空间和时间的工作,并且操作系统对大文件可以实现分批加载。比如《王者荣耀》这款游戏就相当于一个大文件。但是我们的手机运行内存一般也就8/16个G,所以当我们玩这游戏的时候,肯定没有将整个游戏加载到手机上,而是大部分再磁盘上,重要的使用部分在内存上,即采用分批加载的方式来加载的。即在物理内存上是使用多少就加载多少,通过Present项来判断使用的内存是否在物理内存上,如果没有就会发生缺页中断,将使用的数据和代码加载到内存上,之后操作系统再重新建立映射,在页表的物理地址那里填上相应的使用的数据物理地址。
五:结语
今天的分享到这里就结束了,如果觉得文章还可以的话,就一键三连支持一下欧。各位的支持就是捣蛋鬼前进的动力。