写点什么

深入 Linux 内核架构——进程虚拟内存

用户头像
赖猫
关注
发布于: 2020 年 12 月 10 日
深入Linux内核架构——进程虚拟内存

一、简介



用户虚拟地址空间的管理比内核地址空间的管理复杂:



每个应用程序都有自身的地址空间,与所有其他应用程序分隔开;通常在巨大的线性地址空间中,只有很少的段可用于各个用户空间进程,这些段彼此有一定的距离,内核需要一些数据结构,来有效地管理这些(随机)分布的段;地址空间只有极小的一部分与物理内存页直接关联,不经常使用的部分,则仅当必要时与页帧关联;内核信任自身,但无法信任用户进程,因此,各个操作用户地址空间的操作都伴随有各种检查,以确保程序的权限不会超出应有的限制,进而危及系统的稳定性和安全性;fork-exec模型在UNIX操作系统下用于产生新进程,如果实现得较为粗劣,模型功能不强大,内核则必须借助于一些技巧,来尽可能高效地管理用户地址空间。(以下默认系统有一个内存管理单元MMU,支持使用虚拟内存)二、进程虚拟地址空间各个进程的虚拟地址空间起始于地址0,延伸到TASK_SIZE - 1,其上是内核地址空间,用户程序只能访问整个地址空间的下半部分,不能访问内核部分。无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是同样的,虚拟地址空间由许多不同长度的段组成,用于不同的目的,必须分别处理。



1、进程地址空间的布局



虚拟地址空间包含了若干区域,其分布方式特定于体系结构,但它们有以下共同成分:



当前运行代码的二进制代码(其代码通常称为text,所处的虚拟内存区域称为text段); 程序使用的动态库的代码;

存储全局变量和动态产生的数据的堆; 用于保存局部变量和实现函数/过程调用的栈; 环境变量和命令行参数的段;

将文件内容映射到虚拟地址空间中的内存映射。

可执行代码占用的虚拟地址空间区域,开始和结束部分分别通过start_code和end_code标记;start_data和end_data标记了包含已初始化数据的区域。(ELF二进制文件映射到地址空间后,这些区域长度不再改变)

堆的起始地址保存在start_brk,brk表示堆区域当前结束的地址(堆的起始地址在进程生命周期中不变,但其长度会变化,意味着brk的值会变化)。

参数列表和环境参数分别由arg_start和arg_end、env_start和env_end描述,两个区域都位于栈中最高的区域。

mmap_base表示虚拟地址空间中用于内存映射的其实地址。 task_size存储了对应进程的地址空间长度(通常为TASK_SIZE)。



系统中,各个进程都具有一个struct mm_struct实例,实例中保存了进程的内存管理信息,可以通过task_struct访问。



struct mm_struct {
unsigned long (*get_unmapped_area) (struct file filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
unsigned long mmap_base; / mmap区域的基地址 /
unsigned long task_size; / 进程虚拟内存空间的长度 */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
}





(各个体系结构可以通过几个配置选项影响虚拟地址空间的布局,比如在不同mmap区域布局之间选择,创建新内存映射时指定具体地址,寻找新的内存映射低端内存位置的方式等等。)



进程有一个标志PF_RANDOMIZE,设置标志后,内核不会为栈和内存映射的起点选择固定位置,而是每次新进程启动时随机改变这些值的设置。(引入复杂性防止攻击)



图为前述各部分在大多数体系结构里虚拟地址空间中的分布情况。





  • text段映射到虚拟地址空间中的方式由ELF标准确定,每个体系结构都指定了一个特定的起始地址,IA-32系统起始于0x8048000,在text段的起始地址与最低可用地址之间有大约128MB间距,用于捕获NULL指针。堆紧接着text段,向上增长。

  • 栈起始于STACK_TOP(大多数体系结构为TASK_SIZE),如果设置了PF_RANDOMIZE则会减少一个随机量,进程的参数列表和环境变量都是栈的初始数据。

