Linux 进程虚拟内存

引言

本文整理了 Linux 内核中进程虚拟内存的相关知识,其他 Linux 相关文章均收录于 <Linux系列文章>

进程虚拟内存

虚拟地址空间

各个进程的虚拟地址空间起始于 0,一直到 TASK_SIZE - 1,其上是内核地址空间。在 IA-32 系统上地址空间的范围是 4GB,按照默认比例 3:1 来划分的话,内核分配 1GB,各个用户进程可用的部分为 3GB。用户程序只能访问整个地址空间的下半部分,不能访问内核部分,同时用户进程也不能操作另一个进程的地址空间(除非共享内存),因为后者的地址空间不可见。

进程虚拟地址空间由多个不同长度的段组成,用于不同的目的,必须分别处理。例如大多数情况下,不许修改 text 段(代码段),但是可以执行它。另外,必须可以修改映射到地址空间的文本文件内容,但是不可以执行它。进程虚拟地址空间中一般都包含如下不同的段:

  • 当前运行代码的二进制代码。该代码通常称之为 text,所处的虚拟内存区域称之为 text 段。ELF(Executable and Linkable Format)二进制文件映射到地址空间后,该区域长度不变,边界由 start_code 和 end_code 标记。
  • 程序使用的动态库的代码。
  • 存储全局变量和动态产生的数据的堆。start_brk 标识了堆的起始位置,其长度在运行阶段会发生变化。
  • 用于保存局部变量和实现函数过程调用的栈。
  • 环境变量和命令行参数的段。由 arg_start,arg_end,env_start,env_end 标识。
  • 将文件内容映射到虚拟地址空间中的内存映射。

在进程启动时可以指定 PF_RANDOMIZE,如果指定了该标志,则内核不会为栈和内存映射选择固定的起始点,而是每次都在一个小范围内随机,这有助于防止缓冲区溢出攻击。
process-address-space
每个体系结构都制定了一个特殊的起始地址,IA-32 起始于 0x08048000,在 text 段的起始地址与最低的可用地址之间有大约 128 MB 的间距,用于捕捉 NULL 指针。其他体系结构也类似,堆紧随着 text 开始,向上增长。栈起始于 STACK_TOP,如果设置了 PF_RANDOMIZE,则起始位置会有一个小的随机量。大多数体系结构中 STACK_TOP 等于 TASK_SIZE,即用户地址空间的最高可用地址。进程的参数列表和环境变量都是栈的初始数据。在 2.6.7 之后,栈的最大长度遭到限制,因此内存映射区域从栈的下方开始(包含一个安全带),自顶向下拓展,mmap 区域和堆相对拓展,直至耗尽地址空间中的剩余区域。

地址转换

在古老的 x86 体系处理器上,刚开始只有 20 根地址线,寻址寄存器是 16 位,我们知道 16 位的寄存器可以访问 64K 的地址空间,如果程序要想访问大于 64K 的内存,就需要把内存分段,每段 64K,用段地址 + 偏移量的方式来访问,这样使 20 根地址线全用上,最大的寻址空间就可以到 1M 字节,这在当时已经是非常大的内存空间了。在这种段式内存管理机制中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址,也就是是机器语言指令中,用来指定一个操作数或是一条指令的地址。一个逻辑地址由两部分组成,段标识符: 段内偏移量。段标识符是由一个 16 位长的字段组成,称为段选择符。其中前 13 位是个索引号,后面 3 位包含一些硬件细节。
logic-address
不过在现代的 CPU 架构中,段式内存管理显然有些多余,因为现代 CPU 的寻址寄存器已经能够覆盖完整的虚拟地址空间,但是为了兼容老旧的设备,Linux 还需要保存这个段式管理机制,但是对于现代 CPU 架构,Linux 会有一个伪段式内存管理机制,这个伪造的段内存管理器很简单,输入的段地址会原封不动的输出作为线性地址(虚拟地址)。

我们知道页表用于建立用户进程的虚拟地址空间(也就是前面说的线性地址)和系统物理内存(内存、页帧)之间的关联。前面结构主要用来描述内存的结构(划分为结点和内存域),同时指定了其中包含的页帧的数量和状态(使用中或空闲)。页表用于向每个进程提供一致的虚拟地址空间。应用程序看到的地址空间是一个连续的内存区,而页表将连续的虚拟地址空间映射到不连续的物理地址空间。该表也将虚拟内存页映射到物理内存,因而支持共享内存的实现(几个进程同时共享的内存),还可以在不额外增加物理内存的情况下,将页换出到块设备来增加有效的可用内存空间。

