【原理剖析】a-Linux 内存管理

文章目录
  1. 1. 0x01 Linux分页管理机制
  2. 2. 0x02 虚拟内存段的组织与管理
  3. 3. 0x03 内存的共享和保护
  4. 4. 0x04 内存空间管理
  5. 5. 0x05 空闲内存管理
  6. 6. 0x06 内核态的内存的申请与释放
  7. 7. 0x07 用户态内存的申请与释放
  8. 8. 0x08 交换空间
  9. 9. 0x09 页交换进程与页面交换
  10. 10. 0x0a 缺页中断和页面交换
  11. 11. 0x0b 存储管理系统的缓冲机制

概述:主要讲述 Linux 操作系统中的内存管理方式及存储器管理的基本概念及原理,从中说明 Linux 操作系统对内存的管理模式以及在 Linux 操作系统中运行的进程的内存管理情况和内存读取、调用的最底层实现方式

  • Linux 分页管理机制
  • 虚拟内存段的组织与管理
  • 内存的共享和保护
  • 内存空间管理
  • 空闲内存管理
  • 内核态内存的申请与释放
  • 用户态内存的申请与释放
  • Linux 系统交换空间
  • 存储管理系统的缓冲机制

0x01 Linux分页管理机制

Linux 采用“按需调页”算法,支持三层管理策略。由于 Intel CPU 在硬件级提供了段式存储管理和二层页式存储管理功能,Linux 操作系统作为一种软件,必须与之兼容。Linux 根据 Intel 处理器的要求,最低限度地设置与段相关的结构和初始化程序,但实质上是放弃了段式存储管理。Intel 微型计算机上的 Linux 系统考虑到 CPU 的限制,将第二层的页式管理(pmd)与第一层的页式管理(pgd)合并,因此真正发挥作用的是以页目录和页表为中心的数据结构和函数。

在 Linux 中,每个用户进程都可以访问 4GB 的线性虚拟内存空间。其中,03GB 的虚拟地址空间是用户空间,用户进程可以直接对其进行访问;3GB4GB 的虚拟内存地址空间为内核态空间,存放仅供内核态访问的代码和数据,用户态进程不可访问。当用户进程通过中断或系统调用访问内核态空间时,就会触发处理特权级转换,即从操作系统的用户态转换到内核态。

Linux的虚拟地址空间范围为0~4G,Linux内核将这4G字节的空间分为两部分, 将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间。因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。

所有进程从 3GB 到 4GB 的虚拟空间是一样的,有相同的页目录项和页表,对应相同的物理内存段。Linux 以此方式使得内核态进程共享代码段和数据段。

内核态虚拟空间从 3GB 到 3GB+4MB 的一段被映射到物理空间 04MB。因此,进程处于内核态时,只要通过虚拟空间 3GB3GB+4MB 段即可访问物理空间 0~4MB 段。

既然对于用户空间来说访问的内存地址都是连续的 4GB 线性虚拟地址,那么就需要知道 Linux 是如何划分虚拟空间。Linux 采用“按需调页”技术管理虚拟内存。标准 Linux 的虚拟页表分为三级页表,依次为页目录(PGD Page Directory)、中间页目录(PMD Page Middle Directory)和页表(PTE PageTable),如下图所示:

![图片描述](a-Linux 内存管理/1.png)

