进程虚拟内存
一、前言
本篇从用户进程的角度来一窥LINUX内存的真面目,搞懂了用户进程的内存分配和使用方式,内核进程的也就自然明了了。下面我将从以下几个方面来分析用户进程的内存使用:虚拟内存空间与页表、内存映射、内存申请与释放、内存拷贝等。
二、虚拟内存空间与页表
2.1 虚拟内存空间
众所周知,进程在访问内存时使用的是虚拟地址(也就是所谓的线性地址),使用的内存存在于虚拟内存空间。那么什么是虚拟内存空间呢?通俗的讲,虚拟内存空间就是在物理内存上层做了一层隔离,进程在访问内存时好像自己能够使用整个内存空间的内存一样。以32位操作系统为例,其总的可寻址范围为4G
。为了提高效率,LINUX内核将前3G
的地址空间划分为用户态进程的地址空间,后1G
归为内核代码的地址空间。
用户进程在使用内存时,好像自己能够使用所有的3G
内存,每个进程都是这样觉得的,即使是当前可用的物理内存只有1G
。通过虚拟内存空间,可以起到隔离进程内存的作用,各个进程之间仿佛能够使用所有的内存,但是相互之间又不会产生影响,比如非法访问别的进程的内存。这个是怎么实现的呢?是的,是通过页表来实现的。
2.2 页表
页表具体是什么呢?举个例子,我们想要在虚拟内存与物理内存之间建立一个映射关系,最简单直接的办法是什么?可能就是下面的方式(没有一个好的画图软件,手绘,献丑了):
我们都知道,内核在内存使用过程中是以页为单位的,页的大小可以配置,一般默认是4k
。所以在进行线性地址与物理内存的映射时我们也是只需要以页为单位进行。最简单是方式可能是申请一个数组,数组中的每个元素对应线性地址中的一个页,数组中的元素记录这个页映射到的物理内存。并不是所有的线性地址都需要映射,只有那些使用的内存才需要映射。这种方式有个问题,就是这个数组的长度会非常的长,占用的内存比较大。
在这种情况下,页表应运而生。刚才的映射表是一个单一的数组,如果我们采用多级的数组显然能够大幅度节省内存,这种多级的映射数组我们把它称为页表。那么页表是怎么运作的呢?还是先看看它的原理图吧,以常用的x86_64
架构、四级页表为例。
虽然是64位,但是出于寻址效率的考虑,目前内核只用到了其中的48位,能够寻址 2 48 = 256 T 2^{48}=256T 248=256T的内存,足够使用了。四级页表的分配如下图所示(找到了个尚可的画图软件),每一级占用了地址中的9位:
具体是什么意思呢?就是前36位中,每9位确定一张表,最后9位确定的是物理页表page
的地址。例如,地址中的PGD
确定的是pud
表的地址在pgd
表中的偏移量。而12位的offset
确定的是地址在页表中的偏移,刚好4k
,覆盖整个页表。直接描述有点抽象,咱们还是看图说话。
首先,每一张表都是占据4k
内存,表中存储的都是子表的地址,这意味着每张表可以有512个子表。每个进程都有一个唯一的pgd
表,其地址在task->mm->pgd
中。
PGD
表下面有512个PUD
表,每个PUD
表下面又有512个PMD
表,可能有人觉得,这种页表的方式真的节省空间了吗?如果是这样,那可能真的省不了空间。然而,除了PGD
表,其他的子表都是动态创建的,即只有当使用到某个虚拟地址且该虚拟地址对应的物理页找不到并引发了缺页异常时,该虚拟地址对应的三个子表才会被创建。
三、虚拟内存区域
3.1 虚拟内存布局
为了便于对虚拟内存的组织和管理,虚拟内存是无法被直接使用的,而是需要先将其映射为虚拟内存区域。虚拟内存的管理是以区域为单位来进行的,包括内存的访问权限、增长方式、用途等。
内存区域在虚拟内存空间中的布局一般都是编译时确定好的,即哪些内存用作进程栈、哪些用作堆等。不同的体系架构,其内存布局可能不同,下图为常用的一种布局方式:
mm_struct
是进程用来管理虚拟内存的数据结构,其定义如下(只列出了内存布局相关的字段):
struct mm_struct {
......
unsigned long mmap_base; /* base of mmap area */
unsigned long mmap_legacy_base; /* base of mmap area in bottom-up allocations */
unsigned long task_size; /* size of task vm space */
unsigned long highest_vm_end; /* highest vma end address */
pgd_t * pgd;
atomic_t mm_users; /* How many users with user space? */
atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1) */
atomic_long_t nr_ptes; /* PTE page table pages */
/* number of VMAs */
struct list_head mmlist; /* List of maybe swapped mm's. These are globally strung
* together off init_mm.mmlist, and are protected
* by mmlist_lock
*/
unsigned long def_flags;
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;
unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */
struct linux_binfmt *binfmt;
cpumask_var_t cpu_vm_mask_var;
/* Architecture-specific MM context */
mm_context_t context;
unsigned long flags; /* Must use atomic bitops to access the bits */
/* store ref to file /proc/<pid>/exe symlink points to */
struct file __rcu *exe_file;
};
下面我们结合mm_struct
来分析一下虚拟内存的布局。
text
段:用于映射当前进程运行的ELF可执行程序,包括代码段和数据段等。start_code
和end_code
分别用于标记可执行代码的开始和结束地址,start_data
和end_data
则用于标记已初始化的数据区域。为了捕捉空引用,该区域并非从0开始,而是留下了一定的缺口,缺口的大小由具体的体系架构决定,以x86
为例,其大小为128M
。这块内存区域在ELF文件加载完成后大小就固定了。- 堆:堆是一种从低地址向高地址动态增长的内存区域,其起始地址为
start_brk
,堆顶地址为brk
。在用户使用malloc
标准库函数分配小内存时,就是从堆中分配的。 MMAP
映射区域:该内存区域起始于mmap_base
,并且与栈类似,自顶向下增长。该内存区域用于文件映射(如动态库、文件映射读写等)、malloc
大内存的分配等。关于映射区域的布局有两种方式,图中的为较新的布局,使得映射区域可以使用较大的内存空间。在经典布局中,映射区域起始于TASK_UNMAPPED_SIZE
(内存空间1G
处),且向上增长,这就使得堆的可用空间比较受限。- 栈:栈的起始地址为
STACK_TOP
,一般直接设置为TASK_SIZE
,即用户内存空间顶部,且是自顶向下增长。如果开启了地址随机化(防止针对内存的攻击),则栈的起始地址为STACK_TOP
减去一个随机数(1M
以内)。
3.2 malloc
内存分配
malloc
是标准C库为用户程序提供的进行内存分配的函数,那么在用户进程进行内存分配的时候,到底发生了什么呢?下面我们来一探究竟。
首先,只有被分配为内存区域的内存才能被使用,也就是上图中的蓝色部分。当用户进程第一次访问某个虚拟地址时,如果该虚拟地址没有分配物理页,那么会引发缺页异常。在缺页异常处理流程里,会检测该虚拟地址的合法性,包括是否分配了内存区域、读写的合法性等。这就是为啥用户不能随意使用虚拟内存空间中的任意地址。
malloc
是标准C库实现的用来管理用户程序动态内存使用的函数。之所以这么说,是因为内存的分配和管理很大程度上不是由内核进行的,而是由标准库实现的。为了提高内存使用的效率,在分配内存时,根据所分配的内存大小,malloc
会采用不同的方式来分配。当分配的内存大小小于128k
时,malloc
将从堆中进行内存的分配;否则,则使用mmap
的方式,从文件映射区域分配内存。
从堆分配内存
从前面的内存布局中我们可以看到,堆首先是一块已分配了的虚拟内存区域,只不过这块内存区域可以动态扩充和收缩,其方向为自底向上。堆的分配可以使用brk
系统调用,其原型如下:
SYSCALL_DEFINE1(brk, unsigned long, brk)
该系统调用所做的事其实挺简单,它会调整当前进程的堆的大小,使得堆的顶部地址变为系统调用传递进来的参数brk
。换句话说,如果当前堆的顶部mm→brk > brk
,则将堆减小mm→brk - brk
,否则就将堆扩大brk - mm→brk
。如果参数brk
为0,则会返回当前堆顶部的地址,即mm->brk
。
举个例子,当我们想要分配1k
的空间,那么malloc
做的工作可能就是:
{ulong s = sys_brk(0); sys_brk(s+1024); return s;}
例子可能比较极端,但是基本就是这个意思。下面我们再来看一下内存是如何释放的。每次调用malloc
分配内存,标准库就会将分配的内存块的信息保存下来,存储到一个链表中。假设我们分配了三块内存A/B/C
,分别是1k/2k/1k
,现在我们调用free(B)
会发生什么呢?由于堆顶部的C没有释放,因此不能缩小堆空间,标准库会将链表中的B内存标记为空闲。在下次分配内存时,malloc
会首先从链表中查找合适的空闲的内存块来使用,而不是调用brk
从堆中分配。这种方式使得内存分配的效率比较高,但是容易产生空洞,引起内存利用率不足的问题。因此,在分配大内存时,malloc
将不会从堆中分配,而是使用mmap
的方式。
MMAP
内存分配
mmap
是用来进行将文件或者文件中的指定位置映射到内存的系统调用,对该内存读写都会同步到磁盘中的文件上。当不指定要映射的文件时,mmap
会单纯地申请一块内存区域供程序使用,此时的逻辑比较简单。释放的时候,只需要通过系统调用munmap
来释放即可。关于mmap
实现映射的具体原理,这里不再详细讲述。
从分配的原理我们可以看出,使用mmap
的方式可以直接对内存进行分配和释放,不会产生上述的虚拟内存空洞的问题。但是它也有一些弊端,比如频繁的系统调用占用资源、缺页异常会比较频繁等,所以一般只有在分配大内存的时候才会使用这种方式。