CH1-进程虚拟空间
代码学习链接:
Linux 源代码 (v6.15.5) - Bootlin Elixir 交叉引用器
理论知识学习链接:小林coding | Java面试学习
进程虚拟内存空间
为了防止多进程运行时造成的内存地址冲突,内核引入了虚拟内存地址,为每个进程提供了一个独立的虚拟内存空间,使得进程以为自己独占全部内存资源。

内核根据进程运行的过程中所需要不同种类的数据而为其开辟了对应的地址空间。分别为:
- 用于存放进程程序二进制文件中的机器指令的代码段。
- 用于存放程序二进制文件中定义的全局变量和静态变量的数据段和BSS段。
- 那些在代码中被我们指定了初始值的全局变量和静态变量在虚以内存空间中的存储区域我们叫做数据
段。 - 那些没有指定初始值的全局变量和静态变量在虚以内存空间中的存储区域我们叫做BSS段。这些未初
始化的全局变量被加载进内存之后会被初始化为0值。
- 那些在代码中被我们指定了初始值的全局变量和静态变量在虚以内存空间中的存储区域我们叫做数据
- 用于在程序运行过程中动态申请内存的堆。这里的堆指的是OS堆并不是VM中的堆。
- 用于存放这些动态链接库中的代码段,数据段,BSS段,以及通过mmp系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区。
- 用于存放函数调用过程中的局部变量和函数参数的栈。
- 可以使用
cat /proc/pid/maps
或者pmap pid
来查看某个进程的实际虚拟内存布局。
32位
在32位机器上,指针的寻址范围为232,所能表达的虚以内存空间为4GB。所以在32位机器上进程的虚以内存地址范围为:0x00000000-0 xFFFF FFFF。
其中用户态虚拟内存空间为3GB,虚拟内存地址范围为:0x00000000-0xC000000。
内核态虚拟内存空间为1GB,虚拟内存地址范围为:OxC000000-OxFFFF FFFF。

保留区:0x00000000到0x08048000这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不充许访问的。比如在C语言中我们通常会将一些无效的指针设置为NULL,指向这块不允许访问的地址。
代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的
**BSS段:**BSS段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录BSS段的大小,在加载进内存时会生成一段0填充的内存空间。
堆空间:从图中的红色箭头我们可以知道在堆空间中地址的增长方向是从低地址到高地址增长。内核中使用start_brk标识堆的起始位置,bk标识堆当前的结束位置:当堆申请新的内存空间时,只需要将brk指针增加对应的大小,回收地址时减少对应的大小即可。比如当我们通过malloc向内核申请很小的一块内存时(128K之内),就是通过改变bk位置实现的。堆空间的上边是一段待分配区域,用于扩展堆空间的使用。
文件映射与匿名映射区域:进程运行时所依赖的动态链接库中的代码段,数据段,BSS段就加载在这里。还有我们调用mmap映射出来的一段虚拟内存空间也保存在这个区域。注意:在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长。
栈空间:在这里会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。栈空间中的地址增长方向是从高地址向低地址增长。每次进程申请新的栈地址时,其地址值是在减少的。在内核中使用start_stack标识栈的起始位置,RSP寄存器中保存栈顶指针stack pointer,RBP寄存器中保存的是栈基地址。在栈空间的下边也有一段待分配区域用于扩展栈空间,
**内核空间:**进程虽然可以看到这段内核空间地址,但是就是不能访问。
进程虚拟内存空间的管理
1 | struct task_struct |
在进程描述符task_struct结构中,有一个专门描述进程虚拟地址空间的内存描述符mm_struct结构,这个结构体中包含了前边几个小节中介绍的进程虚以内存空间的全部信息。
每个进程都有唯一的mm_struct结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。
fork创建流程
当我们调用fork()函数创建进程的时候,表示进程地址空间的mm_struct结构会随着进程描述符task_struct的创建而创建。
使用_do_fork()函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17long _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结构。子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来。
copy_process函数
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
37static __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 结构,通过 dup_task_struct 复制父进程的 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结构的的创建以及初始化
copy_mm函数
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
41static 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;
....... 省略 .......
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结构。
==特点:通过fork()函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。==
vfork/clone
而当我们通过vfork或者clone系统调用创建出的子进程,首先会设置CLONE_VM标识,这样来到copy_mm函数中就会进入if(clone_flags&CLONE_VM)条件中,在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。
子进程共享了父进程的虚以内存空间,这样子进程就变成了我们熟悉的线程,==是否共享地址空间几乎是进程和线程之间的本质区别。Liux内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。==
==内核线程和用户态线程的区别就是内核线程没有相关的内存描述符mm_struct,内核线程对应的task_struct结构中的mm域指向Null,所以内核线程之间调度是不涉及地址空间切换的。==
当一个内核线程被调度时,它会发现自己的虚拟地址空间为Null,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间mm_struct直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配mm_struct和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。
==父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个mm_struct展开的。==
虚拟内存管理
首先我们需要解析描述符task_struct结构
1 | struct task_struct |
mm_struct