  • 用于内存映射的区域起始于mm_struct->mmap_base,通常设置为TASK_UNMAPPED_BASE(几乎所有情况下其值为TASK_SIZE/3)。



下图所示的这种经典布局意味着堆最高只能到mmap的起始位置(IA-32中通常大小为1G),因此出现了下图所示的新的布局。新的布局中,使用固定值限制栈的最大长度,内存映射区域可以在栈末端下方开始,自顶向下扩展,堆依然处于虚拟地址空间中较低位置向上增长,因此mmap区域和堆可以相对扩展,直至耗尽虚拟地址空间中的剩余区域。(为确保栈和mmap区域不冲突,两者之间设置了一个安全隙)





2、建立布局



在使用load_elf_binary装载一个ELF二进制文件时,将创建进程的地址空间(exec系统调用中实现)。图3为load_elf_binary的代码流程图。





  • 如果全局变量randomize_va_space设置为1,则启用地址空间随机化机制,通常情况下是启用的;

  • 然后由arch_pick_mmap_layout完成选择布局的工作,如果对应体系结构没有提供一个具体的函数,则使用内核的默认例程;

  • 最后setup_arg_pages函数负责在适当的位置创建栈,该函数需要栈顶位置作为参数,栈顶由特定于体系结构的常数STACK_TOP给出,而后调用randomize_stack_top,确保在启用地址空间随机化的情况下,对该地址进行随机偏移。



三、内存映射的原理



由于所有用户进程总的虚拟地址空间比可用的物理内存大得多,所以只有最常用的部分才与物理页帧关联。以文本编辑器为例,通常用户只关注文件结尾处,因此尽管整个文件都映射到内存中,实际上只用了几页来存储文件末尾的数据,至于文件开始处的数据,内核需要在地址空间保存相关信息(如数据在磁盘上的位置,以及需要数据时如何读取)。



内核提供一种数据结构建立虚拟地址空间的区域和相关数据所在位置之间的关联。



按需分配和填充页称为按需调页法(demand paging),它基于处理器和内核之间的交互,使用的数据结构如图所示。





  • 进程试图访问用户地址空间中的一个内存地址,但使用页表无法确定物理地址(物理内存中没有关联页); 处理器接下来触发一个缺页异常,发送到内核;

  • 内核会检查负责缺页区域的进程地址空间数据结构,找到适当的后备存储器,或者确认该访问实际上是不正确的;

