Linux内核原理之进程地址空间

进程地址空间

用户层进程的虚拟地址空间是Linux的一个重要抽象:它向每个运行进程提供了同样的系统视图,使得多个进程可以同时运行,而不会干扰其他进程内存中的内容

简介

本章主要关注用户管理虚拟地址空间的方法

  • 每个应用程序都有自身的地址空间,与所有其他应用程序分隔开
  • 在巨大的线性结构地址空间中,只有很少的段可用于各个用户空间进程,这些段彼此有一定的距离(内核需要一些数据结构来管理这些随机分布的段)
  • 地址空间只有极小的一部分与物理内存页直接关联,不经常使用的部分,则仅当必要时与页帧关联
  • 各个操作用户地址空间的操作都伴随着各种检查,以确保程序的权限不会超过应有的限制

进程虚拟地址空间

进程地址空间的布局

虚拟地址空间中包含了如下区域:

  • 二进制代码,所处的虚拟内存区域为text段
  • 程序使用的动态库的代码
  • 存储全局变量和动态产生数据的堆
  • 用于保存局部变量和实现函数/过程调用的栈
  • 环境变量和命令行参数的段
  • 将文件内容映射到虚拟地址空间的内存映射

系统的各个进程包含一个struct mm_struct的实例,通过task_struct访问,这个实例保存了进程的内存管理信息

Linux内核原理之进程地址空间

  • start_code和end_code标记了可执行代码的虚拟地址空间区域的开始和结束,start_data和end_data标记了已初始化数据的区域(注意:在ELF二进制文件映射到地址空间之后,这些区域的长度不再改变)
  • start_brk保存了堆的起始地址,brk表示堆区域当前的结束地址
  • 参数列表和环境变量的位置分别由arg_start和arg_end、env_start和env_end描述,两个区域位于栈中最高的区域
  • mmap_base:表示虚拟地址空间中用于内存映射的起始地址,调用get_unmapped_area在mmap区域中为新映射找到适当的位置

Linux内核原理之进程地址空间

如图是大多数体系结构虚拟地址空间的分布情况,text如何映射到虚拟地址空间由ELF文件标准确定。每个体系结构指定了特定的其实地址:IA-32系统起始于0x08048000,在text段的起始地址与最低的可用地址之间有大约128MiB的间距,用于捕获NULL指针。堆紧接着text段开始,向上增长

栈起始于STACK_TOP,如果设置了PF_RANDOMIZE,则起始点减少一个小的随机量(大多数体系结构都将STACK_TOP设置为TASK_SIZE)

用于内存映射的区域起始于mm_struct->mmap_base,通常设置为TASK_UNMAPPED_BASE,每个体系结构都需要定义它

内存映射的原理

考察通过文本编辑器操作文件的情况。通常用户只关注文件结尾处,尽管整个文件都映射到内存中,但实际上只使用了几页来存储文件末尾的数据;至于文件开始出的数据,内核只需要在地址空间保存相关信息,如数据在磁盘上的位置以及需要数据时如何读取(text段也是类似,始终只需要其中的一部分)

因此,文本编辑器只需要加载与主要编辑功能相关的代码,其他部分(如帮助系统或web和电子邮件客户端程序)是会在用户明确要求时才加载

如图所示,内核提供了数据结构以建立地址空间区域和相关数据所在位置之间的关联(例如,在映射文本文件时,映射的虚拟内存区必须关联到文件系统在硬盘上存储文件内容的区域)

Linux内核原理之进程地址空间

内核利用address_space数据结构,提供了一组方法从后备存储器读取数据。例如,从文件系统读取时,address_space形成了一个辅助层,将映射的数据表示为连续的线性区域,提供给内存管理子系统

按需分配和填充页称之为按需调页法,它基于处理器和内核之间的交互,使用的各种数据结构如下图

Linux内核原理之进程地址空间

数据结构

struct mm_struct结构提供了进程在内存中布局的必要信息,另外还包含下列成员,用于管理用户进程在虚拟地址空间中的所有内存区域

Linux内核原理之进程地址空间

树和链表

