LV05-06-内存管理-02-进程的虚拟内存

本文主要是内存管理——进程的虚拟内存的相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。

点击查看使用工具及版本
Windows windows11
Ubuntu Ubuntu16.04的64位版本
VMware® Workstation 16 Pro 16.2.3 build-19376536
SecureCRT Version 8.7.2 (x64 build 2214) - 正式版-2020年5月14日
开发板 正点原子 i.MX6ULL Linux阿尔法开发板
uboot NXP官方提供的uboot,NXP提供的版本为uboot-imx-rel_imx_4.1.15_2.1.0_ga(使用的uboot版本为U-Boot 2016.03)
linux内核 linux-4.15(NXP官方提供)
STM32开发板 正点原子战舰V3(STM32F103ZET6)
点击查看本文参考资料
参考方向 参考原文
------
点击查看相关文件下载
--- ---

看到这篇文章:3.5 万字 + 60 张图 |一步一图带你深入理解 Linux 虚拟内存管理 (qq.com),真的是讲解的特别详细,推荐看原文,自己在这里做一个笔记,也是对自己的一个提升了。

一、进程的虚拟内存空间

前边已经了解了虚拟内存空间,我们知道每个进程都有自己的虚拟内存空间,那么这个进程独占的虚拟内存空间到底是什么样子呢?我们先想象一下,如果我们是内核的设计人员,我们该从哪些方面来规划进程的虚拟内存空间呢?

1. 代码段

首先我们会想到的是一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU 会执行二进制文件中的机器码来驱动进程的运行。所以在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。

01

2. 数据段与BSS段

在程序运行起来之后,总要操作变量吧,在程序代码中我们通常会定义大量的全局变量和静态变量,这些全局变量在程序编译之后也会存储在二进制文件中,在程序运行之前,这些全局变量也需要被加载进内存中供程序访问。所以在虚拟内存空间中也需要一段区域来存储这些全局变量。

  • 那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段
  • 那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为 0 值。
02

3. 堆

上面介绍的这些全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆。

03

4. 映射区

除上边的那些之外,我们的程序在运行过程中还需要依赖动态链接库,这些动态链接库以 .so 文件的形式存放在磁盘中,比如 C 程序中的 glibc,里边对系统调用进行了封装。glibc 库里提供的用于动态申请堆内存的 malloc 函数就是对系统调用 sbrk 和 mmap 的封装。这些动态链接库也有自己的对应的代码段,数据段,BSS 段,也需要一起被加载进内存中。

还有用于内存文件映射的系统调用 mmap,会将文件与内存进行映射,那么映射的这块内存(虚拟内存)也需要在虚拟地址空间中有一块区域存储。

这些动态链接库中的代码段,数据段,BSS 段,以及通过 mmap 系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射匿名映射区。

04

5. 栈

最后我们在程序运行的时候总该要调用各种函数吧,那么调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈。

05

6. 命令与总结

针对于linux下进程的内存布局,我们可以使用下边的命令来查看实际的进程虚拟内存布局:

1
2
cat /proc/<pid>/maps
pmap <pid>

其中 pid 为进行的进程号。

现在进程的虚拟内存空间所包含的主要区域,我们就了解了大概了,我们看到内核根据进程运行的过程中所需要不同种类的数据而为其开辟了对应的地址空间。分别为:

  • 用于存放进程程序二进制文件中的机器指令的代码段
  • 用于存放程序二进制文件中定义的全局变量和静态变量的数据段和 BSS 段
  • 用于在程序运行过程中动态申请内存的
  • 用于存放动态链接库以及内存映射区域的文件映射与匿名映射区
  • 用于存放函数调用过程中的局部变量和函数参数的

以上就是我们通过一个程序在运行过程中所需要的数据所规划出的虚拟内存空间的分布,这些只是一个大概的规划,那么在真实的 Linux 系统中,进程的虚拟内存空间的具体规划又是如何的呢?

二、Linux系统的进程虚拟内存空间

