Linux虚拟内存机制-进程地址空间
最近钻研CSAPP,看到虚拟内存章节,反复的看了很多相关的书和材料,感慨内容很多、细节很多。写下自己的心得笔记。
物理寻址
内存,可以把它看做一个连续n字节的大数组,每个字节对应一个物理地址。基于此,CPU访问内存最自然的方式就是物理寻址。早期的计算机或者现在的嵌入式微控制系统比如微波炉、电梯这种,CPU即是直接操纵物理内存。像这种嵌入式使用,其内存运行环境比较单一,通常单道编程即可,所以直接物理内存寻址也很自然。
虚拟内存
而像Linux服务器或者PC机上常见的多道编程的计算机,就对内存管理提出更高的要求,内存里如何加载多个进程?
一个简单的想法是,来一个进程分配一块该程序大小的内存。抛出几个问题来思考下:
1)如果进程运行时需要更多的内存,超出分配的大小,怎么解决?
2)再比如一个进程来了,内存总空余大小满足,但是不连续,没法运行。这种外碎片问题。
3)内存管理怎么保护各个进程的内存不被恶意或者不小心访问。等等。
当然以上的问题也并非没有解决方案,因为在虚拟内存提出以前就是这样做的,但是解决方案复杂又低效。 这就提出了虚拟内存的概念,也被认为是计算机科学里一个非常牛逼的思想。
在说虚拟内存这种硬(枯)核(燥)的概念之前,先对其有个简单的印象:进程地址空间是对物理内存的一个抽象,它提供所有进程一个统一的界面。说白了让每个进程都有自己独占内存空间的错觉。
虚拟内存思想概念
虚拟内存(virtual memory)基本思想:每个进程有自己的地址空间(address spaces),这个空间被分割成很多块,每个块被称作页(page)。每一页有连续的地址范围。这些页被映射到物理内存(不需要连续),而且不需要进程所有的页面都加载到内存才能运行起来程序。当程序执行到需要的地址空间的页面在物理内存时,由硬件(比如MMU内存管理单元,TLB快表)立即映射出物理地址。如果所需地址空间的页面不在物理内存中,则由操作系统负责把这些页面装入到内存,并重新执行失败的指令。
虚拟内存寻址
地址空间 (address spaces)
每个进程有自己的地址空间。什么是地址空间? 说白了,地址空间就是连续的非负整数有序集合,对字节地址的一种描述。比如举例我们常见的32位或者64位地址空间,32位表示的虚拟地址空间就是{0,1,2….,2^32-1},对应的是0-4G。如果物理内存只有2G,那物理地址空间就是{0,1,2….,2^31-1}。虚拟内存的一个关键点就是,虚拟地址会映射到物理地址上。
页(page)
地址空间被分割成很多块,每个块叫页。页具体是什么? 概念上,虚拟内存是N个连续的字节大小块组织在磁盘上,用主存充当虚拟地址空间的缓存。在存储器的层次结构中,磁盘上的数据(低层次)被分区为块,这些块充当磁盘和主存(高层次)之间的传输单元。虚拟地址空间按照固定的块大小分割,这个块就是页,物理地址也按照同样大小分成物理页。通常一个页是4Kb。
进程的内存布局
搞C/C++的同学心中必须要有的一个镜像:内存布局。当然任何想了解程序运行的同学都应该对此了如指掌。 Linux下典型的进程地址空间的内存布局(32位地址空间): 0xc0000000 -> 3G ,内核态和用户态区间。
32位寻址空间最大可以寻址到4G内存。0-3G作为用户态空间,3-4G作为内核态空间。这里有的同学可能会迷惑,我特意说明一二: 1). 这个内存布局指的是进程的地址空间,而非实际的物理内存。比如32位寻址空间的计算机只有1G的物理内存,虚拟地址空间仍然是这个内存布局模型。 2). 分配了1G内核空间,3G用户空间,这不代表内核运行就占用了1G的物理内存,只是说它有这个能力。 3). 可能有同学会想,这个虚拟内存地址怎么和物理地址关联起来,用的什么维度单位描述的内存空间?这就是后边要讲的页表。 简单阐述下这个内存布局的各个段(虚拟内存区域)。从内存低到高
- 代码段(text segment) : 代码段就是可执行文件。
- 数据段(data segment): 数据段,是可执行文件中的初始化的静态变量。
- bss段: bss段可以理解是未初始化的全局变量,内存映射成0值。
- 堆区: 程序运行时需要分配的内存区域 比如我们熟悉的 malloc分配的内存。
- 内存映射: 诸如C库或动态链接库的文件映射、匿名映射区域。
- 用户栈区: 比如局部变量、 函数参数等。栈的增长方向是往低地址的。我们熟悉的后进先出的栈数据结构。
虚拟内存好处
1). 让内存使用的更高效 把DRAM视为虚拟内存的缓存,而不是把进程拥有的一股脑都加载到内存里,只加载需要的。
如图,同一时刻,虚拟地址空间中有三种状态的不相交子集。
- 未分配的 表示虚拟内存中还没有分配的页。比如虚拟页VP0
- 已缓存的 表示虚拟空间中已经分配的页面,并且已经在物理内存中了。比如虚拟页VP1,对应的物理页PP1
- 未缓存的 表示虚拟空间中已经分配的页面,还没有在物理内存中。比如虚拟页VP2
2). 简化了内存管理 虚拟内存让每个进程以为自己独占了整个内存,每个进程有统一的线性地址空间,这极大方便了管理。
比如文本区虚拟地址空间起始处总是在0x08048000,共享库起始处总是在0x40000000,内核态总是在0xc0000000 等等。这种统一的界面显然简化了链接、分享、加载、内存分配的处理。
如图,两个进程i,j有各自的进程地址空间,可以共享映射同一个物理页。
3). 隔离地址空间,内存保护 一个进程不能干预另一个进程的内存(指两个无关的独立进程) 用户进程不能访问特权的内核信息、代码。
地址翻译和相关概念
页表(page table)
虚拟地址空间中的页在或不在物理内存,地址映射关系是什么?这就要引出页表的概念了。 页表说白了就是虚拟地址翻译成物理地址的一个映射表,它是处在内存中。 PTE(page table entry)是页表里某一个元素,页表项的概念。 如图一个页表,为了说明问题,先简单认为PTE只包含,valid位,和地址位。
- valid :表示该页是否在物理内存中缓存了。
- 地址位 :指向物理页号或者磁盘地址。
valid是1,表示缓存了,地址位则指向物理内存。valid是0,如果地址位是null,表示未在虚拟地址空间分配,否则指向磁盘地址。
页命中(page hit)
页命中的概念顾名思义。仍拿上边页表举例,比如CPU发出虚拟地址定位到VP3,发现Valid位是1,表示该页缓存到内存中,即是页命中了。
页命中和不命中的成本相差巨大,还是涉及到存储器层次结构中,DRAM和磁盘的区别。 页命中示意图:
MMU(memory management units)是内存管理单元,作为硬件,CPU芯片的一部分。
{虚拟地址VA解析出虚拟页号VPN(加上内存的页表基址)得到PTEA(页表项地址),取出页表项内容(包括物理页号),得到物理地址,再从内存取出数据对象。} - 大括号这句话写在这里,看完下边的地址翻译内容(比如虚拟页号、物理页号、偏移量的概念),回头再看就更明白了。
缺页异常(page faults exception)
页没命中,即我们经常听到的缺页异常。仍拿上边页表举例,比如虚拟地址定位到VP4的页上,发现页表中PTE的Valid位是0,触发了缺页异常。
缺页异常会调用内核的缺页异常处理,在这个case里,DRAM已经满了,需要按策略在内存里选择一个页面牺牲,替换进来这个缺页。比如VP3存在PP3里,把VP3替换出来到磁盘上(注意如果VP3这个页面在内存里被改动过,内核要把它拷贝到磁盘上),但无论如何它的页表项PTE3要改,记录对应的VP3不在内存缓存着了。
接下来,内核会把VP4拷贝到PP3中,也要更新页表项PTE4,记录VP4在内存了。此时结束缺页异常处理,并重新执行这个导致缺页的指令,重新解析这个虚拟地址对应的物理地址,这次发现页命中了(如图步骤7)。
缺页示意图:
内存保护
内存管理的一个重要目标就是保护内存不被非法访问。比如用户进程去写一个只读的代码文本区、用户进程去修改内核代码和数据结构、用户进程未经其它进程允许修改一个共享库等等。
虚拟内存的机制也很方便的实现对内存进行保护。还记得刚才的页表项么?也是利用它。 通过给页表项增加位来表示允许的操作。(为了说明问题,这里的PTE只展示了权限位,缺省之前介绍的valid位)。如SUP是只能运行在内核态才有权访问页。
如图中,进程i 和进程j 有共享的物理页PP6,进程i 没有对它的写权限,进程j 有对它的写权限。
地址翻译
在虚拟内存系统下,应用程序使用的都是虚拟地址,但是CPU真正获取数据对象还是要用物理地址的。因此这需要有地址翻译的系统,比如上边的页表就是软件层面做翻译的数据结构。
虽然真的已是一图胜千言,还是要再描述下。尤其VPN和PPN可能会让人迷惑。 图里有几个对象角色:
- PTBR :页表的基址寄存器。我们知道每个进程都有自己的页表,页表是在内存。那CPU寻址翻译来查页表,去哪查?它就是这个作用,利用寄存器存页表在内存的位置。
- 虚拟地址:分成两个部分组成。高位的VPN(virtual page number)虚拟页号,低位的VPO(virtual page offset)虚拟页的偏移量。这两个组成部分是紧密相关的,如前边所说,一个虚拟地址空间平均分成了多少页,一个页有多少kb,两者相乘就是整个地址空间。有多少页就是这个VPN(页号),一个页有多少kb就是这个偏移量的p。比如4kb大小的页,寻址某个字节就在4kb页内,具体是哪个字节起始,就需要这个VPO偏移量指定。页号VPN则显然作为页表的索引。
- 物理地址:和虚拟地址表示法一样,也是由页号+偏移量决定。注意,偏移量两者是相同的,因为页大小单位是固定的。因为物理内存大小和虚拟内存地址空间大小并不是一定相同,因此二者能表述的页面数就不同,自然页面号占用的位也不一定相同。
还是拿2G内存举个例子:32位寻址空间,页大小4kb。那么n是32,m是31(2^31=2G),p是12(2^12=4kb),虚拟页号用20bit表示,物理页号则是19bit表示。【页号总数=内存空间大小/页大小】
TLB (translation lookaside buffer)
从上边我们知道,CPU产生一个虚拟地址,MMU每次都要从内存(或者L1高速缓存)中获取一次PTE来拿到PA,然后再从内存取出想要的数据,要耗上两次内存操作。
获取PTE的内存操作怎么看都是一种浪费…对,所以再请出来一个硬件TLB,我们称之为快表,让TLB把PTE缓存起来。
一个TLB命中的示意图(未命中则再从内存中获取,再缓存到TLB中):
多级页表
我们回过头来再看页表这个结构,页表是要常驻内存的,而且注意每个进程都有自己的页表。 给定一个32位地址空间,4KB的页,PTE设为4byte。那么页表需要占用多大内存?【2^32/2^12 * 4 = 4MB 】。如果是64位地址空间,那一个页表的内存会非常巨大。 怎么压缩页表呢?一般是采用对页表分层,不是前边讲的只有一层的页表。(Linux一般是三级页表)
二级页表的表示:Level1每个PTE指向Level2的一块PTE组,Level2的每个PTE再指向最终的物理地址映射。如果Level2的一组PTE块有1024个PTE(让它这一组PTE是4KB的大小,等于一个页的大小),就是包括了1024个页(4MB的虚拟内存空间)。这里可能有点绕,再说一遍,是4KB的大小(这是一组页表项PTEs占用的内存大小,1024个PTE)表达了4MB的内存大小(一个页4KB,1024个页表项)。这样如果4G内存空间,L1页表只需要1024个PTEs即可。
这里举个例子画个图能很好的说明问题:现在地址空间布局是这样,给代码区和数据区分了2K个页内存,接下来6K的页未被分配,再接下来1023个页也没被分配,再下来1个页面分配给了用户栈。
因为中间有6K的空间未分配,所以一级页表PTE2-PTE7的6个页表项都是null,这样对应的二级页表也根本不需要分配空间。这无疑减少了内存的占用,因为一个进程运行在4G虚拟内存空间,其中大多数里头都是未分配的。而且只有这个一级页表需要常驻内存,二级以后并不需要常驻,同样减少了内存占用。
最后来一个K级页表示意图:
写完了不容易,看书就反复看了好多天,写笔记也写了好多天…获取知识的路上收获满满。
参考
- 《深入理解计算机系统》
- 《现代操作系统》
- 《操作系统之哲学原理》