地址空间的每个区域都通过一个vm_area_struct实例描述,各个区域按照两种方法排序:

  • 在一个单链表上(开始于mm_struct->mmap)
  • 在一个红黑树中,根节点位于mm_rb

mmap_cache缓存了上一次处理的区域

用户虚拟地址空间中的每个区域由开始和结束地址描述,现存的区域按起始地址以递增次序被归入链表中,通过扫描链表可以找到特定地址关联的区域,但在有大量区域时这种方式非常低效,因此vm_area_struct的各个实例还通过红黑树管理,可以显著加快扫描速度

在添加新区域时,内核首先搜索红黑树,找到刚好在新区域之前的区域,因此内核可以向树和线性链表添加新的区域,而无需扫描链表。内存中的情况如图所示(注意:树的表示这是象征性的,没有反应真实布局的复杂性)

Linux内核原理之进程地址空间

虚拟内存区域的表示

每个区域表示为vm_area_struct的一个实例,其定义如下

Linux内核原理之进程地址空间

Linux内核原理之进程地址空间

主要成员如下:

  • vm_mm:一个反向指针,指向该区域所属的mm_struct实例
  • vm_start和vm_end指定了该区域在用户空间的起始和结束地址
  • 进程所有vm_area_struct实例的链表通过vm_next实现,红黑树的集成通过vm_rb实现
  • 给出文件的一个区间,内核需要知道该区间所映射到的所有进程(这种映射称为反向映射),未提供所需的信息,所有vm_area_struct实例都通过一个优先树管理,包含在shared成员,细节将在15.4.3节讨论
  • vm_ops:一个指针,指向许多方法的集合,包含区域上各种标准操作的函数指针
  • fault:如果地址空间中的某个虚拟内存页不在物理内存中,自动触发的缺页异常处理程序会调用该函数,将对应的数据读取到一个映射在用户地址空间的物理内存中
  • vm_file:指向file实例,描述了一个被映射的文件

优先查找树

优先查找树用于建立文件中的一个区域与该区域映射到的所有虚拟地址空间之间的关联。内核的一些数据结构用于建立这种关联

  1. 附加的数据结构

    每个打开文件都表示为file结构的一个实例,该结构包含了一个指向地址空间对象struct address_space的指针,该对象是优先查找树的基础,而文件区间与其映射到的虚拟地址空间之间的关联则通过优先树建立。两个结构定义如下

Linux内核原理之进程地址空间

每个文件和块设备都表示为struct inode的一个实例。struct file是通过open系统调用打开文件的抽象,而inode表示文件系统自身中的对象

在打开文件时,内核将file->f_mapping设置到inode->i_mapping,使得多个进程可以访问同一个文件,而不会干扰其他进程:inode是特定于文件的数据结构,而file是特定于进程的

如下图是内存中而各个结构之间的关联

Linux内核原理之进程地址空间

给出struct address_space的实例,内核可以推断相关的inode,而后者可用于访问实际存储文件数据的后备存储器(如块设备)

地址空间address_space是优先树的基本要素,优先树包含了所有相关的vm_area_struct实例,描述了与inode关联的文件区间到一些虚拟地址空间的映射。而每个vm_area_struct实例都包含一个指向所属进程的mm_struct指针,关联就此建立起来了!

  1. 优先树的表示

    优先树用来管理表示给定文件中特定区间的所有vm_area_struct实例,要求该数据结构能够处理文件区间的重叠以及相同的文件区间

Linux内核原理之进程地址空间

区间的边界确定了一个唯一索引,可用于将各个区间存储在一个唯一的树节点中。如果区间B、C和D完全包含在另一个区间A中,那么A将是B、C和D的父节点

而如果当多个相同区间被归入优先树,发生的情况如下图。各个优先树节点表示为一个raw_prio_tree_node实例,该实例直接包含在各个vm_srea_struct实例,该实例与一个vm_set实例在同一个联合中,从而可以将vm_set(vm_area_struct结构组成的链表)和一个优先树节点关联起来

Linux内核原理之进程地址空间

对区域的操作

