You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

21 KiB

22 | 进程空间管理:项目组还可以自行布置会议室

上两节,我们讲了内存管理的三个方面,虚拟内存空间的管理、物理内存的管理以及内存映射。你现在对进程内存空间的整体布局应该有了一个大致的了解。今天我们就来详细看看第一个方面,进程的虚拟内存空间是如何管理的。

32位系统和64位系统的内存布局有的地方相似有的地方差别比较大接下来介绍的时候请你注意区分。好我们现在正式开始

用户态和内核态的划分

进程的虚拟地址空间其实就是站在项目组的角度来看内存所以我们就从task_struct出发来看。这里面有一个struct mm_struct结构来管理内存。

struct mm_struct		*mm;

在struct mm_struct里面有这样一个成员变量

unsigned long task_size;		/* size of task vm space */

我们之前讲过整个虚拟内存空间要一分为二一部分是用户态地址空间一部分是内核态地址空间那这两部分的分界线在哪里呢这就要task_size来定义。

对于32位的系统内核里面是这样定义TASK_SIZE的

#ifdef CONFIG_X86_32
/*
 * User space process size: 3GB (default).
 */
#define TASK_SIZE		PAGE_OFFSET
#define TASK_SIZE_MAX		TASK_SIZE
/*
config PAGE_OFFSET
        hex
        default 0xC0000000
        depends on X86_32
*/
#else
/*
 * User space process size. 47bits minus one guard page.
*/
#define TASK_SIZE_MAX	((1UL << 47) - PAGE_SIZE)
#define TASK_SIZE		(test_thread_flag(TIF_ADDR32) ? \
					IA32_PAGE_OFFSET : TASK_SIZE_MAX)
......

当执行一个新的进程的时候,会做以下的设置:

current->mm->task_size = TASK_SIZE;

对于32位系统最大能够寻址2^32=4G其中用户态虚拟地址空间是3G内核态是1G。

对于64位系统虚拟地址只使用了48位。就像代码里面写的一样1左移了47位就相当于48位地址空间一半的位置0x0000800000000000然后减去一个页就是0x00007FFFFFFFF000共128T。同样内核空间也是128T。内核空间和用户空间之间隔着很大的空隙以此来进行隔离。

用户态布局

我们先来看用户态虚拟空间的布局。

之前我们讲了用户态虚拟空间里面有几类数据例如代码、全局变量、堆、栈、内存映射区等。在struct mm_struct里面有下面这些变量定义了这些区域的统计信息和位置。

unsigned long mmap_base;	/* base of mmap area */
unsigned long total_vm;		/* Total pages mapped */
unsigned long locked_vm;	/* Pages that have PG_mlocked set */
unsigned long pinned_vm;	/* Refcount permanently increased */
unsigned long data_vm;		/* VM_WRITE & ~VM_SHARED & ~VM_STACK */
unsigned long exec_vm;		/* VM_EXEC & ~VM_WRITE & ~VM_STACK */
unsigned long stack_vm;		/* VM_STACK */
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;

其中total_vm是总共映射的页的数目。我们知道这么大的虚拟地址空间不可能都有真实内存对应所以这里是映射的数目。当内存吃紧的时候有些页可以换出到硬盘上有的页因为比较重要不能换出。locked_vm就是被锁定不能换出pinned_vm是不能换出也不能移动。

data_vm是存放数据的页的数目exec_vm是存放可执行文件的页的数目stack_vm是栈所占的页的数目。

start_code和end_code表示可执行代码的开始和结束位置start_data和end_data表示已初始化数据的开始位置和结束位置。

start_brk是堆的起始位置brk是堆当前的结束位置。前面咱们讲过malloc申请一小块内存的话就是通过改变brk位置实现的。

start_stack是栈的起始位置栈的结束位置在寄存器的栈顶指针中。

arg_start和arg_end是参数列表的位置 env_start和env_end是环境变量的位置。它们都位于栈中最高地址的地方。

