🌎进程地址空间
进程地址空间是操作系统提供给每个应用程序或进程的一块独立的内存空间,它决定了进程能够访问的内存范围以及内存布局。话不多说,开启我们今天的话题!
🚀程序地址空间
我们在学习C/C++的时候我相信大部分人都见过下面这张图:
有一些细微的差别,但是并不影响,按照正常人的逻辑思维方式低地址放在下面,高地址放在上面。那么图中这些区域真的就是这样排列的吗?我们来验证一下:
1 #include<stdio.h> 2 #include<stdlib.h> 3 #include<unistd.h> 4 5 int g_unval;//未初始化全局变量 6 int g_val = 100;//已初始化全局变量 7 8 int main() 9 { 10 printf("code addr: %p\n", main);//代码段地址 11 printf("init data addr: %p\n", &g_val);//初始化全局变量地址 12 printf("uninit data addr: %p\n", &g_unval);//未初始化全局变量地址 13 14 char *heap = (char *)malloc(heap);//堆区开辟空间 15 16 printf("heap addr: %p\n", heap);//堆区地址 17 18 printf("stack addr: %p\n", &heap);//栈区地址 19 return 0; 20 } ~
由此可见大小顺序排列为:stack > heap > uninit data > init data > code addr。这样也就证实了这张图是没问题的。
顺序我们有了,可是栈区堆区的增长方向我们还没验证,不妨验证一下:
1 #include<stdio.h> 2 #include<stdlib.h> 3 #include<unistd.h> 4 5 int g_unval;//未初始化全局变量 6 int g_val = 100;//已初始化全局变量 7 8 int main() 9 { 10 printf("code addr: %p\n", main);//代码段地址 11 printf("init data addr: %p\n", &g_val);//初始化全局变量地址 12 printf("uninit data addr: %p\n", &g_unval);//未初始化全局变量地址 13 14 char *heap = (char *)malloc(20); 15 char *heap1 = (char *)malloc(20); 16 char *heap2 = (char *)malloc(20); 17 char *heap3 = (char *)malloc(20); 18 19 printf("heap addr: %p\n", heap); 20 printf("heap1 addr: %p\n", heap1); 21 printf("heap2 addr: %p\n", heap2); 22 printf("heap3 addr: %p\n", heap3); 23 24 printf("stack addr: %p\n", &heap); 25 printf("stack1 addr: %p\n", &heap1); 26 printf("stack2 addr: %p\n", &heap2); 27 printf("stack3 addr: %p\n", &heap3); 28 29 return 0; 30 }
由此可见,我们堆区实在不断增大的,栈区是不断减小的。所以我们只需要记住,堆栈相对而生!
我们还有环境变量与命令行参数,我们也来验证一下:
1 #include<stdio.h> 2 #include<stdlib.h> 3 #include<unistd.h> 4 5 int g_unval;//未初始化全局变量 6 int g_val = 100;//已初始化全局变量 7 8 int main(int argc, char *argv[], char *env[]) 9 { 10 printf("code addr: %p\n", main);//代码段地址 11 printf("init data addr: %p\n", &g_val);//初始化全局变量地址 12 printf("uninit data addr: %p\n", &g_unval);//未初始化全局变量地址 13 14 char *heap = (char *)malloc(20); 15 char *heap1 = (char *)malloc(20); 16 char *heap2 = (char *)malloc(20); 17 char *heap3 = (char *)malloc(20); 18 19 printf("heap addr: %p\n", heap); 20 printf("heap1 addr: %p\n", heap1); 21 printf("heap2 addr: %p\n", heap2); 22 printf("heap3 addr: %p\n", heap3); 23 24 printf("stack addr: %p\n", &heap); 25 printf("stack1 addr: %p\n", &heap1); 26 printf("stack2 addr: %p\n", &heap2); 27 printf("stack3 addr: %p\n", &heap3); 28 29 for(int i = 0; argv[i]; ++i) 30 { 31 printf("&argv[%d]=%p\n", i, argv + i); 32 } 33 for(int i = 0; env[i]; ++i) 34 { 35 printf("&env[%d]=%p\n", i, env + i); 36 } 37 38 return 0; 39 }
很明显,不论是env还是argv的地址都要比栈要大。并且 环境变量比命令行参数的地址要大,那么我们就知道,在程序中,是 先有命令行参数这张表,再有环境变量这张表。
我们知道,一个局部变量是在栈区开辟的,那么如果这个变量是静态的呢?
不难看出,静态变量的地址与全局变量地址离得很近,也就是说,static变量已经变成全局的了。
都说到这个份上了,那么程序地址空间究竟是不是我们的物理内存呢?想要验证,我们看下面这段代码:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 5 int g_val = 100; 6 7 int main() 8 { 9 pid_t id = fork(); 10 if(id == 0) 11 { 12 int cnt = 0; 13 //child process 14 while(1) 15 { 16 printf("child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid, g_val, &g_val); 17 cnt++; 18 if(cnt == 5) 19 { 20 g_val = 200; 21 } 22 sleep(2); 23 } 24 } 25 else 26 { 27 while(1){ 28 printf("parent, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid, g_val, &g_val); 29 sleep(2); 30 } 31 } 32 33 return 0; 34 }
fork一个子进程,在子进程内更改全局变量 g_val 的值,我们看看会发生什么?
可以发现子进程将 g_val 的更改之后,为什么父进程的 g_val 却没有变?难道是写时拷贝?但是他们的地址一模一样,明明是同一个变量啊!!
其实,这是一个叫做 虚拟地址 的东西,虚拟地址并不是真正的物理地址,而更加深入我们得稍后再谈!
但是由此我们就可以知道,程序地址空间并 不是 内存,它的正确叫法为 进程地址空间!
🚀进程地址空间
✈️父子进程修改同一变量原理
对于为什么同一个变量能有不同的赋值,我们其实很疑惑,那么我们不妨好好观察下面这个图:
我们平常写程序所用的地址几乎都是虚拟地址,而虚拟地址是物理地址通过某种映射的结果,所以在进程中存在一张表,这张表对应着 虚拟地址和物理地址 之间的 关系。
而进程创建子进程的时候,子进程会继承父进程这个表的,当子进程对 g_val 的值进行修改,并不能从父进程的表中对应物理地址进行修改,因为进程是具有独立性的!
所以,子进程需要再内存中单独开辟另一块空间用来存储修改过的 g_val ,但是这是子进程和父进程虽然 g_val 虚拟地址相同,物理地址却不同了,所以就能看到 “同一个变量有两个值” 的现象。
✈️什么是进程地址空间
每一个进程都存在一个进程地址空间,且 在32位平台下范围为 [0, 4GB];
在详细说明进程地址空间之前,我们先说个故事:
有一天,阿熊敲代码敲到很晚,并且第二天早上还有课,阿熊眼看时间不早了,于是就简单洗洗睡了,很快,阿熊进入了梦乡…
在梦里,阿熊梦到了自己在一家互联网公司上班,团队6个人氛围和谐。突然有一天,团队接到任务,要实现一个大项目,然后大家开始分工,每个人做不同的模块,于是阿熊他们就愉快的开始工作了…
刚开始,你们干劲十足,可是越往后,项目进度就越慢了,领导眼看快要到截止日期了,终于,他把团队的每个人都单独叫去谈话。
阿熊是第一个被叫过去的,经理对阿熊说:
“阿熊啊,其实我有件事没告诉你们,本来相等项目结束给大家一个惊喜,这次项目完成后,会有一批额外的奖励,这个奖励是给表现最突出的员工的!你知道我为什么单独跟你说这个吗?我觉得你才是团队的核心!你好好干这个奖励非你莫属!若有什么需要你尽管提!还有你可千万别跟他们说啊!!”
阿熊听了之后惊喜连连,于是每天饭也不吃,全在敲代码。殊不知,经理其实偷偷地把整个团队的成员都叫去谈话了,内容竟跟与阿熊说的相差无几。于是接下来这几天,整个团队的项目进度进行的飞快,有时设备上有问题都会向产品经理提出,比较好的是产品经理很快把每个人不同的需要搞到手了。【未完…】
这个领导的行为无异于在画大饼,并且给每个人都划了大饼,而这张大饼其实就可以看作虚拟地址空间!而真正的奖励只有一份,每个人都被告诉一定是自己拿到奖励,整个团队6人,那就是六张大饼,六个虚拟地址空间。
真正的物理地址就像真正的奖励,而虚拟地址空间就是那一张张大饼,如果有需要,可以向领导进行申请,其实就相当于 虚拟地址空间向操作系统申请物理地址空间。
那么领导给员工的大饼要不要进行管理呢?很明显,要管理。难不成这次跟阿熊说好好干有奖金,下次却说好好干给你升部门经理?不合适吧?!
由此类比我们也能得出,操作系统对虚拟内存也要进行管理,那么操作系统如何管理虚拟地址空间呢?还是那六个字:先描述、再组织!这六个字我们已经很熟悉了!所以我们能得知什么?虚拟地址空间本质上就是一种数据结构!
task_struct 指向某一个进程地址空间,而进程地址空间以链式结构存储,于是操作系统对进程地址空间的管理,变为对该链表的增删查改!
阿熊其实意识到了事情的不对,于是在程序里埋了“雷”,谁知道甲方催的急,阿熊的代码还没被测试部门 检测完项目就已经交接了。阿熊等着看领导的好戏如何上演,阿熊已经想到领导难堪的样子了。
一个月后,面试官:“有过工作经历吗?”,阿熊:“我曾经有一份工作…”
这个时候阿熊突然惊醒,原来都是一场梦啊,不过这个梦也太长了点吧,反应过来的阿熊,猛地拿起手机,上面除了显示时间为 上午 11:30以外,还有辅导员的几条未读消息… 【结束】
✈️进程地址空间的属性
那么进程地址空间里的属性字段是怎么一回事呢?要想更好了解进程地址空间,我们有必要深入学习进程地址空间的属性。
在深入了解进程地址空间的属性字段之前,我们还有一个故事要说:
小学的时候,如果你的同桌是个活泼的女生,那我想你的课桌一定被划过三八线吧。此时的小花和小胖正是如此,自从老师调过位置后,小花变成了小胖的同桌,但是小胖体积大难免会占用空间大一些,甚至有一次午睡,小胖一个人趴桌子上都快把小花的位置挤没了。
所以,小花采取了人道主义手段,小胖是敢怒不敢言啊,从此以后,只要小胖越界,那么就免不了一顿毒打。
小花为了和小胖划清界限,于是在课桌上用铅笔画下了 “三八线”,只要小胖稍稍越界,眼疾手快的小花上去就是一拳。 【未完】
那么,小花画38线的本质是在做什么呢?区域划分!我们如何使用计算机语言来描述小花的工作?先描述、再组织!
struct area//区域划分 { int start;//边界开始 int end;//边界结束 } struct desktop_area//书桌区域 { struct area xiaopang_area;//小胖区域 struct area xiaohua_area;//小花区域 } struct desktop_area area = {{1, 50}, {51, 100}};//创建区域对象,给区域进行赋值
为了方便表示,我们可以简化点写:
struct desktop_area { int xiaopang_start;//小胖区域 int xiaopang_end; int xiaohua_start;//小花区域 int xiaohua_end; } struct desktop_area area = {1, 50, 51, 100};//创建结构体对象,划分小花小胖区域
那么小花是如何判断小胖越界的?我们默认桌子上是有刻度的。
有一天,小胖的铅笔盒放在了 60 位置处,正好被小花看到了。小花判断:51 < 60 < 100,于是小花对小胖说:“你越界了。”于是小胖叒挨了一顿毒打。
但是呢,此时的小花越想越气,准备给小胖一些惩罚,小花擦掉原来的三八线,画上了新的三八线,只不过这次的三八线,小胖少了20,只剩下可怜的30了,小胖欲哭无泪。 【结束】
小花根据区域划分来判断小胖是否越界,而对小胖惩罚的行为本质就是在 扩大区域划分。
area.xiaopang_end -= 20;//小胖范围减小 area.xiaohua_start -= 20;//小花范围增大
到目前位置,我们已经可以得出两个结论了,区域划分可以:1、判断是否越界 2、扩大或缩小范围。
那么如果让你来设计进程的地址空间,那么该地址空间中必定有的字段是:
struct XXX { int code_start, code_end; int init_start, init_end; int uninit_start, uninit_end; int heap_start, heap_end; int stack_start, stack_end; //... }
如果让我来设计进程的地址空间,那么该地址空间中必定存在各类型区域的划分!我们来看看内核是怎么描述进程地址空间的(部分属性)?
从内核对 mm_struct 也就是进程地址空间的描述来看,各类型的区域划分是很有必要的,这也是我们最开始那张图各个区域划分的地方。
说了这么多,其实我们区域划分的本质就是:区域内的各个地址都可以使用。
我们的地址空间不具备对代码和数据保存的能力,所以 将进程地址空间上的地址(虚拟\线性)转化到物理内存当中!如何转化呢?系统给我们提供一张虚拟地址和物理地址的映射表——页表(Page tables)
📒✏️总结
- 我们了解了程序地址空间各个区域有什么,并且他们是什么顺序存放的,而在子进程对全局变量做修改与父进程得到两个不同的结果 靠的是页表和写时拷贝!
- 进程地址空间本质上也是一种数据结构,并且彼此是链式连接的,于是 操作系统对进程地址空间的管理变为了对链表的增删查改!
- 我们了解了进程地址空间中一定存在许多的地址划分,用来区分各个地址区域。
创作不易,如果这篇文章对你有帮助的话,还望三连支持博主呀~~