1. 32位系统

在 32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为 4 GB。所以在 32 位机器上进程的虚拟内存地址范围为:0x0000 0000 - 0xFFFF FFFF。

其中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000 。内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF。

06

但是用户态虚拟内存空间中的代码段并不是从 0x0000 0000 地址开始的,而是从 0x0804 8000 地址开始。0x0000 0000 到 0x0804 8000 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。

保留区的上边就是代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的,BSS 段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录 BSS 段的大小,在加载进内存时会生成一段 0 填充的内存空间。

紧挨着 BSS 段的上边就是我们经常使用到的堆空间,从图中的红色箭头我们可以知道在堆空间中地址的增长方向是从低地址到高地址增长

内核中使用 start_brk 标识堆的起始位置,brk 标识堆当前的结束位置。当堆申请新的内存空间时,只需要将 brk 指针增加对应的大小,回收地址时减少对应的大小即可。比如当我们通过 malloc 向内核申请很小的一块内存时(128K 之内),就是通过改变 brk 位置实现的。

堆空间的上边是一段待分配区域,用于扩展堆空间的使用。接下来就来到了文件映射与匿名映射区域。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用 mmap 映射出来的一段虚拟内存空间也保存在这个区域。需要注意:在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长

接下来用户态虚拟内存空间的最后一块区域就是栈空间了,在这里会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。栈空间中的地址增长方向是从高地址向低地址增长。每次进程申请新的栈地址时,其地址值是在减少的。

在内核中使用 start_stack 标识栈的起始位置,RSP 寄存器中保存栈顶指针 stack pointer,RBP 寄存器中保存的是栈基地址。

在栈空间的下边也有一段待分配区域用于扩展栈空间,在栈空间的上边就是内核空间了,进程虽然可以看到这段内核空间地址,但是就是不能访问。

2. 64位系统

我们知道在 32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为 4 GB。那么在 64 位机器上,指针的寻址范围是不是就是为 2^64,所能表达的虚拟内存空间为 16 EB呢 。虚拟内存地址范围为:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。

16 EB 的内存空间,都没见过这么大的磁盘,在现实情况中根本不会用到这么大范围的内存空间,事实上在目前的 64 位系统下只使用了 48 位来描述虚拟内存空间,寻址范围为 2^48 ,所能表达的虚拟内存空间为 256TB。

其中低 128 T 表示用户态虚拟内存空间,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。高 128 T 表示内核态虚拟内存空间,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。

这样一来就在用户态虚拟内存空间与内核态虚拟内存空间之间形成了一段 0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 的地址空洞,我们把这个空洞叫做 canonical address 空洞。

07

那么这个 canonical address 空洞是如何形成的呢?

我们都知道在 64 位机器上的指针寻址范围为 2^64,但是在实际使用中我们只使用了其中的低 48 位来表示虚拟内存地址,那么这多出的高 16 位就形成了这个地址空洞。

注意到在低 128T 的用户态地址空间:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 范围中,所以虚拟内存地址的高 16 位全部为 0 。如果一个虚拟内存地址的高 16 位全部为 0 ,那么我们就可以直接判断出这是一个用户空间的虚拟内存地址。

同样的道理,在高 128T 的内核态虚拟内存空间:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 范围中,所以虚拟内存地址的高 16 位全部为 1 。也就是说内核态的虚拟内存地址的高 16 位全部为 1 ,如果一个试图访问内核的虚拟地址的高 16 位不全为 1 ,则可以快速判断这个访问是非法的。

这个高 16 位的空闲地址被称为 canonical 。如果虚拟内存地址中的高 16 位全部为 0 (表示用户空间虚拟内存地址)或者全部为 1 (表示内核空间虚拟内存地址),这种地址的形式我们叫做 canonical form,对应的地址我们称作 canonical address 。

那么处于 canonical address 空洞 :0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 范围内的地址的高 16 位 不全为 0 也不全为 1 。如果某个虚拟地址落在这段 canonical address 空洞区域中,那就是既不在用户空间,也不在内核空间,肯定是非法访问了。