mmap_base表示虚拟地址空间中用于内存映射的起始地址。一般情况下这个空间是从高地址到低地址增长的。前面咱们讲malloc申请一大块内存的时候就是通过mmap在这里映射一块区域到物理内存。咱们加载动态链接库so文件也是在这个区域里面映射一块区域到so文件。

这下所有用户态的区域的位置基本上都描述清楚了。整个布局就像下面这张图这样。虽然32位和64位的空间相差很大但是区域的类别和布局是相似的。

除了位置信息之外struct mm_struct里面还专门有一个结构vm_area_struct来描述这些区域的属性。

struct vm_area_struct *mmap;		/* list of VMAs */
struct rb_root mm_rb;

这里面一个是单链表,用于将这些区域串起来。另外还有一个红黑树。又是这个数据结构,在进程调度的时候我们用的也是红黑树。它的好处就是查找和修改都很快。这里用红黑树,就是为了快速查找一个内存区域,并在需要改变的时候,能够快速修改。

struct vm_area_struct {
	/* The first cache line has the info for VMA tree walking. */
	unsigned long vm_start;		/* Our start address within vm_mm. */
	unsigned long vm_end;		/* The first byte after our end address within vm_mm. */
	/* linked list of VM areas per task, sorted by address */
	struct vm_area_struct *vm_next, *vm_prev;
	struct rb_node vm_rb;
	struct mm_struct *vm_mm;	/* The address space we belong to. */
	struct list_head anon_vma_chain; /* Serialized by mmap_sem &
					  * page_table_lock */
	struct anon_vma *anon_vma;	/* Serialized by page_table_lock */
	/* Function pointers to deal with this struct. */
	const struct vm_operations_struct *vm_ops;
	struct file * vm_file;		/* File we map to (can be NULL). */
	void * vm_private_data;		/* was vm_pte (shared mem) */
} __randomize_layout;

vm_start和vm_end指定了该区域在用户空间中的起始和结束地址。vm_next和vm_prev将这个区域串在链表上。vm_rb将这个区域放在红黑树上。vm_ops里面是对这个内存区域可以做的操作的定义。

虚拟内存区域可以映射到物理内存也可以映射到文件映射到物理内存的时候称为匿名映射anon_vma中anoy就是anonymous匿名的意思映射到文件就需要有vm_file指定被映射的文件。

那这些vm_area_struct是如何和上面的内存区域关联的呢

这个事情是在load_elf_binary里面实现的。没错就是它。加载内核的是它启动第一个用户态进程init的是它fork完了以后调用exec运行一个二进制程序的也是它。

当exec运行一个二进制程序的时候除了解析ELF的格式之外另外一个重要的事情就是建立内存映射。

static int load_elf_binary(struct linux_binprm *bprm)
{
......
  setup_new_exec(bprm);
......
  retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
				 executable_stack);
......
  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
				elf_prot, elf_flags, total_size);
......
  retval = set_brk(elf_bss, elf_brk, bss_prot);
......
  elf_entry = load_elf_interp(&loc->interp_elf_ex,
					    interpreter,
					    &interp_map_addr,
					    load_bias, interp_elf_phdata);
......
  current->mm->end_code = end_code;
  current->mm->start_code = start_code;
  current->mm->start_data = start_data;
  current->mm->end_data = end_data;
  current->mm->start_stack = bprm->p;
......
}

load_elf_binary会完成以下的事情

  • 调用setup_new_exec设置内存映射区mmap_base

  • 调用setup_arg_pages设置栈的vm_area_struct这里面设置了mm->arg_start是指向栈底的current->mm->start_stack就是栈底

  • elf_map会将ELF文件中的代码部分映射到内存中来

  • set_brk设置了堆的vm_area_struct这里面设置了current->mm->start_brk = current->mm->brk也即堆里面还是空的

  • load_elf_interp将依赖的so映射到内存中的内存映射区域。

最终就形成下面这个内存映射图。

映射完毕后,什么情况下会修改呢?

第一种情况是函数的调用,涉及函数栈的改变,主要是改变栈顶指针。

第二种情况是通过malloc申请一个堆内的空间当然底层要么执行brk要么执行mmap。关于内存映射的部分我们后面的章节讲这里我们重点看一下brk是怎么做的。

