要是我之前就上了TSU向勇和陈渝老师的操作系统课,我的操作系统可能就不会学的这么渣了。。恶补一通。非常感谢该课程团队作出的努力与奉献,有兴趣的大家可以去B站上看向勇老师的授课视频,简直男神级别。
OS的内存管理
操作系统会对计算机的内存进行统一管理,注意一下,这里说的内存不要理解成真正物理内存空间,应该理解成一种可用的内存空间,也就是我们常说的逻辑内存
或者说虚拟内存
,这种逻辑内存有可能分布在外存(硬盘等)之上的,至于具体怎么分布和协调,就靠OS来完成。
OS的内存管理大致有以下4个特点:
- 抽象
可以为用户提供一个理论上无限大的逻辑地址空间,用户编程不需要去考虑实际的物理内存,具体开辟和释放内存空间交给OS来完成。
- 保护
OS确保执行的进程之间的地址空间是相互独立的,不会相互之前产生影响。
- 共享
有可能进程之间需要访问其他一段相同的内存,这个时候OS来完成内存共享,以及进程与进程之间的数据通信也由OS来负责协调
就像上图展示的一样,有4个进程,我们平时编程时所面向的一般也是逻辑内存,逻辑内存理论上来说可以是无限大的。
- 虚拟化
OS将内存和外存进行管理,可以虚拟化出更大的地址空间。
如上图所示,4个进程以及OS,OS及3个进程被加载进了内存,而P4进程被暂时放在外存上。对于用户来说,他认为这4个进程都被放到了“内存”之中。在实际执行到相应进程时,OS会采用一些进程调度算法来将暂时没用到的进程空间和在外存空间的进程进行交换。
地址空间 & 地址生成
逻辑地址生成
我们都知道,写一个程序,基本要面临编码、编译、链接、载入这几个过程,原来迷迷糊糊的不知道链接和载入的作用是什么,现在总算是明白了,先给下面这个图:
以一个C语言程序为例:
- 首先,我们需要coding,生成
XXX.c
文件,C语言里面的变量名,函数名这些其实都是地址的概念,不过是符号地址。 - 然后用gcc编译器(这其实是一个gcc的可选选项),将
XXX.c
文件编译成XXX.s
文件,这一般都是会生成汇编程序。这里的地址也还是符号地址。 - 再用gcc编译器,链接成
XXX.o
文件,在该文件中,会将汇编指令转为一条条的机器码中,其中,符号地址将会开始被转为逻辑地址,而且每一个XXX.o
文件的第一条语句默认从0地址开始编址的。 linker链接器
,链接的目的主要是将一些程序中的库函数链接起来,我的理解是,将程序中引用的各种文件包含起来,这里可能会涉及到多个XXX.O
文件,并将这些一段段都是从0开始的地址归总起来。loader装载器
,这一步主要是程序的重定位,你想,假如有多个程序被链接器链接好了之后,它们各自也从0逻辑地址开始编制,肯定是要有一个软件来负责将这些程序全部定位好,定位到哪呢?对,就是逻辑地址空间上。
以上五个步骤,循序渐进的将逻辑地址空间一步一步确定好,再次声明!上面说的地址全是逻辑地址,至于如何映射到物理地址,甚至说内存之间的空间交换,这就是OS的工作了。
最后,每次实际运行XXX.exe
或XXX.out
文件时,会执行逻辑地址到物理地址的映射。
连续内存分配:内存碎片
连续内存分配会带来两个问题:①内碎片 ②外碎片
- 内碎片
OS给进程分配了一段空间,但是空间没被完全利用,有一段区域始终没被利用,只有直到该进程被杀死或释放的时候,这段内存空间才有可能被重试拿到被其他进程使用。
所谓内碎片也比较好理解,就是OS给进程以及分配的空间里面不能被有效利用的那个fragment。
- 外碎片
外碎片则是相对于内碎片的一个概念,是指由于OS在给不同进程分配内存的过程中,可能两个相邻的内存之间产生了一部分空闲的内存,可是这部分内存又比较小,无法满足一个新来的进程所想要申请的内存空间,这样一来,这一部分空闲内存空间相当于被浪费了,成了外部碎片。
所谓外部随便,也好理解,就是进程外面不能被有效利用的那个fragment。
为了解决,更恰当的说是,为了缓解上述的内碎片和外碎片问题,就出现了3个连续内存分配算法:
- 首次适配
- 最优适配
- 最差适配
这三个算法比较好理解,这里就不展开说了。
连续内存分配:碎片整理
- 压缩式碎片整理
既然产生了碎片,肯定就要想办法来解决,于是就出现了碎片压缩办法,这个方法说起来很简单,无非就是把一些已经占用的空间尽量的归总地连续起来,而将一些碎片也尽量连续起来,就好比一个数组里面有一些零零散散的为0的整数,其他是一些非0整数,我们要做的就是把这些为0的整数放在一起,非0的整数也放在一起。
可是,什么时候来copy这些内存单元呢?copy这些单元后程序的内存地址变了怎么来协调?这些copy操作带来的开销是什么呢?这都是需要考虑的问题。
- 交换式碎片整理
为了克服压缩式碎片整理带来的巨大开销,我们就直接选择不copy了,而是用swap的模式,来进行外存和内存的swap-in
和swap-out
。
非连续内存分配
之所以采用非连续内存分配方式,原因是在连续内存分配时,会产生外碎片
,内碎片
问题,导致内存利用率较低。
而非连续内存分配两种常见的基于硬件的方法就是分段式
和分页式
。分段式在比较古老的CPU中用的多,而现在的大多CPU都采用的是分页式的机制。
分段式
学过一定汇编语言或者嵌入式的同学应该知道,在X86的CPU中,存在一个叫段寄存器的东西,好像是叫ES寄存器,有点忘记了。因为我们的程序地址空间是由多个段组成的,这个段寄存器就是用来具体寻找某个段的地址空间的。
一个进程中,会有相应的主代码段、堆栈段。符号表段等等,如上图,这些段在编程者看来好像是连续着的,实际上他们的地址可能是一段一段被隔开的。
既然进程在实际的物理地址空间中被分段了,那么我到时候访问的时候,肯定需要一个工具来完成相应的逻辑地址到物理地址的对应,这个工具可以用软件或硬件来完成,而硬件肯定是执行效率较高的,于是,段访问的硬件实现大致如下图所示:
- 首先,OS会维护一个段表,每个段有一个唯一的索引,也就是上图中的
段描述符
,这和Linux下的文件描述符
或者socket描述符
都是类似的概念。这张表中还为每个段维护了段的其他一些信息,比如段的基址、长度等。 - 每次CPU具体寻址时,先根据断号找到相应的段,得到其基址,然后再根据偏移地址,和基址进行运算得到物理地址。如果我没记错的话,在X86的CPU中,段基址和偏移地址都是用一个16进制数来表示,例如,段基址放在段寄存器ES中,取值为
0X1000
,偏移址放在偏移地址寄存器EX中,取值为0X1234
,那么计算方法是先将ES左移16位再与EX相加,得到的就是0X11234
这个物理地址,大概就是这样。不过在计算物理地址时,MMU会判断一下偏移地址是不是超过了段的最大长度,比如说,堆段OS只开辟了1M空间,超过了1M就会产生一个异常,这个访问就是非法的。
说了这么多,分段式机制在一些上古时期的CPU里面用的比较多,现在用的比较多的是分页机制
分页式
分页式主要的思路和分段式是差不多的,也是通过页号,页内偏移地址来完成的。不过,和分段式最主要的区别就是:
分页式内存分配方法中的页和帧的大小是固定的,而分段式内存分配方法中的段大小可以是不定的。
比如说,我的数据段可以是1M,而代码段4M,堆栈段2M,这样不同大小的段空间会使得OS维护段地址时有些麻烦。而分页式方式中,所有的页和帧的大小都是固定的,say,1M。别急,页和帧的概念在下面我会一一说明。
页和帧
页这个概念指的是分页式内存管理中的逻辑地址空间中的基本单元,而帧则是物理地址空间中的基本单元。
帧(frame)
如上图,一个物理地址被分隔成了两部分,前一部分的F位表示帧号,后S为表示帧内的偏移地址。现在假设一个物理地址的帧号为f(十进制数),帧内偏移地址为o(十进制数),那么物理地址的计算方式就是:
这其实和分段式中的计算方法是一样的,举个栗子:
页(page)
页这个概念则是相对应逻辑地址空间而言的,注意,页和帧的大小一定是一样的,比如说都是512B或1M,而页号所占的位数可以和帧号所占的位数是不一样的,理论上来说,页号的长度可以是无限大,如下图所示:
那么物理地址的计算方式就是:
于是页和帧就可以匹配起来,并且他们的偏移地址是一样的,OS管理起来就方便了许多。
为了完成上图中的逻辑地址到物理地址的映射,我们还差一个东西,就是页表,它的作用就是记录了页到帧的映射关系,如下图所示:
我举个通俗易懂的例子,比如你被赋予了一项神秘的刺杀任务,你的上司告诉了你目标的所在位置暗号(猎鹰大楼,1208房间),猎鹰大楼相等于是页号,1208房间就是页内偏移地址,并让你去找一个叫钢蛋的人,他会告诉你猎鹰大楼具体是哪个楼。于是,你找到了钢蛋,钢蛋打开密码箱,里面有一张纸,记录了接头暗号,知道了猎鹰大楼其实就是12号楼,然后你就去(12号楼,1208房间)执行任务。这个例子中,钢蛋就是操作系统,密码箱中的纸就是页表。
页表的结构(普通页表、快表、二级页表、多级页表)
普通的页表就是如下图所示,每次寻址时,CPU内部的PTBR会找到页表的基址(也就是存放页表的内存地址的其实单元,类似于中断向量的概念),然后再根据页号找到相应页表项。
注意,每条页表项前面有几个标志位,可以分别来表示该页对应的帧是否驻足在物理内存中、是否是修改位、引用位等等。如果resident bit
取值为0,则会造成一个非法内存访问异常。
页表地址转换的示例如下,注意,图示中的地址和页表项都是从下往上开始计数的:
其实在这里,我们隐藏了一个问题没考虑,页表是放在哪的???
- 放在内存中。这样的话,每次访问一个内存单元需要2次内存访问,第一次获取页表项,第二次访问数据项,这样速度较慢。
- 放在CPU的
CACHE
中。这样CPU直接访问页表项,访问速度快,但是cache的容量又很小。
是的,如今的计算机很多都是64位系统,如果每个页/帧的大小为1K,那么会有2^54个页表项,这是very huge的值,直接放在内存不显示,于是就出现了所谓的快表、二级页表、多级页表。
快表
将那些常用的页表项缓存到TLB中,TLB中的每个项以key-value键值对的方式存在CPU中,如下图所示,如果TLB中的不存在相应要访问的页,则再去找内存中的页表。
二级页表、多级页表
为了解决普通页表太大的问题,我们可以将页表分成两级,也就是说将页号的长度分为2部分,如下图中的绿色和淡蓝色的部分。一级页表中的每一项存的是二级页表的起始地址。假设原理页号部分的二进制数一共可以表示24个页(当然,这好像不可能,但是不影响我们理解),然后我们将这24个数字分为4*6
的组合,一级页表中有4项,每个二级页表有6项。这样一来,如果一级页表中的某项的驻留位为0(即上面在页表结构中说到的resident
位为0),那么它对应的二级页表也不需要存放在内存中了,于是同一时刻只需要4+6=10项放在内存中,而不是原来的24项,数量越大,这个效果越明显。
当然,引申一下就可以到多级页表了。