在 Intel CPU 上,Linux 的页表结构实际上为两级。IA-32 体系结构的页管理机制中的页目录是 PGD,页表是 PTE,而 PGD 和 PMD 实际上合二为一。在用户进程中用到的与内存管理有关的数据结构是 mm_struct 结构,此结构中包含了用户进程中与存储有关的信息,具体结构定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct mm_struct
{
  struct vm_area_struct * mmap; /* 指向虚拟区间(VMA)链表 */
  rb_root_t mm_rb; /*指向red_black树*/
  struct vm_area_struct * mmap_cache; /* 指向最近找到的虚拟区间*/
  pgd_t * pgd; /*指向进程的页目录*/ 
  atomic_t mm_users; /* 用户空间中的有多少用户*/
  atomic_t mm_count; /* 对"struct mm_struct"有多少引用*/
  int map_count; /* 虚拟区间的个数*/
  struct rw_semaphore mmap_sem;
  spinlock_t page_table_lock; /* 保护任务页表和 mm->rss */
  struct list_head mmlist; /*所有活动(active)mm的链表 */
  unsigned long start_code, end_code, start_data, end_data; /*start_code 代码段起始地址,end_code 代码段结束地址,start_data 数据段起始地址, start_end 数据段结束地址*/
  unsigned long start_brk, brk, start_stack; /*start_brk 和brk记录有关堆的信息, start_brk是用户虚拟地址空间初始化时,堆的结束地址, brk 是当前堆的结束地址, start_stack 是栈的起始地址*/
  unsigned long arg_start, arg_end, env_start, env_end; /*arg_start 参数段的起始地址, arg_end 参数段的结束地址, env_start 环境段的起始地址, env_end 环境段的结束地址*/
  unsigned long rss, total_vm, locked_vm;
  unsigned long def_flags;
  unsigned long cpu_vm_mask;
  unsigned long swap_address;
  unsigned dumpable:1;
  mm_context_t context; /* Architecture-specific MM context, 是与平台相关的一个结构,对i386 几乎用处不大*/
};

每一个进程都有一个页目录,存储该进程所使用的内存页面情况。Linux 按照“按需调页”的原则,只分配必需的内存页面,从而避免了页表过多占用存储空间的情况出现。例如,系统调用 fork 分配内存页面的情况如下:

  • 内核态堆栈 1 页;
  • 页目录 1 页;
  • 页表几页;

而系统调用 exec 分配内存页面的情况则:

  • 可执行文件的文件头 1 页;
  • 用户堆栈 1 页或几页;

这样,当进程开始运行时,如果执行代码不在内存中,将产生第一次缺页中断,让操作系统分配内存页面,并将执行代码装入内存中。此后,按需要逐渐分配更多的内存页面,并参与页面调度。当系统内存不足时,由操作系统决定是否将该进程的一部分页面换出到磁盘交换区或交换文件中。进程终止时,操作系统释放所有该进程占用的资源,包括内存页面。

0x02 虚拟内存段的组织与管理

用户程序总共拥有 4GB 的虚拟空间,但是并不是 4GB 空间都可以让用户进程读写或申请使用。用户进程实际上可以申请的虚拟空间为 0 ~ 3GB 。在用户进程创建时,已由系统调用 fork() 的执行函数 do_fork() 将内核的代码段和数据段映射到 3GB 以后的虚拟空间,供内核态进程访问。所有进程的 3GB~4GB 的虚拟控件的映像都是相同的,并以此方式共享代码段和数据段。

为了能够以自然的方式管理进程虚拟控件,Linux 定义了虚拟段(vma virtual memory area),一个 vma 段是某个进程的一段连续的虚拟控件,在这段虚拟空间的所有单元拥有相同的特征。例如,属于同一个进程具有相同的访问权限,将同时被锁定、受保护等。

虚拟段的数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
struct vm_area_struct {
/* 第一个缓存行具有VMA树移动的信息*/
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address within vm_mm. */

struct vm_area_struct *vm_next, *vm_prev; /* 每个任务的VM区域的链接列表,按地址排序*/

struct rb_node vm_rb;

/*
此VMA左侧最大的可用内存间隙(以字节为单位)。
在此VMA和vma-> vm_prev之间,
或者在VMA rbtree中我们下面的一个VMA与其->vm_prev之间。
这有助于get_unmapped_area找到合适大小的空闲区域。
*/
unsigned long rb_subtree_gap;

/* 第二个缓存行从这里开始*/

struct mm_struct *vm_mm; /* 我们所属的address space*/
pgprot_t vm_page_prot; /* 此VMA的访问权限 */
unsigned long vm_flags; /* Flags, see mm.h. */

/*
对于具有地址空间(address apace)和后备存储(backing store)的区域,
链接到address_space->i_mmap间隔树,或者链接到address_space-> i_mmap_nonlinear列表中的vma。
*/
union {
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} linear;
struct list_head nonlinear;
} shared;

/*
在其中一个文件页面的COW之后,文件的MAP_PRIVATE vma可以在i_mmap树和anon_vma列表中。
MAP_SHARED vma只能位于i_mmap树中。
匿名MAP_PRIVATE,堆栈或brk vma(带有NULL文件)只能位于anon_vma列表中。
*/
struct list_head anon_vma_chain; /* 由mmap_sem和* page_table_lock序列化*/
struct anon_vma *anon_vma; /* Serialized by page_table_lock 由page_table_lock序列化*/

