虚拟内存管理
引⽤了绝对物理地址会带来冲突
单⽚机是没有操作系统的,所以每次写完代码,都需要借助⼯具把程序烧录进去,这样程序才能跑起来。另外,单⽚机的 CPU 是直接操作内存的「物理地址」。在这种情况下,要想在内存中同时运⾏两个程序是不可能的。如果第⼀个程序在 2000 的位置写⼊⼀个新的值,将会擦掉第⼆个程序存放在相同位置上的所有内容,所以同时运⾏两个程序是根本⾏不通的,这两个程序会⽴刻崩溃。
操作系统是如何解决这个问题呢?
进程拥有独立的虚拟地址,由操作系统负责把虚拟地址映射到真实的物理地址上
我们可以把进程所使⽤的地址「隔离」开来,即让操作系统为每个进程分配独⽴的⼀套「虚拟地址」,⼈⼈都有,⼤家⾃⼰玩⾃⼰的地址就⾏,互不⼲涉。但是有个前提每个进程都不能访问物理地址,⾄于虚拟地址最终怎么落到物理内存⾥,对进程来说是透明的,操作系统已经把这些都安排的明明⽩⽩了。 我们程序所使⽤的内存地址叫做虚拟内存地址(Virtual Memory Address) 实际存在硬件⾥⾯的空间地址叫物理内存地址(Physical Memory Address)
操作系统通过 MMU 把虚拟地址转化为实际的物理地址。 操作系统引⼊了虚拟内存,进程持有的虚拟地址会通过 CPU 芯⽚中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
操作系统是如何管理虚拟地址与物理地址之间的关系?
Memory Segmentation 内存分段下的映射
分段机制下,虚拟地址和物理地址是如何映射的? 程序是由若⼲个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就⽤分段(Segmentation)的形式把这些段分离出来。 分段机制下的虚拟地址由两部分组成,段选择⼦和段内偏移量。
- 段选择⼦就保存在段寄存器⾥⾯。段选择⼦⾥⾯最重要的是段号,⽤作段表的索引。段表⾥⾯保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
具体的做法: 每个虚拟地址,都划分为段选择和段偏移两部分,给出每段的大小,1kb =1024 个字节 1024 个字节的地址范围 1024 转化为二进制 2^10 (2 的 10 次方)(1 00000 00000)
所以需要 11 位 那么一个两个字节的地址,可以 00000 00000000000 段选择子 5 位, 偏移 11 位 ,根据段选择子找到物理段号,根据物理段号可以找到段基地址 ,段偏移不变。
逻辑段号 段基地址 段界限
0 1000 1000
1 6000 500
2 3000 3000
3 7000 1000
值得注意的是:虚拟地址也要按照程序的性质分段 ,虚拟地址也是按照不规则的段大小来分段的。 比如代码段大小是 1000 数据段大小 500 堆大小是 3000 栈大小 1000 每个程序分到的段数是固定的。如果是分页,每个程序的分到的总页数不定的。
逻辑段号 —物理段号—物理段号—物理段号对应的基地址+逻辑段偏移 逻辑页号 —物理页号—物理页号—物理页号*页大小+逻辑段偏移 分段的办法很好,解决了程序本身不需要关⼼具体的物理内存地址的问题,但它也有⼀些不⾜之处:
- 内存碎片
- 内存交换效率低
分段为什么会产⽣内存碎⽚的问题?
游戏占⽤了 512MB 内存、浏览器占⽤了 128MB 内存、⾳乐占⽤了 256 MB 内存 这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开⼀个 200MB 的程序。 外部内存碎⽚,也就是产⽣了多个不连续的⼩物理内存,导致新的程序⽆法被装载; 内部内存碎⽚,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使⽤,这也会导致内存的浪费;
解决外部内存碎⽚的问题就是内存交换。可以把⾳乐程序占⽤的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存⾥。不过再读回的时候,我们不能装载回原来的位置,⽽是紧紧跟着那已经被占⽤了的 512MB 内存后⾯。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。这个内存交换空间,在 Linux 系统⾥,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,⽤于内存与硬盘的空间交换。
分段为什么会导致内存交换效率低的问题? 分段的方式产生内存碎片,因为有内存碎片,需要 Swap ,Swap 频繁的内外存交换。 对于多进程的系统来说,⽤分段的⽅式,内存碎⽚是很容易产⽣的,产⽣了内存碎⽚,那不得不重新 Swap 内存区域,这个过程会产⽣性能瓶颈。 为了解决内存分段的内存碎⽚和内存交换效率低的问题,就出现了内存分⻚
Memory Segmentation 内存分页下的映射
分段的好处就是能产⽣连续的内存空间,但是会出现内存碎⽚和内存交换的空间太⼤的问题。要解决这些问题,那么就要想出能少出现⼀些内存碎⽚的办法。另外,当需要进⾏内存交换的时候,让需要交换写⼊或者从磁盘装载的数据更少⼀点,这样就可以解决问题了。这个办法,也就是内存分⻚(Paging)。分⻚是把整个虚拟和物理内存空间切成⼀段段固定尺⼨的⼤⼩。这样⼀个连续并且尺⼨固定的内存空间,我们叫⻚(Page)。在 Linux 下,每⼀⻚的⼤⼩为 4KB 。 内存分页的关键词:“固定的页面大小,不固定的页面个数 ”,那么某个程序如果产生了逻辑内存碎片,那么一定会产生物理内存碎片,但是这个物理内存碎片不会超过一个页面的大小。而且由逻辑地址切割得到的不同的页面,可以映射到不同的物理页面。 在 Linux 下,每⼀⻚的⼤⼩为 4KB(2^12 次方)也就是页偏移需要 13 个位置(1 0000 0000 0000 ),还有三个位置,111 表示可以分的最大页数不可以超过这个数值。 64 位系统下面 2^(64-13) 表示可以划分的最大页数。
分页维护可变长的页表,分段维护固定长的页表
MMU 使用页表来把虚拟地址转化为实际的物理地址
⻚表是存储在内存⾥的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的⼯作。
分⻚是怎么解决分段的内存碎⽚、内存交换效率低的问题?
- 内存释放以页为单位 由于内存空间都是预先划分好的,也就不会像分段会产⽣间隙⾮常⼩的内存,这正是分段会产⽣内存碎⽚的原因。⽽采⽤了分⻚,那么释放的内存都是以⻚为单位释放的,也就不会产⽣⽆法给进程使⽤的⼩内存。
- 释放(还出)不经常使用的内存页面 操作系统会把其他正在运⾏的进程中的「最近没被使⽤」的内存⻚⾯给释放掉。也就是暂时写在硬盘上,称为换出(Swap Out)⼀旦需要的时候,再加载进来,称为换⼊(Swap In)。所以,⼀次性写⼊磁盘的也只有少数的⼀个⻚或者⼏个⻚,不会花太多时间,内存交换的效率就相对⽐较⾼。
- 换出和换入磁盘每次也仅仅只是几页(Linux 下,每⼀⻚的⼤⼩为 4KB(2^12 次方))
在分⻚机制下,虚拟地址分为两部分,⻚号和⻚内偏移。⻚号作为⻚表的索引,⻚表包含物理⻚每⻚所在物理内存的基地址,这个基地址与⻚内偏移的组合就形成了物理内存地址,⻅下图。
逻辑页号 物理页号 物理的基地址
0 1000 1000
1 6000 500
2 3000 3000
3 7000 1000
总结⼀下,对于⼀个内存地址转换,其实就是这样三个步骤:
- 把虚拟地址切分成页号 和逻辑偏移量
- 逻辑页号- 物理页号- 物理的基础地址+逻辑偏移
分⻚有什么缺陷吗?
- 每一页的内存很小,导致虚拟内存切割后得到的页数很多,页数很多,那么需要更多的字节来存储,每一个页都需要这个一个存储的字节,导致需要很多字节来存储。
每个进程实际划分得到的页数 整个虚拟地址对应的页数 需要 x 字节来存储,比如 4GB 被划分为 2 ^20 次方个页,0 ~ 2^20 次方这个需要 4 个字节来存储 每个虚拟页都需要这 4 个字节 实际进程的页数 乘以 4 乘以 实际进程得到的页数=总的需要的字节 因为操作系统是可以同时运⾏⾮常多的进程的,每页面的大小又很小那这不就意味着⻚表会⾮常的庞⼤。在 32 位的环境下,虚拟地址空间共有 4GB,假设⼀个⻚的⼤⼩是 4KB(2^12),那么就需要⼤约 100 万 (2^20) 个⻚,每个「⻚表项」需要 4 个字节⼤⼩来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储⻚表。这 4MB ⼤⼩的⻚表,看起来也不是很⼤。但是要知道每个进程都是有⾃⼰的虚拟地址空间的,也就说都有⾃⼰的⻚表那么, 100 个进程的话,就需要 400MB 的内存来存储⻚表,这是⾮常⼤的内存了,更别说 64 位的环境了。
我们把这个 100 多万个「⻚表项」的单级⻚表再分⻚,将⻚表(⼀级⻚表)分为 1024 个⻚表(⼆级⻚表),每个表(⼆级⻚表)中包含 1024 个「⻚表项」,形成⼆级分⻚。如下图 拿出 10 位 (这 10 位足够代表 1024 个页面) 再拿出 10 位(这 10 也足够代表 1024 个页面) 那么如何巧用这两张表表示更大的页数量
分了⼆级表,映射 4GB 地址空间就需要 4KB(⼀级⻚表)+ 4MB(⼆级⻚表)的内存,这样占⽤空间不是更⼤了吗 其实我们应该换个⻆度来看问题,还记得计算机组成原理⾥⾯⽆处不在的局部性原理么? 每个进程都有 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 里面的缓存 MMU 通过 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 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由⻚式内存管理将线性地址映射成物理地址。
这⾥说明下逻辑地址和线性地址:
- 程序所使⽤的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
- 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;
- 逻辑地址是「段式内存管理」转换前的地址
- 线性地址则是「⻚式内存管理」转换前的地址。
Linux 内存主要采⽤的是⻚式内存管理,但同时也不可避免地涉及了段机制。 这主要是上⾯ Intel 处理器发展历史导致的,因为 Intel X86 CPU ⼀律对程序中使⽤的地址先进⾏段式映射,然后才能进⾏⻚式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。 但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作⽤。也就是说,“上有政策,下有对策”,若惹不起就躲着⾛.Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是⼀样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应⽤程序代码,所⾯对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被⽤于访问控制和内存保护。
在 Linux 操作系统中,虚拟地址空间的内部⼜被分为内核空间和⽤户空间两部分,不同位数的系统,地址空间的范围也不同。⽐如最常⻅的 32 位和 64 位系统, 32 位系统的内核空间占⽤ 1G ,位于最⾼处,剩下的 3G 是⽤户空间; 64 位系统的内核空间和⽤户空间都是 128T ,分别占据整个内存空间的最⾼和最低处,剩下的中间部分是未定义的。再来说说,内核空间与⽤户空间的区别:
- 进程在⽤户态时,只能访问⽤户空间内存;
- 只有进⼊内核态后,才可以访问内核空间的内存; 虽然每个进程都各⾃有独⽴的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很⽅便地访问内核空间内存。
⽤户空间内存,从低到⾼分别是 7 种不同的内存段:
- 程序⽂件段,包括⼆进制可执⾏代码;
- 已初始化数据段,包括静态常量;
- 未初始化数据段,包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增⻓;
- ⽂件映射段,包括动态库、共享内存等,从低地址开始向上增⻓(跟硬件和内核版本有关);
- 栈段,包括局部变量和函数调⽤的上下⽂等。栈的⼤⼩是固定的,⼀般是 8 MB 。当然系统也提供了参数,以便我们⾃定义⼤⼩; 在这 7 个内存段中,堆和⽂件映射段的内存是动态分配的。⽐如说,使⽤ C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和⽂件映射段动态分配内存。
一条 Load 指令的执行过程:
- 在 CPU ⾥访问⼀条 Load M 指令,然后 CPU 会去找 M 所对应的⻚表项。
- 如果该⻚表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「⽆效的」,则 CPU 则会发送缺⻚中断请求。
- 操作系统收到了缺⻚中断,则会执⾏缺⻚中断处理函数,先会查找该⻚⾯在磁盘中的⻚⾯的位置。
- 找到磁盘中对应的⻚⾯后,需要把该⻚⾯换⼊到物理内存中,但是在换⼊前,需要在物理内存中找空闲⻚,如果找到空闲⻚,就把⻚⾯换⼊到物理内存中。
- ⻚⾯从磁盘换⼊到物理内存完成后,则把⻚表项中的状态位修改为「有效的」。
- 最后,CPU 重新执⾏导致缺⻚异常的指令。 ⻚表项通常有如下图的字段: 页号 物理页号 状态位 访问字段 修改位 硬盘地址
- 状态位:⽤于表示该⻚是否有效,也就是说是否在物理内存中,供程序访问时参考
- ⽤于记录该⻚在⼀段时间被访问的次数,供⻚⾯置换算法选择出⻚⾯时参考
-
修改位:表示该⻚在调⼊内存后是否有被修改过,由于内存中的每⼀⻚都在磁盘上保留⼀份副本,因此,如果没有修改,在置换该⻚时就不需要将该⻚写回到磁盘上以减少系统的开销;如果已经被修改,则将该⻚重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。
- 硬盘地址:⽤于指出该⻚在硬盘上的地址,通常是物理块号,供调⼊该⻚时使⽤。 总结一下: 需要记录的信息有:1.是否在内存、(如果不在内存)2.在硬盘中间的地址、3.该资源被加载在内存之后是否被修改(如果被修改则需要记录并在操作结束之后返回把修改后的数据写入到硬盘)、4,该资源被调入内存多少次数(用来记录资源的使用率,便于当内存满了的时候,从中不经常使用的资源,并把该资源所占用的内存的位置腾出来)
总结:
- 操作系统为每个进程分配独立且一样的虚拟地址空间。(一样的虚拟地址映射到不同的物理地址)
- 当进程很多,物理内存不够用时,通过内存交换技术把不经常用的内存放到硬盘里(换出),需要的时候(换入)
- 虚拟地址的映射由 MMU 来完成,具体的方式由两种 (分段)和(分页)
- 分段 是根据程序的特性把代码分成不同大小的属性段 同时每段是连续的内存空间,虚拟地址拥有固定的段数,段大小不定 ,正是因为每段的大小不一定,导致了内存碎片问题和内存交换效率低的问题。
- 分页 是把虚拟地址空间大小和物理空间大小分成了相同大小的页面。在 Linux 系统,每一页的大小是 4KB 由于分了页,不会产生内存碎片,并且换入和换出也仅仅是几页的事情。
- 为了解决分页带来的:页表太大的问题,有多级页表,解决了空间上的问题,但是 CPU 在寻址的过程,有很大的时间开销,那么在 CPU 中间设置了一个缓存, 是 TLB 缓存最长被访问的页表项。
Linux 系统主要采⽤了分⻚管理,但是由于 Intel 处理器的发展史,Linux 系统⽆法避免分段管理。于是 Linux 就把所有段的基地址设为 0 ,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被⽤于访问控制和内存保护。
另外,Linxu 系统中虚拟空间分布可分为⽤户态和内核态两部分,其中⽤户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。