1.虚拟内存是什么,如何实现的?
介绍
-
计算机的主存(内存条)被组织成一个由M个连续的字节大小的单元组成的数组,每一个byte都有一个唯一的物理地址。
-
早期的计算机寻址使用物理寻址的方式,也就是cpu需要某一个地址的值,那么久直接从内存中去找这个地址并且取值。
-
而虚拟内存:虚拟虚拟,也就是实际不存在与内存中的地址,cpu生成一个虚拟地址,这个地址在物理内存中是不存在的!那么要如何寻求实际需要的地址呢?cpu芯片中有一个叫MMU(memory management Unit)内存管理单元的硬件,这个硬件的作用是将cpu生成的虚拟地址翻译成实际存在物理内存中的地址。具体细节下面讨论。
页表的概念
-
页表是存在物理内存中的结构体数组,每一个页表条目(page table entry,简称PTE)中的成员有2个:
- 有效位
- 地址
当有效位为1时,表示cpu想要的地址实际存在于物理内存中,而结构体中的地址值就是物理内存中的一个地址;这种情况就是页命中
当有效位为0时,表示cpu想要的地址实际不存在于物理内中,也就是这个虚拟页还没有被分配,这个结构体中的地址指向存在于磁盘上的资源开始的地址;这种情况就是缺页
-
缺页的处理:
当mmu寻得的PTE中有效位为0时,也就是需要的页还没被读到磁盘上,那么就需要将磁盘上的页读到内存中的页表中,相应的,内存中的页表就要牺牲一个PTE来替换成需要的PTE,在磁盘和内存之间传送也的活动叫做交换(swapping)或页面调度(paging),例如,当使用malloc()之后,就会在磁盘,而不是内存中创建PTE并且更新相应页表中的条目。 -
局部性、抖动的概念
局部性也就是当内存足够程序所需,那么程序所需要的页都已经被加载到内存中了,只需要调用就ok了,不会再和磁盘有交互。
抖动也就是当内存不够用了,程序需要的页一部分必须存在磁盘中,当用到的时候,需要不断的在内存和磁盘两者间抽抽插插= =。因为页面调度很耗时,所以这个时候程序就跑得很慢了! -
虚拟内存实现的细节:
CPU中有一个寄存器,叫页表基寄存器(Page Table Base Register,PTBR)指向当前页表。进程是CPU分配资源的单位,而每个进程都有一个逻辑页表基址,对应于task_struct的一个叫mm_struct的结构体,这个mm_struct结构体中有一个叫pgd的字段,指向了第一级页表的基址,当进程被调用,那么这个pgd的值就会被放到页表寄存器CR3中~ -
类似与进程的逻辑pc和cpu中的物理pc,当进程被cpu调度的时候,物理ptbr指向了进程的逻辑btbr,这个时候cpu就可以访问这个进程的页表了。
-
过程
-
1.cpu先生成一个n位虚拟地址,这个地址由两部分组成:1个p位的虚拟页面偏移(Virtual Page Offset,VPO)和一个(n-p)位的虚拟页号(Virtual Page Number,VPN)
2.MMU利用vpo来寻找表,再利用VPN来寻找这个表的条目,MMU就将虚拟地址翻译成了PTE条目的物理地址,那么就可以根据这个地址去内存中找了!
3.1若MMU得到的PTE有效位为1,也就是内存中有这个值,那么就返回资源地址,MMU再向内存中读取资源返回给处理器;
3.2若MMU得到的PTE有效位位0,那么就会触发异常,将控制暂时交给内核,触发处理缺页的内核程序,也就是上面说到的牺牲页表中的一个页(对应于物理内存中的一个资源块),如果这个牺牲页被修改了,则把它换出到磁盘。最后把磁盘中的资源读到这个牺牲页并且更新相应的PTE。 于是乎,当cpu下次再请求这个虚拟地址的时候,需要的资源就已经加载到内存中了,也就能页命中了!~
-
2.使用虚拟地址的好处
-
简化链接
虚拟内存要做的并不是直接映射直接的物理地址,而是映射页表的页面偏移量和页条目偏移量,在Linux中,对于64位地址空间,每个进程的代码段总是从虚拟地址0x400000开始的(位于每个进程的起点),只要这个区域(段)位于页表的固定偏移量上(按照一定规则的),那么虚拟地址就都可以使用同一个(也就是使用相同的页面偏移或页条目偏移) -
简化加载
也就像一些web框架中的一个叫**“懒加载”**的概念,也就是等到资源要被调用的时候我才加载到内存里用,只要我cpu不用,那资源就肯定保存在硬盘,等到用到了,我才调用缺页异常把硬盘的资源读到内存里调用。 -
使内存分配更灵活
例如,当连续调用malloc函数分配用户栈资源时,如果使用物理内存,那么这个栈区域(快)肯定要使用物理内存中连续的字节来表示,但是有了虚拟内存,每一个需要分配的资源都会通过 缺页异常 在物理内存中分配,并且,这个分配不需要是连续的!它可以随机在物理内存中某个空的地方划一块页面给你用。 -
给内存增加访问权限检查
当cpu通过虚拟地址想读取一个存在PTE中的物理资源地址的时候,只要在PTE中增加如SUP、READ、WRITE权限标识位,那么就可以在这一步检查是否访问满足这些权限,如果不满足的话,可能会抛出一个叫段错误的异常。
Linux下的虚拟内存系统
Linux下的内存区域
- Linux中内存被分成许许多多的区域(段),一个区域也就是已经分配的虚拟内存的连续片(chunk),注意,这里的连续片是针对虚拟地址来讲的!。例如,对于每一个进程,代码段、数据段、堆、共享库段、以及用户栈都是不同的区域,不属于某个区域的虚拟页也是不存在的,并且不能被进程引用,否则会触发如段错误这样的异常
- 在Linux中,每一个进程的各个区域是由一个链表连接起来的(详细见p581),这个链表位于task_struct中的mm_struct下的mmap字段,mm_struct下还有一个叫pgd的字段,表示这个进程的页表基址地址,当被cpu调度时会被放至CR3寄存器
内存映射
- 对于进程来说,进程由硬盘进入内存是在 进程的每个区域在被页面调度时,如此,进程的虚拟内存区域就可以被初始化。这个过程被叫做内存映射(memory mapping)
共享对象
- 对于共享对象,可以被映射到多个虚拟区域,每个区域都可以根据地址对这个对象进行读写等操作
私有的写时复制对象
- 对于属于某个进程的某个区域的私有的写时复制对象,当这个私有的写时复制对象被映射到其他进程的时候,这个私有对象对其他进程来说是只可以读,但不可写的,即便如此 ,如果其他进程一定要写,那这个写操作就会触发一个保护故障,这个故障处理程序就会将这个私有的对象页面复制一份到内存的空闲的地方,并且映射到想进行写操作的进程的某个页面的PTE,然后恢复对这个副本页面的写操作权限(通过修改PTE的权限标识位)
从虚拟内存的角度回顾fork函数
- 当一个进程调用fork函数的时候,内核会为这个进程创建一个子进程,这个子进程的task_struct(PCB)除了pid与父进程不同,其他虚拟内存都是一样的,包括页表、mm_struct中的mmap、pgd等,但是,为了让这2个进程对各自的区域操作的时候不互相影响,内核就会令这2个进程的每个区域标记为只读和私有的写时复制
- 所以当父子进程对某个区域进行写操作的时候,内核就会把这个私有的写时复制区域
复制到新的内存块中并且更新PTE恢复写操作权限,这样一来,父子进程需要变动的区域就互不相干了!并且父子进程不需要修改的区域还是共享的!(这一点可以节约内存)
如此,父子进程都有了各自的虚拟地址空间了!
.
从虚拟内存的角度回顾execve(filename,argv[],envir[])函数
-
在当前进程调用execve函数会将目标文件替代当前文件,也就是加载新鲜的程序~ 具体执行步骤如下:
- 删除已经存在的用户区域
- 映射新程序的各个区域到PTE
- 映射共享区域(DLL,例如.so文件)
- 更新pc的值为新程序