const struct vm_operations_struct *vm_ops; /* 用于处理此结构体的函数指针 */

/* 后备存储(backing store)的信息: */
unsigned long vm_pgoff; /* 以PAGE_SIZE为单位的偏移量(在vm_file中),*不是* PAGE_CACHE_SIZE*/
struct file * vm_file; /* 我们映射到文件(可以为NULL)*/
void * vm_private_data; /* 是vm_pte(共享内存) */
};

结合前面的内存管理数据结构,可以绘制如下的 Linux 内存管理框图:

![图片描述](a-Linux 内存管理/2.png)

0x03 内存的共享和保护

Linux 中内存共享以页表的形式实现,共享该页的各进程的页表表项直接指向共享页,如下图所示。这种结构不需要设立共享页表,节约内存的占用,但是效率较低。当共享页状态发生变化时,共享该页的各进程的页表均需要修改,并要多次访问页表。

![图片描述](a-Linux 内存管理/3.png)

Linux 可以对虚拟段中的任意部分加锁或保护。对进程的虚拟地址加锁,其实质就是对 vma 段的 vm_flags 属性与 VM_LOCKED(0X2000) 进行“或”操作,虚拟内存加锁后,它对应的物理页面驻留内存不再被页面置换程序换出。加锁操作有三种:

  • Mlock:对指定的一段虚拟空间加锁;
  • Munlock:对指定的一段虚拟空间解锁;
  • Mlockall:对进程的所有虚拟空间解锁;

对进程的虚拟地址施行地址保护措施就是重新设置 vma 段的访问权限,其实质就是对 vma 段的 vm_flags 属性重置 PORT_READ(0X1)PORT_WRITE(0X2)PROT_EXEC(0X4),重新设定 vm_page_port 属性,与此同时,对虚拟地址范围内的所有页表项,其访问权限也相应调整,保护操作由系统调用 mprotect 实施。

虚拟段加锁、保护操作可以有以下几种方式:

  • 对整个虚拟段加锁或保护
  • 对虚拟段前部加锁或保护
  • 对虚拟段后部加锁或保护
  • 对虚拟段中部加锁或保护

0x04 内存空间管理

尽管 Linux 采用虚拟存储管理策略,有些申请仍然需要直接分配物理空间。例如,为刚创建的进程分配页目录,为装入进程的代码段分配空间,为 I/O 操作准备缓冲区等。物理内存以页帧为单位,页帧的长度固定,等于页长,对于 Intel CPU 默认为 4KB。

Linux 对物理内存的管理通过 mem_map 表描述。mem_map 在系统初始化时,由 free_area_init() 函数创建,如下图所示:

![图片描述](a-Linux 内存管理/4.png)

0x05 空闲内存管理

在物理内存低端,紧跟 mem_map 表的 bitmap 表以位示图方式记录了有有物理内存的空闲情况。与 mem_map 一样,bitmap 表在系统初始化时由 free_area_init() 函数创建,如下图所示:

![图片描述](a-Linux 内存管理/5.png)

0x06 内核态的内存的申请与释放

内核态内存是用来存放 Linux 内核系统数据结构的内存区域,处于进程虚拟空间的 3GB~4GB 范围内。以 Intel CPU 为例,申请的内存块大小 blocksize 有 32、64、128、512 等。在 blocksize 中能够申请到的块的大小近似于 2 的次幂,所差的恰好是管理数据结构所占用的字节数,如 16 是 struct page_dsecriptor 结构的长度。

管理内核空间空闲块的数据结构及相互关系如下图所示:

![图片描述](a-Linux 内存管理/6.png)

0x07 用户态内存的申请与释放

Linux 用 kmalloc() 函数和 kfree() 函数提供内核内存申请、释放的接口,它还实现另一种虚拟空间的申请、释放接口,就是 vmalloc()vfree()

vmalloc() 分配的存储空间在进程的虚拟空间是连续的,当它对应的物理内存扔需要经过缺页中断后由缺页中断服务程序分配,所分配的物理页帧不是连续的。这些特征和访问用户内存相似,所以不妨把 vmalloc()vfree() 成为用户态内存的申请和释放界面

