CSAPP笔记06虚拟内存(下)

CSAPP笔记06虚拟内存(下)

0x00 x86-64linux内存系统

x86-64地址翻译

​ x86-64处理器理论上支持2^64byte的虚拟地址空间,但是现阶段都只开放48位或者56位的虚拟地址。未利用地高位地址都用0填充。

​ 现代x86-64处理器采用四级页表层次结构,每个进程有自己的私有页表层次。并且x86-64处理器允许页表中地址段为NULL,也就是未分配的虚拟地址对应的页表被换出到磁盘上,但是如果已经分配了,那就必须驻留在内存里。CR3控制寄存器储存当前进程的第一级页表的物理基地址,每次上下文切换时,CR3的值也会被切换。

image-20200817102325134

​ 以上是翻译的流程。VPN是 vitrual page number,即虚拟页号,VPO是virtual page offset,虚拟地址偏移。VPN又被分为四个段,对应四级页面的不同部分。

image-20200817102516217

前三级页表的条目的地址都记录的是下一级页表的物理机地址,而第四级页表的条目记录的是虚拟页对应的物理页地址或者磁盘地址。由于虚拟页和物理页的大小相同,且VPO PPO位数相同,所以最终的翻译地址就是物理页地址/磁盘地址+偏移。

​ 上图中还列出了TLB,TLB是对页表的高速缓存,以免每次访问内存都要先读页表。同样,对于在主存的数据,先检查在不在SRAM缓存中。

​ 另外,观察上图的PTE条目,属性字段除了P有效位外,还有R/W位用来控制虚拟页的读写属性;U/S用来防止用户态修改内核数据;XD用来控制是否可执行,可以用来实现不可执行栈。

linux虚拟内存系统

image-20200817103837664

​ linux为每个进程维护一个单独的地址空间。用户态上包含用户程序代码、数据、堆、共享库以及栈(该进程用户态的栈)。

​ 每个进程在虚拟地址空间的顶端,是内核态的代码和数据。内核储存在所有进程共享的物理页面(即映射到每个进程的虚拟地址空间)。比如共享的内核的代码和全局数据。

​ 而内核为了自己更方便的访问内存,又在内核虚拟地址空间(也就是每个进程的虚拟地址空间的顶端)中一组连续的虚拟页面直接映射到连续的全部物理地址。(比如内核要访问该进程的页表,实现这样的映射后,访问页表就很直接了)。

​ 但是每个进程的内核虚拟地址空间中也有不共享的页,比如,指向页表本身物理地址的页,不同进程的内核栈,以及记录虚拟地址空间结构的各种数据结构

①linux虚拟内存区域

​ linux将已经分配的一组连续的虚拟地址空间(虚拟页)组织成区域。比如,代码段、数据段、堆、贡献库和用户栈都是不同的区域

image-20200817105759432

​ linux使用链表来记录所有的区域,每个区域节点记录了该区域对应的虚拟地址的start和end。当在进程中引用一个地址时,内核会先根据这个链表来查找这个虚拟地址属于哪个区域,如果不属于任何区域,那么该对该虚拟地址的访问是非法的。所以,我们分配虚拟地址空间,要么归属于新建一个区域,要么归属于原有的区域。

​ 上图中的vm_area_struct结构即记录了所有区域。内核为了管理不同进程,为每个进程维护了一个task_struct任务结构,task_struct (即task state segment,TSS的加强版),其中mm_struct结构记录虚拟内存的当前状态,里面既包含了当前进程的页表第一级的物理基地址,又包含了vm_struct_area区域的集合。

​ 让我们仔细审查上面的vm_struct_area区域节点,包含了下面的字段:

image-20200817110856621

​ start和end是包含的虚拟地址的起始地址,而prot(protection)这个区域里所有页的读写许可权限(天啊,PTE页表条目上有读写属性,这里又有,不过这个是所有位于区域里的页的)。而flags则是下面会讲到的,共享还是私有区域。next则构成链表。

②linux缺页异常

​ 假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页(有效位为0).缺页异常导致控制转移到内核的缺页处理程序,处理程序执行如下流程:

​ 1)虚拟地址本身是合法的吗?即A是否在某个区域结构定义的区域内?缺页处理程序会搜索区域结构的链表,把A和每个区域中的start和end做比较。如果该地址不合法,那么触发段错误Segmentation fault,终止进程。值得注意的是,一个进程可以创建任意数量的区域,因此linux会将上面的区域结构vm_struct_area组织成一棵树,在树上进行查找,降低查找开销。

​ 2)如果该地址本身合法,那对该地址的访问合法吗?也就是进程是否有读、写或者执行这个区域页面内的权限?(PTE上的属性控制),如果试图进行的访问不合法,那么会触发保护异常(同样表现为segmentation fault)

​ 3)如果该地址本身合法,访问也合法,那么会处理这个缺页:选择一个牺牲页,如果该牺牲页对应的PTE上D位为1(即脏位为1,已经被修改过)那么就分配一个磁盘位置,将该PTE上对应的物理页换出,页换出后,将PTE地址段改成磁盘位置,并将有效位置0。同样的,将缺页的PTE上磁盘位置里的数据,写到该物理页中,然后将PTE上的地址改成物理页的地址,有效位置1.至此,处理程序结束,返回到引发缺页的那条指令,重新执行。

image-20200817144641884

0x01 内存映射

​ 在介绍内存映射之前,先了解如何在用户进程(特权级3的进程)读取一个文件的内容到进程的虚拟地址空间。

read调用

​ 首先,我们用系统调用open函数获得一个文件的描述符,然后分配足够的虚拟地址空间,用malloc在堆中分配(linux对堆的大小没有限制)出一个缓冲区,用来把保存文件数据,然后调用系统调用read函数,将文件读到该缓冲区中。但是read函数是怎么实现的呢?

​ read函数时,内核访问磁盘驱动,先从磁盘读数据到内核缓冲区再从内核缓冲区对应的物理内存处复制到传入地址空间对应的物理内存处

​ 可以看到,调用read函数读文件到进程的地址空间,发生了一次磁盘读取,一次物理内存复制。这个物理内存复制其实是没有必要的!并且read一次将整个文件读入到物理内存!

mmap调用

内存映射(memory mapping)应运而生,它直接将磁盘上的文件映射到进程的虚拟地址空间,将虚拟内存系统

​ 具体而言,它的工作原理是,创建一个区域,分配文件大小的虚拟地址空间(如果文件不是4k对齐的,分配4k对齐的大小,多余的零填充),但是将这些虚拟页对应的有效位置0,地址段填文件对应的磁盘地址。由于操作系统是页面按需调度的(demand paging),因此并不会立即将磁盘上的数据换入物理内存,而是访问哪一个页,等缺页时才在处理程序中换入。

​ 这样,通过mmap读取磁盘文件到内存,并不是从堆这个区域,而是新建一个区域,页面按需调度地访问磁盘文件某一部分,并且不发生物理内存的复制!

​ 值得注意的是,linux中的内存映射不仅支持磁盘上存在的文件,也支持匿名文件,所谓匿名文件,是由内核创建的全0页。如果内存映射到一个匿名文件,那么会创建一个区域,这个区域里的每个虚拟页的PTE对应的地址都是全0的物理页或者磁盘。匿名文件的作用是,用来创建一个全0的区域

​ 其实,虚拟内存系统在物理内存不足时,将进程的地址空间里的数据从物理内存换出到磁盘上,又换入,本身就是一种广义上的内存映射,只不过这些数据可能不对应某个磁盘上的文件;而内存映射,则挑明了,就是一个文件中的数据,我们要使用的数据就是磁盘上的一个文件,但是我们并不想一次性全部加载到物理内存(即使物理内存足够大,我们能做到)。

​ 虚拟内存系统中,决定的能够分配的最大虚拟地址空间,取决于物理内存和能够用来换入换出的文件大小,我们称为交换文件(swap file)

共享对象(共享区域)

​ 许多进程有同样的只读代码区域(是同一个可执行文件的实例,或者共享库,或者内核代码)。如果每个进程都在物理内存中和磁盘文件中维护同样的副本,那就是极端浪费了,我们在之前链接章节,了解到共享库解决了共同使用的代码避免在磁盘重复浪费的问题。现在,内存映射机制给我们提供了一种清晰的机制,用于控制多个进程如何共享对象。