内核提供了各种函数操作进程的虚拟内存区域,在建立和删除映射时,也需要创建和删除区域。内核还负责在管理这些数据结构时进行优化

Linux内核原理之进程地址空间

  • 如果一个区域紧接着现存区域直接添加,内核会将涉及的数据结构合并为一个(前提是涉及的区域访问权限相同),vma_merge函数用于此功能
  • 如果在区域的开始或结束处进行删除,则需要截断区域
  • 如果删除区域中间的一段,则需要截断现存区域,并生成一个新的区域。insert_vm_struct用于插入新区域,get_unmapped_area用于创建新区域

更重要的一个操作是搜索与用户空间中一个特定虚拟地址相关的区域,find_vma函数用于此功能

address_space

文件的内存映射可认为是两个不同地址空间之间的映射,一个是用户进程的虚拟地址空间,另一个是文件系统所在的地址空间

在内核创建一个映射之后。必须建立两个地址之间的关联,以支持二者以读写请求的形式通信,vm_operation_struct结构用于完成该工作,它提供一个操作,来读取已经映射到虚拟地址空间,但内容尚未进入物理内存的页。但是该操作结构不了解映射类型或其性质的相关信息,由于存在许多种类的文件映射(不同类型文件系统的普通文件、设备文件),因此需要更多的信息。

内核使用address_space结构更详细的说明数据源所在的地址空间,它包含了有关映射的附加信息。每个文件映射都有一个相关的address_space实例

address_space结构有一组相关的操作,以函数指针的形式保存在如下结构中

Linux内核原理之进程地址空间

vm_operation_struct和address_space的联系建立:使用内核为vm_operations_struct提供的标准实现连接起来,如下图

Linux内核原理之进程地址空间

其中filemap_fault的实现是相关映射的readpage方法,从而与address_space结构联系起来

内存映射

内存映射:建立虚拟内存和文件区间的映射关系,通过访问虚拟内存可以实现对文件数据的访问

正常的文件读:系统调用,通过文件路径找到对应的inode

C标准库提供了mmap函数建立映射,在内核端提供了两个系统调用mmap和mmap2,它们的参数相同

Linux内核原理之进程地址空间

这两个系统调用都会在用户虚拟地址的addr位置,建立一个长度为len的映射,访问权限通过prot定义,flags是一个标志集,用于设置一些参数,相关的文件通过描述符fd标识

mmap和mmap2的差别在于偏移量(off)的语义,在两个调用中都表示映射在文件中开始的位置,而mmap的位置单位是字节,mmap2的位置单位是页(PAGE_SIZE)

通常C标准库只提供一个函数mmap,由应用程序用来创建内存映射

创建映射

下文只讨论sys_mmap2,它作为系统调用mmap2的入口函数。内核根据参数提供的文件描述符找到file实例,以及所处理文件的所有特征数据。剩余的工作委托给do_mmap_pgoff,它是一个体系结构无关的函数,代码流程图如下

Linux内核原理之进程地址空间

它分为两部分:一部分需要彻底检查用户应用程序传递的参数,第二部分需要考虑大量特殊情况和微妙之处。只考虑代表性的标准情况:用MAP_SHARE映射普通文件

  • 首先调用get_unmapped_area函数,在虚拟地址空间中找到一个适当的区域用于映射

  • 计算标志:calc_vm_prot_bits和calc_vm_flag_bits将系统调用中指定的标志和访问权限常数合并到一个共同的标志集,在后续操作比较易于处理