page-table

  • pgd用于全局页目录项。
  • pud用于上层页目录项。
  • pmd用于中间页目录项。
  • pte用于直接页表项。
  • offset 用于页内偏移。

内核内存管理总是假定使用四级页表,而这在 IA-32 系统中是不对的,因为它只使用两级分页系统。因此,三四级页表必须在体系结构的代码中进行模拟。

内存映射

由于所有用户进程总的虚拟地址空间比可用的物理内存大得多,因此只有最常用的部分才与物理页帧关联。这不是问题,因为大多数程序只占用实际可用内存的一小部分。而不常用的页,内核只需要在地址空间保存相关信息,如数据在磁盘上的位置,以及需要数据时如何读取。

所以,内核必须提供一种数据结构,以建立虚拟地址空间的区域和相关数据所在位置之间的关联。就像在进行文件映射时,映射的虚拟内存区必须关联到文件系统在硬盘上存储文件内容的区域。
memory-map
按需分配和填充页称为按需调页法。它基于处理器和内核之间交互,使用的各种数据结构如下:
demand-paging

  • 进程试图访问用户地址空间中的一个内存地址,但使用页表无法确定物理地址(物理内存中没有关联页)。
  • 处理器接下来触发一个缺页异常,发送到内核。
  • 内核会检查负责缺页区域的进程地址空间数据结构,找到适当的后备存储器,或者确认该访问实际上是不正确的。
  • 分配物理内存页,并从后备存储器读取所需数据填充。
  • 借助于页表将物理内存页并入到用户进程的地址空间,应用程序恢复执行。

这些操作对用户进程是透明的。换句话说,进程不会注意到页是实际在物理内存中,还是需要通过按需调页加载。这里有一点值得提前强调,内存的后背存储器一般指的是磁盘,而什么样的内存需要后背存储器呢?有两种内存会有后备存储器,其一是文件映射的内存,通过系统调用可以将文件的指定区域映射到进程的虚拟地址空间。另一个是匿名内存换出,当物理内存不足时,会将一部分物理内存换出到磁盘中,这些内存可能是堆内存,栈内存等,因为它们没有显示的对应映射文件,所以系统会在交换分区选择一个合适的文件来暂存内存中的内容。

接下来,我们看一看内核是如何维护地址空间中的内存和后备存储器之间的映射关系的。我们知道每个进程 (task_struct) 都会有一个内存管理记录(mm_struct),此外进程的地址空间会被分成多个区域,每个区域描述的是一段连续的、具有相同访问属性的虚存空间,该虚存空间的大小为物理内存页面的整数倍。在这里,每个区都会通过一个(vm_area_struct)描述,通常,进程所使用到的虚存空间并不不连续,且各部分虚存空间的访问属性也可能不同。所以一个进程的虚存空间需要多个 vm_area_struct 结构来描述。进程的各个区域按照两种方式维护:

  1. 在一个单链表上(开始于 mm_struct->mmap)
  2. 在一个红黑树中,根节点存储在 mm_rb

memory-map-structure
用户虚拟地址空间中的每个区域都有一个开始地址和一个结束地址。现存的区域按起始地址以递增次序被归入链表中。当内存区域很多时,如果只是通过扫链表来找到与特定地址关联的区域,显然效率会很低。因此 vm_area_struct 的各个实例还通过红黑树管理,这可以显著加快扫描速度。增加新区域时,内核首先搜索红黑树,找到刚好在新区域之前的区域。然后,内核可以向树和线性链表添加新的区域,而无需扫描链表。

在 vm_area_struct 中主要保存了和后背存储器的映射关系,其中主要包括内存区域在虚拟地址空间的起始地址和结束地址,映射到的文件,以及文件内的起始偏移地址,此外还包括该段内存的权限和属性,是否是共享内存等。

当我们需要查找一个虚拟地址对应的 vm_area_struct 时,我们只需要扫描红黑树,找到第一个结束地址大于目标虚拟地址的 vm_area_struct。因为红黑树的复杂度是 O(logN)所以这个过程会很快,此外内核还会维护一个 mmap_cache,将刚才查询到的 vm_area_struct 保存进去,因为下次搜索虚拟地址时,很可能仍然在该内存区域中(vm_area_struct),想想局部性原理。

