Linux虚拟内存管理

Linux虚拟内存管理

这一章节主要起源于我在了解epoll机制的时候涉及到了mmap,然后里面涉及到了文件映射与匿名映射区。这个区域是在用户态空间的内存中的一块区域,索性又复习了一下虚拟内存管理的知识。主要是参考bin大神的公众号的内容,花了一下午时间看了之后醍醐灌顶,整理了一下,便有此篇。

什么是虚拟地址以及为什么要使用虚拟地址

bin大神做了一个类比,把虚拟地址类比成我们的收货地址。

全部页目录类比省份,上层页目录类比市等等,以此类推。这个全部页目录、上层页目录有点类似于mysql的索引,可以类比一下。

image-20240225195009513

为什么要使用虚拟内存呢?原因是如果直接使用物理内存,我们需要程序员自己手动的去管理内存,而且自己处理冲突。

bin举了以下的例子:如果不使用虚拟内存,在我们的程序中有一个i变量, 如果我们运行三个程序,都使用这样一个代码,会发生什么呢?地址冲突

image-20240225195544851

如果我们使用了虚拟内存,在每个进程中虚拟内存中的地址都为0x354,但是映射到物理内存之后就是完全不同的地址。也就是说在每个进程看来,它自己完全独占了整个内存。

进程虚拟内存空间

代码段: 用于存放进程程序二进制文件中的机器指令

数据段和 BSS 段: 用于存放程序二进制文件中定义的全局变量和静态变量。如果是未初始化会放入BSS段,这一段在初始化内存的时候会被0填充。

堆:用于在程序运行过程中动态申请内存

文件映射与匿名映射区:用于存放动态链接库以及内存映射区域

栈:用于存放函数调用过程中的局部变量和函数参数

32 位机器上进程虚拟内存空间分布

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

用户态虚拟内存空间中的代码段是从0x0804 8000开始的,在操作系统中数值比较小的地址是一个不合法的地址,在C语言中null就指向的是这一块区域的地址。

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

文件映射与匿名映射区域:进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用 mmap 映射出来的一段虚拟内存空间也保存在这个区域。

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

64 位机器上进程虚拟内存空间分布

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 空洞。

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

注意到在用户空间地址中的高 16 位全为0,在内核空间中地址的高 16 位全为1。这样就可以快速知道地址是否非法。

64位虚拟内存空间地址分布如下:

这个多了一个不可访问区域,他的作用就是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。

管理进程虚拟内存空间

在在<linux/sched.h>文件中定义了进程描述符的结构,里面有一个专门描述进程虚拟地址空间的内存描述符 mm_struct 结构,这个结构体中包含了进程虚拟内存空间的全部信息。

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

.......... 省略 .......
}

当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
......... 省略 ..........
struct pid *pid;
struct task_struct *p;

......... 省略 ..........
// 为进程创建 task_struct 结构,用父进程的资源填充 task_struct 信息
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

......... 省略 ..........
}

随后会在 copy_process 函数中创建 task_struct 结构,并拷贝父进程的相关资源到新进程的 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static __latent_entropy 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,
unsigned long tls,
int node)
{

struct task_struct *p;
// 创建 task_struct 结构
p = dup_task_struct(current, node);

....... 初始化子进程 ...........

....... 开始继承拷贝父进程资源 .......
// 继承父进程打开的文件描述符
retval = copy_files(clone_flags, p);
// 继承父进程所属的文件系统
retval = copy_fs(clone_flags, p);
// 继承父进程注册的信号以及信号处理函数
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
// 继承父进程的虚拟内存空间
retval = copy_mm(clone_flags, p);
// 继承父进程的 namespaces
retval = copy_namespaces(clone_flags, p);
// 继承父进程的 IO 信息
retval = copy_io(clone_flags, p);

...........省略.........
// 分配 CPU
retval = sched_fork(clone_flags, p);
// 分配 pid
pid = alloc_pid(p->nsproxy->pid_ns_for_children);

..........省略.........
}

这里我们重点关注 copy_mm 函数,正是在这里完成了子进程虚拟内存空间 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
// 子进程虚拟内存空间,父进程虚拟内存空间
struct mm_struct *mm, *oldmm;
int retval;

...... 省略 ......

tsk->mm = NULL;
tsk->active_mm = NULL;
// 获取父进程虚拟内存空间
oldmm = current->mm;
if (!oldmm)
return 0;

...... 省略 ......
// 通过 vfork 或者 clone 系统调用创建出的子进程(线程)和父进程共享虚拟内存空间
if (clone_flags & CLONE_VM) {
// 增加父进程虚拟地址空间的引用计数
mmget(oldmm);
// 直接将父进程的虚拟内存空间赋值给子进程(线程)
// 线程共享其所属进程的虚拟内存空间
mm = oldmm;
goto good_mm;
}

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

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

...... 省略 ......

copy_mm 函数首先会将父进程的虚拟内存空间 current->mm 赋值给指针 oldmm。然后通过 dup_mm 函数将父进程的虚拟内存空间以及相关页表拷贝到子进程的 mm_struct 结构中。最后将拷贝出来的 mm_struct 赋值给子进程的 task_struct 结构。

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

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

内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct ,内核线程对应的 task_struct 结构中的 mm 域指向 null,所以内核线程之间调度是不涉及地址空间切换的。虽然它不会访问用户态的内存,但是它会访问内核内存,内核会将调度之前的上一个用户态进程的虚拟内存空间 mm_struct 直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。

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

内核如何划分用户态和内核态虚拟内存空间

mm_struct结构中有一个task_size字段,用来划分内核态和用户态的内存空间

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

32 位系统中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000。所以task_size 为 0xC000 000