引言

这一篇文章是在阅读完《操作系统导论》之后的总结,如果你未来想要从业c++,这是一本很好的书,可以帮助你深入了解操作系统。

抽象:地址空间

在我们早期的操作系统中,内存的处理其实并不复杂,内存里面就是一个操作系统加上一个程序的代码

但是随着我们的用户要求变多,我们需要多进程来运行程序。所以我们的计算机原来的内存管理就不行了,随后我们提出了虚拟内存的概念。

虚拟内存

这其实并不是一个很复杂的概念,它解决的问题就是让每一个程序好像有自己的私有空间,但是实际上在物理内存空间里,是共用的一个内存。这个操作不仅仅是给那些没有生命的程序看的,还是给我们程序员看的。当我们输入下面这个代码的时候:

int x = 3;
prinf("%p\n", (void*) &x);

我们会得到x的地址,但是那真的是存储x的物理地址吗?不是的,我们所有看到的都是假的,这都是虚拟地址,而真正的地址只有操作系统知道。

总结:所以到这里,我们有了一个重要的概念:虚拟内存。虚拟内存系统负责为程序提供一个巨大的,私有的空间假象。操作系统在硬件的帮助之下,通过每一个虚拟内存的索引,将其转化为真实的物理地址,物理内存根据所获得的物理地址去获取有效的信息。

机制:地址转换

从这里开始,我们要真正的了解如何管理内存了。

首先我们一切的管理都是基于虚拟内存上面。假设我们有一个程序长这个样子

这里我们需要注意的是,堆是向下生长的,而栈是向上生长的。

我们就拿B程序来举例子,这个是放在内存里面的图,而在虚拟内存里面,可没有A程序,在虚拟内存里面,代码段放在0KB处,堆放在2KB处,栈放在16KB处。但是如果物理内存里面,还是这个地址,那B程序的地址就和A程序的地址完全冲突了,所以实际上B程序应该是放在其他物理内存上的,这也就涉及到动态重定位问题了。

动态重定位

动态重定位离不开硬件的支持,首先每个CPU必须要两个硬件寄存器:基址寄存器和界限寄存器。基址寄存器是负责将地址空间放在物理内存的任何位置,而界限寄存器是保证进程只能访问自己的地址空间。

我们可以理解为基址寄存器就是把一个程序的放在了物理地址的某一个地方,然后把那个地址的头地址给了我们,我们程序员手里的是每个程序的虚拟地址,也就是相对的偏移量,所以当我们需要访问一个代码的地址的时候,其实那个地址等于 (基地址) + (虚拟地址)

而虚拟地址到物理地址的转换正是我们所说的地址转换。

这种基址寄存器配合界限寄存器的硬件结构是芯片中的。有时候我们将这个负责地址转换的部分叫做MMU。

进程

所以当一个进程在运行的时候这一套工作流程是怎么样的呢?

1、创建进程时,操作系统为进程的地址空间找到内存空间,操作系统把整个内存的空闲块用链表串起来,于是检索这一个空闲列表。

2、进程终止的时候,操作系统把这些内存回收给空闲链表。

3、上下文切换的时候,把当前的基址寄存器和界限寄存器的内容保存在内存中,放在每个进程都有的一个结构体中,也就是我们熟知的PCB

对以上的总结:

静态地址的转化:直接安排地址给计算机,在运行的时候不用担心

动态地址转化:子啊运行的时候才知道地址,我们看到的都是虚拟的地址,真实的物理地址只有硬件才知道

但是这两个方法都有一个共同的问题,就是要把整个程序都加载在内存之中,并且位置相对固定,因为每个进程中的堆空间和栈空间要预留很大的内存,所以这样会有很多的外部碎片

分段

为了解决这个问题,我们必须要对原有的结构进行调整。首先我们想到的就是把代码分开存放,而分开的标准就是分段:代码段,数据段,栈段,堆段。。。这些段没有必要放在一起。我们可以这么理解,一个进程的虚拟空间里的段是连续的就可以,但是至于在内存空间中,这些段怎么排放,对于程序员来说并不是很关心。

