目录
一、进程地址空间
在我们学习C/C++的时候,一定经常听到数据存放在堆区、栈区、常量区、全局区等等概念。今天我们来详细了解一下这些是怎么回事。
我们下面这张图是32位系统最多能表示的范围,00000000到FFFFFFFF ,数据都存放在该区域里,我们在该区域里进行位置的划分。其实这并不是真实的内存,而是进程地址空间。
我们可以写一段代码来验证一下地址是否是这样分布的。
cpp
#include<stdio.h>
#include<stdlib.h>
int un_gval;
int init_gval = 100;
int main()
{
printf("代码区:%p\n", main);
const char* str = "hello linux";
printf("字符常量区:%p\n", str);
printf("已初始化全局数据区:%p\n", &init_gval);
printf("未初始化全局数据区:%p\n", &un_gval);
char* heap1 = (char*)malloc(100);
printf("堆区1:%p\n", heap1);
printf("栈区:%p\n", &heap1);
return 0;
}
运行一下可以看到打印出来的地址逐渐变大。
刚好给之前的图对比起来,数据就是按照这个位置来存放的。
我们多创建几个变量,看看堆和栈的生长方向是往哪边的。
cpp
#include<stdio.h>
#include<stdlib.h>
int un_gval;
int init_gval = 100;
int main()
{
printf("代码区:%p\n", main);
const char* str = "hello linux";
printf("字符常量区:%p\n", str);
printf("已初始化全局数据区:%p\n", &init_gval);
printf("未初始化全局数据区:%p\n", &un_gval);
char* heap1 = (char*)malloc(100);
char* heap2 = (char*)malloc(100);
char* heap3 = (char*)malloc(100);
char* heap4 = (char*)malloc(100);
printf("堆区1:%p\n", heap1);
printf("堆区2:%p\n", heap2);
printf("堆区3:%p\n", heap3);
printf("堆区4:%p\n", heap4);
printf("栈区1:%p\n", &heap1);
printf("栈区2:%p\n", &heap2);
printf("栈区3:%p\n", &heap3);
printf("栈区4:%p\n", &heap4);
return 0;
}
打印结果如下,根据之前的图可以看到,堆栈相向而生,栈往地址变小的地方生长,堆往地址变大的方向增长。
虽然栈内的定义的变量地址逐渐减小,但是如果我们将目光放细微一点,比如一个数组,在数组内部,索引大的地方比索引小的地方地址要大。这也是为什么我们变量要使用++。
如下代码,按照我们之前的分析,站内定义的变量地址逐渐减小,arr2的地址肯定比arr1小,但是在数组中,索引9的地址要比索引0地址大。栈全局地址变小,局部地址变大。
这个程序进程地址空间图栈的部分如下所示,开辟空间的起始地址是在低地址处,会根据你开辟的大小,给你预留好位置,内部索引地址逐渐变大。
同理,结构体的地址分布也是类似, 全局地址变小,局部地址变大。 都是以起始地址+偏移量进行访问的。
静态变量也是存放在全局区的,具体存放在已初始化全局数据区,因为编译器将静态变量认为了全局变量,因此函数调用了该变量,函数结束时静态变量并不会被释放。
二、地址空间本质
基于地址空间,重新理解地址。
我们使用代码来举例,如下代码定义了一个全局变量,fork一份子进程,让子进程修改一下全局变量的值,观察父子进程打印出来的值以及值地址变化情况。
cpp
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<unistd.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if(id==0)
{
//child
int cnt = 5;
while(1)
{
printf("child, Pid: %d,Ppid: %d,g_val: %d,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
if(cnt == 0)
{
g_val=200;
printf("子进程的g_val变为了200\n");
}
cnt--;
}
}
else
{
//father
while(1)
{
printf("father, Pid: %d,Ppid: %d,g_val: %d,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
}
打印出来看看结果,这有点颠覆我们的认知,同一地址,竟然能存放两个值,写时拷贝不应该地址不相同吗。
至此,我们能得出一个结论:我们C/C++看到的地址,绝对不是物理地址 。 其实我们平时用到的地址,都是虚拟地址。
我们还知道,基于冯洛伊曼体系结构,进程的变量与数据,最终一定要在内存里。**每一个进程运行之后,都会有一个进程地址空间的存在。通过页表映射结构(kv模型),虚拟地址映射到物理地址,通过查找页表,就可以找到数据真实的存放位置了。**这样才能保证,同一的进程地址空间的地址,有着不同的值。
具体结构如下,父进程task_struct里有字段能指向属于他自己的进程地址空间,进程地址空间里的虚拟地址,通过页表映射能找到物理地址,这样一来就能找到数据真实存放地址了。
父进程fork后创建子进程,子进程也有自己的task_struct,自己的进程地址空间,自己的页表结构,因此我们看到g_val变量在父进程的地址是0x60105c,子进程也是一样的值,一开始父子进程都页表映射都指向的是同一块物理地址,但是当子进程g_val发生变化后,页表的key不变,依然是0x60105c,但value会发生变化,这是写时拷贝,操作系统会在物理内存中新开辟一段空间,将新的值放入进去,同时将该地址写到子进程的页表里,这才完成了流程。
有了这一块知识,现在我们也能理解fork之后,返回的 id 为何可以有两个值。
为了方便理解,我们再讲一个小故事。
有一个大富翁,他拥有十亿美元,男人有钱就变坏,他也不例外,他的理想是一片森林而不是一颗树木,根本不打算结婚。彩旗飘飘的他,生下了4个私生子,这4个私生子互相不知道对方的存在,认为只有自己是他的儿子/女儿。他死后,自己一人能继承富翁的所有财产。平时私生子找大富翁要钱,要得很多大富翁肯定不会给,我都没死呢?你要这么多钱,我还用啥?但是金额不大的情况下,大富翁还是十分慷慨,说给就给。就这样一直生活下去。
在这个故事中,大富翁就是操作系统,十亿美元就是内存,私生子们就是各个进程。大富翁给每一个私生子都花了一张大饼,我的钱都是你的,这一张大饼就是进程地址空间。私生子(进程)每个人都以为自己有十个亿(内存),但是他们每个人都不可能要十个亿(内存)。
每一个进程都要有地址空间,地址空间也要被操作系统管理起来,管理就要用到之前我们在冯诺依曼体系结构中提到的 先描述,再组织 。因此,进程地址空间本质就是一个内核的数据结构对象,就是一个结构体!
三、什么是区域划分
在进程地址空间中,我们进行了很多划分,将数据划分到对应的区中,再用页表映射到物理内存上,这样方便我们更好管理。生活中也存在区域划分的情况,比如我们上学时期同桌给划分的三八线,超过线就要被惩罚。
在Linux中,这个进程/虚拟地址空间的东西,叫做:struct mm_struct
例如
cpp
struct mm_struct
{
long code_start;
long code_end;
long data_start;
long data_end;
long heap_start;
long heap_end;
long stack_start;
long stack_end;
//........等等
}
使用long整形将区域的起始地址和结束地址存放起来,进程就可以将数据放到对于区域的地址范围中。这样就完成了区域划分。
我们打开linux2.6的源码也可以看到一些。
我们之前提到过堆栈相向而生,这样就能让堆栈的区域可以灵活调整,只需要修改一下区域的start和end变量即可。
四、为什么要有地址空间
1.让进程以统一的视角看到内存
任意一个进程,可以通过地址空间+页表将乱序的内存数据,变成有序,分门别类的的规划好!
如果没有地址空间,将来我们有程序加载时,肯定先加载代码,放到内存中某个位置,我们后续继续运行程序,会不断生成新的数据,该数据不一定放在代码加载地址的下面,因为该区域可能存在其他进程的数据。这样数据就是无序的。
有了进程地址空间,进程只知道数据在进程地址空间的某个规定的区域内就可以了,有页表的存在,不需要关心具体在物理内存的那个地方。这样就将无序转为了有序。
2.进程访问内存的安全检查
我们要对进程进行约束,防止进程对物理内存的一些不安全的行为。
比如代码段或者常量区是只读的,通过页表进行虚拟地址向物理地址的转化,同时页表中还有访问权限字段,该字段有r(读)、rw(读写)等等权限来进行进程访问内存的安全检查,如果不加以控制,进程对代码段随意修改,或者对常量区的数据修改,就会在页表处被拦住,不让你继续处理。
还有防止你对非法地址的访问,因为页表中根本没有非法地址的映射。只让你访问已经定义或开辟了内存的内容。比如访问数组索引为-1处的元素,就会提示错误。
3.将进程管理与内存管理进行解耦
进程管理好理解,将进程从阻塞变为运行,加载进程的task_struct数据等等操作都是在进行进程管理。下面举个进程管理的实际应用的例子:
进程进行各种转化(虚拟到物理),各种访问(内存),一定是这个进程正在运行。(进程没在CPU上运行,根本就不会去访问内存)。每一个进程肯定是在CPU上运行的,CPU内存在一个叫做CR3的寄存器,他存放了当前进程页表的地址(物理地址),CR3也是进程的上下文,当进程切换的时候,进程的task_struct一定会保存CR3中的内容,再退出,而另一个进程运行前, 也一定要将CR3的填上自己task_struct中的数据,也就是说进程切换还要将进程地址空间和页表也做切换。这些本质上都是task_struct里面的字段
而内存管理,我们讲个故事
比如我们玩一些比较大的游戏,比如英雄联盟、CF或者其他3A大作,这种游戏小则10多个G,大则50G,一般电脑的内存是装不下的,但是这并不妨碍我们能运行这些游戏。
当一个游戏很大的时候,操作系统并不会将游戏全部加载到内存里,他只需要加载游戏中的某一部分,比如你先登录的时候,他只加载登录这一部分,再比如吃鸡这种多人游戏,他会通过判断地图的远近和对手的距离,只加载你附近的建筑和对手,很远的东西就不考虑(这也是为何吃鸡人少的地方不卡,人一多就开始卡起来)。
在我们学习进程状态的时候在一个状态叫做挂起状态,当操作系统内存资源严重不足,当前进程正在运行或者阻塞,他的代码和数据在内存中仍要占用空间,现在的该进程的某部分内容并不会被调度,操作系统就会将这些代码和数据置换出去。页表中还有一个字段用来表明虚拟地址是否分配有物理地址,里面是否有内容。
如果当前进程从11变成了00字段,就代表代码已经没有分配了,内容已经被置换出去了,该空间就被释放了,就可以给别人使用的,如果查页表时,发现很多映射字段都为00,我们就可以认为当前进程是挂起的。
有了挂起,就可以让游戏的一部分申请进入内存,如果不需要了,就将他字段修改为00,这样就可以让进程边加载边执行。
如果进程地址空间中代码字段为00,当前没有分配且无内容,但现在我们又要运行了,操作系统就会将你的访问请求先暂停,让内存加载这部分内容,页表重新填写映射的物理地址,字段修改为11,最后取消暂停,让你访问。这个工作我们称之为缺页中断。
其实我们这一套操作,叫做内存管理,进程并不知道我们详细做了什么。这样就完成了进程管理与内存管理的解耦
因为有了进程地址空间的存在,让不同的进程经过页表, 映射到物理内存的不同处,从而支持进程独立性。因为每个进程都有自己的进程地址空间与页表。