brk系统调用实现的入口是sys_brk函数就像下面代码定义的一样。

SYSCALL_DEFINE1(brk, unsigned long, brk)
{
	unsigned long retval;
	unsigned long newbrk, oldbrk;
	struct mm_struct *mm = current->mm;
	struct vm_area_struct *next;
......
	newbrk = PAGE_ALIGN(brk);
	oldbrk = PAGE_ALIGN(mm->brk);
	if (oldbrk == newbrk)
		goto set_brk;


	/* Always allow shrinking brk. */
	if (brk <= mm->brk) {
		if (!do_munmap(mm, newbrk, oldbrk-newbrk, &uf))
			goto set_brk;
		goto out;
	}


	/* Check against existing mmap mappings. */
	next = find_vma(mm, oldbrk);
	if (next && newbrk + PAGE_SIZE > vm_start_gap(next))
		goto out;


	/* Ok, looks good - let it rip. */
	if (do_brk(oldbrk, newbrk-oldbrk, &uf) < 0)
		goto out;


set_brk:
	mm->brk = brk;
......
	return brk;
out:
	retval = mm->brk;
	return retval

前面我们讲过了堆是从低地址向高地址增长的sys_brk函数的参数brk是新的堆顶位置而当前的mm->brk是原来堆顶的位置。

首先要做的第一个事情将原来的堆顶和现在的堆顶都按照页对齐地址然后比较大小。如果两者相同说明这次增加的堆的量很小还在一个页里面不需要另行分配页直接跳到set_brk那里设置mm->brk为新的brk就可以了。

如果发现新旧堆顶不在一个页里面麻烦了这下要跨页了。如果发现新堆顶小于旧堆顶这说明不是新分配内存了而是释放内存了释放的还不小至少释放了一页于是调用do_munmap将这一页的内存映射去掉。

如果堆将要扩大就要调用find_vma。如果打开这个函数看到的是对红黑树的查找找到的是原堆顶所在的vm_area_struct的下一个vm_area_struct看当前的堆顶和下一个vm_area_struct之间还能不能分配一个完整的页。如果不能没办法只好直接退出返回内存空间都被占满了。

如果还有空间就调用do_brk进一步分配堆空间从旧堆顶开始分配计算出的新旧堆顶之间的页数。

static int do_brk(unsigned long addr, unsigned long len, struct list_head *uf)
{
	return do_brk_flags(addr, len, 0, uf);
}


static int do_brk_flags(unsigned long addr, unsigned long request, unsigned long flags, struct list_head *uf)
{
	struct mm_struct *mm = current->mm;
	struct vm_area_struct *vma, *prev;
	unsigned long len;
	struct rb_node **rb_link, *rb_parent;
	pgoff_t pgoff = addr >> PAGE_SHIFT;
	int error;


	len = PAGE_ALIGN(request);
......
	find_vma_links(mm, addr, addr + len, &prev, &rb_link,
			      &rb_parent);
......
	vma = vma_merge(mm, prev, addr, addr + len, flags,
			NULL, NULL, pgoff, NULL, NULL_VM_UFFD_CTX);
	if (vma)
		goto out;
......
	vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
	INIT_LIST_HEAD(&vma->anon_vma_chain);
	vma->vm_mm = mm;
	vma->vm_start = addr;
	vma->vm_end = addr + len;
	vma->vm_pgoff = pgoff;
	vma->vm_flags = flags;
	vma->vm_page_prot = vm_get_page_prot(flags);
	vma_link(mm, vma, prev, rb_link, rb_parent);
out:
	perf_event_mmap(vma);
	mm->total_vm += len >> PAGE_SHIFT;
	mm->data_vm += len >> PAGE_SHIFT;
	if (flags & VM_LOCKED)
		mm->locked_vm += (len >> PAGE_SHIFT);
	vma->vm_flags |= VM_SOFTDIRTY;
	return 0;

在do_brk中调用find_vma_links找到将来的vm_area_struct节点在红黑树的位置找到它的父节点、前序节点。接下来调用vma_merge看这个新节点是否能够和现有树中的节点合并。如果地址是连着的能够合并则不用创建新的vm_area_struct了直接跳到out更新统计值即可如果不能合并则创建新的vm_area_struct既加到anon_vma_chain链表中也加到红黑树中。

内核态的布局

用户态虚拟空间分析完毕,接下来我们分析内核态虚拟空间。

内核态的虚拟空间和某一个进程没有关系,所有进程通过系统调用进入到内核之后,看到的虚拟地址空间都是一样的。

这里强调一下,千万别以为到了内核里面,咱们就会直接使用物理内存地址了,想当然地认为下面讨论的都是物理内存地址,不是的,这里讨论的还是虚拟内存地址,但是由于内核总是涉及管理物理内存,因而总是隐隐约约发生关系,所以这里必须思路清晰,分清楚物理内存地址和虚拟内存地址。

在内核态32位和64位的布局差别比较大主要是因为32位内核态空间太小了。

我们来看32位的内核态的布局。

32位的内核态虚拟地址空间一共就1G占绝大部分的前896M我们称为直接映射区

所谓的直接映射区就是这一块空间是连续的和物理内存是非常简单的映射关系其实就是虚拟内存地址减去3G就得到物理内存的位置。

在内核里面,有两个宏:

  • __pa(vaddr) 返回与虚拟地址 vaddr 相关的物理地址;

  • __va(paddr) 则计算出对应于物理地址 paddr 的虚拟地址。

    #define __va(x)			((void *)((unsigned long)(x)+PAGE_OFFSET))
    #define __pa(x)		__phys_addr((unsigned long)(x))
    #define __phys_addr(x)		__phys_addr_nodebug(x)
    #define __phys_addr_nodebug(x)	((x) - PAGE_OFFSET)


但是你要注意这里虚拟地址和物理地址发生了关联关系在物理内存的开始的896M的空间会被直接映射到3G至3G+896M的虚拟地址这样容易给你一种感觉这些内存访问起来和物理内存差不多别这样想在大部分情况下对于这一段内存的访问在内核中还是会使用虚拟地址的并且将来也会为这一段空间建设页表对这段地址的访问也会走上一节我们讲的分页地址的流程只不过页表里面比较简单是直接的一一对应而已。

这896M还需要仔细分解。在系统启动的时候物理内存的前1M已经被占用了从1M开始加载内核代码段然后就是内核的全局变量、BSS等也是ELF里面涵盖的。这样内核的代码段全局变量BSS也就会被映射到3G后的虚拟地址空间里面。具体的物理内存布局可以查看/proc/iomem。

在内核运行的过程中如果碰到系统调用创建进程会创建task_struct这样的实例内核的进程管理代码会将实例创建在3G至3G+896M的虚拟空间中当然也会被放在物理内存里面的前896M里面相应的页表也会被创建。

在内核运行的过程中会涉及内核栈的分配内核的进程管理的代码会将内核栈创建在3G至3G+896M的虚拟空间中当然也就会被放在物理内存里面的前896M里面相应的页表也会被创建。

896M这个值在内核中被定义为high_memory在此之上常称为“高端内存”。这是个很笼统的说法到底是虚拟内存的3G+896M以上的是高端内存还是物理内存896M以上的是高端内存呢

这里仍然需要辨析一下,高端内存是物理内存的概念。它仅仅是内核中的内存管理模块看待物理内存的时候的概念。前面我们也说过,在内核中,除了内存管理模块直接操作物理地址之外,内核的其他模块,仍然要操作虚拟地址,而虚拟地址是需要内存管理模块分配和映射好的。

假设咱们的电脑有2G内存现在如果内核的其他模块想要访问物理内存1.5G的地方应该怎么办呢如果你觉得我有32位的总线访问个2G还不小菜一碟这就错了。

首先你不能使用物理地址。你需要使用内存管理模块给你分配的虚拟地址但是虚拟地址的0到3G已经被用户态进程占用去了你作为内核不能使用。因为你写1.5G的虚拟内存位置一方面你不知道应该根据哪个进程的页表进行映射另一方面就算映射了也不是你真正想访问的物理内存的地方所以你发现你作为内核能够使用的虚拟内存地址只剩下1G减去896M的空间了。

于是,我们可以将剩下的虚拟内存地址分成下面这几个部分。

  • 在896M到VMALLOC_START之间有8M的空间。

  • VMALLOC_START到VMALLOC_END之间称为内核动态映射空间也即内核想像用户态进程一样malloc申请内存在内核里面可以使用vmalloc。假设物理内存里面896M到1.5G之间已经被用户态进程占用了并且映射关系放在了进程的页表中内核vmalloc的时候只能从分配物理内存1.5G开始,就需要使用这一段的虚拟地址进行映射,映射关系放在专门给内核自己用的页表里面。

  • PKMAP_BASE到FIXADDR_START的空间称为持久内核映射。使用alloc_pages()函数的时候在物理内存的高端内存得到struct page结构可以调用kmap将其映射到这个区域。

  • FIXADDR_START到FIXADDR_TOP(0xFFFF F000)的空间,称为固定映射区域,主要用于满足特殊需求。

  • 在最后一个区域可以通过kmap_atomic实现临时内核映射。假设用户态的进程要映射一个文件到内存中先要映射用户态进程空间的一段虚拟地址到物理内存然后将文件内容写入这个物理内存供用户态进程访问。给用户态进程分配物理内存页可以通过alloc_pages()分配完毕后按说将用户态进程虚拟地址和物理内存的映射关系放在用户态进程的页表中就完事大吉了。这个时候用户态进程可以通过用户态的虚拟地址也即0至3G的部分经过页表映射后访问物理内存并不需要内核态的虚拟地址里面也划出一块来映射到这个物理内存页。但是如果要把文件内容写入物理内存这件事情要内核来干了这就只好通过kmap_atomic做一个临时映射写入物理内存完毕后再kunmap_atomic来解映射即可。

32位的内核态布局我们看完了接下来我们再来看64位的内核布局。

其实64位的内核布局反而简单因为虚拟空间实在是太大了根本不需要所谓的高端内存因为内核是128T根本不可能有物理内存超过这个值。

64位的内存布局如图所示。

64位的内核主要包含以下几个部分。

从0xffff800000000000开始就是内核的部分只不过一开始有8T的空档区域。

从__PAGE_OFFSET_BASE(0xffff880000000000)开始的64T的虚拟地址空间是直接映射区域也就是减去PAGE_OFFSET就是物理地址。虚拟地址和物理地址之间的映射在大部分情况下还是会通过建立页表的方式进行映射。

从VMALLOC_START0xffffc90000000000开始到VMALLOC_END0xffffe90000000000的32T的空间是给vmalloc的。

从VMEMMAP_START0xffffea0000000000开始的1T空间用于存放物理页面的描述结构struct page的。

从__START_KERNEL_map0xffffffff80000000开始的512M用于存放内核代码段、全局变量、BSS等。这里对应到物理内存开始的位置减去__START_KERNEL_map就能得到物理内存的地址。这里和直接映射区有点像但是不矛盾因为直接映射区之前有8T的空当区域早就过了内核代码在物理内存中加载的位置。

到这里内核中虚拟空间的布局就介绍完了。

总结时刻

还记得咱们上一节咱们收集项目组需求的时候,我们知道一个进程要运行起来需要以下的内存结构。

用户态:

  • 代码段、全局变量、BSS

  • 函数栈

  • 内存映射区

内核态:

  • 内核的代码、全局变量、BSS

  • 内核数据结构例如task_struct

  • 内核栈

  • 内核中动态分配的内存

现在这些是不是已经都有了着落?

我画了一个图总结一下进程运行状态在32位下对应关系。

对于64位的对应关系只是稍有区别我这里也画了一个图方便你对比理解。

课堂练习

请通过命令行工具查看进程虚拟内存的布局和物理内存的布局,对照着这一节讲的内容,看一下各部分的位置。

欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。