Linux 2.x 内核对内存的管理
Linux 2.x 内核对内存的管理
其着重点在于分页,用分页的机制把进程间的虚拟地址分隔开来。
每个进程都有一套页表,整个系统有一张GDT表(此机制由逻辑地址到线性地址变换,实际上是走了个形式,变换前后地址不变)
每个进程都有一套页表用于从线性地址到物理地址的映射变换。这样,每个进程都有一个4GB的逻辑空间。
(两进程的逻辑地址相同,但它们有不同的页表,故最终会映射到不同的物理位置)
实际上,此机制的地址翻译只有一步——直接把虚拟地址翻译成物理地址。
CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到存储器之前先地址翻译成适当的物理地址。
地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片上叫做存储器管理单元(Memory Management Unit,MMU)的专用硬件,利用存放在主存中的页表来动态翻译虚拟地址,该表的内容是由操作系统管理的。
操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。
按需页面调度和独立的虚拟地址空间的结合,对系统中存储器的使用和管理造成了深远的影响。
简化链接。独立的地址空间允许每个进程的存储器使用相同的基本格式,而不管代码和数据实际存放在物理存储器的何处。
例如:一个给定的linux系统上的每个进程都使用类似的存储器格式。文本节总还是从虚拟地址0x08048000处开始(32位),或者从0x400000处开始(64位)。数据和bss节紧跟在文本节后面。栈占据进程地址空间最高的部分,并向下生长。
这样的一致性极大地简化了链接器的设计和实现,允许链接器生成全链接的可执行文件,这些可执行文件时独立于物理存储器中代码和数据的最终位置的。
简化加载。虚拟存储器还使得容易向存储器中加载可执行文件和共享对象文件。
在ELF可执行文件中.text和.data节是连续的。要把这些节加载到一个新创建的进程中,Linux加载器分配虚拟页的一个连续的片,从虚拟地址0x08048000处开始(32位),或者从0x400000处开始(64位),把这些虚拟页标记为无效的(即未被缓存的),将页表条目指向目标文件中适当的位置。
注意:加载器从不实际拷贝任何数据从磁盘到存储器。在每个页初次被引用时,要么是CPU取指令时引用的,要么是一条正在执行的指令引用一个存储器位置时引用的,虚拟存储器需要会按照需要自动调入数据页。
段
Linux将虚拟存储器组织成一些段的集合。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。内核不用记录那些不存在的虚拟页,而这样的页也不占用其他任何额外资源。
内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct)。任务结构中的元素包含或者指向运行该进程所需要的所有信息(例:PID、用户栈指针、程序计数器等)
mm_struct描述了虚拟存储器的当前状态。pdg指向第一级页表的基址。mmap指向一个vm_area_structs(区域结构)的链表。当内核运行这个进程时,它就将pdg存放在CR3控制寄存器中。
vm_start: 指向这个区域的起始处。
vm_end: 指向这个区域的结束处。
vm_prot:描述这个区域所有页的读写权限。
vm_flags:描述这个区域内的页面是否是与其他进程共享的。
vm_next:指向链表中下一个区域结构。
(每个区域段一个结构 用链表串起来,其作用类似于段表)
利用TLB加速地址翻译
每次CPU产生一个虚拟地址,MMU就必须查阅一个页表项,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这又会要求从存储器取一次数据,代价很大。许多系统试图消除这样的开销,它们在MMU中包括了一个关于页表项的小的缓存,称为翻译地址后备缓冲器(Translation Lookaside Buffer,TLB)。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个页表项组成的块。
(高速缓存Cache是主存上的一部分数据缓存,TLB是缓存一部分页表)
多级页表
如果我们有一个32位的地址空间、4KB的页面和一个4字节的页表项,,那么需要一个4MB的页表驻留在存储器中。对于地址空间为64位的系统来说,问题将更复杂。
用来压缩页表的常用方法是使用层次结构的页表。
例如:一级页表中的每个页表项(PTE,Page Table Entry)负责映射虚拟地址空间中的一个4MB的片(chunk),这里每一片都是由1024个连续的页面组成的。假设地址空间是4GB,1024个PTE已经足够覆盖整个空间了。
如果片i中的每个页面都未被分配,那么一级PTE i就为空。
这种方法从两方面减少了存储器要求。
⑴如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在。这代表一种巨大的潜在节约。因为对于一个典型的程序,4GB的虚拟地址空间的大部分都将是未分配的。
⑵只有一级页表才需要总是在主存中。虚拟存储系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存的压力,只有最经常使用的二级页表才需要缓存在主存中。
地址翻译过程全景
Core i7 MMU使用四级页表来将虚拟地址翻译成物理地址。36位v*n被划分成四个9位的片,每个片被用作到一个页表的偏移量。
CR3寄存器包含L1页表的物理地址。v*n 1 提供一个L1 PTE(页表项)的偏移量,这个PTE包含L2页表的基地址。v*n 2提供到一个L2 PTE的偏移量,以此类推。
页面调度
在虚拟存储器的习惯说法中,DRAM缓存不命中称为缺页(Page fault)。
地址翻译硬件从存储器中读取某页表项,从有效位推断出此虚拟页未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页(当内存不足时),拷回磁盘。(把脏页–已经修改的页 作为牺牲页)
当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。
在虚拟存储器的习惯说法中,块被称为页。在磁盘和存储器之间传送页的活动叫做交换(swapping)或者页面调度(paging)。页从磁盘换入DRAM和从DRAM换出到磁盘交换区。一直等待,直到最后时刻,也就是当有不命中发生时,才换入页面的策略称为按需页面调度。所有现代系统都使用的是按需页面调度的方式。
又是局部性救了我们
尽管在整个运行过程中程序引用的不同页面数可能超过物理存储器的总大小,但是局部性原则保证了在任意时刻,程序将往往在一个较小的活动页面集合上工作,这个集合叫做工作集(working set)。
在初始开销,也就是将工作集页面调度到存储器中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。
注意:如果工作集的大小超出了物理存储器的大小,那么程序将产生一种不幸的状态,叫做颠簸(thrashing),这时页面将不断地换进换出。虽然虚拟存储器通常是有效的,但是如果一个程序性能慢得像爬一样,那么聪明的程序员会考虑是不是发生了颠簸。
存储器映射
Linux通过将一个虚拟存储器区域与一个磁盘上的对象关联起来,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射(memorymapping)。
虚拟存储器区域可以映射到两种类型的对象中的一种:
⑴普通文件:
一个区域可以映射到一个普通磁盘文件的连续部分。例如一个可执行目标文件。
文件区(section)被分成页大小的块,每一块包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。
⑵匿名文件:
一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包括的全是二进制零。
CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在存储器中的。注意在磁盘和存储器之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页(demand-areopage)
交换文件也叫做交换区域(swap area),在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面总数。
(进程动态分配的区域所映射的就是交换文件区域)
再看共享对象
存储器映射的概念来源于一个聪明的发现:
如果虚拟存储器系统可以集成到传统的文件系统中,那么就能提供一种简单而高效的把程序和数据加载到存储器中的方法。
一个对象可以被映射到虚拟存储器的一个区域,要么作为共享对象,要么作为私有对象。
对共享对象的写操作,对于那些也把这个共享对象映射到它们虚拟存储器的其他进程而言也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。
对一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。
即使对象被映射到了多个共享区域,物理内存中也只需要存放在共享对象的一个拷贝。(为了方便,我们将物理页面显示为连续的,但是在一般情况下当然不是这样。)
【Linux IPC中的管道通信 即为此方式的实现】
私有对象是使用一种叫做写时拷贝(copy-on-write)的巧妙技术。
一个私有对象开始生命周期的方式基本上与共享对象的一样,在物理存储器中只保存有私有对象的一份拷贝。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时拷贝。
只要没有进程试图写它自己的私有区域,它们就可以继续共享物理存储器中对象的一个单独拷贝。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。
当故障处理程序注意到保护异常是由于进程试图写私有的写时拷贝区域中的一个页面而引起的,它就会在物理存储器中创建这个页面的一个新拷贝,更新页表条目指向这个新的拷贝,然后恢复这个页面的可写权限。
通过延迟私有对象中的拷贝直到最后可能的时刻,写时拷贝最充分地使用了稀有的物理存储器。
再看fork函数
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟存储器,它创建了当前进程的mm_struct、区域结构和页表的原样拷贝。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝。
当fork在新进程中返回时,新进程现在的虚拟存储器刚好和调用fork时存在的虚拟存储器相同。当这两个进程中的任一个后来进行写操作时,写时拷贝机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
再看execve函数
假设运行在当前进程中的程序执行了如下的调用:
execve(“a.out”, NULL, NULL) ;
execve函数用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:
⑴删除已存在的用户区域
删除当前进程虚拟地址的用户部分中的已存在的区域(段)结构。
⑵映射私有区域
为新程序的文本、数据、bss和栈区域创建新的区域(段)结构。所有这些新的区域都是私有的、写时拷贝的。
文本和数据段 被映射为a.out文件中的文本和数据区。
bss段 是请求二进制零的,映射到匿名文件,但其大小包含在a.out中。
栈和堆 也是请求二进制零的,初始长度为零。
⑶映射共享区域
如果a.out程序与共享库链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
⑷设置程序计数器(PC)
execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向文本段的入口点。
(下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面)
用户级存储器映射函数
Unix进程可以使用mmap函数来创建新的虚拟存储器区域,并将对象映射到这些区域中。
#include <unistd.h>
#include <sys/mman.h>
void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset) ;
mmap函数要求内核创建一个新的虚拟存储器区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片(chunk)映射到这个新的区域。连续的对象片大小为length字节,从距文件开始处偏移量为offset字节的地方开始。start地址仅仅是一个暗示,通常被定义为NULL。
参数prot包含描述新映射的虚拟存储器区域的访问权限位。
PROT_EXEC:可被执行。
PROT_READ:可读。
PROT_WRITE:可写。
PROT_NONE:禁止访问
参数flags 描述被映射对象的类型。
MAP_ANON:被映射的对象是一个匿名对象。
MAP_PRIVATE:被映射对象是一个私有、写时拷贝的对象。
MAP_SHARED:被映射对象是一个共享对象。
例如:
bufp = mmap(-1, size, PROT_READ, MAP_PRIVATE | MAP_ANON, 0, 0) ;
让内核创建一个新的包含size字节的只读、私有、请求二进制零的虚拟存储器区域。
munmap函数删除虚拟存储器区域:
#include <unistd.h>
#include <sys/mman.h>
int munmap(void* start, size_t length) ;