未来我们也可以利用这块 canonical address 空洞,来扩展虚拟内存地址的范围,比如扩展到 56 位。在我们理解了 canonical address 这个概念之后,我们再来看下 64 位 Linux 系统下的真实虚拟内存空间布局情况:

08

从上图中我们可以看出 64 位系统中的虚拟内存布局和 32 位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:

(1)就是前边提到的由高 16 位空闲地址造成的 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。

(2)在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。

(3)用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。

三、进程虚拟内存空间的管理

我们发现无论是在 32 位机器上还是在 64 位机器上,进程虚拟内存空间的核心区域分布的相对位置是不变的,它们都包含下图所示的这几个核心内存区域。

05

唯一不同的是这些核心内存区域在 32 位机器和 64 位机器上的绝对位置分布会有所不同。那么在此基础之上,内核如何为进程管理这些虚拟内存区域呢?

1. 首先是struct task_struct

既然我们要学习进程的虚拟内存空间管理,那就离不开进程在内核中的描述符 task_struct 结构。这个结构体定义在linux内核源码的 include/linux/sched.h 文件中,结构体内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct task_struct {
// ... ...
pid_t pid; // 进程ID
// ... ...
pid_t tgid; // 用于标识线程所属的进程 pid
// ... ...
struct files_struct *files; // 进程打开的文件信息
// ... ...
struct mm_struct *mm, *active_mm; // 内存描述符表示进程虚拟地址空间
// ... ...
};

在进程描述符 task_struct 结构中,有一个专门描述进程虚拟地址空间的内存描述符 mm_struct 结构,这个结构体中包含了进程虚拟内存空间的全部信息。每个进程都有唯一的 mm_struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。

2. 从fork开始分析

我们从创建进程的函数fork()开始,我反正是没有找到fork函数定义在哪,只是在/usr/include/unistd.h这个文件中看到了该函数的声明,这还不是在内核源码中,总之没找到,找到后再补充吧:

1
extern __pid_t fork (void) __THROWNL;

虽然没找到这个函数在哪,但是经过资料查找,发现这个fork其实是调用了do_fork()函数实现进程的创建的,我们找不到fork,但是可以找到do_fork啊,哈哈哈,do_fork函数定义在 linux内核源码的 kernel/fork.c 中:

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
/*
* Ok, this is the main fork-routine.
*
* It copies the process, and if successful kick-starts
* it and waits for it to finish using the VM if required.
*/
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
struct task_struct *p;
// ... ....
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;

if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
//为进程创建 task_struct 结构,用父进程的资源填充 task_struct 信息
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace);
// ... ...
return nr;
}

当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。随后会在 copy_process 函数中创建 task_struct 结构,并拷贝父进程的相关资源到新进程的 task_struct 结构里,其中就包括拷贝父进程的虚拟内存空间 mm_struct 结构。这里可以看出子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来。其中 cpoy_process() 函数定义在linux内核源码的 kernel/fork.c 中

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
/*
* This creates a new process as a copy of the old one,
* but does not actually start it yet.
*
* It copies the registers, and all the appropriate
* parts of the process environment (as per the clone
* flags). The actual kick-off is left to the caller.
*/
static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace)
{
int retval;
struct task_struct *p;
// ... ...
p = dup_task_struct(current); // 创建 task_struct 结构
if (!p)
goto fork_out;

// ... ... (初始化子进程等)
// 开始继承拷贝父进程资源
retval = copy_files(clone_flags, p); // 继承父进程打开的文件描述符
if (retval)
goto bad_fork_cleanup_semundo;
retval = copy_fs(clone_flags, p); // 继承父进程所属的文件系统
if (retval)
goto bad_fork_cleanup_files;
retval = copy_sighand(clone_flags, p); // 继承父进程注册的信号以及信号处理函数
if (retval)
goto bad_fork_cleanup_fs;
retval = copy_signal(clone_flags, p);
if (retval)
goto bad_fork_cleanup_sighand;
retval = copy_mm(clone_flags, p); // 继承父进程的虚拟内存空间
if (retval)
goto bad_fork_cleanup_signal;
retval = copy_namespaces(clone_flags, p); // 继承父进程的 namespaces
if (retval)
goto bad_fork_cleanup_mm;
retval = copy_io(clone_flags, p); // 继承父进程的 IO 信息
if (retval)
goto bad_fork_cleanup_namespaces;
retval = copy_thread(clone_flags, stack_start, stack_size, p);
if (retval)
goto bad_fork_cleanup_io;

if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children); // 分配 pid
if (IS_ERR(pid)) {
retval = PTR_ERR(pid);
goto bad_fork_cleanup_io;
}
}
// ... ...
fork_out:
return ERR_PTR(retval);
}

