操作系统(10) 内存的使用

289 阅读11分钟

本文引用图片均来自 李治军: 操作系统32讲

地址重定位

操作系统要执行程序只需将程序载入内存并设置CS:IP指向程序第一条指令即可,CPU会自动不断往下取值执行

image.png

但如果只是把程序读进内存就开始执行的话会导致内存访问错误,如下图左边是程序编译后得到的内容,程序需要访问偏移40处的main函数

image.png

当把程序载入内存0处时程序访问没有问题,但是内存的空闲位置是不确定的,下一次载入可能是内存1000处,此时main函数位于1040处,call 40会越界访问其他进程的内存

以上问题的产生是因为编译后产生的代码里面的地址并不是实际中程序所处内存的地址,为了确保程序正确运行我们需要修改这些地址使其指向正确的地方,也就是重定位

那么什么时候进行重定位呢?我们有三种候选方案:

  1. 编译时重定位
  2. 载入时重定位
  3. 运行时重定位

编译时重定位

编译时重定位就是在编译程序的时候就确定地址,优点是只需在编译时修改一次地址,但该方法导致程序只能载入到编译时确定的地址中,实际中该地址有可能被其他进程占用,所以该方法并不具备实用性

载入时重定位

载入时重定位就是在程序载入内存的时候确定地址,系统先找到一块空闲内存,然后将程序载入并修改代码中所有地址为正确地址,该方法在初次载入时不会导致内存覆盖。但是在操作系统中为了高效利用内存,有时会将阻塞进程移出放到磁盘等待时机再从磁盘移入内存

image.png

当程序再次被载入内存时可能不再是原来的那块内存,此时程序中的地址都是错误的,同样会导致错误的内存访问。当然也可以每次载入都修改地址,但是这样会消耗大量资源

运行时重定位

运行时重定位就是每条指令运行时才确定地址,该方法不修改代码中的地址而是将它们定义为逻辑地址,然后确定一个基地址,每次访问内存的时候 实际地址(物理地址)= 基地址 + 逻辑地址。基地址被放入PCB中,每次程序被载入内存时只需修改基地址

image.png

由于每条指令都要进行重定位所以CPU中有个MMU(Memory Management Unit)模块专门用于地址翻译以提高工作效率,CPU每次执行访问内存的指令都会自动触发MMU的地址转换。另外在 操作系统(2) 系统调用 中提到的内核态和用户态的内存访问控制也是MMU完成的

程序分段

我们的代码被编译成程序时并不是所有东西包在一起的,实际上程序被分为了一段一段的逻辑空间

image.png

比如代码段是只读的大小不会变化,堆段是可读写的大小随程序运行改变,每段各有特点和用途,显然将各段分开更有利于管理和维护。所以程序读入内存时也不是一整个放进去,而是各段分别载入内存

image.png

将程序分段后每段处于内存不同位置,此时不能再共用一个基址而是每段有自己的基址

image.png

在做地址翻译时先由段号找到段基址再加上逻辑地址形成物理地址,比如jmpi 100 CS //CS = 0,先找到段号0的基址180k然后加上100形成物理地址最后再跳转

在进程中该表被称为LDT(Local Descriptor Table),在操作系统中还有GDT(Global Descriptor Table)可以理解为属于系统的LDT,进程的LDT描述符会被记录在GDT中,在进行进程的地址翻译时得先根据LDTR寄存器的值找到GDT中对应的LDT描述符进而找到内存中的LDT

image.png

内存分区

在操作系统中同时存在多个进程,每个进程都需要将各个段载入内存中。为了避免使用内存时相互覆盖很容易想到的一个方案是将内存划分为一片片,每个数据占一片,这里其实就是内存分区

内存分区有固定分区动态分区

固定分区

固定分区就是系统初始化时直接将可用内存等分为k份,优点是实现简单但容易造成内存浪费。比如每个区划分为1MB,但实际数据只需使用1KB,这样一个分区大部分空间都浪费掉了

动态分区

动态分区则是数据需要多大空间就分配多大空间

image.png

如上图Seg1需要100k大小的空间系统就从空闲内存中划分100k大小的区域,然后更新已分匹配分区表空闲分区表。相应的如果数据不再需要内存,已分配的内存会被回收并且更新两张分区表

当动态分区执行一定次数后也会存在一些问题,假设当前内存分配如下:

image.png

此时进程请求40K的内存空间,我们要如何决定分配从哪个空闲分区分配空间呢?这里就引入了最佳适配首先适配最差适配等分配方案,这些方案的引入增加了系统的复杂性

然后还有这样的问题,内存中被分配的区域并不都是紧挨着的而是中间留有了一定的空隙,这些空隙没有大到可以分配给另一个请求,也没有小到可以忽略不记

