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