linux 操作系统内存管理机制 ------ 为什么要设置虚拟内存

虚拟内存

如果你是电子相关专业的,肯定在大学里捣鼓过单片机。

单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。

另外,单片机的 CPU 是直接操作内存的「物理地址」。

 

 

 

在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第二个程序在 2000 的位置写入一个新的值,将会擦掉第一个程序存放在相同位置上的内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。

这里关键的问题是这两个程序都引用了绝对物理地址,而这正是我们最需要避免的。

我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。

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

 

如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

 

于是,这里就引出了两种地址的概念:

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

 

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

 

 

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

主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段。

内存分段

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

分段的好处是程序可以每个段映射到不同的物理地址上,即不需要为一个程序分配一个连续的物理内存。

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

 

段选择因子和段内偏移量:

  • 段选择因子就保存在虚拟地址。段选择因子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。

  • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

在上面,知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:

 
注意:上图虚拟地址的各个段不是紧排

如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

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

  • 第一个就是内存碎片的问题。
  • 第二个就是内存交换的效率低的问题。

内存碎片原因:每个段的大小不一样,某个段占用的内存释放后,其他需要占用内存的段的大小又大于这个空闲的内存,导致这个内存无法被使用

内存碎片可以通过内存交换的机制解决,可以把分散的正在使用的内存数据写入硬盘,然后再紧排写入内存,就能腾出一个连续的内存。这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。

内存分页

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

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

 

 

页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。

创建进程的页表时,进程的整个虚拟地址空间都要映射到一级页表中,即每个虚拟地址都能在一级页表找到对应的PTE,当进程访问的虚拟地址在PTE中的valid位为0时(表示虚拟地址对应的程序片段没有从flash搬移到dram),系统会产生一个缺页异常,进入系统内核空间搬移对应的程序片段到dram地址空间、更新进程页表,最后再返回用户空间,恢复进程的运行。

内存分页由于内存空间都是预先划分好的大小相同的页,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。

但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象

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

 

 

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

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

 

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

  • 把虚拟内存地址,切分成页号和偏移量;
  • 根据页号,从页表里面,查询对应的物理页号;
  • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

虚拟地址到物理地址的转换过程称为Translation table walks

下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:

 

这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。

有空间上的缺陷。

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

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

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

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

多级页表

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

在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。

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

页表寄存器(translation table base register)存的是页表的物理地址,每个进程都有自己的页表地址,存在进程的PCB中,当进程运行,才把进程的页表地址放入页表寄存器中。

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

其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?

每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 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(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

 

 

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

有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。

段页式内存管理

内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。

 

 

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

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

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

用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

 

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

  • 第一次访问段表,得到页表起始地址;
  • 第二次访问页表,得到物理页号;
  • 第三次将物理页号与页内位移组合,得到物理地址。

可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。

Linux 内存管理

那么,Linux 操作系统采用了哪种方式来管理内存呢?

在回答这个问题前,我们得先看看 Intel 处理器的发展历史。

早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。

但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。

由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,分段之前的地址我们称之为逻辑地址,它有两部分组成,高位的段选择符和低位的段内偏移。在分段时先用段选择符在相应的段描述符表中找到段描述符,也就是某一个段的基地址,再加上段内偏移量就得到了对应的线性地址,线性地址也称之为虚拟地址。然后再由页式内存管理将线性地址映射成物理地址。

 

 

这里说明下逻辑地址和线性地址:

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

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

 

了解完 Intel 处理器的发展历史后,我们再来说说 Linux 采用了什么方式管理内存?

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

这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。

但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。

linux 让所有的段都指向相同的段地址范围,即整个虚拟地址空间,段的基地址都为0,逻辑地址没有了段选择符,这样逻辑地址和线性地址在数值上就相同了。

 

 在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:

 

通过这里可以看出:

  • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

再来说说,内核空间与用户空间的区别:

  • 进程在用户态时,只能访问用户空间内存;
  • 只有进入内核态后,才可以访问内核空间的内存;

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

 

 

接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。

我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:

 

 

通过这张图你可以看到,用户空间内存,从低到高分别是 6 种不同的内存段:

  • 程序文件段(.text),包括二进制可执行代码;
  • 已初始化数据段(.data),包括静态常量;
  • 未初始化数据段(.bss),包括未初始化的静态变量;
  • 堆段,包括动态分配的内存,从低地址开始向上增长;
  • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 );
  • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。

 

 

 

总结:

为了让芯片能同时运行多个程序,就要保证各个程序在内存的地址不能冲突,为了让开发者不必关注程序的物理地址,解决程序在哪个内存地址上运行就由芯片的MMU模块和操作系统承担。操作系统管理内存的方式有两种,一种是内存分段,一种是内存分页。内存分段要求把一个完整的程序都加载到内存,而内存分页可以实现只把需要的页加载到内存,所以内存分页可以让程序需要的总内存大于物理内存大小

 

由于每个进程都有自己的页表,页表是操作系统和MMU维护的,进程没有办法访问其他进程的页表,所以分页可以防止一个进程对另一个进程的内存数据进行篡改

 

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

 

posted @ 2020-11-25 22:06  流水灯  阅读(107)  评论(0编辑  收藏  举报