虚拟内存与进程地址空间

Posted 白龙码~

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟内存与进程地址空间相关的知识,希望对你有一定的参考价值。

文章目录

虚拟内存与进程地址空间

一、虚拟地址与物理地址的定义

计算机的物理内存被组织成拥有M个单元的“数组“,每个单元1字节,每个字节拥有一个唯一的物理地址作为标识,地址范围是[0,M-1]。

计算机的虚拟内存被组织成拥有N个单元的”数组“,每个单元1字节,每个字节拥有一个唯一的虚拟地址作为标识,地址范围是[0,N-1]。对于32位系统,N=232-1,其虚拟内存为4GB,而对于64位系统,N=248-1,虚拟内存为256TB(一般不需要264-1,因为虚拟内存过大会导致资源浪费)。

二、虚拟地址的工作原理

1、分页映射

虚拟内存技术本质是通过一张张映射表实现“虚拟地址->物理地址”的映射关系,映射表中还包括各种标志位。

但是如果存储每个虚拟地址到物理地址的映射,那么在32位系统下,仅存储物理地址和虚拟地址,也至少需要4GB*8=32GB的空间。

为了解决映射表过大的问题,操作系统引入了“页”的概念:

  1. 物理内存按照每页P=2p字节(一般页大小为4KB,即p=12)进行分页,每一页称为物理页(或称页帧、页框、内存块、物理块)。
  2. 虚拟内存也按照同样的页大小分成虚拟页。

因此,操作系统可以通过一张张==“虚拟页->物理页”==的映射表进行内存管理,这些映射表被称为“页表”

当以页进行管理时,按照每页4KB来算,一个4GB的物理空间被分成:

232 / 212 = 220

如果页表的每一条目占8字节,那么存储页表只需要8MB的空间,相较于之前的32GB减少了多个数量级!

2、虚拟页的分类

虚拟页可以分为以下三类:

  1. 未分配的:这类虚拟页没有任何数据与它们相关联,因此不占用任何磁盘空间;
  2. 已缓存的:这类虚拟页有数据与它们关联,且存储在内存中;
  3. 未缓存的:这类虚拟页有数据与它们关联,但是存储在磁盘中;

页表分类,本质就是为了弥补内存空间较小的问题,将内存中不经常使用的页暂时存放到空间更大的磁盘中,需要使用时再将磁盘中的页缓存至内存。[就像mysql索引数据页的管理方式一样]

3、页表

页表的本质就是一个页表条目(Page Table Entry)数组。

页表条目一般包括:

  1. 物理基地址。
  2. 标志位字段,如是否缓存在内存中(P)、是否可读写(R/W))等。

对于已缓存在内存中的虚拟页,页表会记录它在物理内存中的起始位置,而未缓存在内存中的虚拟页,页表会记录它在磁盘中的位置。

多级页表

当虚拟内存为4GB时,页表需要几MB的内存空间,但是对于更大的虚拟内存(比如64位机器),使用单级页表将占用更多的空间,因此引入了“多级页表”的概念。「在Linux下,页表的分级数量不同,从两级、三级再到四级」

所谓多级页表,就是使用层次化的页表结构,以二级页表为例:

第一级页表(也称“页目录”)可以粗粒度地映射4MB的“”,一片相当于1024个4KB的连续页。

如果某一片上的虚拟页被使用了,那么就为这一片创建一个二级页表,二级页表可以细粒度地映射4KB的页。

这种多级页表的方式有两大好处:

  1. 如果一级页表的片没有被使用,那么二级页表也就无需创建了,从而节约了大量空间
  2. 只需将占用空间较小的一级页表和常用的二级页表缓存至物理内存,而不是缓存所有的页表,减少了物理内存的压力

4、地址映射的方式

以使用32位机器、两级页表为例,虚拟地址由32个比特位组成,总共分为三段:

1、高10位用来索引页目录(一级页表);

2、中间10位用来索引二级页表;

3、低12位用来作为页内偏移量,加在当前页所映射的物理基地址上就可以算出虚拟地址具体对应的是哪个物理地址。

即:物理地址=物理基地址+页内偏移量

地址映射示例

以虚拟地址0x11 22 33 44为例,二进制:0001 0001 0010 0010 0011 0011 0100 0100

高10位0001 0001 00,即68,表示它在第67个页目录;

