Linux -- 进程地址空间
目录
进程地址空间
如何理解进程地址空间及其区域划分
为什么要有进程地址空间
我们在学习C/C++语言的时候都了解过程序地址空间的布局,大家应该都见过这张图:
我们怎么验证这张图的内存分布是正确的呢?很简单,通过打印不同数据的地址就可以观察到程序的内存分布:
#include <stdio.h>
#include <stdlib.h>int g_val_1;
int g_val_2 = 123;int main()
{printf("code addr: %p\n", main);char* str = "hello Linux";printf("read only string addr: %p\n", str);printf("init global value addr: %p\n", &g_val_1);printf("uninit global value addr: %p\n", &g_val_2);char* mem = (char*)malloc(100);printf("heap addr: %p\n", mem);printf("stack addr: %p\n", &str);return 0;
}
运行结果如下:
将两张图放在一起对比,确实是按照图上地址由低到高布局的:
并且我们发现堆区和栈区之间间隔非常大的内存
这里再提一个点:我们定义的str存储在字符常量区,是只读的,也可以简单进行验证:
#include <stdio.h>
#include <stdlib.h>int main()
{char* str = "hello Linux";*str = 'H';return 0;
}
这段程序运行时会发生错误,原因就是我们对只读的str进行了修改:
这里观察运行结果时先不要对在代码中对str加上const修饰 ,因为const在编译阶段生效,程序编不过,不会形成可执行程序。over
相信大家也都听说过堆区地址是由低到高增长的,栈区地址是由高到低的,这一点我们也可以通过程序验证,我们分别在堆区和栈区多定义几个变量并打印它们的地址:
#include <stdio.h>
#include <stdlib.h>int g_val_1;
int g_val_2 = 123;int main()
{printf("code addr: %p\n", main);char* str = "hello Linux";printf("read only string addr: %p\n", str);printf("init global value addr: %p\n", &g_val_1);printf("uninit global value addr: %p\n", &g_val_2);char* mem = (char*)malloc(100);char* mem1 = (char*)malloc(100);char* mem2 = (char*)malloc(100);printf("heap addr: %p\n", mem);printf("heap addr: %p\n", mem1);printf("heap addr: %p\n", mem2);printf("stack addr: %p\n", &str);printf("stack addr: %p\n", &mem);int a;int b;int c;printf("stack addr: %p\n", &a);printf("stack addr: %p\n", &b);printf("stack addr: %p\n", &c);return 0;
}
运行结果如下:
可以看到栈区变量打印出来的地址是由高到低的,而堆区则是由低到高的——堆区和栈区相对而生。
同样地,我们还可以验证我们之前学过的一个语法知识:static修饰的局部变量,在编译时已经被编译到全局数据区:将刚才定义的变量a前加上static修饰并观察打印出来的地址
int main()
{// ...static int a;int b;int c;printf("a = stack addr: %p\n", &a);printf("a = stack addr: %p\n", &b);printf("a = stack addr: %p\n", &c);// ...return 0;
}
运行结果如下:查看init global addr、uninit global addr以及a的地址即可验证
到这里,我们初步验证了进程的地址空间分布,但我们还是不够理解。只能通过更多的代码程序来慢慢理解:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int g_val = 100;int main()
{pid_t id = fork();if(id == 0){// 子进程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);}}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和其地址都是相同的。这个不难理解,因为我们认为子进程此时是共享父进程的代码和数据的。但将代码稍加改动:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int g_val = 100;int main()
{pid_t id = fork();if(id == 0){// 子进程int cnt = 0;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;printf("child process change g_val: 100->200\n");cnt--;}}}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的地址明明是一个,但是值竟然不同,这很不合理。但是我们可以得出以下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明该地址绝对不是物理地址
实际上,在Linux下,这种地址叫虚拟地址。我们在用C/C++语言所看到的地址全部都是虚拟地址。物理地址用户一概看不到,由操作系统统一管理,OS负责将虚拟地址转换成物理地址。
进程地址空间
其实,task_struct中还包含了进程地址空间,每个进程都有自己的进程地址空间,是线性/虚拟地址,每个进程也都有自己的页表,页表中存储了虚拟地址到物理地址的映射关系。通过页表映射才是变量存储的实际地址。所以在上面的程序中,父子进程只是虚拟地址相同,但映射到的物理地址是不同的。
其实子进程在被父进程创建时,虽然创建了自己的PCB,但大部分内容还是复制的父进程的,包括代码和数据。但当子进程要对数据进行写入时,会先拷贝一份父进程的数据再进行写入,即“写时拷贝”,此时操作系统访问页表会先触发缺页中断,然后为子进程在物理内存上开辟一份新的内存空间再进行写入。
如何理解进程地址空间及其区域划分
在32位的计算机中,有32位地址和数据总线,每个总系可以表示两种状态'0'和'1',所以地址总线排列组合形成的地址范围为[0, 2^32]。所谓的进程地址空间,本质是描述一个进程可视范围的大小。
对于区域划分的理解我们可以举一个形象的例子,同学A和同学B共享一张100cm的桌子,A和B都在桌子上摆放了自己的物品,但是A总是不遵守规矩,经常抢走B同学的物品。这就引起了B同学的不满,于是B在桌子的50cm处画了一条三八线,规定A不许越界。抽象出数据结构就可以是:
struct desktop_area
{
int A_start = 1;
int A_end = 50;
int B_start = 51;
int B_end = 100;
}如果此时B同学还是不满意,于是将三八线划在了桌面的40cm处,只允许A使用40cm的空间。我们就可以进行以下操作:
B_end -= 10;
A_start -= 10;
这件事情不就是区域划分吗?地址空间内也一定存在各种区域的划分,我们也定义出类似的数据结构,然后对这些区域的start和end进行管理即可。在Linux中确实如此,地址空间本质也是内核的一个数据结构对象,和PCB一样,地址空间要被操作系统管理也要“先描述,再组织”。我们查看linux-2.6.11.1源码,可以看到task_struct中有指向进程地址空间的指针*mm,查看struct mm_struct定义,可以在其中看到划分了不同区域:
目前我们对进程有了更深入的理解,进程包括内核数据结构(task_struct、mm_struct以及页表)和程序的代码和数据。
为什么要有进程地址空间
我们现在已经理解了进程地址空间是什么,但是操作系统为什么要这么做呢?为什么不直接找到每个程序的物理地址,而要大费周章地通过虚拟地址映射到物理地址呢?
- 让进程以统一的视角看待内存。
- 增加进程虚拟地址可以让我们访问内存时增加一个转换的过程,在这个转换的过程中,系统可以对我们的寻址请求进行审查,一旦异常访问可以直接拦截,使非法请求不能到达物理内存,可以保护物理内存。
- 因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。
也许大家还有点似懂非懂,这里多解释几句:
1.如果没有虚拟地址空间和页表,每个进程的数据都需要记录其所在的物理地址。
2.比如代码和字符常量区是只读的,我们不能对字符常量区的内容进行修改,这一点上面也通过程序验证了。但是系统是如何实现这一点的呢?访问物理内存中并不存在所谓的检查。实际上,页表中有对应的标志位表明要访问的内存权限是可读可写还是只读的,当我们访问某块内存时,通过页表这个“中间人”可以起到监督作用——检查对应的标志位是否运行该操作,如果不允许则进行拦截。
3.这一点我们可以通过进程的“挂起态”来理解。当进程被挂起时,虽然进程PCB仍在内存里,但其代码和数据并不在内存里而是在磁盘中。此时如果我们再要运行该进程,访问对应的代码和数据,在页表中的虚拟地址找不到映射的物理地址,就会触发“缺页中断”,操作系统会做出对应的处理,将代码和数据再次加载到内存中,并且填写页表中虚拟地址映射的物理地址。这整个过程进程是毫无所知的,即进程根本不管有没有发生缺页中断,操作系统做了哪些工作,只用专心自己的工作。进程管理和内存管理是分开的。
另外还有几个值得一提的地方:
相信不少人都在电脑上下载过较大的游戏,比如原神等等,动辄需要几十G甚至上百G的空间。可很显然我们电脑的内存根本没有这么多,那我们是怎么运行这些游戏的呢?
我们要知道一点,现代操作系统几乎不做任何浪费空间和时间的事情。虽然整个游戏内存很大,但我们在一段时间内其实只需要访问一部分的内容,根本不需要运行整个程序内存。操作系统采用的是惰性加载的方式——等要用到了再加载对应的内存,即操作系统对大文件可以实现分批加载。
页表存储在哪里?进程怎么找到自己的页表
页表是存储在内存中的。CPU中存在cr3寄存器,这个寄存器的内容就是进程的页表地址,进程通过cr3寄存器即可找到页表。(注意cr3寄存器中存储的是页表的物理地址)。
cr3寄存器中的内容本质属于进程的硬件上下文。所以在进程切换时也要注意保存。