linux进程装载和内存管理

Posted by HHP on June 22, 2019

背景

操作系统的内存管理是整个操作系统中的核心的部分。然而其实在我们的日常工作中,一般是不会接触到这块地方的,因为这块东西已经比较稳定,日常工作中是不会有需求要去改动这块地方的。虽然我们并不需要完全弄透其中的细节,但是如果对其有一个全面而感性的认识,会让我们在工作中事半功倍。

正文

看本文前,大家需要对ELF文件有一个大概的了解。

进程虚拟地址空间

首先我们要搞清楚虚拟地址空间的概念。我们知道现在的处理器大多都是32位或者64位的。下面以32位为例说明(64位同理)。什么是32位呢?简单来说,就是数据总线和地址总线都是32位,拿地址总线来说,就是可寻址范围是0x00000000~0xFFFFFFFF(即最大4GB寻址范围)。这里注意,千万不要将虚拟地址空间虚拟内存两个概念搞混了,虚拟内存在linux里是物理内存加swap交换空间的大小。CPU在读取指令的时候发出寻找的都是虚拟地址,需要通过MMU(硬件概念,中文叫内存管理单元,现代处理器一般都内含)来将虚拟地址转换成物理地址。32位系统下,每一个进程都拥有独立的4GB大小的地址空间。那问题来了,每个进程都有4GB的地址空间,这么多进程,linux是怎么管理如此多和庞大的地址空间的呢?这个我们后面会讲到。在linux下,每一个可执行文件编译好都是elf文件。在Linux下,每一个进程的虚拟地址空间的最高那1GB的地址空间分给了内核,剩下的3GB分给了用户进程。所以编译时可分配的地址空间范围是0x00000000~0xC0000000,0xC0000000~0xFFFFFFFF的空间是不能分配的。

编译好的可执行文件,我们可以通过objdump或者readelf命令查看其文件结构。我以一个a.out可执行文件为例打印出段表,看这里的Address列显示的就是到时进程装载之后各个段的虚拟地址基址size列就是这个段的大小。(如下图所示)。

