在进程描述符 task_struct 结构中,有一个专门描述进程虚拟地址空间的内存描述符 mm_struct 结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。每个进程都有唯一的 mm_struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。
通过 fork() 函数创建子进程,子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来。
而当我们通过 vfork 或者 clone 系统调用创建出的子进程,将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程。
进程的虚拟内存空间分为两个部分:一部分是用户态虚拟内存空间,另一部分是内核态虚拟内存空间。
这就用到了进程的内存描述符 mm_struct 结构体中的 task_size 变量,task_size 定义了用户态地址空间与内核态地址空间之间的分界线。
32 位系统中用户地址空间和内核地址空间的分界线在 0xC000 000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0xC000 000。
我们来看下内核在 /arch/x86/include/asm/page_32_types.h
文件中关于 TASK_SIZE 的定义。
/*
* User space process size: 3GB (default).
*/
#define TASK_SIZE __PAGE_OFFSET
如下图所示:__PAGE_OFFSET 的值在 32 位系统下为 0xC000 000。
/arch/arm/Kconfig
64 位系统中用户地址空间和内核地址空间的分界线在 0x0000 7FFF FFFF F000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0x0000 7FFF FFFF F000 。
我们来看下内核在 /arch/x86/include/asm/page_64_types.h
文件中关于 TASK_SIZE 的定义。
#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
文件中:
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)
mm_struct结构体的主要成员如下:
atomic_t mm_users; | 共享同一个用户虚拟地址空间的进程的数量,也就是线程组包含的进程的数量 |
atomic_t mm_count; | 内存描述符的引用计数 |
struct vm_area_struct *mmap; | 虚拟内存区域链表 |
struct rb_root mm_rb; | 虚拟内存区域红黑树 |
unsigned long (*get_unmapped_area) (struct file *filp, unsigned long addr, unsigned long len,unsigned long pgoff, unsigned long flags); | 在内存映射区域找到一个没有映射的区域 |
pgd_t * pgd; | 指向页全局目录,即第一级页表 |
unsigned long mmap_base; | 内存映射区域的起始地址 |
unsigned long task_size; | 用户虚拟地址空间的长度,用于划分用户态空间和内核态空间 |
unsigned long start_code, end_code; | 代码段的起始地址和结束地址 |
unsigned long start_data, end_data; | 数据段的起始地址和结束地址 |
unsigned long start_brk, brk; | 堆的起始地址和结束地址 |
unsigned long start_stack; | 栈的起始地址 |
unsigned long arg_start, arg_end; | 参数字符串的起始地址和结束地址 |
unsigned long env_start, env_end; | 环境变量的起始地址和结束地址 |
mm_context_t context; | 处理器架构特定的内存管理上下文 |
在 mm_struct 结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。
mm_struct 结构体中的 total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。
注意映射这个概念,它表示只是将虚拟内存与物理内存建立关联关系,并不代表真正的分配物理内存。
当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked_vm 就是被锁定不能换出的内存页总数,pinned_vm 表示既不能换出,也不能移动的内存页总数。
data_vm 表示数据段中映射的内存页数目,exec_vm 是代码段中存放可执行文件的内存页数目,stack_vm 是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。
现在关于内核如何对进程虚拟内存空间进行布局的内容我们已经清楚了,那么布局之后划分出的这些虚拟内存区域在内核中又是如何被管理的呢?我们接着往下看~~~
我们知道内核是通过一个 mm_struct 结构的内存描述符来表示进程的虚拟内存空间的,并通过 task_size 域来划分用户态虚拟内存空间和内核态虚拟内存空间。
而在划分出的这些虚拟内存空间中如上图所示,里边又包含了许多特定的虚拟内存区域,比如:代码段,数据段,堆,内存映射区,栈。那么这些特定虚拟内存区域在内核中又是如何表示的呢?本小节中,笔者将为大家介绍一个新的结构体 vm_area_struct,正是这个结构体描述了这些虚拟内存区域 VMA(virtual memory area)。
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_end 指向了这块虚拟内存区域的结束地址(最高地址)。所以 vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。
内核会将整块物理内存划分为一页一页大小的区域,以页为单位来管理这些物理内存,每页大小默认 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 | 内存区域可能被随机访问 |
比如代码段这块内存区域的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限(Java 中的字节码存储在堆中,所以需要可执行权限),栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。
VM_SHARD 用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享,以便完成进程间通讯。
设置这个值即为 mmap 的共享映射,不设置的话则为私有映射。这个等后面我们讲到 mmap 的相关实现时还会再次提起。
VM_IO 的设置表示这块虚拟内存区域可以映射至设备 IO 空间中。通常在设备驱动程序执行 mmap 进行 IO 空间映射时才会被设置。
VM_RESERVED 的设置表示在内存紧张的时候,这块虚拟内存区域非常重要,不能被换出到磁盘中。
VM_SEQ_READ 的设置用来暗示内核,应用程序对这块虚拟内存区域的读取是会采用顺序读的方式进行,内核会根据实际情况决定预读后续的内存页数,以便加快下次顺序访问速度。
VM_RAND_READ 的设置会暗示内核,应用程序会对这块虚拟内存区域进行随机读取,内核则会根据实际情况减少预读的内存页数甚至停止预读。
我们可以通过 posix_fadvise,madvise 系统调用来暗示内核是否对相关内存区域进行顺序读取或者随机读取。相关的详细内容,大家可以看下笔者上篇文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》中的第 9 小节文件页预读部分。
通过这一系列的介绍,我们可以看到 vm_flags 就是定义整个虚拟内存区域的访问权限以及行为规范,而内存区域中内存的最小单位为页(4K),虚拟内存区域中包含了很多这样的虚拟页,对于虚拟内存区域 VMA 设置的访问权限也会全部复制到区域中包含的内存页中。
接下来的三个属性 anon_vma,vm_file,vm_pgoff 分别和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。
那么这个映射关系在内核中该如何表示呢?这就用到了 vm_area_struct 结构体中的上述三个属性。
当我们调用 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 中的私有数据。具体的存储内容和内存映射的类型有关,我们暂不展开论述。
struct vm_area_struct 结构中还有一个 vm_ops 用来指向针对虚拟内存区域 VMA 的相关操作的函数指针。
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_area_struct 结构中,来看一下与组织结构相关的一些属性:
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. */
/**省略**/
}
在内核中其实是通过一个 struct vm_area_struct 结构的双向链表将虚拟内存空间中的这些虚拟内存区域 VMA 串联起来的。
vm_area_struct 结构中的 vm_next ,vm_prev 指针分别指向 VMA 节点所在双向链表中的后继节点和前驱节点,内核中的这个 VMA 双向链表是有顺序的,所有 VMA 节点按照低地址到高地址的增长方向排序。
双向链表中的最后一个 VMA 节点的 vm_next 指针指向 NULL,双向链表的头指针存储在内存描述符 struct mm_struct 结构中的 mmap 中,正是这个 mmap 串联起了整个虚拟内存空间中的虚拟内存区域。
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
}
在每个虚拟内存区域 VMA 中又通过 struct vm_area_struct 中的 vm_mm 指针指向了所属的虚拟内存空间 mm_struct。
我们可以通过 cat /proc/pid/maps
或者 pmap pid
查看进程的虚拟内存空间布局以及其中包含的所有内存区域。这两个命令背后的实现原理就是通过遍历内核中的这个 vm_area_struct 双向链表获取的。
内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。尤其在进程虚拟内存空间中包含的内存区域 VMA 比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN ) ,可以显著减少查找所需的时间。
所以在内核中,同样的内存区域 vm_area_struct 会有两种组织形式,一种是双向链表用于高效的遍历,另一种就是红黑树用于高效的查找。
每个 VMA 区域都是红黑树中的一个节点,通过 struct vm_area_struct 结构中的 vm_rb 将自己连接到红黑树中。
而红黑树中的根节点存储在内存描述符 struct mm_struct 中的 mm_rb 中:
struct mm_struct {
struct rb_root mm_rb;
}
在进程的task_struct结构体中包含两个指向mm_struct结构的指针:
struct task_struct
{
// ...
struct mm_struct *mm;//内核线程没有用户虚拟地址空间,所以内核线程这个值为空
struct mm_struct *active_mm;//进程的active_mm和mm总是指向同一个内存描述符。内核线程的active_mm在没有运行时是空指针,在运行时指向从上一个进程借用的内存描述符
// ...
};
如果进程不属于线程组,进程描述符的成员mm和active_mm都指向同一个内存描述符,内存描述符的成员mm_users是1、成员mm_count是1。
如果两个进程属于同一个线程组,每个进程的进程描述符的成员mm和active_mm都指向同一个内存描述符,内存描述符的成员mm_users是2、成员mm_count是1。
内核线程的mm始终为空。当内核线程没有运行时,active_mm为空,当内核线程运行时,借用上一个进程的内存描述符,在被借用进程的用户虚拟地址空间的上方运行,进程描述符的成员active_mm指向借用的内存描述符,假设被借用的内存描述符所属的进程不属于线程组,那么内存描述符的成员mm_users不变,仍然是1,成员mm_count加1变成2。
ref:
Linux的进程地址空间[三] - 知乎
进程—内存描述符(mm_struct)_宇哲_安菲尔德的博客-CSDN博客_mm_struct
mm_struct(内存描述符) - CodeAntenna
mm_struct(内存描述符)_zcl9796的博客-CSDN博客_mm_struct
Linux 内核 | 内存管理——Slab 分配器 - 一丁点儿
《linux内核深度解析-基于ARM64架构的Linux 4.x内核》
一步一图带你深入理解 Linux 虚拟内存管理