forked from 0voice/kernel_memory_management
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Create 一文带你了解,虚拟内存、内存分页、分段、段页式内存管理.md
- Loading branch information
1 parent
4d7a2e6
commit f84f1af
Showing
1 changed file
with
379 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,379 @@ | ||
# 虚拟内存 | ||
|
||
### 1. 为什么有虚拟内存? | ||
|
||
CPU是直接操作内存的物理地址。 | ||
|
||
在这种情况下,如果两个程序占用的内存有重叠,要想同时运行两个程序是不可能的。 | ||
|
||
如果第一个程序在2000的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容。 | ||
|
||
所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。 | ||
|
||
因此,有了虚拟内存。每个进程分配独立的一套虚拟地址,互不干涉。(虚拟地址由操作系统负责映射到物理内存) | ||
|
||
|
||
|
||
 | ||
|
||
|
||
|
||
### 虚拟内存地址和物理内存地址 | ||
|
||
**操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。** | ||
|
||
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。 | ||
|
||
于是,这里就引出了两种地址的概念: | ||
|
||
- 我们程序所使用的内存地址叫做**虚拟内存地址**(*Virtual Memory Address*) | ||
- 实际存在硬件里面的空间地址叫**物理内存地址**(*Physical Memory Address*)。 | ||
|
||
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示: | ||
|
||
 | ||
|
||
# 内存分段 | ||
|
||
内存分段是**操作管理虚拟地址与物理地址之间关系**的方式之一,还有一种是内存分页。 | ||
|
||
|
||
|
||
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。**不同的段是有不同的属性的,所以就用分段(*****Segmentation*****)的形式把这些段分离出来。** | ||
|
||
|
||
|
||
### 分段机制下,虚拟地址和物理地址是如何映射的? | ||
|
||
分段机制下的虚拟地址由两部分组成,**段选择子**和**段内偏移量**。 | ||
|
||
|
||
 | ||
|
||
- **段选择子**就保存在段寄存器里面。段选择子里面最重要的是**段号**,用作段表的索引。**段表**里面保存的是这个**段的基地址、段的界限和特权等级**等。 | ||
- 虚拟地址中的**段内偏移量**应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。 | ||
|
||
|
||
|
||
虚拟地址是通过**段表**与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图: | ||
|
||
|
||
 | ||
|
||
|
||
|
||
|
||
|
||
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。 | ||
|
||
|
||
|
||
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处: | ||
|
||
- 第一个就是**内存碎片**的问题。 | ||
- 第二个就是**内存交换的效率低**的问题。 | ||
|
||
|
||
|
||
### 分段为什么会产生内存碎片问题? | ||
|
||
我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中: | ||
|
||
- 游戏占用了 512MB 内存 | ||
- 浏览器占用了 128MB 内存 | ||
|
||
- 音乐占用了 256 MB 内存。 | ||
|
||
这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。 | ||
|
||
如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。 | ||
|
||
|
||
|
||
 | ||
|
||
内存碎片的问题 | ||
|
||
|
||
|
||
这里的内存碎片的问题共有两处地方: | ||
|
||
- 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载; | ||
- 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费; | ||
|
||
|
||
|
||
|
||
|
||
解决外部内存碎片的问题就是**内存交换**。 | ||
|
||
|
||
|
||
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。 | ||
|
||
|
||
|
||
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换 | ||
|
||
|
||
|
||
### 分段为什么会导致内存交换效率低? | ||
|
||
对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 `Swap` 内存区域,这个过程会产生性能瓶颈。 | ||
|
||
|
||
|
||
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。 | ||
|
||
|
||
|
||
所以,**如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。** | ||
|
||
# 内存分页 | ||
|
||
分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。 | ||
|
||
|
||
|
||
要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是**内存分页**(*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*)的解决方案。 | ||
|
||
|
||
|
||
在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 `4KB` 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。 | ||
|
||
|
||
|
||
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 `1024` 个页表(二级页表),每个表(二级页表)中包含 `1024` 个「页表项」,形成**二级分页**。如下图所示: | ||
|
||
 | ||
|
||
|
||
|
||
### 分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗? | ||
|
||
|
||
|
||
当然如果 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*); | ||
|
||
|
||
|
||
 | ||
|
||
|
||
|
||
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。 | ||
|
||
|
||
|
||
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。 | ||
|
||
 | ||
|
||
程序的局部性 | ||
|
||
我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(*Translation Lookaside Buffer*) ,通常称为页表缓存、转址旁路缓存、快表等。 | ||
|
||
|
||
|
||
 | ||
|
||
|
||
|
||
在 CPU 芯片里面,封装了内存管理单元(*Memory Management Unit*)芯片,它用来完成地址转换和 TLB 的访问与交互。 | ||
|
||
|
||
|
||
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。 | ||
|
||
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。 | ||
|
||
|
||
|
||
# 段页式内存管理 | ||
|
||
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为**段页式内存管理**。 | ||
|
||
|
||
|
||
 | ||
|
||
段页式地址空间 | ||
|
||
段页式内存管理实现的方式: | ||
|
||
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制; | ||
- 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页; | ||
|
||
|
||
|
||
这样,地址结构就由**段号、段内页号和页内位移**三部分组成。 | ||
|
||
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示: | ||
|
||
|
||
|
||
 | ||
|
||
段页式管理中的段表、页表与内存的关系 | ||
|
||
段页式地址变换中要得到物理地址须经过三次内存访问: | ||
|
||
- 第一次访问段表,得到页表起始地址; | ||
- 第二次访问页表,得到物理页号; | ||
|
||
- 第三次将物理页号与页内位移组合,得到物理地址。 | ||
|
||
|
||
|
||
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。 | ||
|
||
# 总结 | ||
|
||
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套的**虚拟地址空间**,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。 | ||
|
||
|
||
|
||
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过**内存交换**技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。 | ||
|
||
|
||
|
||
那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。 | ||
|
||
|
||
|
||
那么对于虚拟地址与物理地址的映射关系,可以有**分段**和**分页**的方式,同时两者结合都是可以的。 | ||
|
||
|
||
|
||
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。 | ||
|
||
|
||
|
||
于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 `4KB`。由于分了页后,就不会产生细小的内存碎片。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。 | ||
|
||
|
||
|
||
再来,为了解决简单分页产生的页表过大的问题,就有了**多级页表**,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的**局部性原理**,在 CPU 芯片中加入了 **TLB**,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。 | ||
|
||
|
||
|
||
**Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理**。于是 Linux 就把所有段的基地址设为 `0`,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。 | ||
|
||
|
||
|
||
另外,Linxu 系统中虚拟空间分布可分为**用户态**和**内核态**两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。 |