中间10位10 0010 0011,即547,表示它在第547张页表;

低12位0011 0100 0100,即836,表示它相对于物理基地址的偏移量为836。

假设物理基地址为0x44 33 22 11,那么加上836得到的0x44 33 25 55就是虚拟地址0x11 22 33 44真正对应的物理地址了!

5、缺页

如果CPU可以通过页表索引到已缓存至内存的虚拟页,那么这种情况称为页命中

相反,如果需要的虚拟页没有被缓存至内存而是存储在磁盘,这种情况称为缺页,会导致缺页中断

缺页中断的处理方式

  • 如果当前内存已满,那么内核会在内存中选择一个牺牲页,将它写回磁盘并用磁盘中的所需页覆盖它,即 页面调度(或称页面置换)。常见的页面调度算法有:最近最少使用(LRU)、先进先出(FIFO)。
  • 如果内存未满,那么直接将所需页面缓存至内存即可。

三、虚拟地址的应用

1、内存映射

I.概念

将磁盘上的文件“映射”到内存中,使用户能够通过指针的方式读写内存中的文件,之后再由操作系统将修改后的内容写回文件。

具体地:

Linux使用mmap系统调用完成内存映射。对于给定的一个磁盘文件,mmap为它预设一段虚拟内存作为缓冲区,同时在页表中建立这块虚拟内存和文件在磁盘上的物理地址的映射关系,最后返回缓冲区的起始地址。

注意:该过程内核不进行任何的数据拷贝工作!数据拷贝由”懒加载“完成。

II.“懒加载”模式

当用户利用mmap返回的起始地址进行文件读写时,通过查询页表发现这一段地址并不在内存,而是在磁盘上,因此触发了缺页中断。此时操作系统将缺少的页面从磁盘缓存到物理内存中,供用户读写。

如果用户的写操作改变了文件的内容,那么会由操作系统将被修改的页面(“脏页面”)写回到磁盘的对应位置。

III.内存映射的优势

  1. 相比于read/write进行文件IO,内存映射的效率更高。

    以read为例:

    操作系统首先将数据从硬盘拷贝到内核缓冲区,然后再将内核缓冲区的数据拷贝到用户缓冲区,总共进行了两次数据拷贝。

    而内存映射直接将文件映射到用户空间,只进行一次数据拷贝,减少了拷贝次数。

  2. 用户可以直接通过指针读写文件,相比于文件I/O接口更加的方便。

2、Linux进程虚拟地址空间

Linux为每个进程维护了一个单独的虚拟地址空间,这一空间被分为不同的区(或者称为段),以32位系统为例:

  1. 受保护区:位于虚拟地址空间的最底部,未映射物理地址,因此对该区域的访问时非法的;
  2. 代码区:程序编译后形成的机器指令、常量、函数和静态链接库。代码区通常是只读的,以防被其他程序意外修改;
  3. 已初始化全局数据区:已初始化的全局变量和静态局部变量;
  4. 未初始化全局数据区:未初始化的全局变量和静态局部变量;
  5. 堆区:从低地址向高地址增长,用于动态分配内存;
  6. 内存映射区:通过内存映射技术映射到内存的磁盘文件,包括程序运行所需的动态链接库
  7. 栈区:从高地址向低地址增长,存储非静态局部变量、函数参数等信息;
  8. 命令行参数:执行程序时输入的命令行参数;
  9. 环境变量:系统的环境变量,相当于env命令展示的信息;
  10. 内核区:与进程相关的数据结构(每个进程都不同,比如页表)、内核代码和数据等,用户没有该区域的操作权限。

I.内核级页表与用户级页表

每个进程地址空间的内核区映射了相同的内核级页表,而用户区会分别维护自己的用户级页表。

用户级页表的不同,使得不同进程之间不会发生访问冲突,因此也就维护了进程地址空间的独立性

II.进程地址空间的管理

进程地址空间由task_struct.mm_struct进行管理:

  • pgd指向一级页表的基址(物理地址),当进程运行时就将pgd存入CR3寄存器中;
  • mmap是一个区域结构双链表,每个vm_area_struct节点描述了进程地址空间的一个区域的起始地址和结束地址,以及读写权限等信息。

3、fork与写时拷贝

当父进程创建子进程时,父子访问同一个变量val,该变量的值与地址都是相同的。