image.png

如果对这些空隙不管不顾会造成大量的内存浪费,如果要管那么就需要把所有已分配区域挪位置使它们紧挨着把空隙释放出来,但是挪动内存又需要耗费大量的资源且挪动期间进程无法正常执行

综上来看动态分区也不太好用,内存管理需要另一个方案

内存分页

前面的内存划分我们想的都是划分一个区域能把进程某个段整个包进去,那么分配的区域可不可以只装部分数据(比如数据段的一部分)呢?

进程的内存请求就像吃面包,我们把面包(内存)等分小小的一片片,每次给进程一片,不够就再给,这样即使有浪费最多也不过是一片面包(内存)。每次分配一片,只要内存空闲就能分配出去避免了动态分区中的内存间隙问题,同时每一片大小相等,具有固定分区中易于实现的特点

以上将内存当作面包分成一片片的方法就是内存分页,每一页物理内存有一个页框号,下图是将进程的某个段分页载入内存示例

image.png

每个段分页载入后相应的需要一个页表记录段中的每个页对应物理地址的哪个页,段中地址重定位时先根据逻辑地址确定页号,然后在页表中找到该页号对应的物理页框号,最后再算出物理地址

image.png

如图mov [0x2240], %eax指令中,0x2240除以4k(一页大小)得页号02,查表得对应页框号0303乘以4k得到物理基址,最后加上240逻辑地址得出最终物理地址0x3240

页表的起始地址长度记录在PCB中,当进程被调度时这两个数据被载入CR3寄存器

多级页表

内存分页一节中讲到为了避免浪费面包(内存),面包(内存)的每一片应该小一点,但是,分页小了页数就多了相应的页表就大了

假设32位操作系统,页大小为4K,那么页面数 = 逻辑地址空间大小/页大小 = 232/212=220{2^{32}/2^{12}=2^{20}} = 1M,那么相应的有1M个页表项,每个页表项大小4B(20位用于确定页框号,12位用于标志等),那么页表大小 = 页表项数 * 页表项大小 = 1M * 4B = 4MB

一个进程的页表占用内存4MB,每个进程有自己的页表,当进程多了光页表就占用大量内存这显然是不能接受的

为了减少页表的内存占用人们提出了两种方案:

  1. 只存放部分页面信息 对于一个进程来说32位大小的逻辑地址空间中大部分地址是用不到的,那么用不到的就不存了
image.png

但是这样子页号就不连续了,查找时不再能根据页表基址+偏移的方式快速找到页表项,即使采用折半查找等方法也会产生好几次的运算。地址翻译是很频繁的操作,查找会耗费大量的性能

  1. 多级页表 该方案其实也是只存部分页面信息的思路,但是通过分级制度解决了页号不连续的问题

参考书籍的目录设置章-节-页,将页表拆分成两级(页目录表和页表)

image.png

在原先20位的页号中拆分10位作为页目录号,先找到页目录项,再到页目录项对应的页表中根据页号找页框

页目录项一共210{2^{10}}=1K个,每个页表中页表项也是1K个,页目录项大小等同页表项,所以页目录表大小 = 页表大小 = 4KB

页目录表和页表中每一项都要存储,但是没有用到的页目录项对应的页表不存储。比如上图用到了页目录表中第一、第二和最后一项一共三项,需要存储对应的三个页表,页目录表和页表总共占内存4KB * 4 = 16KB

分级后占用的内存远小于分级前存储完整页表的内存且避免了页目录号不连续或页号不连续的问题

快表

在多级页表中增加了页目录表导致访存次数增加一次,如果是64位操作系统逻辑地址空间更大需要更多分级访存次数增加更多,访存次数增加很明显降低了地址翻译的效率,为缓解该问题人们提出了快表方案

TLB(Translation Lookaside Buffer)是频繁发生的虚拟地址到物理地址转换的硬件缓存,存储了部分页表信息。每次内存访问时硬件先检查TLB看看其中是否有期望的转换映射,有则直接取出对应的物理页框号否则到多级页表中查找

image.png

TLB通过硬件设计在查找时是并行查找而不是一项一项的顺序查找,所以TLB查找速度极快,同时由于程序局部性原理它的命中率也很高。因为TLB查找速度快命中率高所以能缓解多级页表查询效率低的问题

TLB就像CPU的高速缓存很好用但很贵,所以TLB的容量比较小存不了多少映射信息,如果TLB未命中,在复杂指令计算机中由硬件负责找到对应的页表项并更新TLB的内容,但是现在大多都是精简指令算机,由软件负责处理未命中的更新

参考文献