  • 分配物理内存页,并从后备存储器读取所需数据填充; 借助于页表将物理内存页并入到用户进程的地址空间,应用程序恢复执行。



小编推荐自己的Linux、C/C++技术交流群:【960994558】整理了一些个人觉得比较好的学习书籍、视频资料共享在里面(包括C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等等.),有需要的可以自行添加哦!~







四、数据结构



与内存布局相关的信息在struct mm_struct中为:



struct mm_struct {
struct vm_area_struct * mmap; /* 虚拟内存区域列表/
struct rb_root mm_rb;
struct vm_area_struct * mmap_cache; /上一次find_vma的结果 */
}



1、树和链表



每个区域都通过一个vm_area_struct实例描述,进程各区域按两种方法排序:



在一个单链表上(开始于mm_struct->mmap);在一个红黑树中,根结点位于mm_rb。用户虚拟地址空间中的每个区域由开始和结束地址描述。现存的区域按起始地址以递增次序被归入链表中。扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作(数据密集型的应用程序就是这样)。因此vm_area_struct的各个实例还通过红黑树管理,可以显著加快扫描速度。



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





2、虚拟内存区域的表示每个区域都是一个vm_area_struct实例。其结构体如下所示:



vm_area_struct {
struct mm_struct * vm_mm; //反向指针,指向该区域所属的mm_struct实例
unsigned long vm_start; //该区域在用户空间中的起始地址
unsigned long vm_end; //该区域在用户空间中的结束地址
/* 各进程的虚拟内存区域链表,按地址排序 */
struct vm_area_struct vm_next; //进程所有vm_area_struct实例的链表指针
pgprot_t vm_page_prot; //存储该区域的访问权限
unsigned long vm_flags; //描述该区域的一组标志,如下列出
struct rb_node vm_rb; //进程所有vm_area_struct实例的红黑树集成
/
对于有地址空间和后备存储器的区域来说,
shared连接到address_space->i_mmap优先树,
或连接到悬挂在优先树结点之外、类似的一组虚拟内存区域的链表,
或连接到address_space->i_mmap_nonlinear链表中的虚拟内存区域。 */
union {
struct {
struct list_head list;
void parent; / 与prio_tree_node的parent成员在内存中位于同一位置 */
struct vm_area_struct head;
} vm_set;
struct raw_prio_tree_node prio_tree_node;
} shared;
/
*在文件的某一页经过写时复制之后,文件的MAP_PRIVATE虚拟内存区域可能同时在i_mmap树和
*anon_vma链表中。MAP_SHARED虚拟内存区域只能在i_mmap树中。
*匿名的MAP_PRIVATE、栈或brk虚拟内存区域(file指针为NULL)只能处于anon_vma链表中。
*/
struct list_head anon_vma_node; //链表元素,用于管理源自匿名映射(anonymous mapping)的共享页
struct anon_vma anon_vma; //用于管理源自匿名映射(anonymous mapping)的共享页
/ 用于处理该结构的各个函数指针。 /
struct vm_operations_struct * vm_ops; //指向多个方法的集合,用于在区域上执行各种操作
/ 后备存储器的有关信息: */
unsigned long vm_pgoff; //用于只映射文件部分内容时指定文件映射偏移量,单位是PAGE_SIZE,不是PAGE_CACHE_SIZE
struct file * vm_file; //映射到的文件(可能是NULL),指向file实例
void * vm_private_data; //vm_pte(即共享内存),用于存储私有数据,取决于映射类型
};



  • VM_READ、VM_WRITE、VM_EXEC、VM_SHARED分别指定了页的内容是否可以读、写、执行,或者由几个进程共享;

  • VM_MAYREAD、VM_MAYWRITE、VM_MAYEXEC、VM_MAYSHARE用于确定是否可以设置对应的VM_*标志,这是mprotect系统调用所需要的;

  • VM_GROWSDOWN和VM_GROWSUP表示一个区域是否可以向下或向上扩展(到更低或更高的虚拟地址),由于堆自下而上增长,其区域需要设置VM_GROWSUP,VM_GROWSDOWN对栈设置,该区域自顶向下增长;



如果区域很可能从头到尾顺序读取,则设置VM_SEQ_READ,VM_RAND_READ指定了读取可能是随机的,这两个标志用于“提示”内存管理子系统和块设备层,以优化其性能。如果设置了VM_DONTCOPY,则相关的区域在fork系统调用执行时不复制。



VM_DONTEXPAND禁止区域通过mremap系统调用扩展。



如果区域是基于某些体系结构支持的巨型页,则设置VM_HUGETLB标志。



VM_ACCOUNT指定区域是否被归入overcommit特性的计算中。这些特性以多种方式限制内存分配。



3、优先查找树优先查找树(priority search tree)用于建立文件中的一个区域与该区域映射到的所有虚拟地址空间之间的关联。



(1)附加的数据结构



每个打开文件(和每个块设备,因为这些也可以通过设备文件进行内存映射)都表示为struct file的一个实例,该结构包含了一个指向地址空间对象struct address_space的指针,该对象是优先查找树(prio tree)的基础,而文件区间与其映射到的地址空间之间的关联即通过优先树建立。



此外,每个文件和块设备都表示为struct inode的一个实例,struct file是通过open系统调用打开的文件的抽象,与此相反,inode表示文件系统自身中的对象。inode是一个特定于文件的数据结构,file是特定于给定进程的,内存中各结构之间的关联如图所示。





地址空间是优先树的基本要素,优先树包含了所有相关的vm_area_struct实例,描述了与inode关联的文件区间到一些虚拟地址空间的映射。每个struct vm_area的实例都包含了一个指向所属进程的mm_struct的指针,因此建立关联。此外,vm_area_struct还可以通过以i_mmap_nonlinear为表头的双链表与一个地址空间关联,这是非线性映射(nonlinear mapping)所需。



(2)优先树的表示



优先树用来管理表示给定文件中特定区间的所有vm_area_struct实例,它不仅能够处理重叠区间,还处理相同的文件区间。对于重叠区间,区间的边界提供了一个唯一的索引,将各个区间存储在一个唯一的树结点中,如果一个区间完全包含在另一个区间只会中,那么前者是后者的子结点;对于相同区间,可以将一个vm_set的链表与一个优先树结点关联起来,如图所示。





五、对区域的操作



内核提供了各种函数操作进程的虚拟内存区域,还负责在管理这些数据结构时进行优化。





如上图所示:



  • 如果一个新区域紧接着现存区域前后直接添加,内核将涉及的数据结构合并为一个(前提是涉及的所有区域的访问权限相同,而且是从同一后备存储器映射的连续数据);

  • 如果在区域的开始或结束处进行删除,则必须据此截断现存的数据结构;

  • 如果删除两个区域之间的一个区域,那么一方面需要减小现存数据结构的长度,另一方面需要为形成的新区域创建一个新的数据结构。



1、将虚拟地址关联到区域通过虚拟地址,find_vma可以查找用户地址空间中结束地址在给定地址之后的第一个区域,即满足addr小于vm_area_struct->vm_end条件的第一个区域。该函数的参数不仅包括虚拟地址(addr),还包括一个指向mm_struct实例的指针,后者指定了扫描哪个进程的地址空间。



2、区域合并如上图所示,在新区域被加到进程的地址空间时,内核会检查它是否可以与一个或多个现存域合并,通过函数vm_merge实现,该函数的参数包括相关进程的地址空间实例,紧接着新区域之前的区域,该区域在红黑查找树中的父结点,新区域的开始地址、结束地址、标志。如果该区域属于一个文件映射,有一个指向表示该文件的file实例的指针,和指定了映射在文件数据内的偏移量。



3、插入区域insert_vm_struct是内核用于插入新区域的标准函数。实际工作委托给两个辅助函数,如下图所示。





首先调用find_vma_prepare,通过新区域的起始地址和涉及的地址空间(mm_struct),获取相关信息;然后使用vma_link将新区域合并到该进程现存的数据结构中。



4、创建区域



在向数据结构插入新的内存区域之前,内核必须确认虚拟地址空间中有足够的空闲空间,可用于给定长度的区域,该工作分配给get_unmapped_area辅助函数完成。



首先检查是否设置了MAP_FIXED标志,该标志表示映射将在固定地址创建。倘若如此,内核只会确保该地址满足对齐要求(按页),而且所要求的区间完全在可用地址空间内。



如果没有指定区域位置,内核将调用arch_get_unmapped_area在进程的虚似内存区中查找适当的可用区域。如果指定了一个特定的优先选用(与固定地址不同)地址,内核会检查该区域是否与现存区域重叠。如果不重叠,则将该地址作为目标返回。否则,内核必须遍历进程中可用的区域,设法找到一个大小适当的空闲区域。这样做时,内核会检查是否可使用前一次扫描时缓存的区域。如果搜索持续到用户地址空间的末端(TASK_SIZE),仍然没有找到适当的区域,则内核返回一个-ENOMEM错误。(如果mmap区域自顶向下扩展,那么分配新区域的函数是arch_get_unmapped_area_topdown,其处理逻辑与上文所述类似)



六、地址空间



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



在内核创建一个映射时,必须建立两个地址空间之间的关联,以支持二者以读写请求的形式通信。vm_operations_struct结构即用于完成该工作,它提供了一个操作,来读取已经映射到虚拟地址空间、但其内容尚未进入物理内存的页。该操作不了解映射类型或其性质的相关信息,但address_space结构中包含了有关映射的附加有信息。



vm_operations_struct和address_space之间的联系不是静态的,它们使用内核为vm_operations_struct提供的标准连接,几乎所有文件系统都采用这种方式。



struct vm_operations_struct generic_file_vm_ops = {
.fault = filemap_fault,
};



filemap_fault的实现使用了相关映射的readpage方法,也采用了address_space的概念。



七、内存映射



C标准库中通过mmap函数建立文件到内存的映射,在内核一端,提供mmap和mmap2两个系统调用在用户虚拟地址空间中的pos位置,建立一个长度为len的映射,其访问权限通过prot定义。flags是一组标志集,fd是文件描述符标识。mmap和mmap2之间的差别在于偏移量的语义(off),前者单位是字节,后者单位是页。



asmlinkage unsigned long sys_mmap{2}(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long off)



munmap系统调用用于删除映射(此时不需要文件偏移量)。



1、创建映射mmap和mmap2可设置的标志集如下:



  • MAP_FIXED指定除了给定地址之外,不能将其他地址用于映射。如果没有设置该标志,内核可以在受阻时随意改变目标地址;

  • 如果一个对象(通常是文件)在几个进程之间共享时,则必须使用MAP_SHARED;

  • MAP_PRIVATE创建一个与数据源分离的私有映射,对映射区域的写入操作不影响文件中的数据;

  • MAP_ANONYMOUS创建与任何数据源都不相关的匿名映射,fd和off参数被忽略。此类映射可用于为应用程序分配类似malloc所用的内存。

  • prot可指定PROT_EXEC、PROT_READ、PROT_WRITE、PROT_NONE值的组合,来定义访问权限。



sys_map在大多数体系结构上行为类似,最终都会进入do_mmap_pgoff函数,mmap2系统调用入口是sys_mmap2,它会立即将工作委托给do_map2,内核在此找到所处理文件的所有特征数据,随后工作委托给do_mmap_pgoff。



do_mmap_pgoff与体系结构无关,下图为它的代码流程图。





  • do_mmap_pgoff函数分为两个部分,一个部分彻底检查用户应用程序传递的参数,第二个考虑大量特殊情况。

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

  • 然后检查参数,设置所需要的标志;

  • 最后将工作委托给mmap_region,找到映射的起始地址,在这过程中,会对代码执行路径中的不同位置进行几次检查,如果某一次失败则结束操作并返回一个操作代码。



内核维护了进程用于映射的页数目统计。由于可以限制进程的资源用量,内核必须始终确保资源使用不超出允许值。对于每个进程可以创建的映射,还有一个最大数目的限制。



内核必须进行广泛的安全性和合理性检查,以防应用程序设置无效参数或可能影响系统稳定性的参数。例如,映射不能比虚拟地址空间更大,也不能扩展到超出虚拟地址空间的边界。



2、删除映射从虚拟地址空间删除现存映射,必须使用munmap系统调用,它需要两个参数:解除映射区域的起始地址和长度,sys_munmap是该系统调用的入口,sys_munmap系统调用将工作委托给do_munmap函数,其代码流程图如下图所示。





  • 内核首先调用find_vma_prev,以找到解除映射区域的vm_area_struct实例,返回指向前一个区域的指针;

  • 如果解除映射区域的起始地址与find_vma_prev找到的区域起始地址不同,则只解除部分映射,而不是整个映射区域(此时需要通过区域分裂将映射划分为几个部分);

  • 如果解除映射的部分区域的末端与原区域末端并不重合,那么原区域后部仍然有一部分未解除映射,因此需要对这部分也进行分裂;

  • 接下来调用detach_vmas_to_be_unmapped,列出所有需要解除映射的区域;

  • 最后调用unmap_region从页表删除与映射相关的所有项,完成后将相关项从TLB中移除,用用remove_vma_list释放vm_area_struct实例占用的空间,完成从内核中删除映射的工作。



3、非线性映射普通的映射将文件中一个连续的部分映射到虚拟内存中一个同样连续的部分。如果需要将文件的不同部分以不同顺序映射到虚拟内存的连续区域中,则使用非线性映射。sys_remap_file_pages系统调用专门用于该目的,它可以将现存映射移动到虚拟内存中的一个新的位置。其代码流程图如下图所示。