在这个函数中,我们重点关注 copy_mm 函数,正是在这里完成了子进程虚拟内存空间 mm_struct 结构的的创建以及初始化。我们再找一下这个函数在哪。它定义在 linux 内核源码的 kernel/fork.c 文件中:

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
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm; // 子进程虚拟内存空间,父进程虚拟内存空间
// ... ...
// 获取父进程虚拟内存空间
oldmm = current->mm;
if (!oldmm)
return 0;
// ... ...
// 通过 vfork 或者 clone 系统调用创建出的子进程(线程)和父进程共享虚拟内存空间
if (clone_flags & CLONE_VM) {
atomic_inc(&oldmm->mm_users); // 增加父进程虚拟地址空间的引用计数
// 直接将父进程的虚拟内存空间赋值给子进程(线程)
// 线程共享其所属进程的虚拟内存空间
mm = oldmm;
goto good_mm;
}

retval = -ENOMEM;
mm = dup_mm(tsk); // 如果是 fork 系统调用创建出的子进程,则将父进程的虚拟内存空间以及相关页表拷贝到子进程中的 mm_struct 结构中。
if (!mm)
goto fail_nomem;

good_mm:
// 将拷贝出来的父进程虚拟内存空间 mm_struct 赋值给子进程
tsk->mm = mm;
tsk->active_mm = mm;
return 0;

fail_nomem:
return retval;
}

由于我们举的示例是通过 fork() 函数创建子进程的情形,所以这里先忽略 if (clone_flags & CLONE_VM) 这个条件判断逻辑,copy_mm 函数首先会将父进程的虚拟内存空间 current->mm 赋值给指针 oldmm。然后通过 dup_mm 函数将父进程的虚拟内存空间以及相关页表拷贝到子进程的 mm_struct 结构中。最后将拷贝出来的 mm_struct 赋值给子进程的 task_struct 结构。

通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。

而当我们通过 vfork 或者 clone 系统调用创建出的子进程,首先会设置 CLONE_VM 标识,这样来到copy_mm 函数中就会进入 if (clone_flags & CLONE_VM) 条件中,在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。

子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程,是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已

内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct ,内核线程对应的 task_struct 结构中的 mm 域指向 NULL,所以内核线程之间调度是不涉及地址空间切换的

当一个内核线程被调度时,它会发现自己的虚拟地址空间为 NULL,虽然它不会访问用户态的内存,但是它会访问内核内存,内核会将调度之前的上一个用户态进程的虚拟内存空间 mm_struct 直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。

父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个 mm_struct 展开的。

现在我们知道了表示进程虚拟内存空间的 mm_struct 结构是如何被创建出来的相关背景,那么接下来就深入到 mm_struct 结构内部,来看一下内核如何通过这么一个 mm_struct 结构体来管理进程的虚拟内存空间的。

3. 深入到 struct mm_struct

我们前边已经知道了进程的虚拟内存空间分为两个部分:一部分是用户态虚拟内存空间,另一部分是内核态虚拟内存空间。

05

3.1 划分用户和内核