此外,如果内核发现多个内存域之间如果出现了映射文件相同 + 属性相同 + 虚拟地址相邻的情况,会自动将它们合并为一个更大的内存区域。

反向映射

到目前为止,我们只讨论了如何通过虚拟地址找到对应的物理地址(通过页表),而如果我们想要通过物理内存找到所有使用它的虚拟地址空间描述就很麻烦(在需要换页时,需要修改所有使用该物理内存的 vm_area_struct,将换出的文件位置写入 vm_area_struct 中,这点我们刚才介绍了),实际上,在很古老的内核中是通过遍历所有进程内存描述来确认这个问题的,这显然效率不够高,为了解决这个问题,在物理内存的描述结构 struct 中,有一个 mapping 属性,它可能指向两种对象,一个是 address_space 它对应的是通过文件映射方式产生的虚拟内存,另一个是 anon_vma 它对应的是匿名内存(堆栈等)。

1
2
3
4
5
6
7
8
9
10
struct page {
...
union {
struct {
...
struct address_space *mapping; // 如果最低位为 0,则指向 inode address_space,或者为 NULL。如果页映射为匿名内存,最低位为 1,而且该指针指向 anon_vma 对象。
}
...
...
}

文件映射内存

我们先来介绍一下文件映射的情况,address_space 是组合组织内存区域(vm_area_struct)的呢?内核使用了一个被称为优先查找树的结构来组织。
address-space-prio-tree

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

我们前面说了,对于文件映射产生的物理内存页会指向和它相关 address_space,而 address_space 以优先查找树的形式组织了所有使用该物理内存的虚拟地址空间区域,而每个虚拟地址空间区域有对应了所属进程的内存管理记录(mm_struct)。此外,通过该 address_space 内核还能推断出相关的 inode,它可以用来访问实际存储文件数据的后备存储器。除此之外,我们可以看到 vm_area_struct 还可以通过 i_mmap_nonlinear 链表关联,这是非线性映射所需要的。

这里有两个疑问,优先查找树是啥?非线性映射是啥?我们挨个来解答,先说说什么是优先查找树。

优先树用来管理表示给定文件中特定区间的所有 m_area_struct 实例,因为不同进程可能映射同一个文件的不同位置。这要求该数据结构不仅能够处理重叠,还要能处理相同的文件区间。如下图所示: 两个进程将一个文件的[7,12] 区域映射到其虚拟地址空间中,而第 3 个进程映射了区间[10,30].
file-map-memory
重叠区间的管理不是问题,因为区间的边界提供了一个唯一索引,可用于将各个区间存储在一个唯一的树结点中。处理重叠的思想是:如果区间 B、C 和 D 完全包含在另一个区间 A 中,那么 A 将是 B、C 和 D 的父结点。

但如果多个相同区间被归入优先树,要怎么办呢?这可以将一个 vm_set(vm_area_struct) 的链表与一个优先树结点关联起来。
prio-tree-with-set
接下来说一下非线性映射的问题,一般的文件映射是将文件中的一个连续的部分映射到虚拟内存中的一个同样连续的部分。如果需要将文件的不同部分以不同的顺序映射到虚拟内存的连续空间中,通常必须使用多个映射,但是这样做的话会耗费很多内存资源来保存 vm_area_struct。所以从 2.5 的内核中引入了非线性映射系统调用。

1
2
long sys_remap_file_pages(unsigned long start,unsigned long size,
unsigned long __prot,unsigned long pgoff,unsigned long flags)

该系统调用允许重排映射中的页,将现存映射 (位置 pgoff,长度 size) 移动到虚拟内存中的一个新位置。start 标识了移动的目标映射,因而必须落入某个现存映射的地址范围中。它还指定了由 pgoff 和 size 标识的页移动的目标位置。使得内存与文件中的顺序不再等价。实现该特性无需移动内存中的数据,而是通过操作进程的页表实现的。所述区域对应的页表项用一些特殊的项填充。这些页表项看起来像是对应于不存在的页,但其中包含附加信息,将其标识为非线性映射的页表项。在访问此类页表项描述的页时,会产生一个缺页异常,通过页表中的信息,它能找到与之对应的原始内存区域 vm_area_struct 并读入正确的页。

在换出非线性映射时,内核必须确保再次换入时,仍然要保持原来的偏移量。完成这一要求所需的信息存储在换出页的页表项中,再次换入时必须参考相应的信息。正如前面的图中看到的,所有非线性映射的 vm_area_struct 实例维护在一个链表中,表头是 struct address_space 的 i_mmap_nonlinear,也就是说非线性内存区域不再保存在优先树中。

匿名映射内存

匿名内存的处理相对来说比较简单,它不需要处理映射文件不同位置的问题,所以就没有了优先树,这时候 page 的 mapping 指向了一个链表 anon_vma,该链表包含了所有使用该物理页的虚拟地址空间区域。
page-anon-vma

堆的管理

堆是进程中用于动态分配变量和数据的内存区域,堆的管理对应用程序员不是直接可见的。因为它依赖标准库提供的各个辅助函数(其中最重要的是malloc)来分配任意长度的内存区。malloc 和内核之间的经典接口 brk 系统调用配合,负责扩展收缩堆。

堆是一个连续的内存区域,在扩展时自下至上增长。前文提到的 mm_struct 结构,包含了堆在虚拟地址空间中的起始和当前结束地址(start_brk 和 brk)。当需要修改堆的大小时通过 brk 系统调用,它只需要一个参数,用于指定堆最新的结束位置。brk 系统调用会检查当前资源是否超标,确保现在是否按页对齐,最后确认是否和现存的映射重叠。

缺页异常

在实际需要某个虚拟内存区域的数据之前,虚拟和物理内存之间的关联不会建立(节省内存)。如果进程访问的虚拟地址空间部分尚未与页帧关联,处理器自动地引发一个缺页异常,内核必须处理此异常。这是内存管理中最重要、最复杂的方面之一,因为必须考虑到无数的细节。

