目录
[二、Linux 内存管理](#二、Linux 内存管理)
本片文章主要参考小林的《图解系统》。
一、基础知识
不同的进程不能同时使用相同位置的物理内存。
操作系统中会将进程所使用的地址进行隔离,为每一个进程分配独立的一套虚拟内存地址。
而虚拟地址具体映射的实际物理内存对进程是透明的。操作系统会提供⼀种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
虚拟内存的管理存在以下方式
(1)分段;
(2)分页;
(3)分段与分页结合;
1、内存分段
提出较早。因为程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来,每个内存段的物理地址都是连续的。
分段机制下的虚拟地址由两部分组成:段选择因子和段内偏移量。
段选择因子 就保存在段寄存器里面。段选择因子里面最重要的是段号 ,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
分段机制会把程序的虚拟内存分成 4 个段,每个段在段表中有⼀个项,在这⼀项找到段的基地址,再加上虚拟内存的偏移量,于是就能找到物理内存中的地址,如下图
内存分段存在两个不足之处:
(1)内存碎片问题
如上,段所占内存空间在物理上是连续的,通过不断的申请与释放,生成了多个不连续的小物理内存,导致新的程序(内存需求大于最大内存碎片的大小)无法被装载。如下图所示,释放浏览器空间后,虽然未使用内存总和为 256 MB,但无法运行内存需求超 128 MB 的程序。
而且程序占用的内存空间,可能并不会全部使用,造成内存的浪费。
(2)内存交换效率低
内存交换可以解决内存碎片的问题,通过硬盘进行空间交换,整合内存碎片。如上图,浏览器释放内存空间后,生成了两个 128 MB 的内存碎片,我们可以将音乐占用的内存先写到硬盘上,在重新写入内存中,重新写入的时候从游戏占用的 512 MB 内存空间往后写。这样就将两个 128 MB 的小内存空间整合为一个 256 MB 的内存空间了。
但是这样存在一个问题,因为内存分段肯定会生成大量的内存碎片。使用内存交换虽然能够达到内存整合的目的,但是硬盘 IO 速度会成功性能瓶颈。
2、单级分页
分页是把整个虚拟和物理内存空间切成⼀段段固定尺寸的大小。这样⼀个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,页大小为 4KB 。
虚拟地址与物理地址之间通过页表来映射,如下图:
页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的⼯作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生⼀个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页方式中内存操作(申请与释放)的最小单元为"页",只要求页内物理地址是连续的,同一个程序申请的虚拟内存,页之间可以不连续。这样使得内存的管理更加灵活。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。⼀旦需要的时候,再加载进来,称为换入(Swap In)。所以,⼀次性写入磁盘的也只有少数的⼀个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
更进一步,因为页与页之间物理地址不必连续。操作系统在加载程序时,也不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再把对应的程序加载到物理内存里面去,提高物理内存的使用效率。
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。
上面的方法有个缺点。在 32 位的环境下,虚拟地址空间共有 4GB,假设⼀个页的大小是 4KB(2^12),那么就需要大约 100 万(2^20) 个页,每个页表项需要 4 byte 来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,那么 100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
3、多级分页
解决上面问题的方法是多级页表。
对于 4GB 内存的虚拟空间,我们可以将其分为 1024 个页表:一级页表中包含 1024 个页表项,每一项对应一个包含 1024 个页表项的二级页表。
至此有个疑问,将单级分页拆分为二级分页,每个进程映射 4GB 地址空间就需要 4KB(一级页表) + 4MB (二级页表)的空间,不是占用空间更大了吗?
其实对于绝大多数的进程使用的空间远达不到 4GB,所以绝大多数的一级页表项并没有分配物理内存,如果某个⼀级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB ,这对比单级页表的 4MB 是⼀个巨大的节约。
即使对于已分配的页表项,若一段时间内没有被访问,在物理内存紧张的情况下可以将页面换到硬盘中,释放物理内存。
需要注意,一级页表的设定,是为了能够覆盖整个虚拟内存的,虽然存在没有使用的页表项,但是在需要使用时要能够通过创建二级页表找到对应的虚拟内存地址。而对于单级页表因为没有二级页表的存在,所以必须在单级表项预准备好所有的虚拟地址,否则会出现虚拟地址在页表中找不到页表项的问题,导致计算机无法正常工作。
对于 64 位系统,二级分页肯定不够,需要进行四级分页。
(1)全局页目录项 PGD(Page Global Directory);
(2)上层页目录项 PUD(Page Upper Directory);
(3)中间页目录项 PMD(Page Middle Directory);
(4)页表项 PTE(Page Table Entry);
4、TLB
多级页表解决了内存占用的问题,但是对于多级页表,从虚拟地址到物理地址需要多次转换,这显然降低了寻址的速度,增加了响应延迟。
程序是有局部性的,在一段时间内,整个程序的执行仅限于程序中的某一部分。相应的,执行所访问的存储空间也局限于某一内存区域。而这一内存区域体现在固定的页表项上。
我们可以将这些页表项保存在访问速度更快的硬件上。为此,厂商专门在CPU上设计了页表项的cache,即 TLB(页表缓存、转址旁路缓存、块表等)。
CPU内封装的内存管理单元MMU,在寻址时会先查 TLB,然后才会继续查常规的页表。
5、段页式内存管理
内存分段和内存分页不是绝对的,可以将他们组合起来在同一个系统中使用,组合起来后,通常称为段页式内存管理。
实现方式如下:
(1)先将程序划分为多个逻辑意义的段;
(2)再接着对段进行分页。即对分段划分出来的连续空间,再划分固定大小的页;
这样地址结构就由段号、段内页号和页内位移三部分组成。这样每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
段页式地址变换中要得到物理地址须经过三次内存访问:
(1)访问段表,获取页表的起始地址;
(2)访问页表,得到物理页号;
(3)结合物理页号与页内偏移获得物理地址;
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率
二、Linux 内存管理
要知道 Linux 操作系统采用哪种方式,需要结合 Intel 处理器内存管理设计。
Intel 处理器就采用了段页式内存管理。段式内存管理将程序的 "逻辑地址" 映射为 "线性地址",再通过页式内存管理将 "线性地址" 映射为 "物理地址"。
Linux 主要采用页内内存管理,但因 Intel CPU 硬件结构不可避免的设计段机制(Intel CPU 对程序逻辑地址先进行段式映射,再进行页式映射)。
Linux为了规避段式映射,Linux系统中每个段都是从 0 开始的整个 4GB 虚拟空间(32位环境)。这样页式管理面对的就整个虚拟内存空间。这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
在 Linux 中,每个进程的虚拟内存空间被分为内核空间和用户空间。进程进入用户态才可以访问用户空间内存,进程进入内核态才可以访问内核空间的内存。
具体内存分布如下图所示,32位机器与64位机器空间范围不同。
虽然,每个进程的虚拟内存空间是独立的,但是每个进程虚拟内存中的内核地址所关联的都是相同的物理内存,这样进程切换到内核态后,可以方便的访问内核空间内存。
接下来,进⼀步了解虚拟空间的划分情况,⽤户空间和内核空间划分的⽅式是不同的,内核空间的分布情况就不多说了。我们看看用户空间分布的情况,以 32 位系统为例。
用户空间内存,从低到高分别是 7 种不同的内存段:
(1)程序文件段,包括二进制可执行代码;
(2)已初始化数据段,包括静态常量;
(3)未初始化数据段,包括未初始化的静态变量;
(4)堆段,包括动态分配的内存(malloc),从低地址开始向上增长;
(5)文件映射段(mmap()),包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
(6)栈段,包括局部变量和函数调⽤的上下⽂等。栈的大小是固定的,⼀般是 8 MB 。系统也提供了参数,可以自定义;