操作系统内存管理

340 阅读8分钟

概述

本章从最开始的角度出发,从基本的内存使用需求,设计内存使用模型,然后再提出问题,优化模型的角度,一步一步从基本的内存模型演变到现代操作系统的内存模型。

内存使用与分段

我们先来假设一段程序,用汇编来实现,main函数在这段程序的逻辑偏移地址是40

.text
_entry: // 入口地址
    call _main
    call _exit
_main:
    ...
    ret

这段程序放在内存中如下
_entry: // 入口地址
    call 40
    call xx
_main: // 地址的偏移量是40
    ...
    ret

如果想调用main函数,需要执行call指令,里面需要写的是指令的内存地址。假设我们先预定main函数的指令入口是地址40的地方。那么内存分布如图 image.png 相对的,call40的这条指令就得放在操作系统的0地址处。我们知道操作系统是需要同时运行很多程序的,不谈计算机内存0地址开始是操作系统这件事情,就算放在其他内存地址,怎么能保证运行的其他程序没有指定相同的内存地址呢?所以程序应该随便找一块空闲的内存就把程序放进去 image.png 效果如图,那么现在的问题就是如何让call40变成call1040就需要引入重定位的概念

重定位

代码中写的是40,在执行的时候CPU需要获得的是1040的地址.这个目标有几种方案

  1. 编译时重定位 在编译代码的时候直接把40改成1040.这种方式只适合系统非常简单,一定能保证程序会放在1000的地方.因为编译程序的时候不能保证运行的时候别的程序不会占用.

  2. 载入时重定位 在加载的时候改写地址信息,看上去好像不错,但即使程序在内存中运行的时候也会发生地址转换.操作系统有时候会把内存和磁盘进行交换.所以也不是很好的方案

  3. 运行时重定位 在每次执行指令跳转的时候,都实时的获取当前程序处于的位置,然后加上偏移量完成跳转

运行时重定位

通过上面的分析,我们知道最适合的方式是运行时实时计算内存地址.所以我们需要引入一个基地址的概念.也就是当前运行时程序所处于的起始物理内存地址.结合基地址进行重定位的状态如图 image.png 因为每个进程的基地址都不一样,所以我们把基地址存储在PCB

分段

我们知道了操作系统可以通过程序的基地址加偏移量来获得真实的物理地址.那么我们会把整个程序都放在一段内存中吗?我们来看看一段程序的构成

image.png 程序中不同的主体的性质是不同的,比如代码区域是只读的,变量集合是可写并且还会增加等等.如果把这些区域全部放在一起执行,可能就会造成混乱.所以我们是把每个区域分成一个段,代码的偏移地址都是基于更小的段来进行管理. image.png 有了上面的概念之后,我们在PCB中保存的就不仅仅是这个程序的基地址了,而是这个程序的所有段的基地址映射 image.png 我们把这张表称为 LDT表,跟操作系统的那张GDT表差不多意思

内存的分区和分页

我们已经知道一个程序会被分成不同的段放入内存,那么接下来的问题就是一个4g的内存要如何划分.

  1. 等分.把内存全部划分成等分的几个部分.这样显然是不合适的,因为每个段需要的内存大小是不一样的,全部等分会造成浪费
  2. 可变分区.每次来一个段需要使用内存的时候就分配一个段需要的长度

可变分区

image.png 这时候如果需要像操作系统再申请50k的内存,效果如图 image.png 程序不单单只有申请,在退出程序的时候,内存也会相应的释放,假设现场程序2退出了 image.png 这时候如果又来了一个40k的内存申请,空闲区域的两个内存,操作系统会分配给哪一块呢?

  1. 最佳适配,因为50k的内存最合适40k的就分配50k的数据 每次都选择最佳适配的最后就会分出很多细小的内存块

  2. 最差适配.因为200k的内存最不适合40k的,就分配200k的 最差适配的话最后会分配的比较均匀的内存块

  3. 首先适配.从表从头往下找,找到的第一个符合的直接分配 首先适配的优点是查找速度快

要注意,算法没有对错,只有不同场景的适合情况.

内存分页

假设上面的分区情况,有200K和50K的内存,这时候如果想申请一个230K的内存,是申请不到的. 所以现代操作系统其实是将内存分成若干个4k的页我们再来看操作系统初始化的时候对内存的初始化代码

void mem_init(long start_mem,long end_mem)
{
    int i;
    for(i = 0; i<PAGING_PAGES,i++){
        // 把操作系统占用的内存标记已使用
        mem_map[i] = USED;
    }
    
    i = MAP_NR(start_mem)
    end_mem -= start_mem
    // 右移12位表示减去 4K, 因为linux一页是 4K
    end_mem >>= 12
    
    while(end_mem -- > 0){
         mem_map[i++] = 0;
    }
}

这其实就是已经对内存拆分了4k的数据,然后把每个内存的使用情况存在了位图中.当采用分页的方式来分配内存后,操作系统内存如图

image.png 程序也拆分成4k的大小分别找空闲的页框就放进去,这样的话,和段的映射一样,页面也有一个逻辑页面和页框的映射表.指令查找的逻辑如图

image.png 这一套页面寻址的方式是通过CPU的MMU单元自动完成寻址的

多级页表与快表

了解了上面的分页之后,我们来看看分页产生的问题。我们知道每个程序看自己都认为可以随便操作所有的内存,假设一个32位的电脑,可使用的内存为4G,内存的一页大小是4k,那么就会有2^20个页,也就是需要维护一个大小为2^20长度的页表。页表一行为4byte大小,那么一个进程就需要有一个4M大小的页表来维护。如果一个操作系统中有10个进程,那么就需要有40M的页表来维护。

优化方案1

知道了上面的问题后,我们来看看如何能优化表的大小。我们知道,虽然程序可以使用的空间是4G,但是真正的程序并不会真的把4G全部使用,可能只是使用10M的大小。那我们的页表是不是可以只存储程序中使用到的映射关系呢?

image.png 如果没有存储全部的映射,那么我们就不能通过下标随机访问到指定的页号,当我们拿着一个页号的时候需要遍历整个页表来找到真实的物理地址。每次寻址都需要遍历一次页表,这种开销其实也是不能接受的。

优化方案2

我们利用目录的思想,建立两层页表,第一层页表中每一层维护4M大小的数据,第一页中有2^10层,然后每一层中其实又维护了2^10个4k的数据 image.png 这样的话,因为程序其实并不会真的全部使用完4G的空间,假设一个程序需要12M的内存地址。其实只需要在第一层申请3个章,因为第一层维护了4M的空间,(虽然网上很多文章说第一层只需要申请三个,但是我个人认为第一层的4M访问区域是全部占用的,不然怎么做随机访问,省下的空间只是第二层的页表的空间)。这样的设计减少了页表的内存占用,但是增加了CPU需要从目录表(第一层)访问后再次访问页表。多了一次访问

TLB块表

因为多级页表中需要多次访问造成性能下降,CPU里面还搞了一个TLB的硬件,可以缓存一部分的页映射,达到快速获得物理地址的特性

段页结合的实际内存管理

在实际操作系统中有一个虚拟内存的概念,对于每一个用户的进程来说,都是拥有一整个操作系统的内存地址。当通过CS:IP具体定位到虚拟内存中的某一个地址之后,操作系统会翻译成真实的物理地址。当我们写了一个CS:IP定位了一个内存之后,实际上是定位了一个虚拟内存的地址,然后虚拟内存通过页表的映射找到了真实物理内存的地址