[email protected]:~/Desktop/git_test$ readelf -S a.out 
There are 31 section headers, starting at offset 0x19e0:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000400238  00000238
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.ABI-tag     NOTE             0000000000400254  00000254
       0000000000000020  0000000000000000   A       0     0     4
  [ 3] .note.gnu.build-i NOTE             0000000000400274  00000274
       0000000000000024  0000000000000000   A       0     0     4
  [ 4] .gnu.hash         GNU_HASH         0000000000400298  00000298
       000000000000001c  0000000000000000   A       5     0     8
  [ 5] .dynsym           DYNSYM           00000000004002b8  000002b8
       0000000000000060  0000000000000018   A       6     1     8
  [ 6] .dynstr           STRTAB           0000000000400318  00000318
       000000000000003f  0000000000000000   A       0     0     1
  [ 7] .gnu.version      VERSYM           0000000000400358  00000358
       0000000000000008  0000000000000002   A       5     0     2
  [ 8] .gnu.version_r    VERNEED          0000000000400360  00000360
       0000000000000020  0000000000000000   A       6     1     8
  [ 9] .rela.dyn         RELA             0000000000400380  00000380
       0000000000000018  0000000000000018   A       5     0     8
  [10] .rela.plt         RELA             0000000000400398  00000398
       0000000000000030  0000000000000018  AI       5    24     8
  [11] .init             PROGBITS         00000000004003c8  000003c8
       000000000000001a  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         00000000004003f0  000003f0
       0000000000000030  0000000000000010  AX       0     0     16
  [13] .plt.got          PROGBITS         0000000000400420  00000420
       0000000000000008  0000000000000000  AX       0     0     8
  [14] .text             PROGBITS         0000000000400430  00000430
       00000000000001b2  0000000000000000  AX       0     0     16
  [15] .fini             PROGBITS         00000000004005e4  000005e4
       0000000000000009  0000000000000000  AX       0     0     4
  [16] .rodata           PROGBITS         00000000004005f0  000005f0
       0000000000000007  0000000000000000   A       0     0     4
  [17] .eh_frame_hdr     PROGBITS         00000000004005f8  000005f8
       0000000000000034  0000000000000000   A       0     0     4
  [18] .eh_frame         PROGBITS         0000000000400630  00000630
       00000000000000f4  0000000000000000   A       0     0     8
  [19] .init_array       INIT_ARRAY       0000000000600e10  00000e10
       0000000000000008  0000000000000000  WA       0     0     8
  [20] .fini_array       FINI_ARRAY       0000000000600e18  00000e18
       0000000000000008  0000000000000000  WA       0     0     8
  [21] .jcr              PROGBITS         0000000000600e20  00000e20
       0000000000000008  0000000000000000  WA       0     0     8
  [22] .dynamic          DYNAMIC          0000000000600e28  00000e28
       00000000000001d0  0000000000000010  WA       6     0     8
  [23] .got              PROGBITS         0000000000600ff8  00000ff8
       0000000000000008  0000000000000008  WA       0     0     8
  [24] .got.plt          PROGBITS         0000000000601000  00001000
       0000000000000028  0000000000000008  WA       0     0     8
  [25] .data             PROGBITS         0000000000601028  00001028
       0000000000000010  0000000000000000  WA       0     0     8
  [26] .bss              NOBITS           0000000000601038  00001038
       0000000000000008  0000000000000000  WA       0     0     1
  [27] .comment          PROGBITS         0000000000000000  00001038
       0000000000000035  0000000000000001  MS       0     0     1
  [28] .shstrtab         STRTAB           0000000000000000  000018cd
       000000000000010c  0000000000000000           0     0     1
  [29] .symtab           SYMTAB           0000000000000000  00001070
       0000000000000648  0000000000000018          30    47     8
  [30] .strtab           STRTAB           0000000000000000  000016b8
       0000000000000215  0000000000000000           0     0     1

虚拟内存

正如前文所说,虚拟内存在linux下指的是物理内存加swap分区的总大小。swap分区就是当物理内存不够时操作系统暂时将没用到的内存页暂存于此的一块空间,这个空间是在外部存储器上(如磁盘)分配的空间大小。

大家一定要谨记虚拟空间和虚拟内存是完全不同的东西。

VMA、task_struct和mm_struct的概念与关系

要了解linux的内存管理,上面这三个概念也要了解清楚。

先来说说task_struct,task_struct是一个结构体,这个结构体非常的庞大,linux下用它来完整的描述一个进程的所有信息。在每装载一个进程的时候,内核就会帮我们去创建一个新的task_struct结构体。

然后我们知道一个每一个独立的进程都有自己独立的虚拟空间,所以,在task_struct结构体里会有一个struct mm_struct *mm成员,这个mm成员就是用来描述和管理进程的虚拟空间的(如下图为task_struct结构体)。

	struct task_struct {
		...
        struct sched_info		sched_info;

        struct list_head		tasks;

        struct mm_struct		*mm;
        struct mm_struct		*active_mm;
   }

然后说说VMA是什么。我们知道内核要管理这么大片的虚拟地址空间,那么,内核具体是怎么管理的呢?答案就是用VMA。VMA(virtual memory area),即虚拟内存区域。VMA就是一块连续的线性地址的描述和抽象。在linux内核里以vm_area_struct这个结构体来描述一个VMA。 在mm_struct结构体里,有一个struct vm_area_struct *mmap;成员,这个mmap指向一个VMA链表,也就是说所有的VMA是以链表的形式串联在一块形成一个进程的虚拟地址空间完整的描述。一个进程基本上可以分成以下几个区域:

  • 代码VMA,权限只读,可执行,有映像文件
  • 数据VMA,权限可读写,可执行,有映像文件
  • 堆VMA,权限可读写,可执行,无映像文件,匿名,地址向上生长;
  • 栈VMA,权限可读写,不可执行,无映像文件,匿名,地址向下生长。

