一、 进程地址空间
1.概念引入
- 指针指向的地址是内存中的地址吗?下面我们用一个实验来证明一下。
- 先来写程序看一下程序的地址分布。
- 查看并分析运行结果:
- 可见是符合预期的结果的。
说明:在Windows下的内存分布可能不一样,实际情况要看具体的操作系统,如果细究,在各个区域定义一个变量,打印其地址进行验证。
- 创建子进程修改一个指定变量,然后查看其地址。
- 实验代码
再回顾一下之前的知识:
- fork创建子进程,对父进程返回子进程的pid,对子进程返回0。
- 父子进程对只读的代码共享(写时拷贝)。
说明:这里我们是对id变量的修改。
- 运行结果与分析
说明:
- 物理地址是内存某一位置的实际地址,且物理地址是唯一的(因为就一块内存)。
- 此处假设是物理地址,那么其中的值必然是相同的,但是这里不同,显然不是物理地址。
- 结论与引入:既然不是物理地址,那存放的是什么地址呢?我们一般称之为
虚拟地址/线性地址
。
- 那如何解释上述地址相同的现象呢?
2.基本概念
虚拟地址存放的空间
,我们一般叫做进程地址空间
,如上图。
说明:
程序中变量存的是虚拟地址,不是物理地址,那我们之前学的就是错的吗?
解释:
- 很显然不是,是因为所占的角度不一样。
- 首先写代码时我们是站在
应用层
的角度进行考虑的;- 其次我们目前学操作系统,是从
偏硬件
的角度进行理解的;- 而且学了进程地址空间,只会让我们理解的更深,更加偏向底层。
- 因此我们之前学的不是错的,只是理解尚浅。
而且虚拟地址一定能够转换为物理地址,如何转换呢?—— 页表
- 原理:类似哈希表的结构,其虚拟地址->物理地址。
现在我们尝试画图解决一下之前的问题:
- 对上述程序的指定行进行解读:
- fork创建完子进程未返回。
- 通过对父进程进行拷贝(浅拷贝),对代码和数据进行拷贝,生成跟父进程几乎一样(
数据与代码的虚拟地址完全相同
)的进程地址空间(独有的pid不一样
)。- 对页表进行拷贝,生成一模一样的页表。
- 此时的父进程与子进程的id变量指向同一块物理地址。
图解:
- fork返回,对子进程返回0,对父进程返回子进程的pid。
- 写入过程中由于父子进程指向同一块物理空间,因此操作系统给子进程再申请一块物理空间,并将子进程页表的物理地址进行修改,虚拟地址不变。
- 对子进程id变量的物理空间写入0。
- 对父进程id变量的物理空间写入子进程的pid。
图解:
- 那如何解释上述地址相同的现象呢?
3.深入概念
3.1 初识信息交互
问题1:
CPU 、内存、输入输出设备如何进行交互?
解释:
- 通过"线"进行交互。
- 线细分为三种,地址总线,数据总线,控制总线。
- 线简单分为两种,CPU与内存的线称为系统总线,内存与输入输出设备的线称为IO总线。
问题2:
线是什么?为什么要用线?
解释:
- 线简单理解就是用于进行数据传输的通道。
- 线的主要作用是确保计算机组件之间能够进行协调通信与合作。
- 用线的另一个原因在于生产各个部件的厂商不同,但各个部件又需要结合才能使用,因此只需用线将不同的部件结合,在整机效率不变的基础上提升了生产效率。
问题3:
从硬件的角度解释,计算机是如何产生01序列的?
解释:
- 低电频产生低位0,高电频产生高位1。
- 每根线可产生0/1,两种电频,在32位(32根线)的机器下,能产生232种电频信号,因此32位机器最多存放232 byte的数据也就是4GB。
3.2 区域划分
前面我们只是初始概念,下面我们通过问题来对概念进一步的理解。
- 区域划分是什么?请结合进程地址空间进行思考。
下面博主讲个故事引入理解:
回想起美好而又纯洁的青春,一定发生过类似这样的故事。
小帅:hello!大家好,我是小帅。
小美:hi ! 大家好,我是小美。
- 那些同桌之间发生的事:
小帅与小美是同桌,两个人用的是双人桌,小帅有点喜欢小美,想着吸引小美的注意,于是就趁着小美不注意,就用胳膊戳小美,惹的小美不知该如何是好,于是抱着忍一时风平浪静的心态就没搭理小帅,小帅看着无动于衷的小美,心想按照剧本应该理一下我猜对啊,难道得玩把大的?年轻的我们总是想着吸引喜欢人的注意,而不知道出发的方式,于是小帅又趁着小美不注意,正想要顺走小美最喜欢的笔,此时被小美发现了,小美这下子忍不住了,想着老娘不发威,你当老娘是病猫,于是揪着小帅的耳朵,给小帅了一顿"奖励" , 小帅暗想终于理我了,于是跪地求饶,说着姑奶奶绕了我吧,你要我干什么我都答应你,于是小美想着,这臭小子不知到哪回就越界了,于是就说这样吧,我画一条线,你敢越过这条线,就等着挨打吧!小帅嘴上说着,我一定不会再越了,可心里怎么想的,没人知道……
故事的重点在于——划线的操作。
- 划线的本质就是区域划分。
- 这样做的好处就是
防止越界
,使空间分配合理化。 - 当访问的范围不在自己的区域时,直接进行报错,在自己的区域时,就正常运行,这正是小美与小帅的故事背后的逻辑。
- 在
进程地址空间的代码区,常量区,数据区,这三个区域的划线操作是在编译期间就确定的。
,也就意味着编译时就进行划线。 - 而
堆区,栈区,其边界是动态变化的,随着运行时变量的开辟与销毁而变化
。意味着边运行边划线。
那在操作系统中是如何划线的呢?
struct mm_struct
{
//....
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
unsigned long start_code, end_code, start_data, end_data;
/*维护代码区和数据区的字段*/
unsigned long start_brk, brk, start_stack;
/*维护堆区和栈区的字段*/
unsigned long arg_start, arg_end, env_start, env_end;
/*命令行参数的起始地址和尾地址,环境变量的起始地址和尾地址*/
//....
};
说明:
- 进程地址空间本质上就是一个数据结构对象。
- 那操作系统如何对对象进行管理呢?当然是先描述,再组织,最后形成与PCB类似的mm_struct对进程地址空间进行管理。
- 虚拟地址的分布是规律的,那物理地址呢?
- 首先虚拟地址与物理地址通过页表的映射进行联系起来。
- 其次从进程的角度来看,虚拟地址已经是很便于进行管理。
- 而且只需管理虚拟地址,物理地址也被间接的被有规律的管理了起来。
- 因此物理地址没必要再是有序的。
- 总的来看可以认为
物理地址是乱序的存储,有序的被间接管理
。
- 从操作系统的角度看,程序最终如何管理变量,是变量名吗?
- 首先程序代码由编译器与链接器处理之后,
只剩下了二进制指令,不存在变量名。
- 其次从内存的角度看,
获取到了地址,即获取到了数据
,因此在底层是通过不断更改地址来获取数据的。
3.3 进程地址空间
进程相互之间是如何做到独立的?
我们再来讲一个故事:
-
从前有一个大富翁,身价十个亿,而且到处沾花惹草,但是却让3个女人,心甘情愿地为他生了三个儿子,可见"超"能力是多么的有魅力。
-
由于这位大富翁是一位时间管理大师,对三个儿子照顾的很好,竟然让这三个儿子都不知道彼此之间的存在。
-
随着大富翁一天一天的老去,也开始思考如何分配遗产,由于对三个儿子都很疼爱,于是做个一个这样的决定:分别对三个儿子说:儿子," 爹老了,等爹去世了,这十亿资产就是你的了。"
-
因为都认为父亲只有我这一个儿子,因此三个儿子都对此深信不疑。
-
于是有一天
儿子甲
经济困难,来找大富翁要20万美金,大富翁二话没说,直接往儿子甲
卡里打了30万美金,儿子甲屁颠屁颠的走了; -
儿子乙因为快要上大学了,于是问大富翁要了10万美金,大富翁也二话没说,直接往儿子乙身上打了20万。
-
儿子丙因为一事无成,于是整天想着继承大富翁的十亿家产,于是选择一天,鼓足勇气的给大富豪说,“爹,我现在就想继承十亿家产”,大富翁很是生气,于是就怼过去,“你爹还没死呢!就想着继承家产了。” 就直接让儿子丙滚了,儿子丙也觉得没有什么不对,就走了。
…… -
故事到这里就讲完了,回归到操作系统,这里的
大富翁的十亿资产对应的是一整块物理内存,大富翁对应的是操作系统,而这儿子对应是进程
。 -
进程在申请内存时,本质上是操作系统申请的,由操作系统决定是否分配给进程空间,而进程与进程在申请内存之间没有联系。
-
其次每个进程有属于自己的进程地址空间,也就意味着有着各自的虚拟地址。
-
而物理地址实际上是由操作系统进行分配与管理的,两者之间没有联系。
-
但是由于页表的存在,建立了映射关系,可以将虚拟地址与物理地址联系起来。
-
但进程与操作系统还是各管各的,只不过通过页表的修改而将虚拟地址与物理地址统一起来。
问题:
进程为什么不直接在内存上使用物理地址,而非得通过虚拟地址进而使用物理地址?
解释:
- 虚拟地址也是地址,在没有通过页表映射的物理地址之前,操作系统可以进行一层检查,从而过滤掉非法的申请信息,就比如在32位的机器下,申请4GB的内存。
- 内存只有一块,也就意味着物理地址是唯一的,如果直接使用物理内存,也就意味着将内存地址暴露给了用户,一旦用野指针修改其中的未知区域,报错可能会导致整个系统的瘫痪!
- 结构相同的进程地址空间,即虚拟地址存放的位置,意味着可泛化,统一的管理,从而便于进程对进程地址空间的管理。
- 一般我们将进程的操作称作进程管理模块,而操作系统的管理内存的模块称作内存模块,两者通过页表和进程地址空间,即物理地址与虚拟地址进行解耦合,更加的独立。
3.4 再识页表
问题1:
页表如何判断虚拟地址非法?
解释:
- 页表记录的信息不只有虚拟地址与物理地址的映射,还记录着虚拟地址的权限。
- 比如当修改常量区的代码时,这时页表通过其权限判断只有可读权限,由此直接报错,不进行修改。
问题2:
页表的地址是虚拟地址还是物理地址?页表的地址存在哪?
解释:
- 不可能是虚拟地址,因为页表存的是虚拟地址与物理地址的映射,如果其还为物理地址,相当与自己存自己的虚拟地址与物理地址的映射,就套娃了,其次也没必要进行存储。
- 进程加载到CPU上,页表在进行加载地址时,会将其地址放在
cr3寄存器
中进行加载,放的肯定是物理地址,在进程运行完毕时,会将其地址放在进程的上下文
进行带走,以便于下一次的恢复。
缺页中断
这就要再从父进程拷贝子进程具体过程再来理解了。
- 子进程在拷贝父进程的时,会共享数据和代码。
- 子进程在拷贝父进程页表时,栈区的地址在拷贝过程中其权限会发生转变,由写权限变为读权限。
- 在对子进程栈区的数据进行修改时,由于是读权限且在栈区,这时操作系统会再帮子进程申请一块空间,并将其物理地址修改成申请的物理地址再将子进程页表相应的权限改为写权限。
总的来看,在发生页表的当前权限与实际权限发生冲突时,会触发缺页中断。
进程挂起
- 在Linux中我们只看见过R,S,D,T状态,那挂起状态我们并没见过,那有没有呢?
- 从定义的角度来看,所谓进程挂起,也就是进程的代码和数据,不在内存而在磁盘当中。
- 那如何判断数据是否在内存呢?
- 其实很简单,在页表上打一个标记,比如1表示在,0表示不在。
举个例子:当你启动一个大型游戏,比如说原神。
- 在原神的启动时,先生成其进程PCB,进程地址空间,页表。
- 在加载代码和数据的过程中,我们的内存并不可能加载所有的数据,这样内存会爆的。
- 因此内存采用一种
惰性加载
的方式,即用什么加载什么。- 因此在用的时候,查看页表其虚拟地址对应的物理地址是否在内存中,如果不在,操作系统就会把相应的数据和代码加载到内存中,然后再进行执行。
总的来看:这里的页表的
本该存在
与实际不存在
发生冲突,从而触发了缺页中断。
总结
问题1:我们再来看一看进程是由什么组成的?
解释:在原先的PCB数据结构对象 + 代码和数据的基础上,再加上了进程的上下文,进程地址空间(mm_struct),页表。
- 初步认识了进程地址空间与页表,并对程序的底层有了更深的了解。
今天的分享就到这里了,如果感到有所帮助,不妨点个赞鼓励一下吧!