0%

Linux内核内存管理 - 进程内存

这是<Linux内核内存管理>系列的第七篇:

第一篇为内核内存管理过程知识点的的简单梳理

第二篇介绍了内核的数据结构

第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。

第四篇概览了初始化C代码中的内存处理

第五篇(上)第五篇(下)介绍了Memblock和伙伴系统分配器

第六篇介绍了内存检测工具KFence工作原理

前言

malloc() 大概是在Linux平台上用户空间态编程,最常用的内存分配函数。大家可能会想,

  • 这个函数是如何拿到内存的?内核如何为它做的映射?

  • 另外,一个可执行程序有自己的代码和静态数据,内核如何将这个可执行程序代码加载到内存中执行?其对应的静态变量,全局变量等所需内存又是如何分配的?

以上问题是开发用户空间态程序时,容易被忽略的、甚至完全不会被注意到的问题。因为这些都是由程序所链接的C库和底层内核实现的,程序开发者往往无需在意这些细节。

一般情况下,这并不影响大家写出一个像样的程序。但是当面临一些疑难问题时,仅有如何使用C函数的知识,是无法胜任和处理的。

本文意在从以下几点剖析内核处理进程内存有关的过程:

  • 进程创建过程的段映射
  • 进程内存分配过程的堆映射

架构

下图简要描述了Linux内存管理架构:

  • 用户空间态程序使用GLIBC来创建进程或管理内存

这里GLIBC并不是唯一选项,也有许多其替代。例如:musl Libc, 嵌入式设备常用的uClibc等。

  • 内核态和用户空间态处理内存分配和进程管理的接口是系统调用。

当然除了系统调用之外,内核和用户空间态通信方式还有Netlink等。

  • malloc()free()等函数并不会直接跟系统要内存,反而会在必要时才会使用系统调用向内核申请内存。
  • 启动程序时,使用系统调用fork或者clone创建进程,调用execv加载ELF,为进程创建必要的数据结构、分配必要的内存和页表。
  • 内核内存管理如系列文章所述,有按页分配的伙伴系统、更小级别的分配器SLAB。
  • 系统调用通过虚拟地址映射管理VMA来为进程分配和管理内存。
  • 内核根据需要,会将暂时不需要使用的进程内存换出。而当有使用需求时,再将对应内存换入。这就需要内核的缺页中断处理程序及换页机制来保障。

Linux内存管理架构

数据结构

内核为每个进程分配了一个数据结构task_struct,而其中管理内存的部分是mm_struct:

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
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
struct rb_root mm_rb;
....
unsigned long mmap_base; /* base of mmap area */
...
unsigned long task_size; /* size of task vm space */
unsigned long highest_vm_end; /* highest vma end address */
pgd_t * pgd;
...
struct list_head mmlist;
unsigned long total_vm; /* Total pages mapped */
unsigned long locked_vm; /* Pages that have PG_mlocked set */
atomic64_t 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;
...
struct linux_binfmt *binfmt;
...
};

其中所结构体栏位的意义标注如下图:

  • mmap_base指向进程的MMAP空间
  • brk和start_brk分别指向进程堆的当前位置(若进程需要申请更大的堆,则从该位置开始分配)和起始地址。
  • start_code和end_code分别指向代码段的起始地址和结束地址。
  • start_data和end_data分别指向数据段的起始地址和结束地址。
  • start_stack指向栈的初始地址。
  • 除此之外,还有为该进程参数、环境变量所分配的内存(arg_start/arg_end/env_start/env_end)。

以上地址皆为虚拟地址,是内核进程启动的过程中,由内核所初始化。

  • mmap和mm_rb下管理内核所以为该进程分配的虚拟内存,分别使用红黑树和链表管理。
  • pgd指向该进程的页目录

Linux进程内存管理

进程创建时内存管理

您可能会想知道,前一节所提及的那些段地址,内核是如何确定的?其实这跟ELF格式有关。

  • 所有的Linux进程都遵循ELF格式,在链接的过程中,链接器按照Linker Script的指定将程序打包成ELF。

如果没有明确指定,GCC会指定一个默认的Linker Script

  • 内核创建进程时,会初始化mm_struct
  • 内核加载进程的过程中,按照ELF头部信息为该进程分配内存。

一张图描述上述过程。

Linux进程内存初始化

内核加载进程执行,也遵守ELF规范,在此期间为进程分配虚拟内存VMA。

内存分配

用户空间态

使用musl Libc来对malloc()进行介绍。