可分配的虚拟空间在 3GB+high_memory+HOLE_8MB 以上的高端,由 vmlist 链表管理。3GB 是内核态赖以访问物理内存的起始地址。high_memory 是安装在计算机中实际可用的物理内存的最高地址。 因此 3GB+high_memory 也是物理内存的上界。 HOLE_8MB 则是长度为 8MB 的“隔离带”,起越界保护作用。这样,vmlist管辖的虚拟空间既不与进程用户态0~3GB的虚拟空间冲突,也不与进程内核态映射的 3GB~3GB+high_memory 的虚拟空间冲突。

vmlist链表的节点类型 vm_struct 具体内容如下:

1
2
3
4
5
6
struct vm_struct{
unsigned long flags; /* 虚拟内存块的占用标志 */
void *addr; /* 虚拟内存块的起始地址 */
unsigned long size; /* 虚拟内存块的长度 */
struct vm_struct *next; /* 下一个虚拟内存块 */
};

起始时,vmlist 只有一个节点,vmlist_addr 置为 VMALLOC_START(段地址 3GB,偏移量 high_memory+8MB)。动态管理过程中,vmlist 的虚拟内存块按起始地址从小到大排序,每个虚拟内存块之后都有一个 4KB 大小的“隔离带”,用来检查指针的越界错误,用户申请大块连续空间可用 vmalloc() ,如下图所示:

![图片描述](a-Linux 内存管理/7.png)

0x08 交换空间

计算机的物理内存空间总是影响机器性能。内存太小时,操作系统采用交换的方式。1970 年以后,按需调页算法得到了应用,是 Linux 操作系统采用的虚拟存储器的策略。换页操作时,Linux 区分两种不同的内存数据。一部分没有写权限的进程空间在换页时无需存入交换空间,直接丢弃即可。那些修改过的页面,换页时,其内容必须保存,保存的位置属于交换空间的某个页面。

Linux 采用两种方式保存换出的页面,一种是整个块设备,如磁盘的一个分区,称为交换设备;另一种是文件系统的固定长度的文件,称为交换文件。交换设备和交换文件统称为交换空间。

尽管交换空间有两种不同的方式,当它们的内部格式是一致的。一个交换空间最多可容纳 32687 个页面。如果一个交换空间不够用,Linux 允许并行管理多个交换空间,交换设备远比交换文件有效。在交换设备中,属于同一个页面的数据块总是连续的,第一个数据块地址确定,后续的数据库可以按顺序读出或写入,而在交换文件中,属于同一个页面的数据块虽然在逻辑上是连续的,但是数据块的实际位置可能是零散的。

当交换进程 kswapd 尝试换出页面时,调用测试进程 try_to_swap_out() 测试页面的存在时间。如果某个物理页面可以换出,则调用 get_swap_page() 申请交换空间的页面,得到交换进程的入口地址 entry,将要换出的物理页面换到 entry 指定的交换空间的某个页面中。

0x09 页交换进程与页面交换

当物理页面不够时,Linux 存储管理系统必须释放部分物理页面,将它们的内容写到交换空间。实现这个功能的就是内核态交换程序 kswwapd

kswapd 属于一种特殊的进程,成为内核态进程。Linux 的内核态进程没有虚拟存储空间进程,它们运行在内核态,直接使用物理地址空间。它不仅能够将页面换出交换空间,而且保证系统中有足够的空闲页面,保证存储系统高效运行。

kswapd 在系统初始启动时由内核态进程 init 创建,其初始化程序段以调用 init_swap_timer() 函数结束,进而转入 while(1) 循环,并马上睡眠。

内核交换进程依照 3 种方式缩减系统使用的物理页面,

  • page cachebuffer cache
  • 换出 SYSTEM V 共享内存占用的页面
  • 换出或丢弃进程占用的页面

0x0a 缺页中断和页面交换

磁盘中的可执行文件映像 Image 一旦被映射到一个进程的虚拟空间就可以开始执行。由于只有该映像区的开始部分调入内存,因此,进程迟早会执行到那些尚未调入内存的代码。当一个进程访问了一个还没有有效页表项的虚拟地址时(即页表项的 P 位为 0),处理器将产生缺页中断,通知操作系统,并将出现缺页的虚拟地址(在 CR2 寄存器中)和缺页时访问虚拟内存的模式,并传递给 Linux 的缺页中断服务程序。