​ 虚拟地址空间中的区域,有一个属性,表明该区域是共享的还是进程私有的。同样的mmap函数,可以传入参数表明该对象是共享的还是私有的。

共享对象/区域

​ 如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个区域会被设置称同样的共享/私有属性。对该区域的任何写操作,对于其他的也映射了该共享对象的进程也是可见的,而且这些变化也会反应在磁盘的原始对象中

#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(){
    int i=0;
    struct stat f_info;
    int f=open("data.txt",O_RDWR);
    fstat(f,&f_info);
    void*p=mmap(NULL,f_info.st_size,PROT_WRITE,MAP_SHARED,f,0);
    char*p1=(char*)p;
    pintf("content:%s\n",p1);
    p1[0]='H';
    close(f);
    return 0;
}

image-20200817164218221

​ 共享对象在不同进程中映射的示意图:

image-20200817164808062

​ 注意,映射的区域在各自虚拟地址空间中可以是任意位置。

​ (8-19日补充:共享库只有只读代码段是共享区域,而数据段对于每个进程都是私有对象,写时复制,所以全局变量等不会互相影响!)

私有对象/区域

​ 如果对一个映射到私有对象的区域做出改变,那么对其他进程不可见,并且进程对这个区域所作的任何操作都不会反应在磁盘的上的对象中。

​ (将上述代码中改为私有对象后MAP_PRIVIATE)

image-20200817164710291

​ 而私有对象则使用一种叫写时复制(copy-on-write)的技巧被映射到虚拟内存中。这种技术中,私有对象在刚开始被映射时,和共享对象一样,虽然映射到各自虚拟地址空间的区域,但是对应的是同一个物理内存区域:

image-20200817165131205

​ 但是,如果某个进程试图写私有的区域中的一个页面时,会在物理内存中创建该虚拟页原本对应的物理页的副本,然后将虚拟页的PTE更新,映射到副本物理页

image-20200817165730304

​ 具体实现细节时,如果一个对象是私有的,那么映射到进程地址空间里的区域所有页被设置为只读,该区域被设置为私有的、写时复制,如果读该区域,没有任何问题,访问的是共享的物理页,但是如果写该区域里的页,就会产生异常,异常处理程序就指向前面的过程,然后重新执行该指令

写时复制的好处时,最大程度地共用相同的物理内存

再看fork函数

​ 调用fork函数,内核为新进程创建各种数据结构,分配pid,复制当前进程的mm_struct、区域结构和页表的副本,(此时新进程的区域和调用进程的区域完全一致,并且对应的物理内存页相同,因为页表被复制了)。然后内核将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制

​ 这样,当两个进程进行写操作时,会创建新物理页副本,然后更新PTE。

再看execve函数

​ 内存映射在加载程序时也发挥作用,如execve(“a.out”,NULL,NULL);会执行以下几个步骤:

​ ①删除原进程中地址空间里用户部分的所有区域结构,

​ ②映射私有区域,为新程序的代码、数据、bss(匿名文件)、堆(映射到匿名文件)、栈(匿名文件)创建新的私有的(自动写时复制)区域结构。映射到匿名文件,那么初始时全0。

​ ③映射共享区域,比如共享库,会创建共享区域来映射共享库文件。

​ ④设置指针计数器PC,从代码的入口点执行。

image-20200817171211493

mmap函数

​ mmap调用可以实现内存映射,会创建一个新的内存区域。

#include <unistd.h>
#include <sys/mman.h>
void*mmap(void*start,size_t length,int prot,int flags,int fd,off_t offset);   //成功返回对象映射的区域的指针,出错返回-1

image-20200817171524352

image-20200817171533397

注:stdin stdout stderr是三个file指针,其对应的fd(file descriptor)分别是0,1,2

​ 使用open可以获得一个文件的fd,最后要调用close关闭。read可以从文件中读数据到缓冲区buf*,write则可以写数据到文件,使用fd1可以write到stdout,显示到屏幕。