Linux内核原理之进程地址空间

  • 在检查好参数并设置好所有标志之后,剩余工作委托给mmap_region,其中调用find_vma_prepare函数来查找前一个和后一个区域的vm_area_struct实例以及红黑树节点对应的数据;如果在指定的映射位置已经存在一个映射,则通过do_munmap删除它

  • 检查内存限制:如果没有设置MAP_NORESERVE标志或内核参数sysctl_overcommit_memory设置为OVERCOMMIT_NEVER(不允许过量使用),则调用vm_enough_memory,该函数选择是否分配操作所需的内存

  • 在系统分配所需的内存之后,执行以下步骤

    • 分配并初始化一个新的vm_area_struct实例,并插入到进程的链表/红黑树数据结构中
    • 调用特定于文件的函数file->f_op->mmap创建映射,大多数文件系统对应函数generic_file_mmap,它会将映射的vm_ops成员设置为generic_file_vm_ops(vma->vm_ops = &generic_file_vm_ops),其中的关键处理函数是filemap_fault,在应用程序访问映射区域但对应数据不在物理内存时调用,它借助于潜在文件系统的底层例程取得所需数据,并读取到物理内存(注意:映射数据不是在建立映射时立即读入内存,是有实际需要相应数据时才进行读取)
  • 检查是否设置VM_LOCKED标志:如果是,则调用make_pages_present依次扫描映射中的各页,对每一页触发缺页异常以便读入数据

  • 最后返回映射的起始地址,完成系统调用

删除映射

使用munmap系统调用从虚拟地址空间删除现存映射,它需要两个参数:解除映射区域的起始地址和长度,sys_munmap是该系统调用的入口函数,代码流程如下图

Linux内核原理之进程地址空间

  • 首先调用find_vma_prev,以找到解除映射区域的vm_area_struct实例(与find_vma功能相同),它不仅找到与地址匹配的vm_area_struct实例,还会返回指向前一个区域的指针
  • 如果解除映射区域的起始地址与find_vma_prev找到的区域起始地址不同,则只解除部分映射,而不是整个映射区域
    • 内核首先必须将现存的映射划分为几个部分,映射的前一部分不需要解除映射,通过split_vma分裂出来,它会分配一个新的vm_area_struct实例,用原区域的数据填充它,并校准边界,新的区域插入到进程的数据结构中
    • 如果解除映射的部分区域与原区域并不重合,则需要重复前一步的操作
  • 接下来调用detach_vmas_to_be_unmapped,列出所有需要解除映射的区域,它会遍历vm_area_struct实例的线性表,将所有需要解除映射的内存区域通过vm_next连接起来,还会将mmap缓存设置为NULL,使之无效
  • 调用unmap_region从页表删除与映射相关的所有项,同时内核需要确保相关的项在TLB移除或使之无效
  • 最后调用remove_vma_list释放vm_area_struct实例占用的空间

反向映射

内核利用此前讨论的数据结构,可以建立虚拟和物理地址之间的联系(通过页表),以及进程的一个内存区域与其虚拟内存地址之间的关联。但是缺失了一个联系:物理内存页和该页所属进程(更精确地说:所有使用该页的进程的对应页表项)之间的联系。在换出页时,刚好需要该关联,以便更新所有涉及的进程

内核采用逆向映射的方法来建立这种联系

数据结构

page结构包含一个用于实现逆向映射的成员

Linux内核原理之进程地址空间

还有两个其他的数据结构:

  • 优先查找树嵌入了属于非匿名映射的每个区域
  • 指向内存中同一页的匿名区域的链表

用于建立这两个数据结构的成员集成在vm_area_struct中,即shared联合以及anon_vma_node和anon_vma,如下图所示

Linux内核原理之进程地址空间

实现逆向映射的技巧是:不直接保存页和相关的使用者之间的关联,而只保存页和页所在区域之间的关联,包含该页的其他区域可以通过上述的数据结构找到,进而找到所有的使用者(进程)

该方法称为基于对象的逆向映射,没有存储页和使用者之间的直接关联,而是在两者之间插入了另一个对象(该页所在的区域)

建立逆向映射

在创建匿名映射时,需要区分两个备选项:匿名页和基于文件映射的页

  1. 匿名页

    将匿名页插入到逆向映射数据结构有两种方法:对新的匿名页必须调用page_add_new_anon_rmap,已经有引用计数的页,则使用page_add_anon_map。这两个函数唯一的差别是:前者将映射计数器page->_mapcount设置为0,后者将计数器加1。两个函数都并入了__page_set_anon_rmap

Linux内核原理之进程地址空间

  1. 基于文件映射的页

    此类型的页非常简单,只需要对_mapcount加1并更新各内存域的统计量

Linux内核原理之进程地址空间