在系统初始化时,设定了缺页中断服务程序为 do_page_fault()

1
2
3
4
Set_ttrap_gate(14, &page_fault);
ENTRY(page_fault)
pushI$SYMBOL_NAME(do_page_fault)
jmp error_code /* 异常中断服务程序统一入口 */

根据控制寄存器 CR2 传递的缺页地址,Linux 必须找到用来表示缺页的虚拟存储区的 vm_area_struct 结构。在搜索进程的 vm_area_struct 结构时,对搜索时间应有严格的限制。为了有效地处理搜索工作,Linux 将所有的 vm_area_struct 结构通过 AVL 平衡树连接起来。如果没用找到与缺页相对应的 vm_area_struct 结构,那么说明进程访问了一个非法存储器,Linux 向进程发送信号 GIGSEGV ,如果进程没用处理该信号的函数,该进程将被终止。

Linux 接着检测缺页时访问模式是否合法。如果进程对该页的访问超越权限,例如试图对只允许读操作的页面进行写操作,系统也将向该进程发送一个信号,通知进程的存储访问出错。经过以上两步检查,可以确定肯定是正常的缺页中断。

Linux 还区分产生缺页中断的页面是在交换空间,还是在磁盘中作为某一可执行文件映像的一部分,进而做出不同的处理,这一点通过页表项中的位来区分。如果该页面所对应的页表项是无效的(p=0),但是非空,说明缺页在交换空间中。否则,页面是某一个可执行文件映像(image)的一部分。

并不是所有的 vm_area_struct 结构变量都有完整的一套虚拟存储操作,有些甚至没有 nopage 操作函数指针。在这种情况下,Linux 将使用缺省的操作函数为该虚拟页面找到物理页帧,同时为其设置一页表项。如果 vm_area_struct 结构变量中有 nopage 操作函数,Linux 使用该操作函数。

Linux 的 nopage 函数通常用来调入已被存储映射的可执行磁盘映像,而且它是利用 page cache 来将所需映像页调入内存的。

0x0b 存储管理系统的缓冲机制

存储管理系统的缓冲机制主要包括 kmalloc cacheswap cachepage cache Kmalloc cache 在 “内核态内存的申请与释放”已做了介绍,本节介绍另外两种 cache

  1. Swap cache 如果以前被调出到交换空间的页面由于进程再次访问而调入物理内存,只要该页调入后没有被修改过,那么它的内容与交换空间中的内容是一样的。在这种情况下,交换空间中的备份还是有效的。因此在该页再度换出时,就没必要执行写操作。Linux 采用 swap_cache 表描述的 swap cache 来实现这种思想。swap cache 实质上是关于页表项的一个列表,swap_cache 表位于 mem_map 表之前。

每一物理页面都在 swap cache 中占有一表项,swap_cache 表项的总数就是物理页面总数,若该物理页面的内容是新创建的,或虽然曾经换出过,但换出后,该物理页面已经被修改时,则该表项清零。内容非零的表现,正好是某个进程的页表项,描述了页面在交换空间中的位置。

当 Linux 将一物理页面调出到交换空间时,它先查询 swap cache ,如果其中有与该页面对应的有效页表项,那就不需要将该页写出,因为原交换空间中的内容与待换出的页面内容是一致的。

  1. Page Cache Linux 的 page cache 的作用是加快对磁盘的访问速度,文件被映射到内存中,每次读取一页,而这些页就保存于 page cache 中。

每当需要读取文件的一页时,总是首先通过 page cache 读取。如果所需页面在 page cache 中,就返回指向表示该页面的 mem-map-1的指针。否则必须从文件系统中调入。接着,Linux 申请一物理页,将该页从磁盘文件中调入内存。

如果有可能的话,Linux 还发出读取当前页面后一个页面的读操作请求。这种预读一页的思想来自局部原理,即在进程读当前页时,它的后一页也可能被进程用到。

随着越来越多的文件页面被读取、执行,page cache 将会变得越来越大。进程不再需要的页面应从 page cache 中删除。当 Linux 在使用内存过程中发现屋里页面渐变稀少时,将缩减 page cache