C/C++程序员一般将我们所写的程序看成如下这种结构:
我们所写的程序通过编译编译之后就可以以这样的方式进行分布.
我们先通过编写一段C语言代码来进行验证:
运行结果:
我们可以看出来上述地址遵循的就是我们上面画的一种结构。
其实我们的堆区在我们申请空间的时候堆区是会不断往上走的,而栈区定义变量的时候会依次往下走的,所以说堆栈相对而生的,下面我们通过一段代码来进行验证:
我们可以通过查看他们所打印出来的地址看他们是否是堆区上的地址一直变大,而栈区上的地址一直变小来验证是否遵循堆栈相对而生。
运行结果:
所以这说明堆栈确实是相对而生的。
其实呢,我们把遵循这种规则的这种结构叫做进程地址空间。
下面我们编写一段程序来进行理解:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.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("child change g_val: 100->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);
}
}
sleep(100);
return 0;
}
运行之后:
上面这段代码父进程创建出来的子进程我们之前说道子进程没有自己的数据和代码,所以跟父进程共享代码和数据,一开始全局变量的值以及全局变量的地址相同我们也可以理解,但是后面当我们的子进程对全局变量g_val的值进行修改的时候,代码和数据会进行写时拷贝,可是就算是子进程写是拷贝那也应该是把我们的数据代码放在另一个地址处啊,值不相同我们可以理解,但是怎么可能同一个地址会出现两个不同的变量的值,按照我们之前语言层面上的理解,怎么可能同一个地址同一个变量有两个不同的值呢?
如果这是正确的话,那么可能它就是这么设计的,至此我们绝对能得出的结论是:我们C/C++所看到的地址,绝对不是物理地址!!!
我们平时用到的地址 ,都是虚拟地址/线性地址。
下面我们用两个进程同时运行上述代码:
发现这两个进程的pid确实不一样,但是他们的全局变量居然是一样的,而且他们的子进程修改之后地址仍然保持相同。所以我们可以得出结论:每一个进程运行之后,都会有一个自己的进程地址空间的存在。
下面我们通过如下图来进行解释:
当子进程修改全局变量前是如上述情况所描述,其中由于进程具有独立性所以各自有各自的进程地址空间,我们其实是通过一种叫页表的结构将我们的虚拟地址与物理地址进行映射的,当子进程与父进程都没有对我们的数据进行写入修改时,我们的子进程与父进程的数据的物理地址都是相同的,而我们程序打印的地址是我们的进程地址空间的地址,所以我们都看到上述代码中我们这些进程的数据的地址会是一模一样的。
但是当子进程对父子进程的共享数据进行修改时,操作系统会给修改数据的这个进程进行在物理内存中重新找一块空间将我们0x11223344中对应的内容拷贝过去,这也就是我们所说的对数据进行修改时会发生写时拷贝,修改之后的情况如下图所示:
这也就是为什么会出现上述我们的程序运行起来之后,子进程把我们的g_val进行修改后,我们能够看到父进程的g_val的值和地址没有变,而子进程的数据中的g_val的值虽然修改了,但是子进程的该变量在子进程的进程地址空间的地址依然没有发生改变,发生改变的是我们的该变量的值在物理内存上的地址,因为页表重新建立了映射关系,所以才造成了这样的结果。
那么下面我们还可以通过回答下面三个问题来进行理解:
1.写时拷贝是在哪里拷贝呢?物理内存
2.写时拷贝以及修改页表映射关系是谁来做的呢? 操作系统
3.影响不影响上层语言呢?不影响
我们通过两个故事来理解:
故事时刻:
故事一
有一个美国人,他呢是美国的一个大富翁, 五六十岁,有10亿美元的身价,那么这个大富翁呢,他有四个私生子女,我们假设名字叫私生子1,私生子2,私生女3,私生女4,两儿两女,他们互相之间呢都不知道彼此的存在,私生子1呢目前是一个博士生,快要毕业了,比如说学的是c/c++,私生子2呢是一个社会青年,私生女3呢是一个律师,私生女4呢是一个在工厂上班的一个小组长。
然后呢有一天这个大富翁就找到私生子1对他说:"儿子啊,你这个博士啊,你好好念,你是咋家里这么多代唯一一个高材生,等你毕业了以后呢,别学你的计算机了,学什么c/c++呢,你老爹我这有10个亿,将来把家产给你继承下去。" 私生子1一听,高兴坏了:"好的,老爸,那我先把博士生给毕业了,等到时候就直接等着子承父业了。" 然后大富翁过了几天呢,又找到他的私生子2对他说:"你这小子一天天的不干正事干嘛呢,天天在社会上鬼混,哎,算了,这样,你要是能够在你那块地方混出来个人物那我也算你成功,好好干,等你老爸我驾鹤西去的时候,我这有10个亿的家产到时候留给你继承。"? ? 私生子2一听顿时来了劲,在一些打打杀杀的环境下就变得极度猥琐了,变得惜命了,因为他老爹那里有10个亿的家产等着他来继承呢。然后大富翁又过了几天跑过去找到他的私生女3说:"你做的很不错,才干了两三年就成为了一个当地赫赫有名的律师了,你是我的独生女(骗他的这个女儿说),为了奖励你,等我驾鹤西去的时候,你也不用这么辛苦了,我的10个亿都是你的" 私生女3一听也很高兴。最后大富翁去找了他的私生女4说道:"你在这个工厂好好干,干得好将来我驾鹤西去的时候我这10个亿就都是你的了",私生女4一听特激动,瞬间有了干劲。
所以呢大富翁跟四个私生子女每一个人都说将来能够继承大富翁10个亿的家产,但是这些私生子女他们每一个人都不知道彼此的存在,每一个人都认为将来自己是这10个亿的合法继承人。
然后过了一段时间呢,这个博士生就找到大富翁说:"老爹,你能不能给我十万美金?我博士生毕业了想买辆车缺十万美金,要不然找不到女朋友。" 这大富翁一听:"这是正事啊,我一会给你打过去。" 所以后面大富翁就给这个博士生打了十万美金。然后呢过了几天这个社会青年他此时呢又找到了这个大富翁说:"老爸,你能不能给我十个亿?" 大富翁一听:"你要什么十个亿啊,你老爸我还没死呢" 然后社会青年又说:"那行吧,你要是不给我十个亿,给我一百也行,我最近吃不上饭了,打打杀杀的日子我过不下去了"? 然后大富翁一听:"吃饭可以,给你转过去了。" 最后虽然这个大富翁只给了社会青年100美金,那么这个社会青年还会不会认为将来大富翁会给他十个亿呢?肯定还会继续认为会给的。照样会认为。然后律师的那个女儿呢也找到大富翁说:"能不能给我三万美金买个手表,最近要见一个很重要的客户!" 那个在工厂上班的女儿呢也找到他说:"工厂最近出了点状况需要一些资金来援助一下,需要三十万美金"? 然后大富翁一听都是正事,所以就都给他们打过去了。最后呢这些私生子女最后都会少量多次的像大富翁申请一些资金,那么大富翁在自己的能力范围之内都会去满足这些私生子女。你要是像我要八个亿十个亿我肯定不会给你,你要是像我要个几百块几千块那我肯定是可以给你的。其实大富翁的钱平均下来给这些私生女平分下来最多最多就只有2.5个亿。但是每一个私生子女都认为自己将来会有10个亿。
上述图中蓝色字体写的就是通过我们的系统层面的概念与我们上述所说的故事进行类比。
其实还有一个例子就是我们把钱存在了银行里面比如说存了一万块钱,然后我们的银行账户会显示一个数字也就是我们的余额,那你说银行拿着我们的钱干什么去了?他会不会说把我们存进去的钱放到银行的金库里面就在那放着?还是说银行早就把你的钱给花出去了?早就花出去了的话呢就是给那些需要贷款的人给借出去了,然后通过利息银行要赚钱,虽然银行他知道你存了一万块钱,然后呢我们取钱的时候并没有看到我们的一万块钱摆到我们面前但是我们都认为我们的银行那里就是有一万块钱,其实呢银行本身在我们的账户里面写了个数字叫做10000,这玩意本身就是给我们画了一张饼,只不过说银行给我画的是资金空间叫做10000的饼,基本上你并不会把那一万块钱在一定概率上不会把他取完。所以才有了银行的存在。
故事二:
在我们上幼儿园的时候呢,在一个班上呢,有一个叫小胖的小男孩和一个叫小花的小女孩是同桌,他们呢假设共用的是一张100cm的长桌子,刚开始呢,两个人关系还挺好,上课的时候画画的时候铅笔,橡皮擦什么的互相还用一下,互相有什么好吃的还交换着吃。后来呢这个小胖随着时间的退役,小胖呢有很多不好的习惯,比如说不讲卫生,天天鼻涕流着,这个小胖呢喜欢欺负这个小花,然后呢这个小花越看小胖越不顺眼,心里想:天天鼻涕留着还欺负我。然后呢直接在这个桌子中间画了一根线,然后跟小胖说:"你的胳膊要是越过这条线我就打你了!"? 后来呢这个小胖挨了几顿揍。我们呢平常开玩笑都拿这条线叫做38线。那么小花在这张桌子上画的这根线他的本质是什么呢?小花画38线本质是:进行区域划分!然后呢小胖只能规规矩矩的在桌子的左半边老老实实呆着。
那么区域划分到底是什么呢?区域划分本质其实就是通过在结构体中通过定义一个起始位置的变量和一个结束位置的变量来完成区域划分的。我们把桌子的100cm的宽度叫做桌子的地址空间,然后小胖具有的桌子空间范围是0~50,小花:50~100.
那么有一天呢小胖有故意惹小花生气,被小花揍了一顿,然后把38线又向左移了一部份区域变成了40,那么这个过程呢叫做区域调整。
那么我们用计算机语言怎么描述呢?
我们要将区域进行调整其实本质就是可以通过重新设置我们的起始变量的值。
虽然小胖的区域只用[0,40]了,但是呢,这个小胖呢有一个强迫症就是清楚剩下的垃圾,铅笔,纸巾,筷子什么的都喜欢在桌子上摆的整整齐齐的,比如说一号地址放上了自己的铅笔,二号地址放上了他的筷子,三号地址放的是吃饭用的勺子,每一个地址处呢都放置了对应的东西,这其实就叫使用该地址来保存对应的内容,有一天呢幼儿园老师路过看到小胖桌子上摆的这么整齐觉得很好玩就问:"小胖同学,你的桌子上的第三个位置放的是什么呢?" 小胖听了之后去寻找第三个位置放的是自己吃饭的勺子,然后说:"我第一个位置放的是勺子,第一个位置放的是我的铅笔。。。。" 小胖通过寻找自己放置东西的位置这种方式就是去自己的地址空间进行寻址 。
所以通过这个故事我们就可以得出两个结论:一个是我们可以通过在结构体中通过start,end两个变量来进行区域划分,另一个是不要只看start和end中的值,start和end之间所有的线性地址我们也可以使用。
通过上面的故事以及一些描述其实我们还是没有说清楚到底什么才是地址空间。
举这样一个例子,我是一个公司的老板,我手底下有很多的员工,那我是不是要对这些员工进行管理起来呢?答案是要的,那么怎么管理呢?先描述在组织。这就涉及到我们前面所学的进程的概念了,进程是要被操作系统管理的,所以我们的操作系统会对每一个进程通过进程PCB来进行管理。那么我是这样的一个老板,喜欢画大饼的老板,在公司呢找到公司的小王说,好好干,明年给你加薪。然后又找到小李说:"要好好干啊,明年有一个经理的位置给你留着呢!" 然后过了一段时间,我又找到小王说:“这段时间干的还不错,继续加油,明年那个经理的位置等着你呢?”? 小王就说:“啊?老板,你不是说给我加薪的嘛?怎么又变成当经理了?” 那么完蛋了,我给小王和小李画的饼张冠李戴了,所以要想避免这样的情况发生,那么我们是不是应该将我们对公司员工画的饼用一个小本本记下来呢?要说对,所以我们除了要对这些员工进行管理我们,还要对我们画的饼进行管理,那么操作系统要对我们的进程进行管理同样的也要对进程的进程地址空间进行管理,那么如何管理呢?一样的,先描述在组织。
进程地址空间最终一定是一个内核数据结构对象,其实就是一个内核结构体。
在Linux中,这个进程/虚拟地址空间的东西叫做:
那么如何证明呢?
我们通过查看一下我们的linux内核的源码就能够发现:
这不是类似我们上面用小花的行为用计算机语言描述出来的结构体嘛?
这不就是我们上面的进程地址空间进行的区域划分嘛?
由于有了页表的存在,我们将可执行程序中加载到内存中的代码和数据就不需要比如说有某种约束才能找到该进程对应的代码和数据,而是通过页表映射可以直接找到对应进程的代码和数据在物理内存上的地址,而我们的进程可以统一看待我们的内存都是按照左边这个进程地址空间相同的方式来看待内存的。
所以我们就可以得出为什么要有地址空间的第一个理由:
让进程以统一的视角看待内存,所以任意一个进程都可以通过地址空间+页表可以将乱序的内存数据,变成有序,分门别类的规划好。让我们的无序变有序。
其实呢在我们的页表中不仅仅只有物理地址和虚拟地址,还有一个字段是叫做访问权限字段:
因为我们页表中的物理地址中的一些内容可能是只读的,也有可能是可读写的,由于权限不同,所以需要这一字段进行限制。所以就有了我们的第二个要有地址空间的理由:
存在虚拟地址空间,可以有效的进行进程访问内存的安全性检查!
所以我们该如何理解每一个进程都是有页表的?
我们的CPU里面有一个CR3这个寄存器保存的是我们当前正在运行的进程的页表的地址。
当我们的进程进行各种转换,各种访问,一定是这个进程正在运行!那么该进程的数据和代码一定会被从虚拟地址空间读到CPU里,当我们未来要寻址的时候就可以通过虚拟地址经过CR3中的页表地址找到页表然后与物理内存进行映射就可以找到我们需要的数据和代码了。这里的CR3保存的页表的地址一定是物理地址。
?
我们前面说过本质寄存器里面的内容本质就是在当前进程的上下文当中,当我们的进程进行切换时,CR3里面的内容是要被保存到我们的进程上下文当中,每一个进程都需要这么干,所以每个进程都要变自己对应的页表地址给带走,所以我们每个进程都有自己的页表。
我们如何理解切换?
只要我们保存好我们的进程的上下文,那么我们进程的虚拟地址空间,页表地址都会跟着我们的进程上下文走,也就是说,只要进程切换了,我们的虚拟地址空间和页表会跟着进程自动切换。
谁规定我要把一个可执行程序加载到内存就必须全部加载呢?
生活经验告诉我,我们平常玩的那种下载下来占用空间特别大的游戏,比如我们的荒野求生,穿越火线,原神啊等等。这些游戏下来要有几十个G,我们的内存并没有这么大,其实我们将该游戏加载到内存的时候并不需要全部加载到内存,只需要加载一部分,当其他没有加载的可以后续需要加载了再把需要的部分加载到内存。我们之前学习进程状态的时候教材里面有一种状态叫挂起状态,而我们前面学习linux操作系统的进程状态的时候好像并没有提到挂起状态,那么挂起状态如何体现呢?当我们的内存不足时,有一些进程的数据和代码加载到了内存但是并没有去运行,那么这个时候操作系统会把这部分数据和代码换出到硬盘上面来腾出内存空间,这就把这个被换出的这个进程叫做挂起,因为他也没有退出。我们页表当中其实还有一个字段用来用来判断某个物理地址中是否被分配或者是否有内容,可以通过我们的二进制形式来表示。当我们的进程一旦要被挂起的时候就会将该进程的代码和数据从内存中给释放出去,剩下的内存就被其他进程给使用了,然后把我们页表中的字段改成未分配+没有内容(用00表示),比如说我们上面说到的大内存的游戏,把部分代码数据加载到内存的时候内存给该代码数据开空间,然后把代码和数据放上去,把对应页表的字段改成11,如果当前数据代码没有跑完那就继续跑,如果跑完了,可以继续把后续需要使用的代码和数据将上面的代码和数据覆盖然后继续跑,如果不愿意覆盖也可以叫操作系统重新开辟空间,把你对应的数据代码在加载进来。如果页表中只有虚拟地址空间,而页表中是否分配内存是否有内容的字段是00,也就是没有分配内存,也没有内容,那么如果此时该进程要访问该虚拟地址空间中的内容,那么我们操作系统会先将该访问请求暂停,然后把该虚拟地址对应的内存进行分配再把需要的内容加载进内存再放上去,然后把页表中的该字段改成11,此时再让访问请求继续访问,那么我们就把这个过程叫做缺页中断。我们整个的这一部分操作系统为我们做的工作我们的进程不知道,整个的操作系统在页表和物理内存中来回做的这些工作我们叫做内存管理,而我们要访问,要执行代码,进程要调度这部分我们叫做进程管理。实现了我们的进程管理与内存管理的解耦。
第三个理由:将进程管理和内存管理进行解耦!
进程=内核数据结构+进程代码和数据
通过页表,让进程映射到不同的物理内存当中,从而实现进程的独立性!