在前面的章节中,我们主要关注的是内核的虚拟地址空间的管理。从本节开始,我们重点关注管理用户空间的方法,其中由于种种原因,这个比内核地址空间管理更复杂。本节主要围绕以下内容:
- 用户进程的虚拟地址空间是Linux的一个重要抽象,它向每个运行进程提供了同样的系统,每个应用程序都有自身的地址空间,与所有的应用程序分割开,不会干扰到其他进程内存的内容。
- 在内核的虚拟地址空间中,只有很少的段可用于各个用户空间进程,这些段彼此有一定的距离,内核需要一些数据结构来有效的管理这些分布的段
- 地址空间中只有极少的一部分与物理页直接关联,不经常使用的部分,仅当必要时与页帧关联
- 内核无法信任用户进程,所以各个操作系统用户地址空间的操作伴随着各种检查,以确保程序的权限不会超出应有的限制,进而危及到系统的稳定性和安全性
- 用户空间的内存分配方法
1. 进程虚拟地址空间
理论上,64Bit地址支持访问空间是[0, 0xFFFF FFFF FFFF FFFF],而实际上现有的应用程序都不会用这么大的地址空间,而现在ARM64芯片上也不支持访问这么大的地址空间,现有的架构最大支持访问48bit的地址空间。而对于进程有用户态和内核态,同样进程地址空间包括用户地址空间和内核地址空间,用户态访问用户地址空间。对于各个进程的虚拟地址空间起始于地址0,延伸到TASK_SIZE - 1,其上是内核地址空间。
-
在ARM32系统上,地址空间范围为4GB,总的地址空间通常按照3:1划分,各个用户空间进程可用的部分是3GB
-
在ARM64系统上,64位虚拟地址中,并不是所有位都用上,除了高16位用于区分内核空间和用户空间外,有效位的配置可以是:
36, 39, 42, 47
。这可决定Linux内核中地址空间的大小。比如以采用4KB的页,4级页表,虚拟地址为48位的系统为例(从ARMv8.2架构开始,支持虚拟地址和物理地址的大小最多为52位),其虚拟地址空间的范围为256TB ,按照1:1的比例划分,内核空间和用户空间各占128TB。
对于用户程序只能访问整个地址空间的下半部分,不能访问内核部分。同时无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是相同的。
1.1 进程地址空间的布局
一个进程通常由加载一个elf文件启动,而elf文件是由若干segments组成的,同样的,进程地址空间也由许多不同属性的segments组成。虚拟地址空间中包含了若干区域,其分布方式特定于体系结构,但所有的方法都有下列共同的特点,如下图所示
-
text段 :包含了当前运行进程的二进制代码,其起始地址在IA32体系中中通常为0x08048000,在IA64体系中通常为0x0000000000400000
-
data段 :包含程序显式初始化的全局变量和静态变量,即已初始化且初值不为0的全局变量(也包括静态全局变量)和静态局部变量,这些数据是在程序真正运行前就已经确定的数据,所以可以提前加载到内存保存好。
-
bss段 :未初始化的全局变量和静态变量,这些变量的值是在程序真正运行起来并为其赋值后才能确定的,所以程序加载之初,只需要记录它的内存地址和所需大小。出于历史原因,这段空间也称为 BSS 段。
-
heap段 :存储动态分配的内存中的数据,堆用于存储那些生存期与函数调用无关的数据。如用系统调用 malloc 申请的内存便在堆上,这些申请的内存在不需要时必须手动释放,否则便会出现内存泄漏。
-
stack段 :用于保存局部变量和实现函数/过程调用的上下文,它们的大小都是会在进程运行过程中发生变化的,因此中间留有空隙,heap向上增长,stack向下增长,因为不知道heap和stack哪个会用的多一些,这样设置可以最大限度的利用中间的空隙空间。进程每调用一次函数,都将为该函数分配一个栈帧,栈帧中保存了该函数的局部变量、参数值和返回值。
-
文件映射段 :这个段比较特殊,是mmap()系统调用映射出来的。 mmap映射 的大小也是不确定的。3GB的虚拟地址空间已经很大了,但heap段, stack段,mmap段在动态增长的过程还是有重叠(碰撞)的可能。为了避免重叠发生,通常将mmap映射段的起始地址选在TASK_SIZE/3(也就是1GB)的位置。如果是64位系统,则虚拟地址空间更加巨大,几乎不可能发生重叠。
我们以最简单的Helloworld程序为例,其内存空间布局如下图所示
1.2 建立布局
那我们了解了进程在运行过程中的内存空间分布情况,那么如何建立起这种内存空间呢?首先,在Linux系统中运行一个可执行的ELF文件时,内核首先需要识别这个文件,然后解析并装载它以构建进程的内存空间,最后切换到新的进程来运行。
首先,我们来看一下elf文件的格式,Section头表包含了描述文件Sections的信息。每个Section在这个表中有一个入口,每个入口给出了该Section的名字,大小等信息。同时可执行文件有一个头部,里面有一些关键信息,Entry point Address,入口地址,即程序的起点,0x8048300,后面有一些代码,数据
当我们在linux的shell命令中执行某个elf可执行文件的时候,linux系统是如何装载该ELF并执行的呢?其主要是以下几个步骤:
-
创建新进程:首先在用户层面,shell进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用来执行指定的ELF。
-
检查可执行文件的类型:当进入execve()系统调用之后,Linux内核就开始真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),会执行do_execve()查找被执行的文件,如果找到文件,则读取文件的前128个字节,通过来判断该执行文件是哪一种elf文件,例如a.out,java程序,以及脚本开头的文件。
-
搜索匹配的装载处理过程:do_execve()读取128个字节的文件头部后,调用search_binary_handle()去搜索和匹配合适的可执行文件,最常见的可执行文件及处理过程如下
- ELF可执行文件:load_elf_binary
- a.out 可执行文件:load_aout_library
- 可执行脚本程序:load_script()
在装载的过程中,对于可执行文件,应该创建对应的.text段、.data段、stack段等。在Linux中,每个段都用一个vm_area_strcutvm_area_strcut结构体表示,vma是通过一个双向链表串起来,现存的vma按照起始地址依次递增被归入链表中,每个vma是这个链表的一个节点,首先我们来看一个进程有一个struct mm_struct用来描述进程的内存信息
struct mm_struct {
//指向线性区对象的链表头
struct vm_area_struct * mmap; /* list of VMAs */
//指向线性区对象的红黑树
struct rb_root mm_rb;
//指向最后一个引用的线性区对象
struct vm_area_struct * mmap_cache; /* last find_vma result */
//在进程地址空间中搜索有效线性地址区间的方法
unsigned long (*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
//释放线性区时调用的方法
void (*unmap_area) (struct vm_area_struct *area);
// 标识第一个分配的匿名线性区或者是文件内存映射的线性地址
unsigned long mmap_base; /* base of mmap area */
//内核从这个地址开始搜索进程地址空间中线性地址的空闲区间
unsigned long free_area_cache; /* first hole */
//指向页表
pgd_t * pgd;
...
}
对于mmap指向的vm\_area\_struct,其定义如下:
struct vm_area_struct {
unsigned long vm_start; //虚拟内存空间的首地址
unsigned long vm_end; //虚拟内存空间的尾地址
//VMA链表的下一个成员和上级成员,进程VMA连接成一个链表
struct vm_area_struct *vm_next, *vm_prev;
//将本VMA作为一个节点加入到红黑树中,每个进程的struct mm_struct都有一颗这样的红黑树mm_rb
struct rb_node vm_rb;
unsigned long rb_subtree_gap;
//指向该VMA所属的进程struct mm_struct数据结构
struct mm_struct *vm_mm; /* The address space we belong to. */
pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, see mm.h. */
...
}
vm\_area\_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm\_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm\_area\_struct中获得。mmap函数就是要创建一个新的vm\_area\_struct结构,并将其与文件的物理磁盘地址相连。
至此,我们可以看出,虚拟内存即为由一个个vm\_area\_struct结构体,通过链表组装起来的空间,其示意图如下图所示
用户进程拥有用户空间的地址,其可以通过malloc和mmap等函数来申请内存,malloc和mmap等函数的实现都是基于进程线性区描述struct vm_erea_struct,内核管理进程地址空间的数据结构struct vm_erea_struct,简称VMA。
对于每个进程的内存描述符mm_struct,都有各自的VMA,通过mm->mmap链表将所有的VMA管理起来,同时会记录到mm->mm_rb的红黑树,用于高速查找合并VMA等操作。
2. 虚拟内存区域的表示
先来说说task_struct,task_struct是一个结构体,这个结构体非常的庞大,linux下用它来完整的描述一个进程的所有信息。在每装载一个进程的时候,内核就会帮我们去创建一个新的task_struct
结构体。然后我们知道一个每一个独立的进程都有自己独立的虚拟空间,所以,在task_struct结构体里会有一个struct mm_struct *mm
成员,这个mm成员就是用来描述和管理进程的虚拟空间的。由上图可知,每个区域通过一个vm_eara_struct实例描述,进程的各区域按照以下两种方式排序
-
在一个双链表上(开始于mm_struct->mmap)
-
在一个红黑树上,跟节点位于mm_rb
总结来说,简单的理解这三者的关系就是
task_struct
结构体包含了一个mm_sturcut
结构体成员,mm_struct
结构体包含了一个vm_area_struct
结构体成员mmap
,然后这个mmap
成员指向一个VMA链表,管理所有的VMA。用户虚拟地址空间中的每个区域由开始和结束地址描述,现存的区域按照起始地址以递增次序被归入链表中。扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作。因此vm_eara_struct的各个实例可以通过红黑树管理,可以显著加快扫描速度。
3. 总结
当一个进程要运行起来需要以下的内存结构:
用户态:
- 代码段、全局变量、BSS
- 函数栈
- 堆
- 内存映射区
内核态:
- 内核的代码、全局变量、BSS
- 内核数据结构例如 task_struct
- 内核栈
- 内核中动态分配的内存
对于64位的系统,其进程运行状态如下图所示:
Linux 为虚拟内存不同的段,提供了不同的数据结构来描述:
在 Linux 内核眼中所有的进程、线程都是 task 都适用 task_struck 描述。
-
task_struck 数据结构中的 mm 字段(mm_struct 类型)描述了进程或者线程用户态的内存信息;
-
mm_struct 中有映射页的统计信息(总页数, 锁定页数, 数据/代码/栈映射页数等)以及各区域地址
-
mm_struct 维护着 vm_area_struct 的链表,每个链表节点都描述了用户空间虚拟内存的布局划分:
4. 参考文档
趣谈Linux操作系统