文章目录
前言
虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互。他为每个进程提供了一个大的、一致的和私有的地址空间。
重要能力
1)他将主存看成是一个存储在磁盘空间的地址空间的高速缓存,在主存中只保存活动区域,并根据需要,在磁盘和主存间来回传送数据,高效地使用了主存。
2)为每个进程提供了一致的地址空间,从而简化了内存管理
3)保护每个进程地址空间不被其他进程破坏。
早期的物理地址 、现代的 MMU 将虚拟地址转化为物理地址,在这里就不做过多概述手写笔记本有,网上一搜也一大堆。
地址空间
- 地址空间: 非负整数地址的有限集合。
- 一个地址空间的大小是由表示最大地址所需要的位数来描述的。
- 一个包含 N=2的n次方 个地址的虚拟地址空间就叫做一个n位地址空间。现代系统通常支持32位或64位虚拟地址空间。
- 地址空间区分了数据对象(字节)和他们的属性(地址)。
- 允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟内存的基本思想。
- 内存中每字节都有一个选自虚拟地址空间和一个选自物理地址空间的物理地址。
SRAM缓存表示位于CPU和主存之间的L1、L2、L3高速缓存,
DRAM缓存表示虚拟内存系统的缓存,它在主页中缓存虚拟页。
DRAM不命中影响大,因为要由磁盘服务。
因为大的不命中处罚和访问第一个字节的开销,虚拟页往往很大,通常是4KB-2MB
由于大的不命中处罚,DRAM 缓存是全相连的,即任何虚拟页都可以放置在任何物理页中。
因为对磁盘访问时间很长,DRAM缓存总是使用写回而不是直写。
页表、页命中、缺页 不再赘述
分配页面
当你在程序中调用malloc或者new分配内存时,发生了什么?
调用malloc后,会在虚拟内存中分配页面。
注意:malloc 分配的是虚拟内存,当CPU访问的时候,首先肯定会发生缺页,然后再将该页缓存到物理内存中。
如下图所示:本身没有VP5这个虚拟页面,现在malloc后重新分配了一个虚拟页面VP5。
分配好VP5这个虚拟页面后,还需要更新PTE条目,使得PTE5指向VP5。
如果工作集的大小超出了物理内存的大小,那么会产生 抖动(thrashing),这时页面将不断换进换出。
虽然虚拟内存通常是有效的,但是一个程序能慢的像爬一样,那么该考虑是否发生了抖动。
[工具]统计缺页次数: getrusage函数监测缺页的数量 https://www.jianshu.com/p/ae473e8021d0 讲解
该函数用于统计资源使用情况,相当于 ps-aux 只不过是以页为单位,颗粒度较大
虚拟内存作为内存管理的工具
操作系统为每个进程提供了一个独立的页表,也就是一个独立的虚拟地址空间。
多个虚拟页表可以映射到同一个物理页面上。
-
简化链接
独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。
每个进程都使用类似的内存格式。
对于64位地址空间,代码段总是从虚拟地址0x400000开始。
数据段跟在代码段之后,中间有一段符合要求的对其空白。栈占据用户进程地址空间的最高部分,并向下生长。
这样的一致性极大地简化了链接器的设计和实现,允许链接器生成完全链接的可执行文件,这些可执行文件是独立于物理内存中代码和数据的最终位置的。 -
简化加载
容易向内存中加载可执行文件和共享对象内文件。
在ELF可执行文件中 .text和 .date节是连续的。要把这些节加载到一个新创建的进程中,linux加载器,分配虚拟页的一个连续的片,从地址0x08048000处(32bit)开始,或者从0x400000(64bit)开始。
[把这些虚拟页标记为无效,将页表条目指向目标文件中适当的位置,加载器从不实际拷贝任何实际数据从磁盘到存储器] -
简化共享
进程私有代码数据堆栈。操作系统创建页表,将相应的虚拟页映射到不连续的物理页面 -
简化内存分配
malloc在堆空间分配一个适当的数字(例如k)个连续的虚拟存储器页面,并且将他们映射到物理存储器中任意位置的k个任意(不一定连续)的物理页面。
地址翻译
MMU通过读取页表的PTE将虚拟地址翻译成物理地址的过程如下:
- CPU中有一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。
- n位的虚拟地址,包含两个部分:虚拟页面偏移VPO(p位)与虚拟页号VPN(n-p位)
- MMU利用虚拟内存的高n-p位VPN作为索引找到页表的对应的PTE条目,然后获取PTE条目对应的物理页号PPN
- 然后将PPN与VPO串联连接起来,就得到了实际的物理地址。(实际上就是PPN左移p位然后加上VPO,VPO=PPO)
在分页机制中,CPU获得一个虚拟地址之后的步骤:
虚拟内存作为内存保护的工具
段错误(segmentation fault)
可以在页表中加入一些位,用来表示是否必须运行在内核模式,或者是可读可写位控制对页面的读和写访问。如果一条指令违反了这些许可,CPU就触发一个一般保护故障,将控制传递给内核中异常处理程序。Linux shell 一般将这种异常报告为“段错误(segmentation fault)”。
Linux为每个进程维持一个单独的虚拟地址空间:内核虚拟存储器 和 进程虚拟存储器
内核虚拟存储器包含内核中的代码和数据
- 内核虚拟存储器的某些区域被映射到所有进程共享的物理页面,如:内核代码、全局数据结构。
- Linux将一组连续的虚拟页面(大小等同于系统DRAM总量)映射到一组相应的物理页面。【直接映射,不适用页表】
- 内核虚拟存储器包含每个进程不同的数据。页表,内核在进程上下文使用的栈等。
Linux虚拟存储区域
Linux将虚拟存储器组织成一些区域(也叫作段)的集合
一个区域存在着(已分配的)虚拟存储器的连续片,这些片/页以某种形式关联
如:代码段、数据段、堆、共享库段、用户栈
所有存在的虚拟页都保存在某个区域,允许虚拟空间有间隙
task_struct
mm_struct: 描述了虚拟存储器的当前状态
pgd: 指向第一级页表的基址。当程序运行时,内核将pgd放在CR3控制寄存器
mmap: 指向vm_area_structs的链表
vm_area_structs 描述了当前虚拟地址空间的一个区域(area)
vm_start: 指向这个区域的起始处
vm_end: 指向这个区域的结束处
vm_port: 描述这个区域内包含的所有页的读写许可权限
vm_flags: 描述这个区域页面是否与其他进程共享,还是私有等
vm_next: 指向链表的下一个区域
Linux缺页异常处理
MMU在试图翻译虚拟地址A时,触发缺页。 这个异常导致控制转移到缺页处理程序,执行以下步骤:
- 虚拟地址A是合法的吗?A在某个区域结构定义的区域内吗?
解决方法: 缺页处理程序搜索区域结构链表。把A和每个区域的vm_start和vm_end作比较。
如果不合法,触发段错误。 - 试图访问的存储器是否合法?即:是否有读、写、执行这个页面的权限?
如果不合法,触发保护异常,终止进程 - 若不存在以上情况,则选择牺牲页,替换,重新执行指令
存储器映射
定义:Linux通过讲一个虚拟存储区域与一个磁盘上的对象相关联,以初始化这个虚拟存储器区域的内容
虚拟存储区域可以映射到以下两种类型文件:
-
UNIX文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分。
例如:一个可执行文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始化内容。
仅仅是初始化,虚拟页面此时还并未进入物理存储器,直到CPU第一次引用这个页面。 -
匿名文件
匿名文件由内核创建,包含的全部都是二进制零。 CPU第一次引用这样的区域(匿名文件)的虚拟页面时,将存储器中牺牲的页面全部用二进制零覆盖。并将虚拟页面标记为驻留在存储器中。
注意在磁盘和存储器之间并没有实际的数据传送。又叫请求二进制的页。
注意:一个虚拟页被初始化了,他就在一个有内核专门维护的交换文件(交换空间)中切换。在任何时刻,交换空间都限制着当前运行着的进程能分配的虚拟页面的总数。
共享对象
一个对象可以被映射到虚拟存储器的一个区域,要么是作为共享对象,要么作为私有对象
私有对象的写时拷贝
[外链图片转存失败(img-pSnGSuFI-1564540292573)(https://note.youdao.com/yws/res/6545/WEBRESOURCE16a4883918a6e7ce2f759b5a8d9a9799)]
虚拟文件系统(VFS)会根据不同的文件类型,调用相应具体的文件系统所提供的文件操作函数作函数接口由相应的文件系统完成对设备的访问。
例:当你想把u盘中的1.txt拷贝到你的linux操作系统中,假设u盘的文件系统类型是FAT32,Linux文件系统的类型是ext4.此时VFS会调用FAT32所提供的读文件的方法将u盘的数据读入内存,然后调用ext4文件系统所提供的写文件方法,将数据从磁盘写入文件,完成数据的跨文件系统操作。
linux下文件的缓存称为page cache,被修改过的page cache称之为脏页,脏页会在特定的时机被内核线程pdflush写入磁盘
Flusher线程群(Flusher Threads)
Page cache推迟了文件写入后备存储的时间,但是dirty page最终还是要被写回磁盘的。
内核在下面三种情况下会进行会将dirty page写回磁盘:
- 用户调用sync()和fsync()系统调用
- 空闲内存低于特定的阀值(threshold)
- Dirty数据在内存中驻留的时间超过一个特定的阀值
线程群的特点是让一个线程负责一个存储设备(比如一个磁盘驱动器),多少个存储设备就用多少个线程 这样可以避免阻塞或者竞争的情况,提高效率。当空闲内存低于阀值的时候,内核就会调用 weakup_flusher_threads() 来唤醒一个或者多个flusher线程,将数据写回磁盘。为了避免dirty数据在内存中驻留时间过长(避免在系统崩溃时丢失过多数据),内核会定期唤醒一个flusher线程,将驻留时间过长的dirty数据写回磁盘。
由此我们可以推测出close函数的成功返回,并不能保证数据已经写入磁盘。 因为文件系统会使用缓冲区来推迟数据的更新,手动关闭文件并不会使其刷新缓冲区(linux下文件的缓存称为page cache,被修改过的page cache称之为脏页,脏页会在特定的时机被内核线程pdflush写入磁盘)
再看fork函数
假设运行在当前的进程中的程序执行了如下的调用:
execve("a.out",NULL,NULL);
execve函数在当前进程加载并执行目标文件a.out中的程序,用a.out代替当前程序。
加载并运行需要以下几个步骤:
- 删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构
- 映射私有区域:为新程序的文本,数据,bss和栈区域创建新的区域结构。所有新的区域结构都是私有的,写时拷贝的。文本和数据区域被映射到a.out文件中的文件和数据区。bss区域是请求二进制零,映射到匿名文件。
- 映射共享区
- 设置程序计数器
- execve最后一件事设置PC指向文本区域的入口点
使用mmap函数的用户级存储映射
UNIX进程可以使用mmap函数来创建寻得虚拟存储器区域,并将对象映射到这些区域中
#include <unistd.h>
#include <sys/mman.h>
void *mmap(void *start, size_t length, int port, int flags, int fd, off_t offset);
返回:若成功时则指向映射区域指针,若出错则为MAP_FAILED(-1);
munmap删除虚拟存储器的区域
#include <unistd.h>
#include <sys/mman.h>
void *munmap(void *start, size_t length)
返回:若成功则为0,若出错则为-1
mmap为什么比传统的读写速度快
-
因为 mmap 将文件内容直接映射到进程的地址空间,通过对这段内存的读写,来达到对文件的读写目的;
-
而 read,write 的每次调用都需要从用户态到内核态的切换,且数据 需要从用户态拷贝到内核态,然后再写入磁盘,增加了中间步骤
mmap的缺点:不能改变文件长度,无法写入多余的字符。
动态存储器分配
malloc通过调用sbrk函数来实现内存的分配,且在sbrk之上加了一层对所分配的内存的管理,而sbrk以及brk是实现从虚拟内存到内存的映射的
动态存储器分配器维护着一个进程的虚拟存储区域,称为堆(heap)
系统之间细节不同,但是不失通用型。
1. 堆是一个请求二进制零的区域
2. 紧接着未初始化的bss区域,并向上生长(向更高的地址)
3. 对于每个进程,内核维护一个变量[brk],指向堆顶,当堆空间不足时,利用sbrk函数修改该变量
4. 分配器将堆视为一组不同大小的块block的集合来维护
每个块就是一个连续的虚拟存储器片,要么是已分配,要么是空闲。
参考资料:
《深入理解计算机系统》
https://segmentfault.com/a/1190000007333187