目录
2.2虚拟地址空间(struct mm_struct)和页表是什么?
2.3为什么要有地址空间和页表?(包括对延迟分配策略的解释)
2.4可执行程序在形成时就已经有虚拟地址了,所以不运行一个程序也有虚拟地址。但不运行一个程序,操作系统是不会为它分配地址空间和页表的
3.加载本质就是创建进程,那么运行进程时是不是必须把程序的所有代码和数据加载到内存中,并创建内核数据结构建立映射关系?
3.3既然可以分批加载,那么可以分批换出吗?(包括挂起状态的解释)
1.进程的重新理解
学习地址空间之前,我们认为进程=进程的代码和数据+task_struct(即Linux下的PCB),
现在我们认为进程=进程的代码和数据+内核数据结构。详细的说就是进程=进程的代码和数据 + task_struct + mm_struct(即虚拟地址空间)+ 页表 + 物理内存(即虚拟地址空间上的位置通过页表映射到物理地址空间上的位置)。
这里补充一点,磁盘也是有地址的,这是OS中的文件系统部分为磁盘划分的,也就是说文件系统能够知道磁盘空间某一个区域的地址是什么。
2.虚拟地址空间
2.1奇怪的现象
进程相关属性那篇文章里讲了fork会返回两个返回值的原因:在fork函数内部,执行到return返回值那一步,此时已经创建了两个执行流,即创建了两个进程,所以return返回值这一步被两个进程都执行了一次。采用同样的方式也可以理解一个变量可以同时表示两个不同值的原因:两个进程(执行流)互不干扰,表面上是同一个变量,但实际上它们是两个不同的变量,每个变量只属于各自的进程,只是名称相同。但父子进程如下图1中代码,两个进程都在运行时,会发现各自进程打印出来的变量(即下图的g_val)的值不同,但变量的地址相同,如下图2中执行的结果。这是什么情况呢?为什么同一个地址,同时读取时出现了不同的值呢?
2.1.1现象出现的原因(写时拷贝)
从上面的情景可以分析出,之前我们学习的地址都不是物理地址,而是虚拟地址(线性地址)如上图中的0x601054就是虚拟地址,本文所说的进程地址空间也不是物理地址空间,而是虚拟地址空间,实际上也确实如此。几乎所有的语言,如果它有“地址”的概念,从语言层面上说,这个地址一定不是物理地址,而是虚拟地址(线性地址)。虚拟地址就是虚拟地址空间上的地址。
如上图,开始只有父进程,即左边进程的task_struct和mm_struct以及页表,fork之后创建了子进程,即右边的task_struct和mm_struct以及页表,由于子进程继承于父进程,许多属性如地址空间和页表都完全和父进程相同,所以在没有修改子进程中的变量(也就是上图中的g_val)时,两个进程的g_val在虚拟地址空间和物理地址空间的位置完全相同(物理地址空间上的位置是虚拟地址空间上的位置通过页表映射得到的)。但当子进程的g_val修改时,操作系统就会修改子进程页表的映射关系,并将修改后的g_val存储到新映射的物理地址空间的位置上,这种策略也叫写时拷贝。所以表现出父子进程g_val的虚拟地址相同,但地址上的值不同,因为本质上这是两个变量,只是两个变量的虚拟地址相同,物理地址不同。接收fork返回值的变量同时表示两个不同的值也是写时拷贝,如下图。
2.1.2写时拷贝
创建子进程,给子进程分配对应的内核结构,必须子进程自己独有,因为进程具有独立性。理论上,子进程也要有自己的代码和数据,可是一般而言,子进程没有加载的过程,子进程是fork创建的,也就是说,子进程没有自己的代码和数据。所以,子进程只能”使用“父进程的代码和数据。代码都是不可被写的,只能读取,所以父子共享没有问题。而数据是可能被修改的,所以必须分离。那对于数据而言,创建进程的时候就直接拷贝分离?这当然是不合理的,子进程可能根本就不会用到数据空间,即使用到了,也可能只是读取而非修改,即使需要修改,但不是立刻修改的情况,直接拷贝依旧会有占用内存,但不发挥价值的弊端(无效占用内存),所以直接拷贝分离会造成大量空间的浪费。所以操作系统采用了写时拷贝的策略,即修改数据时再拷贝分离,本质上这就是延迟分配或者说延迟申请的策略。
所以为什么要使用写时拷贝?1.因为有写时拷贝技术的存在,所以,父子进程得以彻底分离,完成了进程独立性的技术保证。2.写时拷贝,是一种延时申请技术,可以提高整机内存的使用率。
2.2虚拟地址空间(struct mm_struct)和页表是什么?
1.Linux中的PCB(进程控制块)即struct task_struct中有一个成员mm_struct*mm指针指向虚拟地址空间即struct mm_struct,所以可以通过进程的PCB找到虚拟地址空间。如上图,虚拟地址空间是一种存在于内核中的数据结构,被称为struct mm_struct。默认虚拟地址空间的范围和下文中的排布一样,是从0x0000 0000到0xFFFF FFFF。它里面至少要有各个区域的划分,如上图中的堆的起始位置(heap_start)和堆的结束位置(heap_end)。划分的方法也无非就是加或者减start和end的值,那么根据什么划分呢?事实上在程序文件加载进内存成为进程前,因为可执行文件需要被编译,在编译阶段,文件里的每一行代码每一个数据都已经被编译器完成编址了,这个地址也是虚拟地址,假如main函数的入口地址为0,退出出口地址为100,那么代码区的start和end就分别为0和100。将程序,即每一行代码和数据加载进内存时也不是我们想象中的非要在物理内存上紧挨着,而是哪有内存就加载到哪,所以语句之间是乱序的,是不紧邻的,但因为有页表的映射,CPU每次读写都只认虚拟地址,所以在CPU看来进程的每一行代码和数据依然是有序的。那么如何映射页表的呢?首先认识一点,物理内存是不知道自己的物理地址是多少的,只有OS知道某个物理内存的物理地址是多少,因为物理地址是由OS中的文件系统模块定义的。每一行语句加载进物理内存后,都会获得一个OS定义的物理地址,因为是OS控制将你加载进物理内存的,所以OS是知道你存在了哪个物理地址上,将这个OS定义的物理地址填充到页表上,同时在物理内存中存储的不止语句,语句后跟着该语句的虚拟地址,此时OS识别到后将语句的虚拟地址也填充到页表上,那么页表的映射关系就建立完毕了。此时虚拟地址空间的每一个区域的范围划分完毕,页表映射也建立成功,此时就可以开始执行进程的代码了,找到第一行语句代码的虚拟地址,首先判断该虚拟地址是否处于之前划分的分区范围内,比如代码段的范围,即start和end分别是0和100,但该语句的虚拟地址为200,此时就直接终止进程,如果虚拟地址是合法地址,那么根据页表找到并执行物理地址上的指令,这也是为什么还得有地址空间这个内核数据结构的原因,因为地址空间的范围划分能够判断一条指令是否合法,当然地址空间除了范围的划分还有其他属性,所以肯定还有其他的功能。然后继续执行第二行代码的指令,如果某行代码是执行一个函数,即是一个跳转指令,那么该指令的内容就是一个虚拟地址,CPU读取到该虚拟地址就继续根据页表映射拿到物理地址上的指令并执行。最后还有一个问题,CPU如何拿到进程的第一条指令,或者说如何知道第一行代码在哪呢?不同OS下情况不同,有些OS是硬编址的,如默认从代码区的首部虚拟地址开始。
2.每个进程的页表不止一个,但单个页表本质就是一个简单的哈希表,key是虚拟地址,value是物理地址,和地址空间一样,只有程序变成进程后才会被操作系统创建出来。注意:通过页表不仅仅可以映射到物理地址空间上(即物理内存),还可以映射到磁盘上。
3.地址空间和页表是每一个进程都私有一份。只要保证每一个进程的页表,映射的是物理内存的不同区域,就能做到进程之间不会互相干扰,保证进程的独立性。
4.如上图,物理内存是以4KB为一个模块,被分为许多个块的,这个块也叫页框。假如物理内存为4G,那么就会被分为100W块,我们需要将每一块管理起来,如何管理呢?通过struct page这种内核数据结构,每一块4KB都需要用一个page结构体变量管理,管理物理内存也就变成了管理100w个page结构体变量。不止物理内存,在磁盘上的可执行文件,文件的代码和数据,也就是文件的内容按照区域划分也被以4KB为一个单位分为了许多块,这个块叫页帧。也正是因为磁盘和内存都是以4KB为单位被分为许多块,所以我们才说OS控制进行IO的时候每次读写4KB内容。
5.如上图,我们之前说每个进程的页表不止一个,事实上确实如此,假如是32位机器,那么32位地址可以被分成3个部分,第一个部分为最高位的10位,第二个部分为次高位的10位,第三个部分为最低位的12位,OS会为每个进程分配两个页表,分别是一级页表和二级页表,因为10位数字可以表示2^10个地址,所以每个页表有2^10行,每行表示一对key和value。32位地址中最高位的10位给一级页表使用,即在一级页表的key中填上32位地址中最高位的10位,然后一级页表的key通过一级页表映射到二级页表的某个key的位置上。32位地址中次高位的10个位给二级页表使用,在之前通过一级页表映射到的二级页表的key上填入32位地址中次高位的10个位,然后次高10位的地址通过二级页表映射到物理内存上的某块4KB的首地址上,我们最终需要访问的32位地址上的数据肯定在这4KB的某个位置,但我们如何知道具体在哪呢?通过32位地址的最低12位,这12位是用于表示偏移量的,2^12刚好是4KB,如果最后12位是0000 0000 0000 0001,那么需要访问的数据就在首地址偏移1字节的地址上,如果最后12位是1111 1111 1111 1111,那么需要访问的数据就在首地址偏移4KB-1的地址上。那么偏移量一定能够保证找到最终要访问的数据吗?答案:没错。因为上文也说过,编译器在编译文件时也是按照4KB为单位将文件的内容划分成许多块的。那为什么要设计一级页表和二级页表,直接用一个页表完成映射不行吗?答案是不行。因为如果只有一个页表,假如是32位机器,那么key中有2^32个地址,value中也有2^32个地址,那么总共就有4G*2个地址,32位机器下一个地址是4字节,那么一个进程的页表就需要4G*2*4字节,即32G空间。一个进程的页表就需要如此大的空间肯定是不合理的。那64位机器如何完成页表映射呢?和32位机器的原理一样,只不过又多了几级页表。
2.3为什么要有地址空间和页表?(包括对延迟分配策略的解释)
1.保护物理内存,因为物理内存是可以随便读写的,容易误操作,加入虚拟地址空间和页表后,因为虚拟地址空间和页表都是由操作系统创建并维护的,所以凡是使用虚拟地址空间和页表进行映射,一定是在操作系统的监管之下,也就保护了物理内存中的所有合法数据,包括内核和其他进程的相关数据。凡是非法的访问或者映射,操作系统都会识别到,并且操作系统会终止这个正在非法访问的进程。比如我们写程序时修改一个字符常量,程序就会崩溃。一个字符常量存储在虚拟地址空间上的常量区,根据页表的映射关系将字符常量映射到物理内存上,物理内存当然是可以随便读写的,所以不可以对字符常量写是因为从软件层面上限制了这个修改操作,也就是因为页表中存在权限,并且对字符常量设置的权限是只读的。
2.因为有地址空间和页表的存在,所以可以在物理地址空间上对数据进行任意位置的加载,也就完成了内存管理模块(比如开辟空间)和进程管理模块(比如调度进程)的解耦合,也就可以采用延迟分配空间的策略提高整机的效率。什么叫延迟分配策略呢?本质上,因为有虚拟地址空间的存在,所以上层申请空间(如new或者malloc),其实是在虚拟地址空间上申请的,物理内存甚至可以一个字节都不给你,而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法帮你申请内存,构建页表映射关系,然后再让你进行物理内存的访问。使用这种策略几乎可以使物理内存的有效使用率是100%。
3.可以帮助实现进程的独立性。因为一个进程的一行语句或者一个数据在理论上可以加载到物理内存的任意位置,所以在物理内存中,所有数据和代码都是乱序的,但是因为页表的存在,它可以将虚拟地址空间上的虚拟地址和物理地址进行映射,那么在进程视角,所有的内存分布,都可以是有序的。 因为有地址空间的存在,每一个进程都认为自己独占所有虚拟地址空间,比如32位机器下的4GB空间,并且各个区域是有序的,进而可以通过页表映射到不同的区域,来实现进程的独立性。每一个进程都不知道,也不需要知道其他进程的存在。
2.4可执行程序在形成时就已经有虚拟地址了,所以不运行一个程序也有虚拟地址。但不运行一个程序,操作系统是不会为它分配地址空间和页表的
1.如标题所说,即使可执行程序没有被加载到内存中运行,但由于可执行程序已经通过编译链接形成了,那么此时程序中的每一行代码和每一个变量都有一个虚拟地址,并且按照虚拟地址空间的排布规则编址,方便运行程序使之变成进程后将每一行代码和每一个变量的虚拟地址填入页表,也方便通过这些虚拟地址填写地址空间里的各种区域的范围。
2.不仅是操作系统需要遵守虚拟地址空间,编译器也是需要遵守虚拟地址空间的,即编译器编译程序代码时就已经形成了地址空间的各个区域,如代码区,数据区等,并且,采用和Linux内核中一样的编址方式,给每一个变量,每一行代码都进行了编址,故,程序在编译的时候,每一个字段早已经具有了一个虚拟地址。
3.物理地址空间里除了有代码和数据的具体内容,还包括这句代码或者数据的虚拟地址,所以cpu读取的物理地址空间里的地址实际上也是虚拟地址,比如cpu读取到跳转指令,像读取到函数需要跳转到对应地址时,这个地址就是虚拟地址,拿到虚拟地址后再次通过页表映射找到函数的物理地址,然后cpu继续执行。
2.5虚拟内存
虚拟地址空间不等于虚拟内存。虚拟内存是一种逻辑上扩充物理内存的技术。基本思想是用软、硬件技术把内存与外存这两级存储器当做一级存储器来用。虚拟内存技术的实现利用了自动覆盖和交换技术。简单的说就是将硬盘的一部分作为内存来使用。
2.6进程地址空间(虚拟地址空间)的排布![](https://i-blog.csdnimg.cn/blog_migrate/132b5e47a9e06231284d94b60771f571.png)
2.6.1进程地址空间的大小
32位下,进程地址空间的取值范围是0x0000 0000到0xFFFF FFFF(即42亿9千万,也是int可表示的最大值),42亿9千万字节是4G,所以32位机器支持的最大内存就是4G。0-3G属于用户,3-4G属于内核即操作系统。
2.6.2命令行参数环境变量区存储什么?
存储了如main函数的参数int argc(记录命令行参数的个数),char*argv[](记录命令行参数有哪些),char*env[](记录环境变量有哪些)等。
2.6.3堆和栈
堆和栈相对而生,如上图中箭头方向,栈从高地址到低地址使用,堆从低地址向高地址使用。不同环境表现效果不一样,特别是Windows下的VS2019及其之后的VS系列,但Linux中编译器是严格遵守前面规定的。
2.6.4代码区存储了什么?
1.函数就存储在代码区,比如函数名代表函数地址,这个函数的地址所指向的区域就是代码区的一部分。
2.字面常量就存储在代码区,如10,“hello”,它们只能被读,不可以被写。如果细分的话,字面常量会在常量区,常量区的地址高于代码区,低于已初始化全局数据区。
3.加载本质就是创建进程,那么运行进程时是不是必须把程序的所有代码和数据加载到内存中,并创建内核数据结构建立映射关系?
3.1答案(包括对新建状态和分批加载的解释)
并不是,甚至在极端的情况下,只有内核的几个数据结构即task_struct(PCB),mm_struct(虚拟地址空间)和页表被创建出来了。此时这些结构虽然被创建出来了,但还没有初始化,所以几个结构都是空壳,这种状态就是新建状态。只有真正调度这个进程,cpu执行进程代码时才会将几个结构初始化并以此建立映射关系,所以理论上可以通过这种方式完成进程的分批加载。
3.2分批加载的例子
就比如大型的游戏一般都有50G,我们的内存大的也就只有32G,很显然将所有代码和数据加载到内存中是不可能的,即使内存足够大,可以将程序完全加载进内存,这样也会造成大量内存的浪费,因为cpu的速度虽然快,但也架不住代码量大,由于每一行代码每一个数据都会占用内存,所以处于进程中靠后的代码在内存中会占用大量内存,但此时这些代码不需要被执行,处于等待被执行的状态,这不就是浪费了大量内存吗?所以可以采用分批加载的方式,当一部分代码快执行完毕时,再加载后一部分代码进入内存。
3.3既然可以分批加载,那么可以分批换出吗?(包括挂起状态的解释)
分批加载是将磁盘上程序的代码和数据加载进内存,这是一个载入的过程,那么可以分批载入的话,可以分批换出吗?
答案是可以。有些代码是执行完毕后就不再执行,比如游戏的菜单界面,一般进入游戏后就不需要再次执行,此时这部分代码就可以被换出内存了,避免占用内存资源却又产生不了价值。又比如有些进程处于阻塞状态,比如网络不好,进程一直在等待网络资源,短时间内不会被执行了,此时就可以将该进程的所有代码和数据换出内存,只保留进程的内核数据结构在内存中,如task_struct,页表和mm_struct,这种状态就叫做挂起状态。挂起状态和新建状态本质上没有什么区别,可能就只是挂起状态多执行了几行代码。
分批换出的误解:不要以为代码和数据被换出内存是要将它们换出并载入到磁盘上,由于通过页表不仅可以向物理地址空间(即物理内存)上映射,还可以向磁盘映射,所以分批换出只需要将内存上的部分代码和数据清除即可,如果后序需要已经清除的这部分代码和数据,通过页表和磁盘的映射关系即可找到它们。