这样有什么好处呢?首先对于空间的利用率变高了。既然段的排放没有了限制,那么我们只需要可以准确的找到段的位置,至于怎么排放,怎么节约怎么排,所以可能出现的情况是一个进程的代码段后面跟着的是另一个进程的代码段。

怎么引用一个段

那既然知道了优势,怎么引用一个段呢?

其实也是比较简单的。每个进程都有一个段寄存器,这个寄存器里面存的是基地址和段的大小,也就是知道知道了一个段的全部信息。

但是我们还需要知道的是,段寄存器是怎么知道我们引用的是哪个段?段内的偏移量是多少?

我们用14位的虚拟地址来模拟一下:

01    0000011010000   前两位也就是表示的是哪个段(堆段)  而后面的12位就是偏移量。

假如要执行某一行的代码,我们通过13位和12位来判断是哪个段,再通过基地址加上偏移量找到内存地址。

问题

不过分段在现在的操作系统之中运用的很少,因为它也有很大的问题

首先我们可以想象一下,有些段很大,有些段很少,这些不规则的段,很可能造成很多的外部碎片,如果我们有一个32KB的内存空间,我们已经用来30KB,接着刚好又来了一个2KB的段,很明显,从逻辑上来说,我们的内存可以刚刚放下,但是实际上是大概率不行,因为里面空闲的内存都是一小块一小块的,加起来可以放下但是这些块是分开的。

对于这个问题,我们第一个想法就是紧凑这些内存,但是这个成本很高,因为拷贝段是内存密集型的,要占用的大量的处理器时间。所以一种很有效的方法是空闲链表管理法,试图保留大的内存块进行分配,这里也会涉及到很多的算法,大家如果有兴趣,可以去看看。

但是无论怎么样,仍然无法解决问题,只能优化。就像书中的一句话:如果有一千种解决方案,就没有特别好的。

分页

这是我们现在操作系统最常用的方法,分页

它的核心就在于它把每个内存空间分成等块的,而不是根据一个进程的大小或者段来分内存,这个样就可以大量减少外部碎片。

我们必须要解释一下几个概念:左边的这一整个就是页表,这个页表是一个数据结构,主要的作用就是为地址空间的每一个虚拟页面保存地址转换。我们一般的一个页框的大小就是4KB。

每个页中都存了大量的进程信息,但是我们应该如何找到我们需要的页呢,总不能说是一个一个遍历吧。

为了转换该过程生成的虚拟地址,虚拟地址分为两个组件:一个是虚拟页面号,一个是页内的偏移量

这个过程还是比较容易理解的,首先是输入虚拟地址,前4位正是虚拟页面号,拿到这个虚拟的页面号之后我们直接去查页表,0010对应的是2,也就是第二页,第二页的数是110,然后拿这个110与它的后面12位的偏移量进行一个组合,变成了输出的物理地址。

这个实际上我们可以理解成前四位对应的基地址的转换,而后面的偏移量是不变的。

页表存在哪?

这是我们面临的第一个问题,页表存在哪?

是存在内存里面吗?

假设我们是32位的操作系统,那么如果偏移量是12位,则有20位的VPN,20位的VPN意味着有2^20个地址转换,也就是4MB的内存大小存一个页表,这是很夸张的。也就是说我们什么都没干,就白白浪费了大部分的内存去存储一张内存表。

硬件必须要知道当前运行进程的页表的位置,然后硬件从这个位置提取页表,然后从页表之中提取页框号,然后把它与来自虚拟地址的偏移量组合起来,形成所需要的物理地址。

多级页表

这里与《操作系统导论》的顺序有所不同,因为我在阅读到页表存储问题的时候,就在考虑如何存储这么大的页表,所以这里我们稍微调换一下讲述的顺序。

我们这里一直都是以32位的操作系统举例子。

我们的第一种解决方案并不是多级页表,而是增加页的大小,页的大小是16KB,也就是18位的虚拟页号和14位的偏移量,每个线性页表有2^18个页项,一共是1MB,减少了四分之一,但是这一有一个很大的问题,就是会造成很多的内部碎片。

于是我们才想到了多级页表。这是一种很高级的解决方案。相比于单级页表来说,多级页表的优势就在于我们需要的时候才创建对应的页项,而单级页表必须要在创建的时候就把所有的叶项都创建出来,无论有没有用,即使未来永远不会用到。

