CSAPP阅读笔记-虚拟内存-寻址和内存映射-来自第九章9.1-9.8的笔记-P559-P586
虚拟内存的概念:
我的理解:虚拟内存是一种对主存的抽象概念,虚拟内存由虚拟地址标记,虚拟内存一般比实际物理内存大,但其实它并不是真正的物理内存,对外部来说,似乎内存很大,实际上虚拟地址标记是与磁盘上的文件关联的,实现了对物理内存的“感官性的放大”,实际要使用关联的文件时再把它从磁盘复制调入内存。这种抽象其实是基于第八章讲述过的进程的概念提出的,有了进程的概念,我们就可以理解程序为什么可以切成一个个部分,并发执行。同样的,对于很大的,无法全部在内存中执行的程序,也可以切成一个个小部分,通过虚拟内存的概念,存在磁盘上,运行程序时,动态地从磁盘中加载程序的某一部分到内存中执行。
除此之外,虚拟内存的另一个好处是可以为每个进程提供一致的地址空间,如第八章展示的那样,每个进程分几个固定区域,这样可以简化内存管理,另外,还可以保证每个进程地址空间不被其它进程破坏。
虚拟内存的寻址:
虚拟内存使用虚拟寻址来访问主存,如下图所示:
CPU生成虚拟地址(VA),随后通过MMU硬件(位于CPU芯片内),按照一定的规则去主存中对应的页表中找到对应的页表项并返回给MMU,MMU据此构造实际的物理地址并去访问主存,主存返回数据给处理器。
虚拟内存与磁盘上的文件关联,但不等价于磁盘上的文件,虚拟内存的基本单位是“页”,对每一页,如果它未被分配,则没有数据与之关联,不占用磁盘空间,若已分配,则可进一步被划分为是否被缓存,用以指代该页是否被缓存在主存中,如何检测某一页是否已经被分配呢?页表的每一个条目都有一个有效位,它代表当前当前页是否被缓存在主存中,若设置了有效位,则页表该条目后面的地址字段就指向主存中缓存的物理页,否则,指向磁盘的物理页。可以看到,页表中出现的每一条目都是已分配的。
缺页异常:
虚拟内存的基本单位是“页”,对每一页,如果它未被分配,则没有数据与之关联,不占用磁盘空间,若已分配,则可进一步被划分为是否被缓存,用以指代该页是否被缓存在主存。
当MMU找到的对应的页表条目有效位没有设置时,代表此页在磁盘上,还没有被缓存到主存中,此时触发缺页异常,缺页异常处理程序选择一个牺牲页(主存中的牺牲页),先查看此页是否已经被修改,被修改了则将它复制回磁盘,随后修改这一页的有效位,反映它不再存在于主存中,随后从磁盘复制缺的页并更新对应的页表条目。异常处理程序返回后重新启动导致缺页异常的指令,完成正常处理。
具体案例如下:
首先MMU访问PTE3(页表条目3,对应虚拟页为VP3),有效位为0,触发缺页异常,内核选定物理页3(PP3,此时对应VP4)为牺牲页,查看VP4是否被修改并决定是否要复制回磁盘,随后修改VP4的有效位为0,代表VP4已经不在主存中了,即解开VP4与PP3的关联,随后从磁盘复制VP3到主存,并修改页表,使VP3与PP3关联。异常处理程序返回后重新启动导致缺页异常的指令,完成正常处理。
之前讲的缺页,本质是把已分配,但未缓存的页,从磁盘上缓存到主存里,那么存不存在新建页的情况呢?
答案是malloc,malloc可以在磁盘上创建新的空间,并更新对应的页表项指向磁盘上的新创建的页面(注意此时有效位仍为0)。
虚拟内存在内存管理上的作用
主要体现在两方面:
一是虚拟内存把每个进程都抽象为同一种形式,每个进程有其独立的代码段,数据段,堆,栈等区域,方便管理。
二是方便向内存加载可执行文件和共享对象文件。第八章中我们知道,加载器在将程序加载到进程时,是通过替换进程内部的各区域来实现的。
实际上,具体过程是:加载器为代码段和数据段分配(新增页表条目)虚拟页,将页表条目指向目标文件中的对应位置,同时会把条目标记为无效的,根据上面的知识可知,这代表条目对应的地址字段指向磁盘的物理页,因此加载器实际上没有把数据从磁盘拷贝到主存上,只有在某条指令真正引用到此条目对应的内存位置时,才会按需调入到内存中。
三是方便共享,每个进程都有一个独立的页表,因此会存在不同进程的页表项对应的不同虚拟页映射到同一物理页的情况,方便不同进程共享代码。
四是简化内存分配,由于页表这种分条目的工作方式,在分配内存时就没必要分配整块的物理内存,页面可以随机分散,大大简化了内存分配。
地址翻译的具体步骤
如下图,CPU里有个页表基址寄存器,指向页表,n为虚拟地址分两部分:虚拟页号(VPN)和虚拟页内偏移(VPO),前者用来选择适当的页表条目,从页表条目中得到对应的物理页号(PPN)并与物理页面偏移(PPO,它与VPO位数一样)结合起来的到实际物理地址。
举个实际的缺页访问案例:
如上图所示,缺页访问时步骤如下:
1.CPU生成虚拟地址,传给MMU
2.MMU生成页表条目地址,去高速缓存/主存中请求得到它
3.高速缓存/主存向MMU返回页表条目
4.返回的条目有效位为0,MMU触发缺页异常,调用内核中的缺页异常处理程序
5.缺页处理程序确定内存中的牺牲页,检测牺牲页是否有改动,有则写回磁盘
6.缺页处理程序调入新的页面并更新页表条目
7.处理程序返回导致缺页的指令,再次执行。
若使用了高速缓存,则上述的步骤会有微小变化,主要反映在地址翻译会发生在高速缓存查找之前,相当于在主存和MMU中加了一层高速缓存,因此高速缓存里也会有一份页表(页表条目可以缓存),MMU生成页表条目地址后直接去高速缓存中请求,若不命中,高速缓存会继续向主存的页表请求,同理,若返回的条目有效位为0,触发缺页异常后,高速缓存会进一步向主存中请求调页。
进一步的改善是使用TLB,它是MMU里的一个缓存,缓存一部分的页表条目,它是组相联的,根据VPN可以进行组选择与行匹配,从而选中某个页表条目,剩下的步骤和之前的类似,若页表条目命中,则根据得到的物理地址去高速缓存取数据,否则会从高速缓存中取对应的页表条目,替换一个已有的条目。TLB的优势在于地址翻译都是在芯片上的MMU进行,速度非常快。
举个例子:
假设虚拟地址14位长,物理地址12位长,页面大小64字节,TLB四路组相联,总共16个条目,L1高速缓存是物理寻址,直接映射的,行大小4字节,共16组。
假设现在CPU执行一条读取0x03d4处的指令,现在进行分析:
首先,页面大小64字节,代表VPO和PPO都有6位,所以VPN有14-6=8位,因为TLB四路组相联,因此组索引有2位(TLBI),剩下6位是标记位(TLBT),用来进行行匹配。
MMU先从虚拟地址中抽取出VPN,如下图:
此时先查看TLB,用TLBI作组索引匹配,用TLBT作行匹配,TLB实际情况如下图
此时匹配成功,得到PPN为0x0D,与VPO连接,得到物理地址0x354。
随后MMU把物理地址发送给高速缓存,这里高速缓存有16个组,所以组索引是4位,行大小4字节,所以块内偏移有2位,剩下12-4-2=6位是标记位,用来进行行匹配。
缓存从中抽取出块内偏移CO(0x0),组索引(0x5)和行标记(0x0D),如下图:
随后根据它们去高速缓存中进行匹配,高速缓存具体情况如下:
可以看到,缓存成功命中,取出的数据是0x36,返回给MMU,MMU再回传给CPU。
注意,这里是假设了取PTE和取实际数据都命中,有可能会有这种情况:取PTE在TLB中命中,但是根据拼出的物理地址到高速缓存取数据时发生不命中。
多级页表
当页面很多时,页面条目也会很多,会使页表过于臃肿,此时可以采用多级页表。
举个例子:
一级页表中每个条目对应一个4MB的片,它由1024个连续的页面组成(对应二级页表),如果某个片中每个页都没有被分配,则对应的一级页表条目为空。
它的优势:
1.假设有很多连续的页是未分配的,则很可能对应的二级页表不存在,也就没必要占有很大的页表空间,若是单级页表,这些未分配的条目都是要占用页表空间的。
2.主存中只需要存放一级页表,二级页表可以在用到时再临时调入,节省空间。
那么,如何根据CPU生成的虚拟地址,结合多级页表,来找到最终的物理地址呢?
如下图所示,根据页表的级数不同,虚拟地址会被划分同样个数的VPN和一个VPO,每个VPN对应该级页表的索引,某一级的每一个页表条目,都指向下一级某个页表的基址,最后一级则指向PPN,这里有个要注意的,就是内存中存在很多进程,每个进程有自己的页表,因此同一级别的页表是很多的,这就是为什么某一级的页表条目会指向下一级页表的基址,因为基址有很多,需要选定后再配合索引找到对应某一级的页表条目。
注意:页表条目中不是只有对应的PPN,它还会有一些位,用于标记各种权限,具体看书就行。
Linux虚拟内存系统
如之前所述,每个进程的虚拟内存结构都一样,有代码段,数据段,栈,堆,共享库,内核区等等。
有几个注意的点:
1.每个进程虚拟内存中的内核的某些区域(如内核代码,全局数据结构)会被映射到所有进程共享的物理页面。
2.Linux中,对一组连续的虚拟页面,会同样映射到一组连续的物理页面。
内核区的数据结构如下:
内核为每个进程维护一个单独的任务结构task_struct,里面有一个条目指向mm_struct,用于描述虚拟内存当前状态,里面的pgd条目指向第一级页表基址,当运行进程时,pgd会放在CR3控制寄存器中,mmap条目指向一个vm_area_struct链表,它描述了虚拟地址空间的不同区域,区域结构如下:
vm_start:指向区域起始处
vm_end:指向区域结束处
vm_prot:描述区域内所有页的许可权限
vm_flags:描述区域内页面是否为进程私有
vm_next:指向下一个区域
任意虚拟页都属于某个区域
应用场景:
假如当前正在翻译某个虚拟地址,触发了缺页异常,跳转到内核,此时内核会做一些检查,比如:
1.该虚拟地址是否合法,是否在某个区域内。检测方法为搜索区域结构的链表,前面说过,Linux中,对一组连续的虚拟页面,会同样映射到一组连续的物理页面,而且,任意虚拟页都属于某个区域,因此只要将该虚拟地址与每个区域结构的vm_start和vm_end作比较(这里如果顺序搜索链表,效率较低,Linux里构建了树来进行查找),若不处于两者之间,代表不处于这个区域,若该虚拟地址不属于任何区域,说明指令不合法,触发段错误。
2.内存访问是否合法。前面我们说过,页表条目里不仅存有PPN,也有一些用于标记权限的位,内核会对其进行检测,若不合法,触发保护异常。
3.若以上两点都合法(即虚拟地址落在地址空间的某个页面范围内),则正常处理(调入页面)。
如之前所述,每个区域都会映射到对应的磁盘空间上文件上(比如可执行目标文件),映射时会有一些特殊情况,比如:
1.区域比文件大,此时会用0来填充区域剩余部分。
2.区域可以映射到匿名文件(由内核创建,全是二进制0),注意对映射到匿名文件的区域内的页在第一次调用时,虽然有缺页异常,按照之前的说法,要从磁盘拷贝对应的页进牺牲页,但这里因为是全0,会直接用0覆盖牺牲页,不会有磁盘和内存间的数据传送。
之前我们提到过,每个进程都有一个独立的页表,因此会存在不同进程的页表项对应的不同虚拟页映射到同一物理页的情况,方便不同进程共享代码。
那么如果有这样一种需求:需要某一段可执行文件被两个进程共享,但又要求两个进程对它写的时候不会互相影响,怎么办呢?
解决这个问题用的是一种叫做“写时复制”的技术,此时被映射的文件仍旧只有一份副本,被两个进程共享,此时每个进程对应的页表条目会把映射的页标记为只读,区域结构标记为私有的写时复制,若没有进程试图对文件进行写操作,则相安无事,一旦有进程试图写此文件,会触发保护故障,故障处理程序发现进程试图写私有写时复制区域的页面,则会在主存中为此页面创建副本,更新页表条目指向这个副本,随后恢复此页面的可写权限,故障处理程序返回后,CPU重新执行写操作,此时可顺利进行,这样可以节省内存。
第八章提到过的fork函数可以创建新进程,新进程有着和父进程同样的上下文状态,其实指的就是创建了父进程的mm_struct,区域结构和页表的原样副本,而且它会把父子进程的每个页面都标记为只读,每个区域结构标记为私有的写时复制,通过这种手法,既节省了内存空间,又为每个进程保留了私有的地址空间。
同样的,execve函数也是类似的,只不过它会先将当前进程的虚拟地址空间的用户部分的区域结构删除,随后为目标文件创建新的区域结构,对于私有区域,如代码段,数据段,bss段,栈区,会把创建的区域结构创建为私有的,写时复制的。代码段会映射到目标文件的.text,数据段对应.data,bss映射到匿名文件,栈和堆也映射到匿名文件,初始长度为0。对于目标文件链接的共享库,也会相应地映射到对应的区域。如下图所示:
最后介绍一个函数,mmap,原型如下:
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset);
它可以创建一个新的虚拟内存区域,并将指定的文件的某块区域映射到新创建的虚拟内存区域,若成功,会返回新区域的地址,具体参数看书即可。
还有一个
int munmap(void *start,size_t length)
它可以删除某个地址开始的,一定长度的虚拟内存区域。
给一道例题:
答案如下:
其实这道例题没有什么太需要注意的点,大致思路就是把磁盘文件打开,得到其大小信息,创建一个与之同样大小的虚拟内存区域,把文件映射到该区域,随后将此文件写入到stdout里,可以做个实验自己试一下,创建个txt文件,随便输点东西,然后将此程序编译并运行,参数为该txt文件名,会看到控制台输出了txt文件的东西。
但我有个疑惑,既然这里实质上是把磁盘文件写到stdout里,为什么不直接用write函数呢?为什么要来个映射?
于是我搜了下write函数的用法,打算改造一下程序,看看能不能实现,结果发现write函数的第二个参数是个指针,指向源文件,而open函数返回的是文件描述符,是个int,所以无法直接open后写入到stdout中,这么看来这个mmap函数做的映射还有个把文件描述符转换为指针的功能,毕竟返回的是创建的虚拟内存区域的地址。我寻思着应该有某个函数可以直接由文件描述符得到文件的地址,或者直接打开文件时就返回文件地址,但稍微找了一下,没找到,但估计是有的,反正也不是什么大问题,先不管了。