  • 内核首先检查所有标志,并确保重新映射的范围有效后,通过find_vma选中目标区域的vm_area_struct实例,如果目标区域此前没有进行过非线性映射,则vm_area_struct->vm_flags不会设置VM_NONLINEAR标志,此时需要从优先树移除该线性映射,并将其插入到非线性列表中;

  • 然后由populate_range设置修改过的页帧项;

  • 最后一步是读入映射的页(在需要的情况下才会读入,通过设置MAP_NONBLOCK标志可阻止读入)。



八、反向映射



  • 在映射一页时,它关联到一个进程,但不一定处于使用中;

  • 对页的引用次数表明页使用的活跃程度,为确定该数目,内核首先必须建立页和所有使用者之间的关联,接下来必须借助于一些技巧来计算出页使用的活跃程度。



内核通过页表建立了虚拟和物理地址之间的关系,内核还完成了进程的一个内存区域与其虚拟内存页地址之间的切换。除此以外,内核还采用了一种逆向映射方法(一些附加的数据结构和函数),建立页和所有映射了该页的位置之间的关联。



1、数据结构内核使用了简洁的数据结构,以最小化逆向映射的管理开销。page结构包含了一个用于实现逆向映射的成员。



struct page {
atomic_t _mapcount; // 内存管理子系统中映射的页表项计数,用于表示页是否已经映射,还用于限制逆向映射搜索。
};



_mapcount表明共享该页的位置的数目。计数器的初始值为1。在页插入到逆向映射数据结构时,计数器赋值为0。页每次增加一个使用者时,计数器加1。这使得内核能够快速检查在所有者之外该页有多少使用者。此外,通过在优先查找树中嵌入属于非匿名映射的每个区域和指向内存中同一页的匿名区域的链表便可在给定的page实例中找到所有映射了该物理内存页的位置。



内核在实现逆向映射时采用的技巧是,不直接保存页和相关的使用者之间的关联,而只保存页和页所在区域之间的关联。包含该页的所有其他区域(进而所有的使用者)都可以找到。该方法又名基于对象的逆向映射(object-based reverse mapping),因为没有存储页和使用者之间的直接关联。相反,在两者之间插入了另一个对象(该页所在的区域)。



2、建立逆向映射在创建逆向映射时,有必要区分两个备选项:匿名页和基于文件映射的页。(1)匿名页



将匿名页插入到逆向映射数据结构中有两种方法。对新的匿名页必须调用page_add_new_anon_rmap;对已经有引用计数的页,则使用page_add_anon_rmap。这两个函数之间唯一的差别是,前者将映射计数器page->_mapcount设置为0(新初始化的页_mapcount的初始值为-1),后者将计数器加1。



(2)基于文件映射的页



基于文件映射的页的逆向映射的建立比较简单,基本上,所需要做的只是对_mapcount变量加1(原子操作)并更新各内存域的统计量。



3、使用逆向映射函数page_referenced使用了逆向映射方案所涉及的数据结构,统计了最近活跃地使用(即访问)了某个共享页的进程的数目,这不同于该页映射到的区域数目。



该函数相当于一个多路复用器,对匿名页调用page_referenced_anon,而对基于文件映射的页调用page_referenced_file。分别调用的两个函数,其目的都是确定有多少地方在使用一个页,但由于底层数据结构的不同,二者采用了不同的方法。



九、堆的管理



堆是进程中用于动态分配变量和数据的内存区域,堆的管理对应用程序员不是直接可见的。



堆是一个连续的内存区域,在扩展时自下至上增长。mm_struct结构包含了堆在虚拟地址空间中的起始和当前结束地址(start_brk和brk)。



brk系统调用只需要一个参数,用于指定堆在虚拟地址空间中新的结束地址,其入口是sys_brk函数,代码流程图如下图所示。





  • brk机制不是独立的内核概念,是基于匿名映射实现的,以减少内部开销。