既然每个进程都认为自己占有所有的内存空间,那么用户态的地址空间和内核态的地址空间在内核中是如何被划分的呢?这就用到了进程的内存描述符 mm_struct 结构体中的 task_size 变量,task_size 定义了用户态地址空间与内核态地址空间之间的分界线。

1
2
3
struct mm_struct {
unsigned long task_size; /* size of task vm space */
}

通过前边我们知道在 32 位系统中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000 。内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF。

06

32 位系统中用户地址空间和内核地址空间的分界线在 0xC000 000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0xC000 000。

我们来看下内核在 arch/x86/include/asm/processor.h 文件中关于 TASK_SIZE 的定义(参考原文找的是 arch/x86/include/asm/page_32_types.h 这个文件,但是不幸的是,用的内核中相应的文件没得这个宏)。

1
2
3
4
/*
* User space process size: 3GB (default).
*/
#define TASK_SIZE PAGE_OFFSET

PAGE_OFFSET的值怎么找呢?说真的,我没找到,用SourceInsight找了一堆,好多值,然后还是决定查阅资料,查阅资料后,了解到这个值在 32 位系统下为 0xC000 000。而在 64 位系统中,只使用了其中的低 48 位来表示虚拟内存地址。其中用户态虚拟内存空间为低 128 T,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。内核态虚拟内存空间为高 128 T,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。

08

64 位系统中用户地址空间和内核地址空间的分界线在 0x0000 7FFF FFFF F000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0x0000 7FFF FFFF F000 。参考原文在这个文件中有这个 TASK_SIZE的宏定义,但是我用的内核的 arch/x86/include/asm/page_64_types.h 文件中没看到 TASK_SIZE 的定义,这里还是参考的原文中的定义吧:

1
2
3
4
5
6
7
8
#define TASK_SIZE		(test_thread_flag(TIF_ADDR32) ? \
IA32_PAGE_OFFSET : TASK_SIZE_MAX)

#define TASK_SIZE_MAX task_size_max()

#define task_size_max() ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)

#define __VIRTUAL_MASK_SHIFT 47

我们来看下在 64 位系统中内核如何来计算 TASK_SIZE,在 task_size_max() 的计算逻辑中 1 左移 47 位得到的地址是 0x0000800000000000,然后减去一个 PAGE_SIZE (默认为 4K),就是 0x00007FFFFFFFF000,共 128T。所以在 64 位系统中的 TASK_SIZE 为 0x00007FFFFFFFF000 。

这里我们可以看出,64 位虚拟内存空间的布局是和物理内存页 page 的大小有关的,物理内存页 page 默认大小 PAGE_SIZE 为 4K。

PAGE_SIZE 定义在 arch/x86/include/asm/page_types.h 文件中:

1
2
3
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)

而内核空间的起始地址是 0xFFFF 8000 0000 0000 。在 0x00007FFFFFFFF000 - 0xFFFF 8000 0000 0000 之间的内存区域就是我们在前边介绍的 canonical address 空洞。

所以,到目前为止,我们了解了 mm_struct 结构体中的第一个重要成员 task_size,它的作用就是划分进程虚拟内存空间中的用户空间和内核空间,如下图所示:

09

接下来,我们就来了解一下其他的成员,以及内核是如何通过这个结构体的相关成员完成进程虚拟内存空间的布局的。

3.2 mm_struct相关成员

我们可以看一下这个结构体的相关成员,这个结构体是定义在linux内核源码的 include/linux/mm_types.h 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct mm_struct {
unsigned long task_size; /* size of task vm space */
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 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 */
// ... ...
}

内核中用 mm_struct 结构体中的上述属性来定义上虚拟内存空间里的不同内存区域。

start_code 和 end_code 定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。

start_data 和 end_data 定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。

后面紧挨着的是 BSS 段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS 段), BSS 段的大小是固定的,

下面就是 OS 堆了,在堆中内存地址的增长方向是由低地址向高地址增长, start_brk 定义堆的起始位置,brk 定义堆当前的结束位置。

