虚拟内存与mmap,brk


 1. 基本概念及相关术语

1.1 基本概念

        虚拟内存使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。即将不完整,不连续的物理内存映射为连续的虚拟内存。虚拟内存主要有以下三个作用:

(1) 它将主存看成是磁盘的一个高速缓存,只在主存中保存活动区域(通常一个进程只有执行上下文被加载到主存,其余的在磁盘中,随用随加载);

(2) 为每个进程提供一致的地址空间,简化了内存管理;

(3) 它保护每个进程的地址空间不被其他进程破坏(在页表的PTE条目中加入额外控制信息实现内存保护)。

  虚拟内存有两个重要的地址,虚拟地址(virtual address, VA)和物理地址(physical address)。在访问某个对象时,CPU给出虚拟地址,通过查询计算得到物理地址,然后访问物理地址上的对象。整个过程如下图:

                

                                            图1  CPU访问主存

1.2 相关术语

在表述虚拟内存相关概念时,有些约定的缩写和表达方式

  • N=2n:虚拟地址数量,n表示虚拟地址位数;
  • M=2m:物理地址数量,m表示物理地址位数;
  • P=2p:页大小,p表示页偏移量的位数;
  • VPO(virtual page offset):虚拟地址页偏移;
  • VPN(virtual page number):虚拟地址页号
  • PPO(physical page offset):物理地址页偏移
  • PPN(physical page number):物理地址页号。
  • 页表(Page Table, PT):记录虚拟地址到物理地址的映射的表
  • 页表项(Page Table Entry, PTE):页表中一行,PTE的索引即VPN;
  • 页表项地址(PTEA):在CPU中有个页表基址寄存器,记录页表起始地址,页表基址寄存器+PTE索引=PTEA;
  •  MMU(Memory Management Unit):内存管理单元,用于虚拟地址到物理地址寻址的硬件。

一个页表的常见结构如下图:

                          

                                      图2 页表常见结构(有效位表示该PTE是否有VP到PP的映射)

eg: 给定一个32位虚拟地址空间和一个24位物理地址空间,,对于下面的页大小,确定VPN,VPO,PPN,PPO的位数。

P VPN位数 VPO位数 PPN位数 PPO位数
1KB 22 10 14

10

4KB 20 12 12

12

注:VPO表示对象在页中的偏移,VPO=PPO,VPO位数=log2(P),VPN表示虚拟页号,对应PTE表索引,PPN表示物理页号。

  一个虚拟地址翻译成物理地址,方法如下图:

       

                            图3 虚拟地址翻译为物理地址

  地址翻译时,给定虚拟地址,低p位表示页偏移,其中VPO=PPO,高n-p位表示虚拟页号,即PTE的索引号,找到对应PTE记录,得到物理页号PPN,跟PPO组合得到物理地址。所以访问一个对象,首先访问页表,从虚拟地址转化为物理地址,再从访问物理地址得到对象。由于页表和物理地址都在内存中,因此存在两次内存访问。

1.3 地址翻译加速

  从1.2中得知为了访问对象,需要两次内存访问,每次内存访问一般几十到几百个周期,为了加快地址翻译,减少内存访问次数,有两种辅助设备:SRAM缓存和TLB缓存。

  SRAM缓存:在CPU和主存(DRAM)之间,还有L1, L2, L3三级高速缓存(SRAM)。因此,可以将部分PTE条目和对象存到SRAM中,减少内存访问次数,添加了SRAM的访问机制如下图。

                     

                                     图4 加入SRAM的对象访问过程

可见,在访问时,优先访问SRAM获取PTE和数据,没有再访问主存,还没有则引起缺页中断。SRAM的访问通常几个时间周期。

  TLB缓存:在MMU中的虚拟地址缓存器,称为翻译后备寄存器(Translantion Lookaside Buffer)。每一行由一个或多个PTE条目组成,其中TLBI用于行号索引,TLBT用于同一行某个PTE的选择。

                                                

                                                          图5 虚拟地址在TLB中的含义

比如某一时刻,TLB中的快照如下:

                                    

                                                                         图6 TLB快照,四组,四路组相联 

页面大小64字节,虚拟地址长度14位,物理地址长度为12位。给定虚拟地址0x03d4,其二进制表示为0b 00 0011 1101 0100,低6位0b 01 0100为VPO,因为四路组相联,所以第6-7位为TLBI(TLB索引),为0b 11,剩余为TLBT(TLB标记),TLBI表示TLB表的行号,找到TLBT为0x03的位置,得到PPN为0D。结合VPO,得到物理地址为0b 0011 0101 0100,即0x0354。加入TLB之后的对象访问过程如下:

                              

                                                    图7 加入TLB的对象访问过程

 2. Linux虚拟内存

2.1 Linux虚拟内存组织机制

Linux系统为每个进程维护一个单独的地址空间,如图8(a)所示,同时为每个进程维护一个结构体,其中包含虚拟内存相关信息,如图8(b)所示。

         

  (a) Linux进程的虚拟内存                                                      (b)管理虚拟内存的结构体

                              图8 Linux虚拟内存

其中vm_prot描述虚拟内存页的读写权限,vm_flags记录该虚拟页是共享还是私有等其他常见信息。

2.2 内存映射

  Linux系统将虚拟内存和一个磁盘对象关联起来,以初始化虚拟内存区域的内容,称为内存映射。有两种类型的内存映射:

(1) 映射到Linux文件系统中的普通文件;

(2) 映射到匿名文件,匿名文件是由内核创建的全是二进制0的文件,CPU第一次使用该虚拟页面时,内核就选择一个物理页面进行覆盖(整个过程没有跟磁盘发生数据交互)。

  一个对象映射到虚拟内存中,要么以共享对象存在,要么以私有对象存在。不论哪一种模式,在物理内存中只有一份副本共享对象一个进程的写操作,其他进程都可见,并且能反映到磁盘上;私有对象一个进程的写操作,其他进程不可见,并且不能反映到磁盘上

         

            (a) 内存映射到共享区域                                                  (b) 内存映射到私有区域

                                                  图9 多个进程映射同一对象

  对于多个进程内存映射到私有区域时,物理内存只有一份副本,此时采用一种"写时复制"策略。即进程在写时,复制修改的部分到内存其他区域。这样对其他进程来说,对象没有修改过。

2.3 mmap函数

  mmap函数提供用户级的内存映射,该函数能够把某个磁盘文件映射到内存中,函数的主要格式如下:

void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
start:内存起始地址,通常为NULL,让系统自己选择
length:内存的长度
prot:
    PROT_READ:数据可读
    PROT_WRITE:数据可写
    PROT_EXEC:数据可执行
    PROT_NONE:数据不可访问
flags:
    MAP_SHARED:共享对象,进程间可察觉修改,并能反映到磁盘
    MAP_PRIVATE:私有对象,一切操作只在本进程可见,修改不会写入磁盘
    MAP_FIXED:基本不用
fd:映射的文件的描述符,通常应先打开文件,再调用mmap,此后关闭文件映射仍然存在
offset:文件偏移量,一般为0
该函数返回内存中对应的地址

调用mmap之后,内存与磁盘文件之间就建立了映射关系,如下图所示:

munmap用于解除映射关系

int munmap(void* start,size_t length);

使用mmap的作用主要有以下两个:

(1) 将磁盘文件映射到内存中,这样所有读写均针对内存读写(可以使用memcpy等内存操作函数,而不是read,write等IO操作函数),加快访问速度;

(2) 在无亲缘关系的进程间提供共享内存。

使用mmap函数,需要注意以下问题

(1) 在文件映射之前,必须打开该文件,而且mmap的prot权限不能超过打开的权限。比如open打开时只设置了读文件,那么prot就不能设置PROT_WRITE;

(2) 内存映射通常都是按虚拟内存的页为基本单位的。比如一个页512字节,但是映射的文件只有12字节。那么剩下的500字节会自动填充为零,即时修改了后面的500字节,也不会写入到文件(所以较好的操作是直到文件大小,直接加长文件);

(3) 如果试图访问不存在的映射关系,比如页面大小512字节,实际文件大小为12字节,用mmap映射的时候映射1000个字节,那么实际可操作的结果如下:

(4) 将内存写入磁盘的操作通常由页守护进程完成,如果想人为控制将内存数据写入磁盘,可以调用以下函数:

#include 

int msync(void *addr,size_t len,int flags); 

flags:
    MS_ASYC:异步写入
    MS_SYC:同步写入,等待写入之后才会返回

(5) 进程终止或调用munmap时解除映射关系,关闭文件描述符不会解除映射关系。

下面举一个简单的例子:父子进程同时修改一个文件写入数据:

 1 #include 
 2 #include 
 3 #include 
 4 #include 
 5 #include 
 6 #include 
 7 #include 
 8 #include <string.h>
 9 
10 #define FILE_MODE (S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH)
11 
12 int main()
13 {
14     int fd;
15     if((fd=open("map.txt",O_RDWR|O_CREAT|O_TRUNC,FILE_MODE))<0)
16     {
17         printf("open file failed\n");
18         exit(1);
19     }
20 
21 
22     if(ftruncate(fd,50)<0)  //文件大小50字节
23     {
24         printf("ftruncate error\n");
25         exit(1);
26     }
27     
28     char *buf;//起始地址
29     
30     buf=(char*)mmap(NULL,50,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
31     close(fd);
32     pid_t pid;
33     if((pid=fork())<0)
34         printf("fork error\n");
35     
36     char* msg="hello world\n";
37     char* msg1= "good news";
38     if(pid==0) //子进程
39     {
40         memcpy(buf,msg,strlen(msg));
41         exit(0);
42     }
43     else
44     {
45         int stat;
46         wait(&stat);
47         memcpy(buf+strlen(msg),msg1,strlen(msg1));
48     }
49     return 0;
50     
51 }

第22-26行就是申请文件大小为50字节,那么实际内存可修改的部分就是buf~(buf+49)。注释该段再执行就会报SIGBUS错误。

执行结果是当前目录多了map.txt,其内容为:

hello world

good news

 2.4 Linux进程分配内存的方式

关于此部分详细介绍参考博文:

  简单来说,当我们调用分配内存的函数时(如malloc),底层通过调用brk()或mmap()实现。当遇到小于128KB的内存时,调用brk()函数将数据段堆的_edata地址往高地址推(即图8a中brk指向的指针,此时只分配虚拟内存,没有物理内存。当产生缺页中断时,才调用物理内存)。当申请内存大于128KB时,调用mmap()在堆栈之间的共享区域分配内存(此部分内存可以单独释放)。