  • 内核首先检查用作brk值的新地址是否超出堆的限制;

  • 然后sys_brk将请求地址按页长度对其;

  • 接着如果需要收缩堆时将调用do_munmap,如果堆将要扩大,内核首先必须检查新的长度是否超出进程的最大堆长度限制,若超出限制,则什么也不做,否则,将扩大堆的工作交给do_brk并返回新的brk的值(实质上do_brk是do_mmap_pgoff的简化版本,它在用户地址空间中创建了一个匿名映射,省去了一些数处理)。



十、缺页异常的处理



如果进程访问的虚拟地址空间部分尚未与页帧关联,处理器自动地引发一个缺页异常,由内核处理此异常。下图给出了内核在处理缺页异常时,可能使用的各种代码路径的概述。





缺页异常主要通过函数do_page_fault处理,其代码流程图如下图所示。





do_page_fault需要传递两个参数:发生异常时使用中的寄存器集合(pt_regs *regs),提供异常原因信息的错误代码(long error_code),具体检测关联流程如图15所示。如果页成功建立,则例程返回VM_FAULT_MINOR(数据已经在内存中)或VM_FAULT_MAJOR(数据需要从块设备读取)。内核接下来更新进程的统计量。但在创建页时,也可能发生异常。如果用于加载页的物理内存不足,内核会强制终止该进程,在最低限度上维持系统的运行。如果对数据的访问已经允许,但由于其他的原因失败(例如,访问的映射已经在访问的同时被另一个进程收缩,不再存在于给定的地址),则将SIGBUS信号发送给进程。



十一、用户空间缺页异常的校正



确认缺页异常是从允许的地址触发后,内核必须确定将所需数据读取到物理内存的适当方法。该任务委托给函数handle_mm_fault,它不依赖于底层体系结构,而是在内存管理的框架下、独立于系统而实现。该函数确认在各级页目录中,通向对应于异常地址的页表项的各个页目录项都存在。函数handle_pte_fault分析缺页异常的原因。



如果页不在物理内存中,则必须区分下面3种情况:



  • 如果没有对应的页表项(page_none),则内核必须从头开始加载该页,对匿名映射称之为按需分配(demandallocation),对基于文件的映射,则称之为按需调页(demandpaging)。如果vm_ops中没vm_operations_struct,则不适用上述做法。在这种情况下,内核必须使用do_anonymous_page返回一个匿名页;

  • 如果该页标记为不存在,而页表中保存了相关的信息,则意味着该页已经换出,因而必须从系统的某个交换区换入(换入或按需调页);

  • 非线性映射已经换出的部分不能像普通页那样换入,因为必须正确地恢复非线性关联,pte_file函数可以检查页表项是否属于非线性映射,do_nonlinear_fault在这种情况下可用于处理异常。1、按需分配/调页按需分配页的工作委托给函数do_linear_fault,在转换一些参数之后,其余的工作委托给函数__do_fault,函数__do_fault的代码流程图如下图所示。





对给定涉及区域的vm_area_struct的读取操作,内核进行以下三步操作:



  • 使用vm_area_struct->vm_file找到映射的file对象;

  • 在file->f_mapping中找到指向映射自身的指针;

  • 每个地址空间都有特定的地址空间操作,从中选择readpage方法,使用mapping->a_ops->readpage(file,page)从文件中将数据传输到物理内存。



如果需要写访问,内核必须区分共享和私有映射。对私有映射,必须准备页的一份副本。



2、匿名页对于没有关联到文件作为后备存储器的页,需要调用do_anonymous_page进行映射。除了无需向页读入数据之外,该过程几乎与映射基于文件的数据没什么不同。在highmem内存域建立一个新页,并清空其内容。接下来将页加入到进程的页表,并更新高速缓存或者MMU。



3、写时复制写时复制在do_wp_page中处理,主要步骤为:



  • 内核首先调用vm_normal_page,通过页表项找到页的structpage实例,本质上这个函数基于pte_pfn和pfn_to_page,这两者是所有体系结构都必须定义的。前者查找与页表项相关的页号,而后者确定与页号相关的page实例;