没有选择Glibc分析的原因,是因为没有搞懂Systemtap的原理。使用musl Libc分析不会影响理解。

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
static void *__simple_malloc(size_t n)
{
static uintptr_t brk, cur, end;
static unsigned mmap_step;
size_t align=1;
void *p;

if (n > SIZE_MAX/2) {
errno = ENOMEM;
return 0;
}

if (!n) n++;
while (align<n && align<ALIGN)
align += align;

LOCK(lock);

cur += -cur & align-1;

if (n > end-cur) {
size_t req = n - (end-cur) + PAGE_SIZE-1 & -PAGE_SIZE;

if (!cur) {
brk = __syscall(SYS_brk, 0);
brk += -brk & PAGE_SIZE-1;
cur = end = brk;
}

if (brk == end && req < SIZE_MAX-brk
&& !traverses_stack_p(brk, brk+req)
&& __syscall(SYS_brk, brk+req)==brk+req) {
brk = end += req;
} else {
int new_area = 0;
req = n + PAGE_SIZE-1 & -PAGE_SIZE;
/* Only make a new area rather than individual mmap
* if wasted space would be over 1/8 of the map. */
if (req-n > req/8) {
/* Geometric area size growth up to 64 pages,
* bounding waste by 1/8 of the area. */
size_t min = PAGE_SIZE<<(mmap_step/2);
if (min-n > end-cur) {
if (req < min) {
req = min;
if (mmap_step < 12)
mmap_step++;
}
new_area = 1;
}
}
void *mem = __mmap(0, req, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED || !new_area) {
UNLOCK(lock);
return mem==MAP_FAILED ? 0 : mem;
}
cur = (uintptr_t)mem;
end = cur + req;
}
}

p = (void *)cur;
cur += n;
UNLOCK(lock);
return p;
}
weak_alias(__simple_malloc, __libc_malloc_impl);

void *__libc_malloc(size_t n)
{
return __libc_malloc_impl(n);
}

这段代码比较容易理解,我们只关注其中__syscall(SYS_brk, ….)。它的作用就是使用brk这个系统调用向内核要内存。

weak_alias的定义如下:

1
##define weak_alias(old,new) __attribute__((__weak__, __alias__(old)))

其中Weak Alias的意义即给old symbol设置一个别名new

内核空间态

内核空间态处理brk系统调用的代码如下:

  • 首先做一些必要的检查,如检查所申请的堆大小是否超过系统的rlimit,出错则退出返回错误。解释如下:

RLIMIT_DATA

The maximum size of the process’s data segment (initialized data, uninitialized data, and heap). This limit affects calls to brk(2) and sbrk(2), which fail with the error ENOMEM upon encountering the soft limit of this resource.

  • 如果当前brk大于所需brk,则将多出的部分从该进程的堆VM映射中移除并返回。
  • 如果当前brk小于所需brk,则为其扩展堆的VM映射并返回。
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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
unsigned long newbrk, oldbrk, origbrk;
struct mm_struct *mm = current->mm;
struct vm_area_struct *next;
unsigned long min_brk;
bool populate;
bool downgraded = false;
LIST_HEAD(uf);

if (mmap_write_lock_killable(mm))
return -EINTR;

origbrk = mm->brk;

#ifdef CONFIG_COMPAT_BRK
/*
* CONFIG_COMPAT_BRK can still be overridden by setting
* randomize_va_space to 2, which will still cause mm->start_brk
* to be arbitrarily shifted
*/
if (current->brk_randomized)
min_brk = mm->start_brk;
else
min_brk = mm->end_data;
#else
min_brk = mm->start_brk;
#endif
if (brk < min_brk)
goto out;

/*
* Check against rlimit here. If this check is done later after the test
* of oldbrk with newbrk then it can escape the test and let the data
* segment grow beyond its set limit the in case where the limit is
* not page aligned -Ram Gupta
*/
if (check_data_rlimit(rlimit(RLIMIT_DATA), brk, mm->start_brk,
mm->end_data, mm->start_data))
goto out;

newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm->brk);
if (oldbrk == newbrk) {
mm->brk = brk;
goto success;
}

/*
* Always allow shrinking brk.
* __do_munmap() may downgrade mmap_lock to read.
*/
if (brk <= mm->brk) {
int ret;

/*
* mm->brk must to be protected by write mmap_lock so update it
* before downgrading mmap_lock. When __do_munmap() fails,
* mm->brk will be restored from origbrk.
*/
mm->brk = brk;
ret = __do_munmap(mm, newbrk, oldbrk-newbrk, &uf, true);
if (ret < 0) {
mm->brk = origbrk;
goto out;
} else if (ret == 1) {
downgraded = true;
}
goto success;
}

/* 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_flags(oldbrk, newbrk-oldbrk, 0, &uf) < 0)
goto out;
mm->brk = brk;

success:
populate = newbrk > oldbrk && (mm->def_flags & VM_LOCKED) != 0;
if (downgraded)
mmap_read_unlock(mm);
else
mmap_write_unlock(mm);
userfaultfd_unmap_complete(mm, &uf);
if (populate)
mm_populate(oldbrk, newbrk - oldbrk);
return brk;

out:
mmap_write_unlock(mm);
return origbrk;
}

VMA

要更进一步理解以上过程,皆需理解VMA的管理方式。引用<深入理解Linux内核架构>一书的介绍:

VMA管理

  • 如果一个新区域紧接着现存区域前后直接添加(因此也包括在两个现存区域之间的情况),内核将涉及的数据结构合并为一个。当然,前提是涉及的所有区域的访问权限相同,而且是从同一后备存储器映射的连续数据。

  • 如果在区域的开始或结束处进行删除,则必须据此截断现存的数据结构。

  • 如果删除两个区域之间的一个区域,那么一方面需要减小现存数据结构的长度,另一方面需

    要为形成的新区域创建一个新的数据结构。

代码部分不做进一步分析,大家可以直接看内核源码或者找相关资料学习。

结语

本文概要介绍了Linux内核对进程内存的管理方式。主要有:

  • 进程内存管理架构
  • 进程加载执行时内存分配
  • 堆管理
  • VMA的管理方式

进程内存管理还涉及到以下知识,将会在之后的文章中介绍:

  • 内存映射mmap
  • 反向映射
  • 缺页管理