目录
操作系统与进程以及虚拟地址空间的关系
大富翁是操作系统,十亿美金是内存,我们的大富翁向分别三个私生子许诺,当大富翁死后,会把所有的遗产给他们,所以每一个孩子都认为自己独占这十亿美金,但是十亿美金只是大富翁给分别给三个私生子大脑中构建的蓝图,这个蓝图就是虚拟地址空间,三个私生子对应的是三个进程,三个进程互相独立,1W表示进程实际申请的空间。
操作系统给每一个进程申请虚拟地址空间,每一个进程都认为自己独占虚拟地址空间,但是进程在实际运行时只申请很小一部分空间,所以虚拟地址空间就是操作系统给每一个进程画的大饼。
我们的进程要被管理,所以我们的饼也要被管理,虚拟地址空间的管理方式是先描述,再组织:
先描述,再组织:首先把虚拟地址空间以结构体的形式进行描述:
再组织:再把这些结构体以数据结构的形式连接起来
所以,虚拟地址空间的本质是内核的一种数据结构。
进程地址空间:
这是我们在c语言中讲过的地址空间,上面有代码区,已初始化全局数据区,未初始化全局数据区,堆区,栈区等。
我们知道,系统有32位和64位的区别,32位指的是地址空间有2^32个地址,在虚拟地址空间中,每一个地址代表一个字节,所以我们虚拟地址空间一共占用2^32个字节,也就是4GB
在进程地址空间中,每一个字节都代表一个不同的地址。
地址的意义:
答:地址最大的意义是保证唯一性即可,如何保证唯一性:我们使用2进位制的32位的数据即可。
例如:最低的地址:00000000 00000000 00000000 00000001
最高的地址:FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
这些地址是什么地址?是物理地址吗?
答:不对,这里的地址是虚拟地址。
我们的进程地址空间有代码区,数据区,堆区,栈区等等,我们是如何划分这些区域的?
进程地址空间的划分:
我们举一个例子帮助理解:在我们小学的时候,我们是男女同桌,男孩子非常调皮捣蛋,于是女孩子就划了一个38线,课桌一共有100cm,从[1,50]是属于男生的,从[51,100]是属于女生的。
这个过程就叫做区域划分,我们可以用代码的形式进行表达:
后来男孩子不乐意了,因为男孩子比较胖,总是越过38线,然后被女孩子惩罚,男孩子向女孩子提出意见,女孩子就把三八线做出调整:
男孩子占有[1,45]的区域,中间的[45,55]是缓冲地带,两个人都可以进这个区域,最后的[56,100]的区域是属于女生的。
这就是区域的调整:
对start的end的修改就是对区域的调整。
我们的进程地址空间也是这样的:
代码区,数据区,堆区和栈区都有自己的start和end,也就有自己的范围。
具体的步骤:
我们首先调用一个进程,生成进程的pcb
然后操作系统申请进程空间
进程的pcb存储的有指向进程空间的指针:
进程空间有代码区数据区,堆区和栈区等等的起始位置。
正是这些起始位置划分了我们各个区域。
并且我们的每一个区域中有多个数字,每一个数字可以代表一个地址,所以一个区域中有多个地址。
所以区域的起始地址和区域的结束地址之间的空间就是虚拟地址。
如何进行区域调整:
当进程运行时,我们的代码区和数据区已经固定,已初始化全局数据区和未初始化全局数据区也是固定的,但是堆区和栈区就没有固定,堆区我们可以通过malloc,new来调整区域,栈区我们可以通过调用函数,局部对象的创建也可以调整区域。
所以我们的堆和栈的区域是在不断的进行调整的。
区域修改的本质就是修改栈区和堆区的起始位置,也就是begin和end
现象:进程与虚拟地址空间的关系:
首先,在我们调用进程时,我们会创建进程的pcb
task_struct就是进程控制块,进程控制块中包含有进程的pid,ppid还有进程的状态,优先级等等。
最重要的是进程控制块包含有指向虚拟地址空间的指针:
我们找到pcb的源码
我们在pcb中找到了mm,mm是指向mm_struct的指针,也就是说mm指向了我们的虚拟地址空间。
接下来,我们查看mm_struct的源码:
我们的虚拟地址空间中包含代码区的头尾位置和数据区头尾位置还有堆区和栈区的头尾位置以及命令行参数的头尾位置,环境变量的头尾位置。
虚拟地址与物理地址
上面是我们讲的虚拟地址,但是无论如何,我们的数据都会存储在物理地址(内存)上,那我们的虚拟地址和物理地址(内存)是如何进行交互的呢?
我们编译的可执行程序实质上是存储在磁盘上的一个文件,如图:
我们的可执行程序保存有自己的代码和数据:
程序在进行运行的时候,必须把代码和数据加载到内存中去:
我们的磁盘和内存之间的交互叫做IO流。
IO的单位是4kb,也就是说磁盘和内存之间交互至少是4kb的倍数。
我们可以把内存想想为一个大数组,内存有4GB,IO交互的基本单位是KB,所以我们可以这样写:
假设我们my.exe传递给内存的代码和数据一共有4千字节,内存上存储我们的my.exe的代码和数据的起始位置是0xXXXX,假设我们要访问第n个位置的数据,我们直接访问0xXXXX+n位置的数据即可。
我们的内存和虚拟地址空间如何进行交互呢?
通过页表进行交互:
我们一步一步的分析:
我们首先创建一个变量c,&c得到变量c的地址,这个地址是存储在进程地址空间的虚拟地址:0x12345678,我们把该地址传递给页表,页表通过映射找到内存上存储c的地址0x1111 2222,我们通过该地址找到内存上的c,把c修改为10即可。
页表:
页表起始是非常复杂的,我们画的图起始并不贴切:
假如按照我们画的,因为我们的虚拟地址空间有2^32次方个地址,所以我们的页表也至少有2^32个字节,因为我们页表的左侧和右侧都有一个数据,所以我们至少有2^32*2,也就是32gb的空间,这是不可能的,我们的页表怎么可能比内存还要大。
所以页表的结构形式其实不是这样的,我们以后会对页表进行详解。
线性地址:
从我们的字符c的地址(虚拟地址)到进程地址空间再到页表再到物理内存,这些细节都是由我们的操作系统完成的。
在linux中,因为我们的虚拟地址的排布是从0到0xFFFF FFFF等是2^32次方个地址完全连续的,所以线性地址也叫做虚拟地址。
进程接触不到物理内存:
如图所示,我们的两个进程son1和son2同时运行,进程在进行调用时,必须把磁盘中的可执行程序中的代码和数据加载到内存中去,所以内存中就有了对应的代码和数据,我们创建了进程也就出现了进程控制块,进程控制块中有指向进程地址空间的指针,我们找到了进程地址空间,进程地址空间中保存有虚拟地址,我们使用该虚拟地址通过页表进行映射,映射出与该虚拟地址对应的数据在内存中的存储位置,我们就找到了物理内存,所以物理内存是通过页表和虚拟地址空间进行交互的。
为什么存在虚拟地址空间?
换句话说,能不能让物理内存直接和进程之间交互?
答:并不能,如图:
问题1:越界,假如我们调用的进程代码写错,导致了越界了,那么内存上的空间直接就受影响,甚至导致我们的内存直接挂掉。
问题2:防止恶意进程:
假如我们的内存中保存的有重要的数据,进程可以直接访问内存,恶意进程就可以直接获取到我们内存上的用户名和密码了。
我们的页表不仅仅提供映射的作用,也提供拦截的作用:
对于合法的进程和合法的地址,我们的页表会直接映射,对于不合法的进程或者恶意地址,我们的页表会进行检查并进行拦截,然后把该非法地址交给操作系统处理。
一个地址两个值的原因:
这是我们之前写的代码,我们设置一个全局变量为100,我们创建一个子进程,我们在子进程中对全局变量的值进行修改,我们进行打印之后发现在父进程和子进程中,全局变量的地址是相同的,但是全局变量的值却是不同的。
我们画图进行解释:
首先,我们创建子进程时,子进程会拷贝父进程的pcb以及地址空间,如图:
这时候,子进程和父进程的global_value是共享的,所以他们不仅地址相等,值也是相等的。
当我们在子进程内部对全局变量进行修改时:
因为进程具有独立性,当一个进程对被共享的数据进行了修改,另一个进程共享的进程就发生了改变,这并不叫做独立性,我们如何保证进程的独立性呢?
当共享的数据其中一个进程对该数据进行修改时,我们会在物理内存上首先开辟一个新的空间,然后把值100赋给这个新空间。
然后让页表指向该新的空间。
这时候,当我们子进程对全局变量进行修改时,修改的是新的物理内存上的变量:
这个时候,因为我们的父进程和虚拟空间通过页表进行映射,映射到的物理内存和我们子进程和虚拟空间通过页表进行映射,映射到的物理内存的地址不同,并且值不相同,但是我们的虚拟地址是相同的,所以子进程和父进程(虚拟地址)相同但是值却不同。
写时拷贝:对于共享数据,当任何一方进行对该数据进行修改,我们会首先在内存首先开辟一块新的空间,把原值赋给新的空间,然后修改映射关系,然后再让进程对值进行修改。
写时拷贝由操作系统来完成。
进程的独立性:
进程的独立性体现在两个方面:
每一个进程有自己独立的数据结构:
每一个进程有自己独立的代码和数据:写时拷贝把不同进程的数据进行分离。
进程=内核数据结构+代码和数据。
重新理解地址空间:
首先,我们提出一个问题:可执行程序中有地址吗?(在没有加载到内存的情况下)
有,例如:
int add(int a, int b)
{
return a + b;
}
int main()
{
int a = 0;
int b = 1;
add(a, b);
}
我们写一个简单的add函数,然后进行调试:
我们转到反汇编:
所以,在程序运行之前,可执行程序就已经有地址了。
链接:把我们的代码和库关联起来。
链接的本质:把我们使用的库函数的地址放到可执行程序中。
在程序运行之前,程序中存在的地址就叫做逻辑地址。
虚拟地址空间的编址方式,不仅我们的操作系统要遵守,我们的编译器也要遵守:
编译器在对代码和数据进行编译时,就是按照虚拟地址空间的方式对我们的代码进行编址的。
如图所示:
我们的my.exe中的代码和数据在磁盘中就是以32位为地址空间进行编址的。
我们把磁盘中的代码区和数据区的代码和数据画出来。
在生成可执行程序之前,每一个代码或者数据都有自己的虚拟地址,这个地址也叫做逻辑地址。
我们在调用可执行程序时,会把磁盘中的代码和数据加载到内存中。
这些函数和变量只要加载到物理内存,他们也就有了对应的物理地址。
我们现在有两套地址:1:标识物理内存中代码和数据的地址
2:在程序内部互相跳转的地址--虚拟地址
我们物理内存中存储的这些代码和数据有两套地址,跳转的地址,例如这里的0x1122,0x3344,0x2233等地址,第二套地址是物理地址,我们的代码和数据被加载到内存时的具备了物理地址,这个地址我们是看不到的。
我们调用该进程,进程pcb指向虚拟地址空间,因为我们的物理内存中有虚拟地址,这个地址是可知的,所以我们的虚拟地址空间也就匹配了一个虚拟地址。
我们的cpu并不会和物理内存交互,这里cpu和虚拟地址空间的虚拟地址进行交互,因为我们的虚拟地址和物理地址在页表已经产生了映射:
我们的cpu通过与虚拟地址空间进行交互,虚拟地址通过页表与物理内存进行交互,我们的cpu也就找到了对应的物理内存,所以我们的cpu就没有见到过物理内存。
总结:在进程调用之前,首先在磁盘中形成有可执行程序,可执行程序有代码和数据的地址,当我们调用进程时,磁盘中的可执行程序的代码和数据会加载到内存中,这时候代码和数据就有了物理地址。
我们进程在调用时,进程的pcb找到虚拟地址空间,虚拟地址在物理内存中是已知的,我们构建虚拟地址空间,cpu对虚拟地址空间中的地址进行处理,因为虚拟地址空间的地址通过页表和物理地址进行交互,cpu就得到了数据,cpu就计算数据。
为什么要使用虚拟地址空间:
1:防止非法进程对内存访问。
2:保证进程的独立性。
3:让进程以统一的视角,来对代码和数据进行编译。
进程控制:
我们的虚拟地址空间的4gb有3gb是留给用户使用的,有1gb空间是留给内核使用的。
深入理解fork函数:
fork函数:从进程中创建子进程,新的进程是子进程,原进程是父进程。
前两条的意思:我们会把父进程的地址空间,pcb,页表拷贝给子进程,并让子进程的地址空间与页表建立映射。
第三条:系统进程列表我们可以理解为一个指针数组:
这里的hash数组中存储的是指向进程pcb的指针,我们可以通过pid访问hash数组找到指针,访问进程pcb,找到进程的属性。
所以这里的意思是把进程的pcb以指针的形式传递给hash数组。
如何理解这三个问题?
问题2:我们知道,一个父亲可以有n个孩子,但是一个孩子最多只能有一个父亲,所以子进程的父进程是唯一的,我们不需要寻找,所以子进程的返回值就是0,父进程可以有n个子进程,所以我们需要标注我们找的是哪一个子进程,所以返回子进程的pid。
问题1:我们提出一个问题:fork是函数吗?是,fork函数的实现是在操作系统的。
我们画图进行解释:
这就是我们fork函数的底层实现。当返回pid前,我们的核心代码已经执行完毕,也就是说,在我们返回pid的时候,我们的进程已经被创建好了,已经在运行队列中等待调度了。
因为子进程已经创建完毕,于是我们开始分流。
子进程和父进程同时在fork函数中,并且同时返回,所以就出现了不同的返回值。
问题3:
返回的本质就是写入,这里是写时拷贝,假设我们的子进程先返回,于是就对id进行写入,因为进程具有独立性(我们都id新开辟了一段空间留给子进程,在该空间中对id值进行修改),所以我们的子进程和父进程的id的虚拟地址是相同的,但是物理地址以及映射关系是不同的,所以出现了一个id,两个值的存在。