  • 在用page_cache_get获取页之后,接下来anon_vma_prepare准备好逆向映射机制的数据结构,以接受一个新的匿名区域,由于异常的来源是需要将一个充满有用数据的页复制到新页,因此内核调用alloc_page_vma分配一个新页,cow_user_page接下来将异常页的数据复制到新页,进程随后可以对新页进行写操作;

  • 然后使用page_remove_rmap,删除到原来的只读页的逆向映射,新页添加到页表,此时也必须更新CPU的高速缓存;

  • 最后,使用lru_cache_add_active将新分配的页放置到LRU缓存的活动列表上,并通过page_add_anon_rmap将其插入到逆向映射数据结构。此后,用户空间进程可以向页写入数据。



4、获取非线性映射由于异常地址与映射文件的内容并非线性相关,因此必须从先前用pgoff_to_pte编码的页表项中,获取所需位置的信息(pte_to_pgoff分析页表项并获取所需的文件中的偏移量(以页为单位))。在获得文件内部的地址之后,读取所需数据类似于普通的缺页异常。因此内核将工作移交先前讨论的函数__do_page_fault,处理到此为止。



十二、内核缺页异常



在访问内核地址空间时,缺页异常可能被以下条件触发:



  • 内核中的程序设计错误导致访问不正确的地址,这是真正的程序错误(这在稳定版本中应该永远都不会发生,但在开发版本中会偶尔发生);

  • 内核通过用户空间传递的系统调用参数,访问了无效地址;

  • 访问使用vmalloc分配的区域,触发缺页异常。



前两种情况是真正的错误,内核必须对此进行额外的检查。vmalloc的情况是导致缺页异常的合理原因,需要加以校正。直至对应的缺页异常发生之前,vmalloc区域中的修改都不会传输到进程的页表,必须从主页表复制适当的访问权限信息。



在处理不是由于访问vmalloc区域导致的缺页异常时,异常修正(exception fixup)机制是一个最后手段。在某些时候,内核有很好的理由准备截取不正确的访问。例如,从用户空间地址复制作为系统调用参数的地址数据。



在向或从用户空间复制数据时,如果访问的地址在虚拟地址空间中不与物理内存页关联,则会发生缺页异常。当处于内核态时,该异常订单处理方式与用户状态稍有不同。



每次发生缺页异常时,将输出异常的原因和当前执行代码的地址。这使得内核可以编译一个列表,列出所有可能执行未授权内存访问操作的危险代码块。这个“异常表”在链接内核映像时创建,在二进制文件中位于__start_exception_table和__end_exception_table之间。每个表项都对应于一个struct exception_table实例,该结构是体系结构相关的。



在异常处理过程中,借助于函数fixup_exception搜索异常表,查找适当的匹配项;在找到修正例程时,将指令指针设置到对应的内存位置。在fixup_exception通过return返回后,内核将执行找到的例程。如果没有修正例程,就表示出现了一个真正的内核异常,在对search_exception_table(不成功的)调用之后,将调用do_page_fault来处理该异常,最终导致内核进入oops状态(出现了致命问题,给出各错误状态)。



十三、在内核和用户空间之间复制数据



内核经常需要从用户空间向内核空间复制数据(比如系统调用中采用指针间接传递冗长的数据结构),从内核空间向用户空间也有写数据需求。



由于用户空间程序不能访问内核地址,也无法保证用户空间中指针指向的虚拟内存页确实与物理内存页关联,所以不能只是传递并反引用指针。内核提供了几个标准函数,以处理内核空间和用户空间之间的数据交换。



下图是用户空间和内核空间之间交换数据的标准函数示例。





下图是处理用户空间数据中的字符串标准函数的定义。





发布于: 2020 年 12 月 10 日阅读数: 24
用户头像

赖猫

关注

还未添加个人签名 2020.11.28 加入

纸上得来终觉浅,绝知此事要躬行

评论

发布
暂无评论
深入Linux内核架构——进程虚拟内存