1. 虚拟内存⚓
1.1 虚拟内存⚓
我们可以把进程所使用的地址隔离开来,即让操作系统为每个进程分配独立的一套虚拟地址。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
- 程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
- 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
1.1.1 为什么不直接使用物理内存⚓
- 需要精确的知道每一个变量在内存中的具体位置
- 我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置
- 除此之外我们还需要考虑为每个进程究竟要分配多少内存?
- 内存紧张的时候该怎么办?
- 如何避免进程与进程之间的地址冲突?
- 需要处理多进程之间的协同问题
- 等等一系列复杂且琐碎的细节。
1.1.2 虚拟内存的作用⚓
- 虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
1.2 内存管理⚓
额外可以参考的文章: - 【操作系统】总结三(内存管理)
1.2.1 内存分段⚓
虚拟地址=段选择因子+段内偏移量 段选择因子=段号+特权 段号对应段表 段表里面有 段内描述符=段基址+段界限 段基址+段内偏移量=物理内存地址
分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量。
- 段选择因子就保存在段寄存器里面。段选择因子里面最重要的是段号,用作段表的索引。每个段在段表中有一个段内描述符,通过段号索引到这个段内描述符,段内描述符里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
段式内存管理的优点(ChatGPT):将内存划分为逻辑上独立的段,方便管理和保护不同类型的数据,提供更好的内存保护机制。每个段可以具有不同的属性,如只读、可写、可执行等。
不足之处:
- 第一个就是内存碎片的问题。
- 第二个就是内存交换的效率低的问题。
1.2.1.1 内存碎片⚓
内存碎片主要分为,内部内存碎片和外部内存碎片。
- 内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片。
- 但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题。
1.2.1.2 内存交换⚓
解决「外部内存碎片」的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap
空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。
为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。
1.2.2 简单的内存分页⚓
把物理内存分成一个一个的片段,称之为页,然后把虚拟内存分成一样大小的片段,使用页表建立他们之间的映射关系。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB
。
分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
虚拟地址与物理地址之间通过页表来映射。
页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。
当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
虚拟地址 = 虚拟页号+页内偏移量 从页表中索引对应的物理页号,得到物理内存基址, 物理地址=物理内存基址+页内偏移量
页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移量的组合就形成了物理内存地址:
1.2.2.1 内存碎片⚓
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。
但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。
1.2.2.2 内存交换 Swap⚓
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
1.2.2.3 缺陷⚓
每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。页表是存储在内存中的。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12)
,那么就需要大约 100 万(2^20)
个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB
的内存来存储页表。
如果进程很多的话,内存就不够用了。
要这个问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。
1.2.3 多级页表⚓
对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页:
1.2.3.1 占用空间⚓
分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?
如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
为什么不分级的页表就做不到这样节约内存呢
保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了。
1.2.3.2 64 位⚓
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
- 全局页目录项 PGD(Page Global Directory) ,9位
- 上层页目录项 PUD(Page Upper Directory),9位
- 中间页目录项 PMD(Page Middle Directory),9位
- 页表项 PTE(Page Table Entry),9位
加上 页内偏移 的 12 位,一共是 48 位的虚拟地址。
而 32 位操作系统使用的是:页目录项(10位)+ 页表项(10位) + 页内偏移(12位)。共计 32 位的虚拟地址格式
1.2.3.3 TLB⚓
多级页表解决了空间上的问题,但是却增加了转换所需的时间。
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件。
于是 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer)
,通常称为页表缓存、转址旁路缓存、快表等。
内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
1.2.4 段页式内存管理⚓
段页式内存管理实现的方式:
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
- 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
虚拟地址= 段号+段内页号+页内位移
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
段页式内存管理兼具了段式内存分配和页式内存分配的优点,虚拟内存段不需要再对应连续的物理内存空间,提高了内存交换的效率。具有以下优点(ChatGPT):
-
逻辑地址空间更灵活:段页式内存管理可以将程序的逻辑地址空间划分为多个段,使得不同部分的代码和数据可以被独立管理和保护。
-
虚拟内存支持:通过分页机制,段页式内存管理可以提供虚拟内存支持。程序可以访问远超物理内存容量的逻辑地址空间,而不需要一次性加载全部数据到内存中。
-
内存保护:通过设置合适的访问权限和段界限,段页式内存管理可以实现对不同段的保护,防止程序越界访问和非法操作。
-
共享和动态链接:段页式内存管理允许多个程序共享相同的代码段,减少内存占用。同时,也支持动态链接,使得程序在运行时可以加载和卸载模块。
尽管段页式内存管理提供了上述优点,但它也存在一些缺点,如内存碎片问题、页表管理开销较大等。因此,实际的操作系统会根据具体需求和硬件平台选择合适的内存管理机制。
1.3 Linux 内存管理⚓
1.3.1 Intel 处理器⚓
由于 Intel 处理器的发展史,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。
由段式内存管理映射而成的地址称之为线性地址(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。
逻辑地址和线性地址:
- 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
- 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;
逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。
1.3.2 Linux⚓
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
- 32 位系统的内核空间占用
1G
,位于最高处,剩下的3G
是用户空间; - 64 位系统的内核空间和用户空间都是
128T
,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
用户空间分布的情况,以 32 位系统为例:
用户空间内存,从低到高分别是 6 种不同的内存段:
- 程序文件段,包括二进制可执行代码
- 已初始化数据段,包括静态常量
- 未初始化数据段,包括未初始化的静态变量
- 堆段,包括动态分配的内存,从低地址开始向上增长
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB,可以自定义。
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()
或者 mmap()
,就可以分别在堆和文件映射段动态分配内存。