本文是本人在阅读《现代操作系统》以及其他操作系统相关知识后对内存相关知识的一些回顾和总览,由于目前学艺不精而且文笔有限,行文比较混乱,敬请谅解。
纯属个人见解,如有错误欢迎指正
CPU运行的过程实际上就是不断取指令、分析指令、运行指令的过程,这个过程的执行需要操作系统来控制,这个过程是离不开内存的操作的。首先代码本身也就是指令是存在于内存中的,其次数据也是存在于内存中的,我们无论是取指令还是取数据都要依靠地址总线寻址,数据总线传输具体内容。
那么CPU是如何区分数据和指令的呢?想要解决这个问题需要依靠硬件的力量,电脑内部的程序计数器所指向的部分就是指令。程序计数器在进程创建时指向了第一条指令,每执行一条指令程序计数器就指向下一条指令,直到程序结束。
程序计数器一般是IP寄存器,通过CS(代码段寄存器)和IP(程序计数器)的配合来定位指令。
在获得指令后就可以分析指令和执行指令了,在执行指令的过程中也会用到内存,因为执行指令一般都需要用到是数据,而这个数据就来自于内存(也有可能是在寄存器中,但最开始一定是存放于硬盘,然后再到内存,最后再存放到寄存器中的)。
那么我们如何找到内存中的数据和传输内存的数据呢?答案还是依靠硬件,通过寄存器和地址总线的配合找到所需要的内存,通过数据总线来传输内存。
物理内存编址是从0开始直到内存的最大值,地址总线能寻址的范围一般需要大过物理内存的最大地址,当我们要寻址时就通过寄存器来获得地址值,通过地址总线来找到实际物理地址,找到地址后就通过数据总线来传输数据。
地址总线的宽度代表了计算机的寻址能力,数据总线的宽度代表了计算机一次能传输多少数据。
如此一来,计算机就能正常执行取指令和执行指令的过程了(由于主要讨论的是内存,所以忽略控制器部分),但是这样做好像还是有缺陷的,那就是如果我们直接通过绝对的物理地址来寻址的话,每次内存中只能放入一个程序,也就是说每次只能运行一个进程,因为我们写程序时不可能知道其他程序会用到什么地址,如果有两个程序同时存在于内存中我们无法保证他们的地址是否会互相影响。
为了解决这一问题,伟大的计算机科学家们提出了内存分段的概念,既然我们无法保证程序的内存不会与其他程序的内存发生冲突,那就将这个任务交给操作系统来解决,这个解决办法就是内存分段,每个进程可以看作是一个段,在进程内部我们可以从0开始编址(这个地址叫做段内偏移地址),而加载进程时操作系统为我们提供一个段基址,不同的进程拥有不同的段基址,而每个进程中有相关的寄存器(段基址寄存器和界限寄存器)来保存这两个地址,由操作系统保证每个进程中段基址加上段内偏移范围内的地址不会被其他进程访问到。
这么一来内存中就可以存放多个程序了,但是问题依然存在,如果要执行多个进程那么内存必须足够大到能放下所有的程序,显然在现代的计算机中这是个不可能实现的目标,限制普通计算机内存大小一般不会超过64Gbytes,当任务足够多时,64G内存也可能不够用,要解决这一问题,就需要用到一项技术,内存交换技术,内存交换技术实际上就是当程序使用时就将程序放入内存中,程序运行一段时间后阻塞了或者是进程结束后就将程序移出内存(这里涉及进程的调度,之后再讨论),这样一来就能保证多进程的顺利运行了(至少在内存方面看起来是的),但是实际上还是有问题,在多个程序一开始进入内存时,他们之间一般都是紧密排列的,但是当经过多次的进程换入和换出换出后,多个进程之间在内存中的排列不可避免的会有缝隙产生,这个缝隙就是我们一般所说的外部碎片(除了进程的换入和换出之外,由于进程大小一般不是固定的,因为进程可能会申请堆空间和栈空间导致进程占用空间会变大和变小,这种情况也会产生内存碎片)。
会出现内存碎片主要原因在于每个进程内的内存必须是连续的,只有进程内部的内存是连续的进程内部才能顺利的进行寻址,因为前面说到程序计数器的运行是依靠地址加一来进行的,如果地址不连续那么加一所得的就不是目标指令了,对于数据来说也是如此,如果内存地址不连续,那么一个4个字节的int型变量可能都无法顺利读取。现在问题就出在我们想要经常内部的地址是的连续,但是实际上却无法做到,好在伟大的科学家们又一次出手解决了这一问题,在计算机领域里面流传着这么一句话“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”,那么这个问题能不能通过加个中间层来解决呢?答案显然是肯定的,这个中间层就是我们常说的虚拟内存,虚拟内存的实现依靠的是内存分页技术,虚拟内存的核心在于每个进程都有自己的一套连续的虚拟内存(虚拟内存的大小可以是计算机所有内存的大小,甚至大于计算机的总内存大小),在进程看来自己拥有的内存是连续的,可是实际上虚拟内存对应的物理内存却不一定是连续的,这么说可能有点抽象,具体的做法就是将虚拟内存分成相等的很多份,每一份都被称为一页,而在物理内存中也进行同样的操作,只不过物理内存里面分出来的内存被称为页框,页框和页的大小一般是一样的,每个页放入页框后就可以被进程使用,只要我们记住每个页和页框之间的关系,通过连续的虚拟内存也可以在不连续的物理内存中寻址。为了方便区分每个页,我们用页号来表示每个页,页号和页框的对应关系就被存放在页表中,通过页号我们可以找到页号对应页框的地址,再加上一个页内的偏移就可以找到我们所要的具体内存地址了(因此地址的存放格式就变为了前半段为页号后半段为页内偏移)。
引入了虚拟内存和内存分页后,我们的内存换入换出就不必一定要以进程为单位来执行了,而是可以以页的单位来执行换入和换出(内存换页算法以后再讨论),这样就带来了多个好处,1、内存碎片大大的减少了,一个内存碎片的大小最多不会超过一页的大小 2、一个进程可以获得大于实际内存的虚拟内存大小(因为一个进程执行时不是所有代码都要在内存中的,可以将进程没有用到的代码先移出内存,将用到的先放入,这样就可以获得大于实际内存的虚拟内存)3、每个页面都有自己的权限(可读可写通过保护位实现)
可惜世界上是没有免费的午餐的,采用内存分页的办法也有着一个很大的缺陷,为了时每个进程都有自己独立的虚拟地址,那么每个进程都必须配备一个页表,假设一个页的大小为4Kbytes,那么4Gbytes的内存就需要1Mbytes,一个页表项为4个字节,那么页表的大小就为4Mbytes,如果有1000个进程的话,光是页表的大小就能把内存占满,这还是在32位系统的情况下,如果是64位系统的话,页表所占用的总空间更是不敢想啊。为什么页表会这么大呢?这是因为为了方便我们通过页表号来索引页表项,那么页表项就必须连续存放(类似于数组的随机访问),可是我们明明不是把所有虚拟内存都用上了,我们一个进程可能只用到4M的虚拟内存就必须要把整个4G的内存的映射情况都记下来,这显然造成了极大的浪费,既然如此只能再加一个中间层,采用二级页表了,二级页表按字面意思就是在一级页表的基础上加多了一层页表,二级页表中的第一级不存放页号和页框的映射关系而是存放所求页在哪一个第二集页表,而第一级页表存放的才是页和页框的映射关系,此时地址存放的内容就分成了三段,第一部分存放页在第一级页表的索引,第二部分存放页在第二级页表的索引,第三部分存放页内偏移。二级页表带来的好处是不需要存放整个虚拟地址对应实际物理地址的关系了,可以只存放实际用到的地址之间的映射关系。在64位系统中可能会用到多级页表。此时虽然占用的空间少了,但是每次发生缺页时都要经过多级查询才能找到实际物理地址,太浪费时间了,所以又加入了一个中间层,缓冲也就是我们所说的快表,把常用到的页和页框对应关系存放到一个寄存器中,这样就可以做到快速访问。
至此,经过多次迭代,内存的使用就从一开始的直接使用物理地址,再到使用分段地址,再到使用分页的虚拟地址,再到现在被大量使用的多级页表和快表的配合。