下图指出了elf文件里各个段和VMA的关系。如下图中的VMA0映射的就是代码段,VMA1映射的就是数据段,堆VMA和栈VMA和elf是没有映射关系的。

总结来说,简单的理解这三者的关系就是task_struct结构体包含了一个mm_sturcut结构体成员,mm_struct结构体包含了一个vm_area_struct结构体成员mmap,然后这个mmap成员指向一个VMA链表,管理所有的VMA。

进程的装载过程

之前跟很多人聊天,包括同事和一些也是做技术的朋友,感觉很多人对进程的装载也是很模糊的。很多人都以为,装载的时候是整个elf文件直接装载进内存里面的,然后当内存不够的时候就发生页置换,将没用的页放进swap里。当然系统是可以这么设计,但是有一个问题是,这样的设计真的有必要吗?我们有必要在装载进程的时候就把整个elf装载进去吗?,因为我们很多时候在使用一个进程时并不需要用到elf文件的所有内容的,所以这样第一浪费内存,第二浪费加载的时间,linux是不会这么干的。

实际上linux在装载进程的时候,会先做三件事:

  • 给这个新的进程创建一个新的虚拟空间;
  • 读取可执行文件头,建立虚拟空间和可执行文件之间的映射关系(最重要的一步,VMA的初始化等等);
  • 将cpu的指令寄存器设置成可执行文件的入口地址,然后启动;

但是其实做好了这几步之后,elf文件的指令和数据是没有被装进内存的。而是当cpu要开始执行这个地址(这里说的地址都是说的虚拟地址)的指令时,发现该虚拟地址所在的页面是一个空页面,然后内核会产生一个页错误,找出空页面所在的VMA,计算出相应页面在可执行文件中的偏移,然后分配一个物理页,再建立虚拟页和物理页的映射关系,然后cpu再从原来的页错误产生处重新开始执行指令。

这样的设计可以充分的利用内存,用到什么再加载什么进来,不多做无用功。这就是进程在linux中的加载过程。

虚拟存储管理

前文说了进程的装载过程,没什么毛病,但是大家有没有想过,即使我用到的时候才加载内容进物理内存,省去了大量的内存空间,但是假设我启动了很多进程,内存不够用了,然后会怎么样呢?linux会怎么做呢?

这个就涉及到前面说过的虚拟内存了。一般来说,我们在装系统的时候都会分一个swap分区。swap分区就是处理这种当内存不够用时的情况了。当内存不够用的时候,会发生页置换,linux会根据页置换LRU算法(最近最少使用算法),找到最近最少使用的页,从内存换出,然后再加载当前需要用的内容进内存。

那么问题又来了,是不是所有的页置换都由swap来承担呢?如果是的话,那我们的swap不是要很大?比如说现在很多程序都是10几20G的大小,但内存只有那么4G、8G,那我的swap空间要弄多大才行啊?

有必要全部都用swap做缓存吗?没有。还记得前文介绍VMA的时候给大家介绍的4个VMA区域和它们的一些特点吗?像代码VMA,是只读的,有映像文件;而堆VMA,可读写,没映像文件,是匿名的。既然如此,代码段是只读的,而且有映像文件,那么我们为什么还要把它刷到swap呢?直接刷回磁盘不久好了~

所以结论是:当物理内存不足时,linux会选择一些页面flush回磁盘或swap分区。而swap分区只暂存没有文件背景的页面,即匿名页(anonymous page),如堆,栈,数据段等。而像代码段这些文件页会刷回磁盘的其他分区。

