进程地址空间
其实内核除了管理本身的内存外,还必须管理用户空间中进程的内存。我们称这个内存为进程地址空间,也就是系统中每个用户空间进程所看到的内存。
Linux操作系统采用虚拟内存技术,因此,系统中的所有进程之间以虚拟方式共享内存。对一个进程而言,它好像都可以访问整个系统的所有物理内存。更重要的是,即使单独一个进程,它拥有的地址空间也可以远远大于系统物理内存。
地址空间
进程地址空间由进程可寻址的虚拟内存组成,而且更为重要的特点是内核允许进程使用这种虚拟内存中的地址。每个进程都有一个32位或者64位的平坦(flat)地址空间,空间的具体大小取决于体系结构。术语“平坦”指的是地址空间范围是一个独立的连续区间(比如,地址从0扩展到4294967295(32位无符号整数的十进制最大值)的32位地址空间)。一些操作系统提供了段地址空间,这种地址空间并非是一个独立的线性区域,而是被分段的,但现代采用虚拟内存的操作系统通常都使用平坦地址空间而不是分段式的内存模式。通常情况下,每个进程都有唯一的这种平坦地址空间。一个进程的地址空间与另一个进程的地址空间即使有相同的内存地址,实际上也彼此互不相干。我们称这样的进程为线程。
内存地址是一个给定的值,它要在地址空间范围之内,比如4021f000。这个值表示的是进程32位地址空间中的一个特定的字节。尽管一个进程可以寻址4GB的虚拟内存(在32位的地址空间中),但这并不代表他它就有权访问所有的虚拟地址。在地址空间中,我们更为关心的是一些虚拟内存的地址空间,比如08048000-0804c000,它们可被进程访问。这些可被访问的合法地址空间称为内存区域。通过内核,进程可以给自己的地址空间动态地添加或减少内存区域。
进程只能访问有效内存区域内的内存地址。每个内存区域也具有相关权限如对相关进程有可读、可写、可执行属性。如果一个进程访问了不在有效范围内的内存区域,或以不正确的方式访问了有效地址,那么内核就会终止该进程,并返回“段错误”信息。
内存区域可以包含各种内存对象,比如:
可执行文件代码的内存映射,称为代码段(text section)。
可执行文件的已初始化全局变量的内存映射,称为数据段(data section)。
包含未初始化全局变量,也就是bss段的零页(页面中的信息全部为0值,所以可用于映射bss段等目的)的内存映射。
用于进程用户空间栈(不要和进程内核栈混淆,进程的内核栈独立存在并由内核维护)的零页的内存映射。
每一个诸如C库或动态连接程序等共享库的代码段、数据段和bss也会被载入进程的地址空间。
任何内存映射文件。
任何共享内存段。
任何匿名的内存映射,比如由malloc()分配的内存。
进程地址空间中的任何有效地址都只能位于唯一的区域,这些内存区域不能相互覆盖。可以看到,在执行的过程中,每个不同的内存片段都对应于一个独立的内存区域:栈、对象代码、全局变量、被映射的文件等。
内存描述符
内核使用内存描述符结构体表示进程的地址空间,该结构包含了和进程地址空间有关的全部信息。内存描述符由mm_struct结构体表示,定义在文件<linux/sched.h>中。下面给出内存描述符的结构和各个域的描述:
1 | struct mm_struct { |
mm_users域记录正在使用该地址的进程数目。比如,如果两个线程共享该地址空间,那么mm_users的值便等于2;mm_count域是mm_struct结构体的主引用计数。所有的mm_users都等于mm_count的增加量。这样,在前面的例子中,mm_count就仅仅为1。如果有9个线程共享某个地址空间,那么mm_users将会是9,而mm_count的值将再次为1。当mm_users的值减为0(即所有正使用该地址空间的线程都退出)时,mm_count域的值才变为0。当mm_count的值等于0,说明已经没有任何指向该mm_struct结构体的引用了,这时该结构体会被撤销。当内核在一个地址空间上操作,并需要使用域该地址关联的引用计数时,内核便增加mm_count。内核同时使用这两个计数器是为了区别主使用计数(mm_count)和使用该地址空间的进程的数目(mm_users)。
mmap和mm_rb这两个不同数据结构体描述的对象是相同的:该地址空间中的全部内存区域。但是前者以链表形式存放而后者以红-黑树的形式存放。红-黑树是一种二叉树,与其他二叉树一样,搜索它的时间复杂度为O(logn)。
内核通常会避免使用两种数据结构组织同一种数据,但此处内核这样的冗余确实派得上用场。mmap结构体作为链表,利于简单、高效地遍历所有元素;而mm_rb结构体作为红-黑树,更适合搜索指定元素。内核并没有复制mm_struct结构体,而仅仅被包含其中。覆盖树上的链表并用这两个结构体同时访问相同的数据集,有时候我们将此操作称作线索树。所有的mm_struct结构体都通过自身的mmlist域连接在一个双向链表中,该链表的首元素是init_mm内存描述符,它代表init进程的地址空间。
另外要注意,操作该链表的时候需要使用mmlist_lock锁来防止并发访问,该锁定义在文件kernel/fork.c
中。
分配内存描述符
在进程的进程描述符中(在<linux/sched.h>中定义的task_struct结构体就表示进程描述符),mm域存放着该进程使用的内存描述符,所以current->mm便指向当前进程的内存描述符。fork()函数利用copy_mm()函数复制父进程的内存描述符,也就是current->mm域给其子进程,而子进程中的mm_struct结构体实际是通过文件kernel/fork.c
中的allocated_mm()宏从mm_cachep slab缓存中分配得到的。通常,每个进程都有唯一的mm_struct结构体,即唯一的进程地址空间。
如果父进程希望和其子进程共享地址空间,可以在调用clone()时,设置CLONE_VM标志。我们把这样的进程称作线程。是否共享地址空间几乎是进程和Linux中所谓的线程间本质上的唯一区别。
除此之外,Linux内核并不区别对待它们,线程对内核来说仅仅是一个共享特定资源的进程而已。
当CLONE_VM被指定后,内核就不再需要调用allocated_mm()函数了,而仅仅需要在调用copy_mm()函数中将mm域指向其父进程的内存描述符就可以了:
1 | if(clone_flags & CLONE_VM) { |
撤销内存描述符
当进程退出时,内核会调用定义在kernel/exit.c
中的exit_mm()函数,该函数执行一些常规的撤销工作,同时更新一些统计量。其中,该函数会调用mmput()函数减少内存描述符中的mm_users用户计数,如果用户计数降到零,将调用mmdrop()函数,减少mm_count使用计数。如果使用计数也等于零了,说明该内存描述符不再有任何使用者了,那么调用free_mm()宏通过kmem_cache_free()函数将mm_struct结构体归还到mm_cachep slab缓存中。
mm_struct与内核线程
内核线程没有进程地址空间,也没有相关的内存描述符。所以内核线程对应的进程描述符中mm域为空。事实上,这也正是内核线程的真实含义----它们没有用户上下文。
省了进程地址空间再好不过了,因为内核线程并不需要访问任何用户空间的内存(那它们访问谁的呢?),而且因为内核线程在用户空间没有任何页,所以实际上它们并不需要有自己的内存描述符和页表(后面的内容将讲述页表)。尽管如此,即使访问内核内存,内核线程也还是需要使用一些数据的,比如页表。为了避免内核线程为内存描述符和页表浪费内存,也为了当新内核运行时,避免浪费处理周期向新地址空间进行切换,内核线程将直接使用前一个进程的内存描述符。
当一个进程被调度时,该进程的mm域指向的地址空间被装载到内存,进程描述符中的active_mm域会被更新,指向新的地址空间。内核线程没有地址空间,所以mm域为NULL。于是,当一个内核线程被调度时,内核发现它的mm域为NULL,就会保留前一个进程的地址空间,随后内核更新内核线程对应的进程描述符中的active_mm域,使其指向前一个进程的内存描述符。所以在需要时,内核线程便可以使用前一个进程的页表。因为内核线程不访问用户空间的内存,所以它们仅仅使用地址空间中和内核相关的信息,这些信息地的含义和普通进程完全相同。
虚拟内存区域
内存区域由vm_area_struct结构体描述,定义在文件<linux/mm_types.h>中。内存区域在Linux内核中也经常称作虚拟内存区域(virtual memory areas,VMAs)。
vm_area_struct结构体描述了指定地址空间内连续区间上的一个独立内存范围。内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性,比如访问权限等,另外,相应的操作也都一致。按照这样的方式,每一个VMA就可以代表不同类型的内存区域(比如内存映射文件或者进程用户空间栈),这种管理方式类似于使用VFS层的面向对象方法,下面给出该结构定义和各个域的描述:
1 | struct vm_area_struct { |
每个内存描述符都对应于进程地址空间中的唯一区间。vm_start域指向区间的首地址(最低地址),vm_end域指向区间的尾地址(最高地址)之后的第一个字节,也就是说,vm_start是内存区间的开始地址(它本身在区间内),而vm_end是内存区间的结束地址(它本身在区间外),因此,vm_end-vm_start的大小便是内存区间的长度。
内存区域的位置就在[vm_start,vm_end]之中。注意,在同一个地址空间内的不同内存区间不能重叠。
vm_mm域指向和VMA相关的mm_struct结构体,注意,每个VMA对其相关的mm_struct结构体来说都是唯一的,所以即使两个独立的进程将同一个文件映射到各自的地址空间,它们分别都会有一个vm_area_struct结构体来标志自己的内存区域;反过来,如果两个线程共享一个地址空间,那么它们也同时共享其中的vm_area_struct结构体。
VMA标志
VMA标志是一种位标志,其定义见<linux/mm.h>。它包含在vm_flags域内,标志了内存区域所包含的页面的行为和信息。和物理页的访问权限不同,VMA标志反映了内核处理页面所需要遵守的行为准则,而不是硬件要求。而且,vm_flags同时也包含了内存区域中每个页面的信息,或内存区域的整体信息,而不是具体的独立页面,下表列出了所有VMA标志的可能取值。
VMA标志
标志 | 对VMA及其页面的影响 |
---|---|
VM_READ | 页面可读取 |
VM_WRITE | 页面可写 |
VM_EXEC | 页面可执行 |
VM_SHARED | 页面可共享 |
VM_MAYREAD | VM_READ标志可被设置 |
VM_MAYWRITE | VM_WRITE标志可被设置 |
VM_MAYEXEC | VM_EXEC标志可被设置 |
VM_MAYSHARE | VM_SHARE标志可被设置 |
VM_GROWSDOWN | 区域可向下增长 |
VM_GROWSUP | 区域可向上增长 |
VM_SHM | 区域可用作共享内存 |
VM_DENYWRITE | 区域映射一个不可写文件 |
VM_EXECUTABLE | 区域映射一个可执行文件 |
VM_LOCKED | 区域中的页面被锁定 |
VM_IO | 区域映射设备I/O空间 |
VM_SEQ_READ | 页面可能会被连续访问 |
VM_RAND_READ | 页面可能会被随机访问 |
VM_DONTCOPY | 区域不能在fork()时被拷贝 |
VM_DONTEXPAND | 区域不能通过mremap()增加 |
VM_RESERVED | 区域不能被换出 |
VM_ACCOUNT | 该区域是一个记账VM对象 |
VM_HUGETLB | 区域使用了hugetlb页面 |
VM_NONLINEAR | 该区域是非线性映射的 |
让我们进一步看看其中有趣和重要的几种标志,VM_READ、VM_WRITE、和VM_EXEC标志了内存区域中页面的读、写和执行权限。这些标志根据要求组合构成VMA的访问控制权限,当访问VMA时,需要查看其访问权限。比如进程的对象代码映射区域可能会标志为VM_READ和VM_EXEC,而没有标志为VM_WRITE;另一方面,可执行对象数据段的映射区域标志为VM_READ和VM_WRITE,而VM_EXEC标志对它就毫无意义。也就是说,只读文件数据段的映射区域仅可被标志为VM_READ。VM_SHARED指明了内存区域包含的映射是否可以在多进程间共享,如果该标志被设置,则我们称其为共享映射;如果未被设置,而仅仅只有一个进程可以使用该映射的内容,我们称它为私有映射。
VM_IO标志内存区域中包含对设备I/O空间的映射。该标志通常在设备驱动程序执行mmap()函数进行I/O空间映射时才被设置,同时该标志也表示该内存区域不能被包含在任何进程的存放缓存(core dump)中。VM_RESERVED标志规定了内存区域不能被换出,它也是在设备驱动程序进行映射时被设置。
VM_SEQ_READ标志暗示内核应用程序对映射内容执行有序的(线性和连续的)读操作;这样,内核可以有选择地执行预读文件。VM_RAND_READ标志的意义正好相反,暗示应用程序对映射内容执行随机的(非有序的)读操作。因此内核可以有选择地减少或彻底取消文件预读,所以这两个标志可以通过系统调用madvice()设置,设置参数分别是MADV_SEQUENTIAL和MADV_RANDOM。文件预读是指在读数据时有意地按顺序多读取一些本次请求以外的数据—-希望多读的数据能够很快就被用到。这种预读行为对那些顺序读取数据的应用程序有很大的好处,但是如果数据的访问是随机的,那么预读显然就多余了。
VMA操作
vm_area_struct结构体中的vm_ops域指向与指定内存区域相关的操作函数表,内核使用表中的方法操作VMA。vm_area_struct作为通用对象代表了任何类型的内存区域,而操作表描述针对特定的对象实例的特定方法。
操作函数表由vm_operations_struct结构体表示,定义在文件<linux/mm.h>中。
1 | struct vm_operations_struct { |
下面介绍具体方法:
1 | void open(struct vm_area_struct *area); |
当指定的内存区域被加入到一个地址空间时,该函数被调用。
1 | void close(struct vm_area_struct *area); |
当指定的内存区域从地址空间删除时,该函数被调用。
1 | int fault(struct vm_area_struct *area,struct vm_fault *vmf); |
当没有出现在物理内存中的页面被访问时,该函数被页面故障处理调用。
1 | int page_mkwrite(struct vm_area_struct *area,struct vm_fault *vmf); |
当某个页面为只读页面时,该函数被页面故障处理调用。
1 | int access(struct vm_area_struct *vma,unsigned long address,void *buf,int len,int write); |
当get_user_pages()函数调用失败时,该函数被access_process_vm()函数调用。
内存区域的树形结构和内存区域的链表结构
上文讨论过,可以通过内存描述符中的mmap()和mm_rb域之一访问内存区域。这两个域各自独立地指向与内存描述符相关的全体内存区域对象。其实,它们包含完全相同的vm_area_struct结构体的指针,仅仅组织方法不同。
mmap域使用单独链表连接所有的内存区域对象。每一个vm_area_struct结构体通过自身的vm_next域被连入链表,所有的区域按地址增长的方向排序,mmap域指向链表中第一个内存区域,链中最后一个结构体指向空。
mm_rb域使用红-黑树连接所有的内存区域对象。mm_rb域指向红-黑树的根节点,地址空间中每一个vm_area_struct结构体通过自身的vm_rb域连接到树中。
红-黑树是一种二叉树,树中的每一个元素称为一个节点,最初的节点称为树根。红-黑树的多数节点都有两个子节点:一个左子节点和一个右子节点,不过也有节点只有一个子节点的情况。树末端的节点称为叶子节点,它们没有子节点。红-黑树中的所有节点都遵从:左边节点值小于右边节点值;另外每个节点都被配以红色或黑色。分配的规则为:红节点的子节点为黑色,并且树中的任何一条从节点到叶子的路径必须包含同样数目的黑色节点。记住根节点总为红色。红-黑树的搜索、插入、删除等操作的复杂度都为O(log(n))。
链表用于需要遍历全部节点的时候,而红-黑树适用于在地址空间中定位特定内存区域的时候。内核为了内存区域上的各种不同操作都能获得高性能,所以同时使用了这两种数据结构。
实际使用中的内存区域
可以使用/proc文件系统和pmap(1)工具查看给定进程的内存空间和其中所含的内存区域。我们来看一个非常简单的用户空间程序的例子,它其实什么也不做,仅仅是为了做说明:
1 | int main(int argc,char *argv[]) |
下面列出该进程地址空间中包含的内存区域。其中有代码段、数据段和bss段等。假设该进程与C库动态连接,那么地址空间中还将分别包含libc.so和ld.so对应的上述三种内存区域。此外,地址空间中还要包含进程栈对应的内存区域。/proc/<pid>/maps
的输出显示了该进程地址空间中的全部内存区域:
每行数据格式如下:前三行分别对应C库中libc.so的代码段、数据段和bss段,接下来的两个行为可执行对象的代码段和数据段,再下来三个行为动态连接程序ld.so的代码段、数据段和bss段,最后一行是进程的栈。
注意,代码段具有我们所要求的可读且可执行权限;另一方面,数据段和bss(它们都包含全局数据变量)具有可读、可写但不可执行权限。而堆栈则可读、可写,甚至还可执行—-虽然这点并不常用到。
该进程的全部地址空间大约为1340KB,但是只有大约40KB的内存区域是可写和私有的。如果一片内存范围是共享的或不可写的,那么内核只需要在内存中为文件保留一份映射。对于共享映射来说,这样做没有什么特别的,但是对于不可写内存区域也这样做,就有些让人奇怪了。如果考虑到映射区域不可写意味着该区域不可被改变(映射只能用来读),就应该清除只把该映射读入一次是很安全的。所以C库在物理内存中仅仅需要占用1212KB空间,而不需要为每个使用C库的进程在内存中都保存一个1212KB的空间。
进程访问了1340KB的数据和代码空间,然而仅仅消耗了40KB的物理内存,可以看出利用这种共享不可写内存的方法节约了大量的内存空间。
注意没有映射文件的内存区域的设备标志为00:00,索引节点标志也为0,这个区域就是零页—-零页映射的内容全为零。如果将零页映射到可写的内存区域,那么该区域将全被初始化为0。这是零页的一个重要用处,而bss段需要的就是全0的内存区域。由于内存未被共享,所以只要一有进程写该处数据,那么该处数据就将被拷贝出来(写时拷贝),然后才被更新。
每个和进程相关的内存区域都对应于一个vm_area_struct结构体。另外进程不同于线程,进程结构体task_struct包含唯一的mm_struct结构体引用。
操作内存区域
内核时常需要在某个内存区域执行一些操作,比如某个指定地址是否包含在某个内存区域中。这类操作非常频繁,另外它们也是mmap()例程的基础。为了方便执行这类对内存区域的操作,内核定义了许多的辅助函数。
它们都声明在文件<linux/mm.h>中。
find_vma()
为了找到一个给定的内存地址属于哪一个内存区域,内核提供了find_vma()函数。该函数定义在文件<mm/mmap.c>中:
1 | struct vm_area_struct *find_vma(struct mm_struct *mm,unsigned long addr); |
该函数在指定的地址空间中搜索第一个vm_end大于addr的内存区域。换句话说,该函数寻找第一个包含addr或首地址大于addr的内存区域,如果没有发现这样的区域,该函数返回NULL;否则返回指向匹配的内存区域的vm_area_struct结构体指针。注意,由于返回的VMA首地址可能大于addr,所以指定的地址并不一定就包含在返回的VMA中。因为很有可能在对某个VMA执行操作后,还有其他更多的操作会对该VMA接着进行操作,所以find_vma()函数返回的结果被缓存在内存描述符的mmap_cache域中。实践证明,被缓存的VMA会有相当好的命中率(30%-40%),而且检查被缓存的VMA速度会很快,如果指定的地址不在缓存中,那么必须搜索和内存描述符相关的所有内存区域。这种搜索通过红-黑树进行:
1 | struct vm_area_struct *find_vma(struct mm_struct *mm,unsigned long addr) |
首先,该函数检查mmap_cache,看看缓存的VMA是否包含了所需地址。注意简单地检查VMA的vm_end是否大于addr,并不能保证该VMA是第一个大于addr的内存区域,所以缓存要想发挥作用,就要求指定的地址必须包含在缓存的VMA中—-幸好,这也正是连续操作同一VMA必然发生的情况。
如果缓存中并未包含希望的VMA,那么该函数必须搜索红-黑树。如果当前VMA的vm_end大于addr,进入左子节点继续搜索;否则,沿右边子节点搜索,直到找到包含addr的VMA为止。如果没有包含addr的VMA被找到,那么该函数继续搜索树,并且返回大于addr的第一个VMA。如果也不存在满足要求的VMA,那该函数返回NULL。
find_vma_prev()
find_vma_prev()函数和find_vma()工作方式相同,但是它返回第一个小于addr的VMA。该函数定义和声明分别在文件mm/mmap.c
和文件<linux/mm.h>中。
1 | struct vm_area_struct *find_vma_prev(struct mm_struct *mm,unsigned long addr,struct vm_area_struct **pprev); |
pprev参数存放指向先于addr的VMA指针。
find_vma_intersection()
find_vma_intersection()函数返回第一个和指定地址区间相交的VMA。因为该函数是内联函数,所以定义在文件<linux/mm.h>中:
1 | static inline struct vm_area_struct * |
第一个参数mm是要搜索的地址空间,start_addr是区间的开始首位置,end_addr是区间的尾位置。
显然,如果find_vma()返回NULL,那么find_vma_intersection()也会返回NULL。但是如果find_vma()返回有效的VMA,find_vma_intersection()只有在该VMA的起始位置于给定的地址区间结束位置之前,才将其返回。如果VMA的起始位置大于指定地址范围的结束位置,则该函数返回NULL。
mmap()和do_mmap():创建地址区间
内核使用do_mmap()函数创建一个新的线性地址区间。但是说该函数创建了一个新的VMA并不非常准确,因为如果创建的地址区间和一个已经存在的地址区间相邻,并且它们具有相同的访问权限的话,两个区间将合并为一个。如果不能合并,就确实需要创建一个新的VMA了。但无论哪种情况,do_mmap()函数都会将一个地址区间加入到进程的地址空间中—-无论是扩展已存在的内存区域还是创建一个新的区域。
do_mmap()函数定义在文件<linux/mm.h>中。
1 | unsigned long do_mmap(struct file *file,unsigned long addr, |
该函数映射由file指定的文件,具体映射的是文件中从偏移offset处开始,长度为len字节的范围内的数据。如果file参数是NULL并且offset参数也是0,那么就代表这次映射没有和文件相关,该情况称作匿名映射。如果指定了文件名和偏移量,那么该映射称为文件映射。
addr是可选参数,它指定搜索空闲区域的起始位置。
prot参数指定内存区域中页面的访问权限。访问权限标志定义在文件<asm/mman.h>中,不同体系结构标志的定义有所不同,但是对所有体系结构而言,都会包含下表中所列举的标志。
页保护标志
标志 | 对新建区间中页的要求 |
---|---|
PROT_READ | 对应于VM_READ |
PROT_WRITE | 对应于VM_WRITE |
PROT_EXEC | 对应于VM_EXEC |
PROT_NONE | 页不可被访问 |
flag参数指定了VMA标志,这些标志指定类型并改变映射的行为。它们也在文件<asm/mman.h>中定义,参见下表:
页保护标志
| MAP_SHARED | 映射可以被共享 |
| MAP_PRIVATE | 映射不能被共享 |
| MAP_FIXED | 新区间必须开始于指定的地址addr |
| MAP_ANONYMOUS | 映射不是file-backed,而是匿名的 |
| MAP_GROWSDOWN | 对应于VM_GROWSDOWN |
| MAP_DENYWRITE | 对应于VM_DENYWRITE |
| MAP_EXECUTABLE | 对应于VM_EXECUTABLE |
| MAP_LOCKED | 对应于VM_LOCKED |
| MAP_NORESERVE | 不需要为映射保留空间 |
| MAP_POPULATE | 填充页表 |
| MAP_NONBLOCK | 在I/O操作上不堵塞 |
如果系统调用do_mmap()的参数中有无效参数,那么它返回一个负值;否则,它会在虚拟内存中分配一个合适的新内存区域。如果有可能的话,将新区域和邻近区域进行合并,否则内核从vm_area_cachep长字节(slab)缓存中分配一个vm_area_struct结构体,并且使用vma_link()函数将新分配的内存区域添加到地址空间的内存区域链表和红-黑树中,随后还要更新内存描述符中的total_vm域,然后才返回新分配的地址区间的初始地址。
在用户空间可以通过mmap()系统调用获取内核函数do_mmap()的功能。mmap()系统调用定义如下:
1 | void *mmap2(void *start,size_t length,int prot,int flags,int fd,off_t pgoff); |
由于该系统调用是mmap()调用的第二种变种,所以起名为mmap2()。最原始的mmap()调用中最后一个参数是字节偏移量,而目前这个mmap2()使用页面偏移作最后一个参数。使用页面偏移量可以映射更大的文件和更大的偏移位置。原始的mmap()调用由POSIX定义,仍然在C库中作为mmap()方法使用,但是内核中已经没有对应的实现了,而实现的是新方法mmap2()。虽然C库仍然可以使用原始版本的映射方法,但是它其实还是基于函数mmap2()进行的,因为对原始mmap()方法的调用是通过将字节偏移转化为页面偏移,从而转化对mmap2()函数的调用来实现的。
munmap()和do_munmap():删除地址区间
do_munmap()函数从特定的进程地址空间中删除指定地址区间,该函数定义在文件<linux/mm.h>中:
1 | int do_munmap(struct mm_struct *mm,unsigned long start,size_t len); |
第一个参数指定要删除区域所在的地址空间,删除从地址start开始,长度为len字节的地址区间。如果成功,返回零。否则,返回负的错误码。
系统调用munmap()给用户空间程序提供了一种从自身地址空间中删除指定地址区间的方法,它和系统调用mmap()的作用相反。
1 | int munmap(void *start,size_t length); |
该系统调用定义在文件mm/mmap.c
中,它是对do_munmap()函数的一个简单的封装:
1 | asmlinkage long sys_munmap(unsigned long addr,size_t len) |
页表
虽然应用程序操作的对象是映射到物理内存之上的虚拟内存,但是处理器直接操作的却是物理内存。所以当应用程序访问一个虚拟地址时,首先必须将虚拟地址转化为物理地址,然后处理器才能解析地址访问请求。地址的转换工作需要通过查询页表才能完成,概括的讲,地址转换需要将虚拟虚拟地址分段,使每段虚拟地址都作为一个索引指向页表,而页表项则指向下一级别的页表或者指向最终的物理页面。
Linux中使用三级页表完成地址转换。利用多级页表能够节约地址转换需占用的存放空间。如果利用三级页表转换地址,即使是64位机器,占用的空间也很有限。但是如果使用静态数组实现页表,那么即使在32位机器上,该数组也将占用巨大的存放空间。Linux对所有体系结构,包括对那些不支持三级页表的体系结构(比如,有些体系结构只使用两级页表或者使用散列表完成地址转换)都使用三级页表管理,因为使用三级页表结构可以利用“最大公约数”的思想—-一种设计简单的体系结构,可以按照需要在编译时简化使用页表的三级结构,比如只使用两级。
顶级页表是页全局目录(PGD),它包含了一个pgd_t类型数组,多数体系结构中pgd_t类型等同于无符号长整型类型。PGD中的表项指向二级页目录中的表项:PMD。
二级页表是中间页目录(PMD),它是个pmd_t类型数组,其中的表项指向PTE中的表项。
最后一级的页表简称页表,其中包含了pte_t类型的页表项,该页表项指向物理页面。
多数体系结构中,搜索页表的工作是由硬件完成的。虽然通常操作中,很多使用页表的工作都可以由硬件执行,但是只有在内核正确设置页表的前提下,硬件才能方便地操作它们。下图描述了虚拟地址通过页表找到物理地址的过程。
每个进程都有自己的页表(当然,线程会共享页表)。内存描述符的pgd域指向的就是进程的页全局目录。注意,操作和检索页表时必须使用page_table_lock锁,该锁在相应的进程的内存描述符中,以防止竞争条件。
页表对应的结构体依赖于具体的体系结构,所以定义在文件<asm/page.h>中。由于几乎每次对虚拟内存中的页面访问都必须先解析它,从而得到物理内存中的对应地址,所以页表操作的性能非常关键。但不幸的是,搜索内存中的物理地址速度很有限,因此为了加快搜索,多数体系结构都实现了一个翻译后备缓冲器(translate lookaside buffer,TLB)。TLB作为一个将虚拟地址映射到物理地址的硬件缓存,当请求访问一个虚拟地址时,处理器将首先检查TLB中是否缓存了该虚拟地址到物理地址的映射,如果在缓存中直接命中,物理地址立刻返回;否则,就需要再通过页表搜索需要的物理地址。
虽然硬件完成了有关页表的部分工作,但是页表的管理仍是内核的关键部分—-而且在不断改进。2.6版内核对页表管理的主要改进是:从高端内存分配部分页表。今后可能的改进包括通过在写时拷贝(copy-on-write)的方式共享页表。这种机制使得在fork()操作中可由父子进程共享页表。因为只有当子进程或父进程试图修改特定页表项时。可以看到,利用共享页表可以消除fork()操作中页表拷贝所带来的消耗。
小结
这章的内容不能不说是很“难缠”啦。其中,我们看到了抽象出来的进程虚拟内存,看到了内核如何表示进程空间(通过mm_struct)以及内核如何表示该空间中的内存区域(通过结构体vm_area_struct)。除此之外,我们还了解了内核如何创建(通过mmap())和撤销(通过munmap())这些内存区域,最后还讨论了页表。因为Linux是一个基于虚拟内存的操作系统,所以这些概念对于系统运行来说都是非常基础的,一定要仔细领会。
下一章,我们要讨论页缓存—-一种用于所有页I/O操作的内存数据缓存,而且还要涵盖内核执行基于页的数据回写。