我们使用 malloc 申请小块内存时(低于 128K),就是通过改变 brk 位置调整堆大小实现的。

接下来就是内存映射区,在内存映射区内存地址的增长方向是由高地址向低地址增长,mmap_base 定义内存映射区的起始地址。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段以及我们调用 mmap 映射出来的一段虚拟内存空间就保存在这个区域。

start_stack 是栈的起始位置在 RBP 寄存器中存储,栈的结束位置也就是栈顶指针 stack pointer 在 RSP 寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。

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

10

在 mm_struct 结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。这部分会在下一小节介绍

mm_struct 结构体中的 total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。

注意映射这个概念,它表示只是将虚拟内存与物理内存建立关联关系,并不代表真正的分配物理内存。

当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked_vm 就是被锁定不能换出的内存页总数,pinned_vm 表示既不能换出,也不能移动的内存页总数。

data_vm 表示数据段中映射的内存页数目,exec_vm 是代码段中存放可执行文件的内存页数目,stack_vm 是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。

了解了这个结构体各个成员的含义,也就相当于了解linux是如何布局虚拟内存的内存空间的,到现在现在关于内核如何对进程虚拟内存空间进行布局的内容我们已经清楚了,那么布局之后划分出的这些虚拟内存区域在内核中又是如何被管理的呢?

4. 内核怎么管理内存区域

经过前边的了解,我们知道内核是通过一个 mm_struct 结构的内存描述符来表示进程的虚拟内存空间的,并通过 task_size 域来划分用户态虚拟内存空间和内核态虚拟内存空间。

05

而在划分出的这些虚拟内存空间中如上图所示,里边又包含了许多特定的虚拟内存区域,比如:代码段,数据段,堆,内存映射区,栈。那么这些虚拟内存区域在内核中又是如何表示的呢?

4.1 vm_area_struct结构体

我们来了解一个新的结构体 vm_area_struct,正是这个结构体描述了这些虚拟内存区域 VMA(virtual memory area)。这个结构体是定义在linux内核源码的 include/linux/mm_types.h 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct vm_area_struct {

unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address within vm_mm. */
/*
* Access permissions of this VMA.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags;

struct anon_vma *anon_vma; /* Serialized by page_table_lock */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units */
void * vm_private_data; /* was vm_pte (shared mem) */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}

每个 vm_area_struct 结构对应于虚拟内存空间中的唯一虚拟内存区域 VMA,vm_start 指向了这块虚拟内存区域的起始地址(最低地址),vm_start 本身包含在这块虚拟内存区域内。vm_end 指向了这块虚拟内存区域的结束地址(最高地址),而 vm_end 本身包含在这块虚拟内存区域之外,所以 vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。

11

4.2 定义虚拟内存区域的访问权限和行为规范