多级页表就像一个树,在一个页里面存了另一个页表。我们可以简单地计算一下,一个页的大小是4KB,一个地址是4字节,32位,那么一个页可以存1024个二级页表页表,以此类推,我们一页就可以存4MB的页表,十分划算

而我们真实的虚拟地址就是存在在二级页表里面

那我们应该如何去查询呢?

首先我们给出的虚拟地址就需要进行改变。如图,我们的PT1存的是第一级页表的页目录索引,通过这个我们可以找到在一级页表里面的索引,从而找到对应的二级页表,拿到二级页表之后,通过PT2拿到对应的索引,进行与之前相同的转换,得到的值再与偏移量一组合,就是我们真实的物理地址。

是不是非常的方便呢?

TLB

在解决了如何存下这么大的一个页表之后,我们还不是很满足,因为查询的效率还是太低了。

效率低怎么办。硬件是解决这个问题的最好办法。就像在内存与CPU之间加一个高速缓存一样,我们在这里加入了TLB这个寄存器。这个寄存器是怎么工作的呢?

首先我们还是从虚拟地址中提取页号,然后注意:我们先去TLB里面查询,有没有这个页号,如果有,我们就直接命中。然后只需要在TLB中取出页帧号,与原来的虚拟地址中的偏移量进行组合,这个过程之中根本不需要查页表。

那如果我们没有命中呢?我们就会重新查表,找到页帧号,然后更新TLB,然后重新执行这个命令,因为我们更新了TLB,所以当再次查询TLB的时候,我们一定可以查到相关的页号。

TLB中存的不仅仅是页号,还有有效位,这个很重要。一开始的时候TLB是空的,当一个进程被CPU选中之后,TLB就会开始工作,不断地更新里面的内容,但是TLB只有一个,如果当另外一个进程来的时候,里面的内容就全部失效了,我们有两种方式解决这个问题,一个是全部删除,但是效率比较低,一个是将有效位全部标记为0,相当于未命中。

交换

但是随着程序的变多,用户的需求量变大,我们又面临的一个大问题。

我们之前所有的操作,都是建立在内存空间足够大,我们的一切都可以放在内存上,但是现在不可以了,我们内存终有一天不够用,所以我们需要开始与磁盘打交道,我们需要把一部分的页放在磁盘的交换区上面,随时等待着交换。

磁盘的I/O是非常慢的,所以我们不可能说内存不够了,就在磁盘上存数据,然后疯狂的I/O。我们的操作还是要把磁盘的数据交给内存,但是内存已经满了,所以我们有得必有失,我们需要交换一些数据出去。

所以我们在磁盘上开辟了一个区域,swap区,专门与内存进行交换的。这个区域大家应该不是很陌生,在装linux版本的时候,大家都应该配置过。而操作系统只需要记住这个硬盘的地址就可以了

页错误

在这里,我们的讲述也就到了尾声。在故事的结尾,让我们重新梳理一遍整个查询的过程吧。

首先我们给出了虚拟地址,然后硬件提取出虚拟地址的页号,拿着这个页号到TLB中查询,这里有两种情况:第一种是命中,很幸运,我们的这一个过程就结束了,因为我们成功的拿到了物理地址。第二种情况是未命中,我们必须要重新在页表中寻找,这里又有两种情况:第一种是找到了页帧号,于是我们更新TLB,并重新执行指令,拿到了物理地址。第二种情况就很惨了,没找到物理内存中的页,我们称这种情况叫“页错误”。页错误的时候我们的操作系统就需要工作了,操作系统找到硬盘中的交换区,找到存储这个页的地址,然后把页读取到内存之中,这个过程会触发一次磁盘I/O,然后操作系统会更新页表,这里我们又会遇到两种情况:第一种是内存还没满,我们开开心心的更新了我们的页表。但是第二种情况是内存满了,很难受,我们必须要交换一部分页出去,到磁盘上,但是我们应该交换哪些页呢?这里就涉及到了很多的算法,如果大家想要了解的话,可以看看我博客之前的内容。当更新页表之后,我们也要更新我们的TLB,并再次执行指令,然后命中成功。

内存管理的故事到这里就结束了,感谢大家的阅读。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