内存的段页存储
这节讲一下操作系统的内存,内存是一个非常重要的知识,还是需要大致了解一下的。
虚拟内存
讲虚拟内存之前先了解一下单片机,单片机是没有操作系统
的,所以每次写完代码,都需要借助工具把程序烧录
进去,这样程序才能跑起来。
另外,单片机的 CPU 是直接
操作内存的「物理地址」。
在这种情况下,单片机要想在内存中同时运行
两个程序是不可能
的。如果第一个程序在 2000 的位置有值,第二个程序也在相同的位置写入了值,将会擦掉第一个程序存放在相同位置上的所有内容,导致第一个程序完全奔溃。
单片机关键的问题是这两个程序都引用了绝对物理地址
,而这正是我们最需要避免的。
我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」
,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
于是,这里就引出了两种地址的概念:
- 我们程序所使用的内存地址叫做
虚拟内存地址
(Virtual Memory Address) - 实际存在硬件里面的空间地址叫
物理内存地址
(Physical Memory Address)。
操作系统是如何管理虚拟地址与物理地址之间的关系?
主要有两种方式,分别是
内存分段
和内存分页
,分段是比较早提出的,我们先来看看内存分段
分段
进程是由若干个逻辑分段组成的,如可由代码段
、数据段
、BSS段
、栈段
、堆段
组成。不同的段是有不同的属性的,所以虚拟地址
和物理地址
也就使用 分段
(Segmentation)的形式把这些段分离出来。
在段存储中,进程的内存空间被分成若干段,每个段内
的地址空间是必须连续
的,不可分割的内存块,但不同段之间
的地址空间可以不连续
的,不同段之间的地址空间可以分布在内存中的不同位置,它们之间可以有空闲区域或其他进程的段。
虚拟地址和物理地址之间是通过
段表
来映射的,段表内存在元素段号
、段基地址
和段界限
(该段占用的最大地址)。
如果要访问段 3 中偏移量 500 的虚拟地址,我们根据段表映射出段 3 基地址 7000 + 偏移量 500 = 7500,7500就是需要访问的真是物理地址了。
如果要访问段 3 中偏移量 1200 的虚拟地址,我们根据段表映射出段 3 基地址 7000 + 偏移量 1200 = 8300,而段3段最大地址为7000 + 1000 = 8000,明显8300地址越界了,此时系统会抛出异常。
存在的问题
- 第一个就是
外部内存碎片
的问题。
虽然每个段内的地址空间是连续的,但由于各个段之间的分布不一定连续,当多个进程释放不同段的内存时,可能会导致地址空间中出现分散的空闲区域,难以重组成大的连续内存块,从而浪费了内存。
比如a进程中地址段的内存地址为0-100,数据段为150-250,堆段为400-800,栈段为850-900,且a进程此时释放,则这4个段的内存地址也同时释放;如果此时新的进程b的地址段内存占用200,数据段占用200,堆段占用 600,栈段占用600;那么只有400-800段可存放b进程的地址段或者是数据段,而b进程的堆段和栈堆需要申请更大的连续内存空间,进而a进程中0-100、150-250和850-900内存段将不会被存储,也就出现了外部内存碎片化了。 - 第二个就是
内存交换的效率低
的问题。
解决「外部内存碎片」的问题就是内存交换
。内存交换是将LRU最不长使用的内存置换到SWAP磁盘空间中,也就是设计到磁盘的读写IO,众所周知读写IO的速度是很慢的。
如果内存交换的时候,交换的是一个占内存空间很大
的程序,这样整个机器都会显得卡顿
,这也就是为什么会出现内存交换的效率低
的问题。
为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页
。
分页
分页是把整个虚拟和物理内存
空间切成一段段固定尺寸的大小。这样一个连续
并且尺寸固定的内存空间,我们叫页
(Page)。在 Linux 下,每一页的大小为 4KB
。
虚拟地址与物理地址之间通过页表来映射,如下图:
页表是存储在
内存里的,内存管理单元
(MMU)就做将虚拟内存地址转换成物理地址的工作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常
,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
在分页机制下,虚拟地址分为两部分,页号
和页内偏移
,而页表也分为俩部分,虚拟页号
和物理页号
,虚拟页号作为页表的索引,虚拟内存通过虚拟页号在页表中找到对应的物理页号,然后加上页内偏移即真实的物理地址,见下图。
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
- 把虚拟内存地址,切分成页号和偏移量;
- 根据页号,从页表里面,查询对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
解决分段的问题
上面说了分页解决了分段存在的俩大问题,即外部内存碎片化高
和内存交换效率低
的问题,那么来看看分页是怎么解决这俩问题的。
使用分页的方式,其内存空间都是预先划分好的,且是紧密排列的,
仍然举一个分页多个进程申请内存的例子:比如有3个进程a b c,a占用的内存地址为0-100,b为101-200,c为201-300,如果b进程释放后101-200的内存地址也同时释放,那么此时如果来一个需要500内存的进程d时,其实101-200的内存地址是可以被d使用,d剩余的400内存地址可以继续使用301-700,也就是说d进程的内存地址不是连续的,存储在; 101-200和301-700俩个地方。也正是这种存储方式,解决段存储时的内存碎片化的问题了。
由于分段的方式要求段内
的内存必须是连续
的,而分页的方式则不需要
要求内存连续,正因为是这个原因,所以分页解决了分段的外部内存碎片化高
的问题。
在虚拟内存或者物理内存中每页大小为4kb,而小于4kb也是按照一页来存储,也就页内会出现内存浪费的情况,同理,页表会导致页内内存碎变化
的问题。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
本质是分段的方式内存交换时是按照段为维度进行交换,段的内存又大又小,而分页的方式是按照页为维度进行交换,但是页的大小是固定的。
举个分段例子,比如系统中只有1个进程,内存段大小分别为100K,200K,300K,且此时已经没有了内存空间,新的进程申请内存时需要进行内存交换。如果新的进程的一个段占用内存大小为8K,那么需要将内存段100K的这个段进行内存交换。
在举个分页的例子,比如系统只有1个进程,且内存占用400K,也就是100页,此时系统也正好没有内存空间,新的进程申请内存时需要进行内存交换。如果新的进程占用内存大小为8K,那么只需要将这个100页中取出俩页「最近没被使用」的页即可,而不像分段中直接内存交换100K。
很明显的可以看出分页的方式对于内存交换时的效率明显高于分段的方式。
自身存在的问题
简单的分页有什么缺陷吗?
有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在 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 位的系统,两级分页肯定不够了,可以变成了四级目录,或者更多级。
TLB
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
我们可以把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB
(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表
等。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
总结
虚拟内存的作用大致下面3点:
- 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
在段存储中,每个进程的内存被划分为若干段,每一段都是连续的、不可分割的内存块。这意味着进程在申请内存时,必须要找到足够大的连续内存块,否则申请将失败。这导致了外部碎片化问题,因为释放的内存块可能会分散在地址空间中,难以重组成连续的内存块,从而浪费了内存。
在页存储中,内存被划分为固定大小的页面,通常为4KB或其他大小。进程申请的内存可以跨越多个页面,这些页面可以在内存中的不同位置,不需要连续。这种方式使得内存管理更加灵活,减少了外部碎片化问题,因为操作系统可以更容易地找到足够的空闲页面来满足进程的需求,而不需要考虑地址的连续性。