1
2
3
4
5
6
7
8
9
10
11
struct vm_area_struct {

unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address within vm_mm. */
/*
* Access permissions of this VMA.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags;
// ... ...
}

还是vm_area_struct 结构体,这个结构体的 vm_page_prot 和 vm_flags 成员都是用来标记 vm_area_struct 结构表示的这块虚拟内存区域的访问权限和行为规范。

上边我们也提到,内核会将整块物理内存划分为一页一页大小的区域,以页为单位来管理这些物理内存,每页大小默认 4K 。而虚拟内存最终也是要和物理内存一一映射起来的,所以在虚拟内存空间中也有虚拟页的概念与之对应,虚拟内存中的虚拟页映射到物理内存中的物理页。无论是在虚拟内存空间中还是在物理内存中,内核管理内存的最小单位都是页。

vm_page_prot 偏向于定义底层内存管理架构中页这一级别的访问控制权限,它可以直接应用在底层页表中,它是一个具体的概念。

页表用于管理虚拟内存到物理内存之间的映射关系,这部分内容后边会学习到。

虚拟内存区域 VMA 由许多的虚拟页 (page) 组成,每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由 vm_page_prot 决定的。

vm_flags 则偏向于定于整个虚拟内存区域的访问权限以及行为规范。描述的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面。它是一个抽象的概念。可以通过 vma->vm_page_prot = vm_get_page_prot(vma->vm_flags) 实现到具体页面访问权限 vm_page_prot 的转换。

点击查看常用的 vm_flags
vm_flags访问权限
VM_READ可读
VM_WRITE可写
VM_EXEC可执行
VM_SHARD可多进程之间共享
VM_IO可映射至设备 IO 空间
VM_RESERVED内存区域不可被换出
VM_SEQ_READ内存区域可能被顺序访问
VM_RAND_READ内存区域可能被随机访问

vm_flags 为 VM_READ,VM_WRITE,VM_EXEC 定义了虚拟内存区域是否可以被读取,写入,执行等权限。比如代码段这块内存区域的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限,栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。

12

vm_flags 为 VM_SHARD 时,用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享,以便完成进程间通讯。

设置这个值即为 mmap 的共享映射,不设置的话则为私有映射。

vm_flags 为 VM_IO 时的设置表示这块虚拟内存区域可以映射至设备 IO 空间中。通常在设备驱动程序执行 mmap 进行 IO 空间映射时才会被设置。

vm_flags 为 VM_RESERVED 时的设置表示在内存紧张的时候,这块虚拟内存区域非常重要,不能被换出到磁盘中。

vm_flags 为 VM_SEQ_READ 时的设置用来暗示内核,应用程序对这块虚拟内存区域的读取是会采用顺序读的方式进行,内核会根据实际情况决定预读后续的内存页数,以便加快下次顺序访问速度。

vm_flags 为 VM_RAND_READ 时的设置会暗示内核,应用程序会对这块虚拟内存区域进行随机读取,内核则会根据实际情况减少预读的内存页数甚至停止预读。我们可以通过 posix_fadvise,madvise 系统调用来暗示内核是否对相关内存区域进行顺序读取或者随机读取。

通过这一系列的介绍,我们可以看到 vm_flags 就是定义整个虚拟内存区域的访问权限以及行为规范,而内存区域中内存的最小单位为页(4K),虚拟内存区域中包含了很多这样的虚拟页,对于虚拟内存区域 VMA 设置的访问权限也会全部复制到区域中包含的内存页中。

4.3 关联内存映射中的映射关系

1
2
3
4
5
6
7
8
struct vm_area_struct {
// ... ...
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units */
void * vm_private_data; /* was vm_pte (shared mem) */
// ... ...
}

vm_area_struct 结构体接下来的三个属性 anon_vma,vm_file,vm_pgoff 分别和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。

那么这个映射关系在内核中该如何表示呢?这就用到了 vm_area_struct 结构体中的上述三个属性。

10

当我们调用 malloc 申请内存时,如果申请的是小块内存(低于 128K)则会使用 do_brk() 系统调用通过调整堆中的 brk 指针大小来增加或者回收堆内存。

如果申请的是比较大块的内存(超过 128K)时,则会调用 mmap 在上图虚拟内存空间中的文件映射与匿名映射区创建出一块 VMA 内存区域(这里是匿名映射)。这块匿名映射区域就用 struct anon_vma 结构表示。

当调用 mmap 进行文件映射时,vm_file 属性就用来关联被映射的文件。这样一来虚拟内存区域就与映射文件关联了起来。vm_pgoff 则表示映射进虚拟内存中的文件内容,在文件中的偏移。

当然在匿名映射中,vm_area_struct 结构中的 vm_file 就为 null,vm_pgoff 也就没有了意义。

vm_private_data 则用于存储 VMA 中的私有数据。具体的存储内容和内存映射的类型有关,我们暂不展开论述。

4.4 针对虚拟内存区域的相关操作

在struct vm_area_struct 结构中还有一个 vm_ops 用来指向针对虚拟内存区域 VMA 的相关操作的函数指针。

1
2
3
4
5
struct vm_area_struct {
// ... ...
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}