  • 缺页异常是由于访问用户地址空间中的有效地址而引起,还是应用程序试图访问内核的受保护区域?
  • 目标地址对应于某个现存的映射吗?
  • 获取该区域的数据,需要使用何种机制?

下图给出了内核在处理缺页异常时,可能使用的各种代码路径的个粗略的概观。实际上各个操作都要复杂得多,因为内核不仅要防止来自用户空间的恶意访问,还要注意许多细枝末节。此外,决不能因为缺页处理的相关操作而不必要地降低系统性能。
page-fault
缺页处理的实现因处理器的不同而有所不同。由于 CPU 采用了不同的内存管理概念,生成缺页异常的细节也不太相同。因此,缺页异常的处理例程在内核代码中位于特定于体系结构的部分。在下文中,我们将着重介绍 IA-32 体系结构上采用的方法。大多数其他 CPU 的实现都是类似的。下图就是 IA-32 处理缺页异常的流程,
ia-32-handle-page-fault
上面的图可以分几条线来看,就会很清晰:

  1. 如果访问的是用户空间地址,并且找到了对应的内存映射,则正常处理按需调页
  2. 如果没找到对应的内存映射,或者权限不对,并且在用户态,则会触发段错误,进程会被终止
  3. 如果地址超过用户地址空间的范围,并且处于核心态,而且该异常不是由保护错误触发时(CPU 权限保护),就说明是 vmalloc 的异常,这时候该进程的页表必须和主页表中的信息同步。
  4. 除了上述的情况,并且当前处于内核态时,会触发 fixup_exception

前两个应该大家已经有一定的认识了,这里可能不知道 fixup_exception 和 vmalloc异常 是什么。在访问内核地址空间时,缺页异常可能被各种条件触发:

  • 访问使用 vmalloc 分配的区域,触发缺页异常。
  • 内核中的程序设计错误导致访问不正确的地址,这是真正的程序错误。当然,这在稳定版本中应该永远都不会发生,但在开发版本中会偶尔发生。
  • 内核通过用户空间传递的系统调用参数,访问了无效地址。

vmalloc 导致的缺页异常是可以接受的,只要加以处理就行。因为直至对应的缺页异常发生之前,vmalloc 区域中的页表修改都不会传播到进程的页表(为了不浪费资源)。所以在发生 vmalloc 的缺页时,将主页表复制适当的访问权限信息到进程页表就行了。

在处理不是由于访问 vmalloc 区域导致的缺页异常时,异常修正(exception fixup)机制是一个最后一层防护网,如果这层防护网没防住,内核就会认为这是一个内核程序错误或者非法访问,内核会记录下错误。那么,怎么理解这最后一层防护网(exception fixup)呢?在内核运行在某些特定的函数中时(只有少数的几个地方),确实可能会出现不正确的访问,但是这些不正确的访问内核都有应对策略,内核中存在一个异常表,异常表中记录了每种可能发生异常的地点,以及对应的处理函数,当发生异常时,内核会从异常表中查找当前的异常发生地是否记录在案,如果找到了对应的项则会跳到对应的异常处理函数中继续执行。而如果系统没找到对应的处理函数,最终会导致内核进入 oops 状态(产生错误日志并杀死所有相关进程甚至整个系统停止工作)。

接下来我会介绍一个异常修复(fixup_exception)的例子,在内核中有一组用于和用户空间复制数据的接口 copy_from_user,copy_to_user,它们用于在系统调用时,从用户空间拷贝数据到内核或者从内核拷贝到用户空间。例如,在系统调用中通过指针间接地传递冗长的数据结构时。反过来,也有从内核向用户控件写数据的需求。在系统调用时,要将用户内存拷贝到内核内存中,这时候是允许发生缺页异常的,因为这是用户态和内核态的交接处,是合理的,所以这些函数处于异常表中。

这里之所以存在这一组函数来复制数据,而不直接使用指针,一方面是因为用户进程无法访问内核空间,所以必须由内核调用 copy_to_user 来将数据传递给用户进程。另一方面,之所以在系统调用时会使用 copy_from_user,其实是一个策略问题,我们前面谈论了很多缺页异常的问题,我们会发现,当处于内核态时,缺页异常只允许发生在 vmalloc 缺页时,以及一些特定的地点(fix_exception 防护网),如果在其他地方发生了缺页异常,系统都会将其认为是非法行为或者是内核程序错误。为了让异常网的范围尽可能的小,Linux 内核希望尽可能少的在内核态时发生缺页中断,这样能让内核错误无处遁形(隐藏在正常的缺页中),所以内核在进行系统调用时会将用户数据拷贝进内核,以防止用户内存的缺页异常在整个内核的各个角落扩散(内核之中各个模块藕断丝连,你应该能想象这种灾难)。

你可能会想我们不能在内核态也允许用户内存的按需调配吗?我认为 Linux 之所以这样设计,是想清晰地隔离正常 case 和前面提到的诸如内核错误这样的异常。这是一个设计上的策略,用户进程内存的缺页异常只允许发生在用户态,内核态的缺页只发生在 vmalloc异常和异常表中的少数几个地方,这样在发生内核错误时就能够及时被发现而不至于隐藏在用户内存的缺页异常中,而且在系统调用前将用户内存拷贝进内核内存能减少异常表的大小(减少扩散),这样内核开发者就不必到处确认是否这个函数内是否也会发生缺页异常(减少工作量)。

参考内容

[1]《Linux内核设计与实现》
[2]《Linux系统编程》
[3]《深入理解Linux内核》
[4]《深入Linux内核架构》
[5] Linux 内核进程管理之进程ID
[6] 服务器三大体系SMP、NUMA、MPP介绍
[7] Linux中的物理内存管理 [一]
[8] Linux内核中的page migration和compaction机制简介
[9] 物理地址、虚拟地址(线性地址)、逻辑地址以及MMU的知识
[10] 逻辑地址
[11] linux内核学习笔记-struct vm_area_struct
[12] Linux中匿名页的反向映射
[13] 系统调用过程详解
[14] 再谈Linux内核中的RCU机制
[15] Unix domain socket 和 TCP/IP socket 的区别
[16] Linux通用块设备层
[17] ext2文件系统结构分析
[18] linux ACL权限规划:getfacl,setfacl使用
[18] 查找——图文翔解RadixTree(基数树)
[19] 页缓存page cache和地址空间address_space
[20] rocketmq使用的系统参数(dirty_background_ration dirty_ratio)
[21] Linux内存调节之zone watermark
[22] Linux的内存回收和交换
[23] Linux中的内存回收[一]
[24] linux内存源码分析 - 内存回收(整体流程)
[25] Linux 软中断机制分析
[26] 对 jiffies 溢出、回绕及 time_after 宏的理解
[27] learn-linux-network-namespace
[28] 显式拥塞通知
[29] 聊聊 TCP 长连接和心跳那些事
[30] 关于 TCP/IP,必知必会的十个问题
[31] TCP协议三次握手连接四次握手断开和DOS攻击
[32] TCP 的那些事儿(上)
[33] TCP 的那些事儿(下)

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/linux/process-virtual-memory/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。