Skip to content

操作系统之内存管理

Published: at 07:01 AM
   
16 min read
graph TD
    A[提纲] --> B(虚拟内存)
    A --> C(内存分段)
    A --> D(内存分页)

虚拟内存

对于没有操作系统的机器(如单片机)或早期简单操作系统的机器来说, cpu 是直接操作内存的「物理地址」

在这种情况下,如果想同时运行两个程序

那么可以这么解决 某台计算机总的内存大小是 128M,现在同时运行两个程序 A 和 B,A 需占用内存 10M,B 需占用内存 110M。

  1. 先将内存中的前 10M 分配给程序 A
  2. 接着再从内存中剩余的 118M 中划分出 110M 分配给程序 B

但是这种简单的内存分配策略问题很多

  1. 进程地址空间不隔离。恶意程序可以随意修改别的进程的内存数据,有 bug 的程序也可能不小心修改了其它程序的内存数据,导致其它程序的运行出现异常。
  2. 内存使用效率低。在 A 和 B 都运行的情况下,如果用户又运行了程序 C,而程序 C 需要 20M 大小的内存才能运行,而此时系统只剩下 8M 的空间可供使用,就需要把一个已经运行的程序的数据暂时拷贝到硬盘上,释放出空间公程序 C 使用,然后再将程序 C 的数据全部装入内存中运行。这个过程,有大量的数据在装入装出,导致效率十分低下。
  3. 程序运行的地址不确定。当内存中的剩余空间可以满足程序 C 的要求后,操作系统会在剩余空间中随机分配一段连续的 20M 大小的空间给程序 C 使用,因为是随机分配的,所以程序运行的地址是不确定的。

那怎么解决这个问题的呢?

这里问题的关键是,直接操作了「物理地址」,而物理地址只有唯一的一个, 操作系统的解决方案是为每一个进程分配独立的一套「虚拟地址」

graph TD
    A[进程] --> B(虚拟内存)
    B --> C(物理内存)

操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

程序访问虚拟内存的时候,操作系统把虚拟内存转换成不同的内存地址,这样,不同的程序运行的时候就不会冲突了。

我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address) 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。

CPU 芯片中内存管理单元(MMU)负责把虚拟地址转换成物理地址

操作系统是如何管理虚拟地址与物理地址之间的关系?

主要有两种方式,内存分段、内存分页

内存分段

程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个 10M 大小的空间映射到物理地址空间中某个 10M 大小的空间。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的

假设有两个进程 A 和 B, 进程 A 所需内存大小为 10M,其虚拟地址空间分布在 0x00000000 到 0x00A00000, 进程 B 所需内存为 100M,其虚拟地址空间分布为 0x00000000 到 0x06400000。

那么按照分段的映射方法, 进程 A 在物理内存上映射区域为 0x00100000 到 0x00B00000, 进程 B 在物理内存上映射区域为 0x00C00000 到 0x07000000。

于是进程 A 和进程 B 分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。 从应用程序的角度看来,进程 A 的地址空间就是分布在 0x00000000 到 0x00A00000,在做开发时,开发人员只需访问这段区间上的地址即可。应用程序并不关心进程 A 究竟被映射到物理内存的那块区域上了,所以程序的运行地址也就是相当于说是确定的了。

分段解决了,问题 1、3,(进程地址空间不隔离程序运行的地址不确定),但是没有解决问题 2 内存使用效率低 另外,还会带来内存碎片的问题

分段为什么会产生内存碎片的问题?

假设有 1G 的物理内存,用户执行了多个程序,其中:

A占用了 512MB 内存

B占用了 128MB 内存

C占用了 256 MB 内存。

这个时候,如果我们关闭了 B,则空闲内存还有 1024 - 512 - 256 = 256MB。

如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。

物理内存
|------------------------------------------------------------------------|
开启 A B C 三个程序
|----------------------------|----------|-------------------|-------------|
|          A 512M            |  B 128M  |      C 256M       |  空闲 128M  |
关闭 B 程序
|----------------------------|----------|-------------------|-------------|
|          A 512M            | 空闲 128M |      C 256M       |  空闲 128M  |

内存碎片的问题共有两处地方:

外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;

内部内存碎片,程序所有的内存都被装载到了物理内存,
但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;

解决外部内存碎片的问题就是内存交换

可以把 程序 C 占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。 写的时候紧跟着 A 程序 512M 后写,这样,可空出完整的 256M 空间,新的 200MB 程序就可以装载进来。

内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。

内存分页

要解决内存分段带来的内存碎片和内存使用效率低问题,主要思路是

  1. 减少出现一些内存碎片
  2. 当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点

这个办法,也就是内存分页(Paging)分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。

虚拟地址与物理地址之间通过页表来映射 页表实际上存储在 CPU 的内存管理单元 (MMU) 于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。

而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

分页是如何解决分段的内存碎片、内存交换效率低的问题?

由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。 而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上, 称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间, 内存交换的效率就相对比较高

另外,可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是* *只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。**

分页机制下,虚拟地址和物理地址是如何映射的?

对于一个内存地址转换,其实就是这样三个步骤:

把虚拟内存地址,切分成页号和偏移量;

根据页号,从页表里面,查询对应的物理页号;

直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

简单的分页有什么缺陷吗?

有空间上的缺陷。

因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

多级页表

要解决上面的问题,就需要采用的是一种叫作**多级页表(Multi-Level Page Table)**的解决方案。

我们把这个 100 多万个((2^20))「页表项」的单级页表再分页, 将页表(一级页表)分为 1024 个页表(二级页表), 每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但**如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。 **

假设只有 20% 的一级页表项被用到了, 那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB, 这对比单级页表的 4MB 是不是一个巨大的节约?

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是: 全局页目录项 PGD(Page Global Directory); 上层页目录项 PUD(Page Upper Directory); 中间页目录项 PMD(Page Middle Directory); 页表项 PTE(Page Table Entry);

TLB

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。

程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。

于是在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。