其中,vm_operations_struct结构体定义在 linux 内核源码的 include/linux/mm.h 文件中:

1
2
3
4
5
6
7
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
vm_fault_t (*fault)(struct vm_fault *vmf);
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
// ... ...
}
  • 当指定的虚拟内存区域被加入到进程虚拟内存空间中时,open 函数会被调用
  • 当虚拟内存区域 VMA 从进程虚拟内存空间中被删除时,close 函数会被调用
  • 当进程访问虚拟内存时,访问的页面不在物理内存中,可能是未分配物理内存也可能是被置换到磁盘中,这时就会产生缺页异常,fault 函数就会被调用。
  • 当一个只读的页面将要变为可写时,page_mkwrite 函数会被调用。

struct vm_operations_struct 结构中定义的都是对虚拟内存区域 VMA 的相关操作函数指针。内核中这种类似的用法其实有很多,在内核中每个特定领域的描述符都会定义相关的操作。比如内核中的文件描述符 struct file 中定义的 struct file_operations *f_op。里面定义了内核针对文件操作的函数指针,具体的实现根据不同的文件类型有所不同。

点击查看实例
  • Socket 文件类型

针对 Socket 文件类型,这里的 file_operations 指向的是 socket_file_ops。

13
  • ext4 文件系统

在 ext4 文件系统中管理的文件对应的 file_operations 指向 ext4_file_operations,专门用于操作 ext4 文件系统中的文件。还有针对 page cache 页高速缓存相关操作定义的 address_space_operations 。

14

4.5 虚拟内存区域在内核中是如何被组织的

前边我们介绍了内核中用来表示虚拟内存区域 VMA 的结构体 struct vm_area_struct ,并了解了 struct vm_area_struct 中的一些重要的关键属性。现在我们已经熟悉了这些虚拟内存区域,那么接下来的问题就是在内核中这些虚拟内存区域是如何被组织的呢?

05

我们继续来到 struct vm_area_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
struct vm_area_struct {

struct vm_area_struct *vm_next, *vm_prev;
struct rb_node vm_rb;
struct list_head anon_vma_chain;
struct mm_struct *vm_mm; /* The address space we belong to. */

unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/*
* Access permissions of this VMA.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags;

struct anon_vma *anon_vma; /* Serialized by page_table_lock */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
void * vm_private_data; /* was vm_pte (shared mem) */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}

在内核中其实是通过一个 struct vm_area_struct 结构的双向链表将虚拟内存空间中的这些虚拟内存区域 VMA 串联起来的。

vm_area_struct 结构中的 vm_next ,vm_prev 指针分别指向 VMA 节点所在双向链表中的后继节点和前驱节点,内核中的这个 VMA 双向链表是有顺序的,所有 VMA 节点按照低地址到高地址的增长方向排序。

双向链表中的最后一个 VMA 节点的 vm_next 指针指向 NULL,双向链表的头指针存储在内存描述符 struct mm_struct 结构中的 mmap 中,正是这个 mmap 串联起了整个虚拟内存空间中的虚拟内存区域。

1
2
3
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
}

在每个虚拟内存区域 VMA 中又通过 struct vm_area_struct 中的 vm_mm 指针指向了所属的虚拟内存空间mm_struct。

15

我们可以通过 cat /proc/pid/maps 或者 pmap pid 查看进程的虚拟内存空间布局以及其中包含的所有内存区域。这两个命令背后的实现原理就是通过遍历内核中的这个 vm_area_struct 双向链表获取的。

内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。

尤其在进程虚拟内存空间中包含的内存区域 VMA 比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN ) ,可以显著减少查找所需的时间。所以在内核中,同样的内存区域 vm_area_struct 会有两种组织形式,一种是双向链表用于高效的遍历,另一种就是红黑树用于高效的查找。

而红黑树中的根节点存储在内存描述符 struct mm_struct 中的 mm_rb 中:

1
2
3
struct mm_struct {
struct rb_root mm_rb;
}
16