每个进程都有唯一的mm_struct结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。内核中采用了一个叫做内存描述符的mm_struct结构体来表示进程虚拟内存空间的全部信息。
1 | struct mm_struct { |
start_code
和end_code
定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。start_data
和end_data
定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。后面紧挨着的是
BSS
段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段0填充的内存区域(BSS段),BSS
段的大小是固定的下面就是OS堆了,在堆中内存地址的增长方向是由低地址向高地址增长,
start_brk
定义堆的起始位置,brk
定义堆当前的结束位置。接下来就是内存映射区,在内存映射区内存地址的增长方向是由高地址向低地址增长,
mmap_base
定义内存映射区的起始地址。进程运行时所依赖的动态链接库中的代码段,数据段,BSS段以及我们调用mmap映射出来的一段虚以内存空间就保存在这个区域。start_stack
是栈的起始位置在RBP寄存器中存储,栈的结束位置也就是栈顶指针stack_pointer
在RSP寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。arg_start
和arg_end
是参数列表的位置,env_start
和env_end
是环境变量的位置。它们都位于栈中的最高地址处。

mm_struct结构体中的**total_vm
表示在进程虚拟内存空间中总共与物理内存映射的页的总数**。==注意映射这个概念,它表示只是将虚拟内存与物理内存建立关联关系,并不代表真正的分配物理内存。==
当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked_vm
就是被锁定不能换出的内存页总数,pinned_vm
表示既不能换出,也不能移动的内存页总数。data_vm
表示数据段中映射的内存页数目,exec_vm
是代码段中存放可执行文件的内存页数目,stack_vm
是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。
vm_area_struct
而在划分出的这些虚拟内存空间中如上图所示,里边又包含了许多特定的虚以内存区域,比如:代码段,数据段,堆,内存映射区,栈。那么这些虚拟内存区域在内核中又是如何表示的呢?一个新的结构体vm_area_struct,.正是这个结构体描述了这些虚拟内存区域VMA (virtual memory area)
1 | struct vm_area_struct { |
每个vm_area_struct
结构对应于虚拟内存空间中的唯一虚拟内存区域VMA,vm_start
指向了这块虚以内存区域的起始地址(最低地址),vm_start
本身包含在这块虚拟内存区域内。vm_end
指向了这块虚以内存区域的结束地址(最高地址),而vm_end
本身包含在这块虚拟内存区域之外,所以vm_area_struct
结构描述的是[vm_start,vm_end)
这样一段左闭右开的虚拟内存区域。

虚拟内存区域在内核中是如何被组织的
双向链表
在内核中其实是通过一个
struct vm_area_struct
结构的双向链表将虚拟内存空间中的这些虚拟内存区域VMA
串联起来的vm_area_struct
结构中的vm_next
,vm_prev
指针分别指向VMA
节点所在双向链表中的后继节点和前驱节点,内核中的这个VMA
双向链表是有顺序的,所有VMA
节点按照低地址到高地址的增长方向排序。双向链表中的最后一个VMA
节点的vm_next
指针指向NULL
,双向链表的头指针存储在内存描述符struct mm_struct
结构中的mmap
中,正是这个mmap
串联起了整个虚拟内存空间中的虚拟内存区域。红黑树
尤其在进程虚拟内存空间中包含的内存区☒域VMA比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是O(IogN),可以显著减少查找所需的时间。
每个VMA区域都是红黑树中的一个节点,通过
struct vm_area_struct
结构中的vm_rb
将自己连接到红黑树中。而红黑树中的根节点存储在内存描述符
struct mm_struct
中的mm_rb
中:
虚拟内存区域的访问权限和行为规范
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_f1ags)
实现到具体页面访问权限vm_page_prot
的转换。标志 值 描述 VM_READ 0x1 可读 VM_WRITE 0x2 可写 VM_EXEC 0x4 可执行 VM_SHARED 0x8 共享映射,虚拟内存区域映射的物理内存是否可以在多进程之间共享 VM_MAYREAD 0x10 可能可读 VM_MAYWRITE 0x20 可能可写 VM_MAYEXEC 0x40 可能可执行 VM_GROWSDOWN 0x100 向下扩展的栈 VM_DENYWRITE 0x800 拒绝写入文件 VM_LOCKED 0x2000 内存锁定 (mlock) VM_IO 0x00004000 虚拟内存区域可以映射至设备 IO 空间中
内存映射中的映射关系
接下来的三个属性anon_vma
,vm_file
,vm_pgoff
分别和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,==映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。==
当我们调用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中的私有数据。
例如:
1 | static int mmap_mmap(struct file *file, struct vm_area_struct *vma) |
针对虚拟内存区域的相关操作
struct vm_area_struct
结构中还有一个**vm_ops
用来指向针对虚拟内存区域VMA的相关操作的函数指针**
1 | struct vm_operations_struct{ |
- VMA 添加时:虚拟内存区域(VMA)加入进程地址空间时,内核自动调用其 回调函数。
open
- VMA 移除时:VMA 从进程地址空间解除映射时,内核自动调用其 回调函数。
close
- 缺页异常触发时:访问未驻留物理内存的页面(如未分配或已换出至交换区)引发缺页异常,内核调用 回调函数处理。
fault
- 写时复制场景:尝试写入只读页面时(例如写时复制机制触发),内核调用
page_mkwrite
回调函数。