当子进程修改val后,父进程的val并没有变化,但是它们的地址依然相同。

这是因为:

父进程调用fork时,内核为子进程创建了父进程的mm_struct和页表的==原样副本==,此时父子进程拥有近乎相同的虚拟内存地址空间和页映射规则,所以看到的变量地址是相同的。

但是,内核会将两进程页表的每一个页都标记成只读。

如果其中一个进程要对内存的某一页进行写操作,同时它又不是该页的唯一拥有者,就会触发“写时拷贝”:在物理内存中创建该页面的一个副本,让它的页表映射这个新的副本,并恢复进程对该页的写权限。

也就是说,写时拷贝会修改原虚拟地址映射的物理地址,这就是为什么值修改了但是地址还没变的原因。

写时拷贝的优势

1、针对操作系统而言,通过写时拷贝,既可以节省物理空间,又能够保证进程间的独立性,彼此的私有数据不会受影响。

2、针对fork而言,写时拷贝只有在共享数据被修改时才会进行拷贝,而fork函数内部无需进行数据的拷贝工作,因此提高了fork的效率。

4、execve与可执行文件加载

Linux下大部分可执行文件都是ELF文件。

ELF文件被设计得非常容易加载到进程地址空间当中,其各类信息如下:

I.加载过程

  1. 在命令行键入./prog命令执行可执行目标文件prog;
  2. Linux命令行解释器bash创建子进程(这也是为什么用命令行执行的程序的父进程都是bash的原因)并通过execve函数调用加载器;
  3. 加载器将子进程地址空间的用户区相关结构删除并创建新的结构,同时初始化用户区堆栈;
  4. 将可执行目标文件中的代码段和数据段映射到进程地址空间的对应代码区和数据区(通过内存映射技术);
  5. 动态链接库映射到进程地址空间的共享区;
  6. 将子进程的程序计数器设置为prog的第一条指令;
  7. 进程放入任务队列等待被调度。

II.不变的地址

对比一个可执行文件的执行结果我们发现:

  • 对于已初始化的全局变量和静态变量、未初始化的全局变量和静态变量以及函数,它们的地址是固定的。
  • 局部变量在每一次执行时地址都会改变。

这是因为:在编译链接成ELF可执行文件时,会将链接时的全局变量、静态变量以及函数对应的地址全部保存在符号表(.symtab)和对应的区域(.data等),但是局部变量并不会被保存;

当执行./可执行文件命令时,操作系统将这些信息全部映射到进程地址空间的对应区域。这就意味着每次执行这些代码时,全局变量、静态变量和函数的地址都不会改变了,因为它们的地址都是在编译链接后经过重定位存储到ELF文件的,而局部变量是在进程运行时在栈上动态分配内存

四、虚拟内存的作用

  1. 简化了加载:目标文件可以根据进程地址空间进行分页,然后通过内存映射加载到对应区域;
  2. 简化了共享:操作系统可以将不同进程中适当的虚拟页面映射到相同的物理页面,使它们能够共享一些代码、数据;
  3. 简化了内存分配:当用户调用malloc时,系统只需要分配可用的连续虚拟页并将它们映射到物理内存即可;
  4. 简化了内存保护:操作系统不允许用户修改只读代码段、不允许一个进程修改另一个进程的私有数据,也不允许进程修改与其它进程共享的虚拟页面。而这一切只需要在页表条目上添加一些特殊的许可位,如可读、可写,就能很好地控制进程对内存系统的访问。如果进程的指令违反了这些许可位,就会触发异常处理,在Linux下这种异常被称为段错误(segmentation fault)
  5. 弥补了内存较少的问题:运行在操作系统上的进程越来越多,而每个进程都需要使用内存资源。通过虚拟内存+页面调度的方式,操作系统可以将最近不常用的页面存储到磁盘上,将需要的页面缓存到内存,以满足进程对内存的使用需求。

以上是关于虚拟内存与进程地址空间的主要内容,如果未能解决你的问题,请参考以下文章

操作系统实现之内存分页机制.虚拟空间

操作系统实现之内存分页机制.虚拟空间

操作系统实现之内存分页机制.虚拟空间

虚拟内存与进程地址空间

Linux进程地址空间与虚拟内存

进程地址空间与虚拟内存