进程虚拟内存

进程虚拟内存

一、前言

本篇从用户进程的角度来一窥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_codeend_code分别用于标记可执行代码的开始和结束地址,start_dataend_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的方式可以直接对内存进行分配和释放,不会产生上述的虚拟内存空洞的问题。但是它也有一些弊端,比如频繁的系统调用占用资源、缺页异常会比较频繁等,所以一般只有在分配大内存的时候才会使用这种方式。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值