内存分段、分页

前言

每个进程都有一套自己的虚拟地址,尽管进程可能有相同的虚拟地址,但经过映射后就是不同的物理地址了,以此来实现进程隔离等功能。

内存分段

介绍

一开始使用分段来进行内存管理的,但是用多了会发现,使用分段管理会产生大量的外部碎片。
说到分段,肯定要讲到GDT(全局描述符表)段选择子 还有段描述符
简单来说,它们之间的关系就是GDT是一个数组,里面存着段描述符,通过段选择子(索引)找到对应的段描述符。

分段.webp
具体的寻址方式就是虚拟地址=段选择子+段内偏移量
通过段选择子找到在GDT表中找到对应的段描述符,然后就是判断特权级,特权级允许后找到对应的段基址,然后再加上偏移量就是对应的物理地址了。

段描述符的结构如下图:
段描述符.webp
在进程中,我们一般将进程的TSS描述符(一种特殊的段描述符,里面存着进程的数据段、代码段等段的位置和进程运行所需所有的寄存器的值,和进程特权级等) 作为段内描述符,当进行任务切换的时候,系统加载进程对应的TSS结构来恢复进程。

// 这是我做过的一个极简版32位操作系统中的代码
// 项目地址:https://github.com/xjintong/SimilarLinux0.11
typedef struct _tss_t {
    uint32_t pre_link;  
    uint32_t esp0, ss0, esp1, ss1, esp2, ss2;
    uint32_t cr3;
    uint32_t eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;
    uint32_t es, cs, ss, ds, fs, gs;
    uint32_t ldt;   
    uint32_t iomap;     
}tss_t;

不足

分段会产生两个问题。

  • 外部内存碎片

分段会产生外部内存碎片。就是因为段的长度是不固定的,对于内存的分配有可能不是很合理,就比如说下面,空闲内存大于新进程的内存,但是由于两个空闲内存是分开的,导致新进程不能加载到内存中。

分段外部碎片.webp \

解决这个问题的方法就是内存交换
也就是先将进程2放到磁盘中去,然后把新进程放到内存中(挨着进程1放),然后再把磁盘中的进程2重新读取到内存,这个在磁盘上和内存进行数据交换的空间就是swap空间。

  • 内存交换率低

磁盘读写数据相对内存来说是非常慢的,如果是一个内存占用非常大的进程要从内从交换到磁盘,所消耗的时间是非常大的。

内存分页

一级页表

我们可以将所有的物理内存和虚拟均匀分成等份的(一般就是4KB)页,然后创建一个表,里面存储着虚拟页和物理页的映射关系,到时候就直接查表,找到对应的物理页,最后再加上页偏移就是物理地址了。

一级页表的起始位置放在CR3寄存器中。

一级分页.webp

采用分页很好的解决了外部内存碎片化和内存交换率低的问题。但是如果只用一级页表,还是有着很大的问题。

我们以32位操作系统为例,内存总共4GB(2^32) ,一个页4KB(2^12),那么就需要百万级(2^20)的映射关系,一个页表项需要4字节存储,然后所有的表项就需要4MB(2^22)内存存储。

看着不是很多吧,但是每个进程都需要一个这样的表,如果进程变多了,这得多消耗多少没必要消耗的空间。
而且我们必须有这个表的全部,因为是靠索引来找到对应的页的,你如果只取一部分那它的索引号就变了。所以我们需要多级页表来解决这个问题。

多级页表

32位操作系统用二级页表就够了,64位操作系统用四级页表,这里只讲32位的。
我们先把内存分成1024(2^10)个大的页目录,一个页目录指向一个页表(一个页表含有1024个页目录),

第一级是页目录(page directory),一共1024(2^10)个目录项,一个目录项4字节大小,所以页目录占4KB(2^22)大小内存。
第二级是页表(page table),它也是1024(2^10)个页表项,一个页表也是4KB大小。一个页表的最大寻址空间是4MB(因为1024个页表项,对应1024个页物理地址,一个页物理地址是4KB)。

一个目录项对应着一个页表,所以一个目录项最大寻址4MB,一个目录有1024个目录项,所以一个目录最大寻址4GB。
二级分页.webp
虚拟地址的高10位是页目录索引,中间10位是页表索引,最后12位是偏移量。

页目录的结构和页表的结构是一样的,只不过他俩描述的主体不一样,一个描述的是页目录,一个描述的是页表。
页目录和页表结构.webp
通过页目录和页表可以获取物理地址的前20位,后12位就是加上虚拟地址的后12位。虚拟地址的后12位存储的权限,读写标志等等。
通过二级页表我们不用保存所有的映射关系了。有一个页目录就能扫完4GB内存,然后再找对应的页表,如果没有就再创建。如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表

就像下面这样二级表有1024个,每个二级表有1024个表项并且索引都是从0开始的,这样我们单独拿出一个二级表,它的索引也不会变化。由于局部性原理,我们用不到那么多的页表项。
二级页表.webp

TLB

从虚拟地址到物理地址的转换还需要查两次表,64位的还要查4次,时间也是有消耗的。TLB里面保存了一些之前已经转换好的虚拟地址和物理地址的映射关系。
详细的请看我写的另一篇文章

Linux内存布局

inter处理器早期是用分段管理内存,后来发现不是很好用,后来又整了分页,但是还保留着分段,就是虚拟地址是通过分段获取的,然后再通过分页。
分段和分页.webp
可以看到最左边还有分段的,是通过分段获取的线性地址。

但是分段确实有点弊端,Linux无法改变硬件,只能改变自身了。Linux用了一个技巧使分段相当于失效了。

Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。

Linux的地址空间分为内核空间和用户空间。

Linux内存分配.webp
在内核空间是不开启分页机制的,所有进程共享内核内存。有人认为这是为了防止从用户态到内核态的性能消耗。

最后

每个进程都有完全属于自己的地址转换表,所以即便是相同的虚拟地址空间也会映射到不同的物理地址上,以此来达到隔离进程的效果。
在进程看来,自己是独享整个内存,自己可以随便找个地址,但其实是通过页表映射到个各个物理上。

THE END