Linux操作系统学习---进程地址空间
前言:
在学习c,c++这些偏底层的语言时,我们常常会对一个变量取地址,一遍对他进行一系列的操作 . 可是 , 这真的是真实的物理地址吗 ? 其实并非如此 , 通过了解进程地址空间,我们就能解开这个困惑.
一、虚拟地址空间的概念:
同地址,不同值的代码示例:
下面通过创建子进程来看一个很离谱的例子
代码核心逻辑:
代码执行结果:
反过来思考, 如果语言层面用 & 获取的地址是实际的物理地址 , 那岂不是严谨的计算机世界存在一个明显的逻辑漏洞!!! 当然不会 , 因此 , 他必然不会是物理地址,而是虚拟地址 .
由于很多时候和进程的创建有关 , 所以又叫进程地址空间 . 虚拟地址空间和进程地址空间是一个东西!!!
感性的的理解虚拟地址空间(老板画饼):
想象一个场景 , 一个老板为了激励员工 , 分几次独立的和好几个骨干员工谈话 。
第一次对小王说 : 小王呀,好好干,到时候让你升职当经理 ,于是小王工作的比平时更加卖力了 ;第二次对小张说 :小张啊 , 好好干 , 到时候给你涨工资,于是心花怒放的小张工作的比生产队的牛都要勤快了;第三次对小李啊 ,好好干,我正考虑着要不要给你的年终奖来个大的,于是惊诧不已的小李满心都是他的宝贝年终奖的开始埋头苦干了。
可是,所有的承诺都不用当时兑现,但是群情激昂的员工们创造的更高的价值却是实实在在的造福了公司这个系统。
也可以这样理解虚拟地址空间:整个操作系统就是一个公司,每个进程就是员工,老板画的饼就是操作系统分配的虚拟内存空间。这个空间到底有没有呢?暂时不知道,但是进程们确确实实认为自己拥有了这部分空间。
实现虚拟地址空间的关键技术:
页表的存在:
既然有虚拟地址,那必然也对应的有一个物理地址,也就是存在映射关系 。用来保存虚拟地址到物理地址的映射关系的东西叫做页表。
- 页表也是一个数据结构,每个进程都有一个指针指向他们自己的页表内容,以此来访问共有或者私有的代码和数据。
- 子进程刚刚创建时,父子进程共用一个页表,也就是存有相同的一个指针变量。
- 当进程访问虚拟地址时,操作系统就可以根据页表的内容映射到实际的物理地址空间,进而完成对实际物理内存的各种操作。
进程内存描述符:
Linux操作系统内核存在一个叫做mm_struct的数据结构,中文翻译就是进程内存描述符。它的作用是来适合描述当下系统内的虚拟内存空间的分布情况,其一部分成员变量就是标识了每个区域起始和结束地址的整形变量。
下面这样经典的内存分布图里各个区域分界的分界值就由mm_struct来维护。
注意:在学习c和c++时我们直接称它为内存分布图其实是不准确的,但从语言层面的实用角度来说也够了。实际上我们访问到的地址都是虚拟地址,物理地址从硬件的设计层面就被隐藏了!!!
理解分配空间:
在操作系统运行的过程中,会伴随着各个内存区域大小的不断变化,有了上面的认识我们就知道,当一个区域的空间发生变化,其实让进程内存描述符mm_struct更改一下成员变量里的边界值就可以了。
虚拟地址存在的意义
虚拟的地址的存在,使得进程在访问具体内存时必须拿着虚拟地址到自己的页表里去查找实际的物理地址 ,相较于直接访问物理地址,势必就会损失一定的效率。但是,存在即合理,尤其是这种认为设计而非天然存在的事物,下面进行探讨:
1,以有序的方式管理无序的物理地址:
首先明确一点,物理内存空间的申请在进程的角度来看,是近乎无序的。这样并不方便上层开发者的理解和操作,毕竟,如果通过程序打印出来的地址东一块西一块的也就很难区分什么栈区堆区了
比喻:
就像存在一个小城市,除开少数的商城和公园等公共区以及办公写字楼、工业园之外,还有散落在城市各个地方的居民区 。
如果十分耿直,只拿着一张城市地图想要找到具体的那个居住区就很困难,毕竟好不容易找到的一个具体的小区却可能并非预期的那一个,但是在看地图时又会被这个小区周围的各种公共区域给混淆。
但是,如果有一张地图直接记录了所有小区具体的位置,那就很容易找到具体的小区了。即便各个不同的小区所在的区域并非毗邻
在这个例子里,可以想象居民区就是一种认为规定的具体的内存空间类型,比如栈区 。但是,不同的小区之间的物理位置大概率不会在一起,就像栈区里连续的的内容也许在物理地址上是不连续的。
总结:本就零分布的内存空间根据其性质划分为了具有多种性质的虚拟地址空间(堆区、栈区、代码段……),虚拟地址空间通过页表可以映射到具体的物理地址空间。有了虚拟地址空间就可以操作物理地址,因此,对散乱无章的物理空间的操作就转化为了对井井有条的虚拟地址空间的操作
2、提升了操作系统的安全性:
我们曾经说过,外壳程序存在的价值之一就是一定程度上保护操作系统的安全(拒绝非法请求)。虚拟地址的存在也是如此。
举一个我们在学习c语言时会碰到的例子,那就是 char* str = “……”这样的写法,即让一个无限制的字符指针来指向一块不可修改的空间。
如下图,c++由于存在更严格的类型检查让这个情况甚至都没来得及发出去,但是在c语言里你的确可以试图通过字符指针来修改字符串,但是,运行程序后就崩溃了!!!
可以理解为通过虚拟地址映射到物理地址后,发现这个物理地址所指向的空间的权限是只读,但由于已经深入到硬件层面,问题严重,就给我们上层用户来了个程序崩溃的惩罚!
3、实现进程管理和内存管理的解耦合:
我们在计算机上执行操作,本质上就是让一个个的进程进行一系列的行为。由于页表的存在,当我们想要访问内存空间时,只需要关心虚拟地址空间,至于最终如何映射到物理地址空间,那是另外的事,不用我们操心
从设计的角度来讲,进程的设计(软件层面)就不用关心如何处理实际的物理内存,而内存的管理(硬件层面)也不用关心如何协调分配进程的空间,只需要接受页表的映射。这样分开来设计,提升了操作系统的可以移植性和可维护性。
进程管理的设计和内存管理的设计独立开来,但又通过某些设计来产生交互(页表),就达到的设计上的解耦合
细化虚拟地址空间的管理:
1、如果只有mm_struct的缺陷:
通过前面的认知我们知道了虚拟地址空间里各个区域的划分由进程内存描述符管理,在Linux下叫做mm_struct。
其他区域好理解。比如:栈区遵循后进先出原则,发生修改时只用改改边界就可以;代码段在程序加载时确认,于程序运行期间不修改……等等都很合理,似乎mm_sturct已经够用了。
但是堆区怎么办呢,他的空间没法在程序加载时确认,而是会在程序运行期间时不时的发生修改,并且堆区空间的申请和释放并不遵循栈区那样后进先出的规范,呈现出碎片的开辟和销毁模式。这样看来mm_struct过于简单的使用几个边界完全无法胜任此时的需求。
2、mm_struct和页表之间——vm_area_struct
因此,在Linux操作系统中,进程PCB和页表之间不仅隔着一个mm_struct,mm_struct和页表之间还隔着一个vm_area_struct的数据结构!!!
上图中多出来的vm_area_struct就是真正管理各个区域自己的内存空间分布的数据结构, 作为链表,申请一次空间就新增一个节点。
而之前的mm_struct其实是一个类似于蓝图的数据结构,更加的宽泛,描述了vm_area_struct的整体情况,比如各个不同类型区域的边界。
回答刚才的问题:堆区在程序运行时是不是开辟的随机空间如何管理?当然是每次开辟空间就新增一个链表节点!!!
理解相关的现象:
有了上述所有的知识储备,我们就可以粗略的理解以前见识过的一些现象了!!!
1、写时拷贝:
在学习进程时我们曾使用过系统调用fork()来创建子进程,可是对于一个变量为何可以有两个返回值理解的比较浅显,只知道是写时拷贝,下面结合页表和虚拟地址空间来进一步认识这个现象。
上图是比较详细的解析,但总结下来就两点:
- 如何发生写时拷贝:当一个进程对公共数据进行修改时,被处于只读权限的页表地址检测到 ,进而告诉操作系统进行写实拷贝:重新分配空间、拷贝原数据、继续正常操作。
- 为啥一个变量可以接受fork()两个不同的返回值:当fork()函数执行后,返回一个值的过程也是赋值的过程,相当于父或子进程对这同一个值进行修改,于是谁先赋值成功,谁就先拥有新的变量的地址。也就是说当fork分流后,起初的值在内存空间上来说已经不是同一个了!!!
2、进程挂起:
曾经说过进程挂起就是在内存资源短缺的时候将睡眠状态,甚至是运行状态的进程的大部分内容暂时换入磁盘分区。如何换入呢?其实就是将被换走的进程的页表里的物理地址给抹除,以此让其他进程可以映射这块物理地址,申请更多物理内存
3,进程独立:
我们知道进程具有独立性,至于为啥,其中的原因就是:虽然起初父子进程的大部分内容相同,但是只要其中一方试图修改共有的内容,就会发生写实拷贝,进而将页表里同一个虚拟地址映射的物理地址修改,避免了进程间的互相干干扰。
总结:
了解了进程地址空间(虚拟地址空间)这一块的内容后 , 我们就可以从以往语言学习里对指针的理解发生一定程度的革新 ; 此外 , 也可以理解诸如为啥会存在同地址却不同值的情况.这对于后续的操作系统的学习都是很有帮助的,当然了,这还只是皮毛,还有更多内容等着我们去探索!!!