大家不知道有没有过这样的体验,当你在运行一个程序时,想用cp命令或者scp命令从其他地方拷贝一个同名文件到当前运行该程序的目录下,系统会报Text file busy的错误。其实这就是为了保护运行中的程序的一种机制。因为进程的文件页有可能会刷回磁盘该程序的位置或者重新读取到内存,若是允许你随便在运行中修改该文件,万一读到内存了岂不是要乱套了?大家可以验证一下~~

linux分页机制

这部分主要是浅谈一下,因为要深究的话东西是很多的,这里也不打算展开太多。之前看过一篇比较好的文章,说的也很通俗易懂,大家有兴趣可以看看。链接:深入理解计算机系统-之-内存寻址(六)–linux中的分页机制。不想深入的可以看我下面写的。

我这里主要简单说说一些入门概念和为什么要采用多级页表。其他的我就不重复造轮子了。

前面聊了虚拟地址空间,知道了最终是用VMA来描述整个空间的。现在再深入一点点,即使是32位系统,虚拟空间的大小也有4GB,然后我们还有将虚拟空间地址映射到物理地址,这个映射关系肯定是需要一种数据结构来帮忙完成的。那是什么数据结构呢?再linux里,是用页表来进行管理的。

什么是页表?

多个页表项的集合则为页表,一个页表内的所有页表项是连续存放的。页表本质上是一堆数据,因此也是以页为单位存放在主存中的。

什么是页表项?

每个页表项其实就是一个无符号长整型数据(所以在32位系统下,一个页表项占32位,在64位系统下,一个页表项占64位)。每个页表项分两大类信息:页框基地址和页的属性信息。每个页表项的结构图如下(32位系统):

什么是多级页表?为什么要用多级页表?

其实页表要完成的本质任务很简单,就是将线性虚拟地址转换成物理地址。因为每一个进程都有自己独立的虚拟空间,所以对应的,每一个进程都有自己的页表。假设采用一级页表,也就是页表里每一个页表项存储一个物理地址。我们来看看系统是怎么做的(大概思想,实际上系统是不会采用一级页表的)。

首先,32位线性地址划分成table和offset两部分,然后在mm_struct中有一个成员p,存储着页表的基地址,这个基地址是一个物理内存地址,就是页表的真实物理地址。当需要开始转换地址时,将p的值赋给CR3寄存器,指向页表基地址,然后table部分的数值与基址相加,得到一个新的物理地址指向页表中的一个页表项,然后拿到页表项中的值,这个值就是对应的物理页的基址。将这个基址再和offset段的值相加得到最终要访问的物理页里面的地址,拿到想要的数据。这个过程不难理解,好像也没毛病。但仔细想想,32位系统里,虚拟空间有4GB,一个内存页大小为4KB,那么页表项也要有1024*1024个才行,然后一个页表项占32位即4bytes,那么一个页表就要占4*1024*1024bytes即4MB的大小。一个进程的页表就要占4MB,我们的系统会同时运行几十几百个进程,那光页表就把内存占光了,那还得了!

​ 一级页表图

这时,多级页表出来了,用它,就可以有效解决这个问题了。下面时四级页表的模型,也是目前liunx采用的页表模型。可以看到,四级页表将线性地址分成了5个部分,其中四个部分是和页表相关的。转换的原理和上面所说的一级页表转换是一样的,只不过多了几层索引罢了。

​ linux四级页表图

那为什么采用多级页表可以缓解内存占用的问题呢?实际上,linux在进程初始化的时候是不会完整的创建整个多级页表的,他只会创建一个全局目录。当在索引时找不到对应的下级表基地址时,才会创建一个新的下级表,所以大大的节省了内存的空间。如下图所示,当我访问一个一开始没有的地址时,只会创建这么一些这么少的几个表,所以大大节约了内存空间。

​ 四级页表页表创建过程

当然,真正的转换除了软件层面的页表,还需要硬件MMU的帮助~具体还会涉及到体系结构的问题,上面只是说了比较重点的东西。

——END——