快捷搜索:

操作系统基础篇——内存管理


1. 虚拟内存

单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。另外,单片机的 CPU 是直接操作内存的「物理地址」。在这种情况下,要想在内存中同时运行两个程序是不可能的。

操作系统是如何解决这个问题呢?

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

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

操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,

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

内存分段和内存分页

2. 内存分段

不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。

    段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

分段解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

    第一个就是内存碎片的问题。 第二个就是内存交换的效率低的问题。
分段会产生内存碎片
    外部内存碎片:也就是产生了多个不连续的小物理内存,导致新的程序无法被装载; 内部内存碎片:程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;

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

分段会导致内存交换效率低

对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。

因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。

3. 内存分页

分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。

要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。

这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。

虚拟地址与物理地址之间通过页表来映射,如下图:

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

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

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

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

更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

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

在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,

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

    把虚拟内存地址,切分成页号和偏移量; 根据页号,从页表里面,查询对应的物理页号; 直接拿物理页号 + 偏移量,就得到了物理内存地址。
简单的分页有什么缺陷吗?

1. 页表会非常的庞大

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

但每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。那么100 个进程就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了

多级页表

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

映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存。

如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

    全局页目录项 PGD(Page Global Directory); 上层页目录项 PUD(Page Upper Directory); 中间页目录项 PMD(Page Middle Directory); 页表项 PTE(Page Table Entry);

TLB

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

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

我们把最常访问的几个页表项存储到访问速度更快的硬件,在 CPU 芯片中加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB ,通常称为页表缓存、转址旁路缓存、快表等。

有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。但是TLB 的命中率很高,因为程序最常访问的页就那么几个

4. 段页式内存管理

段页式内存管理实现的方式:

    先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制; 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

这样,地址结构就由段号、段内页号和页内位移三部分组成。

段页式地址变换中要得到物理地址须经过三次内存访问:

    第一次访问段表,得到页表起始地址; 第二次访问段内页表,得到物理页号; 第三次将物理页号 + 页内偏移,得到物理地址,去内存中的这个物理地址取数据。

5. Linux 内存管理

逻辑地址和线性地址:

    程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址; 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;

逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。

Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。

这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。

Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。


6. 总结


为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。

每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。

操作系统负责虚拟地址与物理地址的映射,有分段、分页、段页式。

内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小不统一,这就会导致内存碎片和内存交换效率低的问题。

于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。分页后,就不会产生细小的内存碎片。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。

再来,为了解决简单分页导致页表过大的问题,就有了多级页表,它解决了空间上的问题,但会导致 CPU 在寻址的过程中,需要很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。

Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。

另外,Linux 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。

经验分享 程序员 职场和发展