开篇先抛出几个问题,之后逐个击破:
什么是进程的虚拟地址空间?为什么进程要有自己的虚拟地址空间,这样做有什么好处?
我们都听说过页映射,什么是页映射,操作系统为什么要以页映射方式将程序映射到进程地址空间,这样做有什么好处?程序运行过程中发生页错误如何处理?
什么是进程?从操作系统的角度来看,进程是如何被建立的?
进程虚拟地址空间的分布是什么样的?
Linux是如何装载并运行ELF程序的?
虚拟地址空间
what:虚拟地址空间就是我们常说的虚拟内存,虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存的使用也更有效率。当处理器读取或写入内存位置时,都会使用虚拟地址。在读取或写入操作过程中,处理器会将虚拟地址转换为物理地址。
why:使用虚拟内存有如下好处:
程序员无需操心如何存储数据或者程序等内容。
程序可以使用一系列连续的虚拟地址来访问物理内存中不连续的大内存区域,用户看到的是连续地址,而无需关心更底层物理地址的排布。
通过使用虚拟内存,程序可以使用大于实际可用物理内存的空间,当物理内存不够用时,操作系统会将物理内存页保存在磁盘文件,数据页或者代码页会根据需要在物理内存和磁盘之间移动。
不同进程使用的虚拟地址彼此隔离,用户无需担心会影响到其它程序内存地址中的数据,操作系统的内存管理模块会将虚拟地址映射到物理地址。
更多详解虚拟内存的内容可以看我之前的文章:
页映射
background:程序运行时所需要的指令和数据必须放在内存中才可以正常执行,最简单的办法就是将运行所需要的指令和数据全部装进内存,但是很多时候程序需要的内存可能大于实际可用的物理内存,为了解决这种不够用的问题引入了动态装入的概念,可以将程序最常用的部分驻留在内存中,而将一些不常用的数据存在磁盘中。
what:页映射不是一次性将所有的程序和数据装入内存,而是将内存和磁盘中的数据和指令按页为单位分成若干份,以后所有的装载和操作的单位就是页。页的大小不固定,但是一般都是4096字节。
how:如下图,举个例子,可执行程序所需要的指令和数据总和占8个页,编号为VP0-VP7,而实际的物理内存只有4个页,编号为PP0-PP3,4个页的物理内存无法同时将8个页的程序都装载进去,所以需要动态装入,假设程序入口地址在VP0,这时内核发现VP0不在内存中,所以将VP0分配给了PP0,将VP0的内容装入了PP0,运行一段后程序需要用到VP2,内核又将VP2分配给了PP1,之后又用到VP4和VP6,内核又分别分配给了PP2和PP3。这时候程序只需要VP0、VP2、VP4和VP6这四个页就可以一直运行下去,如果程序又需要VP5,那内核就必须会放弃正在使用的四个内存页中的一个才可以把VP5装载进去继续执行,至于选择哪个,操作系统内核会有多种换出算法来处理这种问题。
why:其实上面已经介绍了原因,如果一次性把所有指令和数据都加载到内存中,物理内存可能不够用,所以需要使用动态装入,所以引入了页映射的方法。
进程如何被建立
这里首先需要弄清楚程序和进程的区别?程序(可执行文件)是一个静态的概念,它就是预先编译好的指令和数据集合的一个文件,进程则是一个动态的概念,它是程序运行的一个过程。
从操作系统角度看,一个进程最关键的特征是它拥有独立的虚拟地址空间,很多时候一个程序被执行都伴随着一个新的进程被创建,之后装载相应的可执行文件并运行。上述经历了什么步骤?
创建一个独立的虚拟地址空间:这里的创建空间并不是真正的创建空间,而是创建映射函数所需要的数据结构,方便后面映射需要。
读取可执行文件头,建立虚拟空间和可执行文件的映射关系:上面的映射数据结构是为了建立虚拟空间到物理内存的映射关系,这一步是虚拟空间与可执行文件的映射关系。
将CPU的指令寄存器设置成可执行文件入口,启动运行:这里可以简单的理解为操作系统执行了一条跳转指令,跳转到可执行文件的入口地址。
页错误:当程序执行一个地址的指令时,发现是个空页面,所以就认为是个页错误,这时候控制权交由操作系统,操作系统有专门的错误处理程序处理这种情况,查询第二步骤建立的映射数据结构,找到空页面所在的虚拟内存区域,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中的该虚拟页与物理页建立映射关系,控制权返还给进程,进程从页错误的位置继续执行。
进程虚拟空间分布
如果您读过我之前的文章应该就知道,一个正常的进程,可执行文件中不只包含数据段和代码段,还有好多个段,这里通过readelf可以查看:
$ readelf -S test
There are 9 section headers, starting at offset 0x1208:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 00000000004000e8 000000e8
0000000000000056 0000000000000000 AX 0 0 1
[ 2] .rodata PROGBITS 000000000040013e 0000013e
0000000000000006 0000000000000000 A 0 0 1
[ 3] .eh_frame PROGBITS 0000000000400148 00000148
0000000000000078 0000000000000000 A 0 0 8
[ 4] .data PROGBITS 0000000000601000 00001000
0000000000000008 0000000000000000 WA 0 0 8
[ 5] .comment PROGBITS 0000000000000000 00001008
0000000000000029 0000000000000001 MS 0 0 1
[ 6] .symtab SYMTAB 0000000000000000 00001038
0000000000000150 0000000000000018 7 7 8
[ 7] .strtab STRTAB 0000000000000000 00001188
000000000000003a 0000000000000000 0 0 1
[ 8] .shstrtab STRTAB 0000000000000000 000011c2
0000000000000042 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific
通过上面的结果可以看出这里的段叫Section,拿这个举例,ELF文件映射是以系统页为单位,每个段在映射时的长度都是系统页的整数倍,假设程序有8个段,每个段都占512字节,占用了8个页,但是一个页却有4K的大小,空间利用率只有1/8,造成极大的空间浪费。实际上,从操作系统装载可执行文件的角度看,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,它主要就是关心段的权限(可读、可写、可执行),ELF文件中段的权限主要就有几种组合:
以代码段为代表的权限为可读可执行的段
以数据段和BSS段位代表的权限为可读可写的段
以只读数据段位代表的权限为只读的段
对于相同权限的段,可以把它们(Section)合并到一起当作一个段(Segment)进行映射。拿前面的例子,之前8个section需要8个页,而这种方式8个Section可能会被合并成2个Segment,占用2个页。
Segment的概念实际上是从装载的角度重新划分了ELF的各个段,在将目标文件链接成可执行文件的时候,链接器尽量把相同权限属性的段分配在同一空间,多个Section变成一个Segment,而系统就是按这种Segment来映射可执行文件的。
Segment和Section是从不同的角度划分一个ELF文件,称为不同的视图,从Section的角度来看ELF文件就是链接视图,从Segment的角度来看ELF文件就是执行视图,当我们在谈到ELF装载时,段专门指Segment,其它情况下,段指的是Section。
通过readelf命令可以查看可执行文件的Segment。
$ readelf -l test
Elf file type is EXEC (Executable file)
Entry point 0x400123
There are 3 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x00000000000001c0 0x00000000000001c0 R E 0x200000
LOAD 0x0000000000001000 0x0000000000601000 0x0000000000601000
0x0000000000000008 0x0000000000000008 RW 0x200000
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
Section to Segment mapping:
Segment Sections...
00 .text .rodata .eh_frame
01 .data
02
这里有很少的Segment,描述Segment属性的结构叫程序头,这里只需要知道ELF可执行文件中有一个专门的数据结构叫程序头表,它用来保存Segment的信息,因为目标文件不需要被装载,所以没有程序头表,而可执行文件和共享库文件都有头表,他们会被用于装载,这里的各个Segment都是通过匿名虚拟内存区域(VMA)来映射。
可以通过cat来查看VMA:
cat /proc/72/maps7f445f4b0000-7f445f4c7000 r-xp 00000000 00:00 516559 /lib/x86_64-linux-gnu/libgcc_s.so.17f445f4c7000-7f445f4c8000 ---p 00017000 00:00 516559 /lib/x86_64-linux-gnu/libgcc_s.so.17f445f4c8000-7f445f6c6000 ---p 00000018 00:00 516559 /lib/x86_64-linux-gnu/libgcc_s.so.17f445f6c6000-7f445f6c7000 r--p 00016000 00:00 516559 /lib/x86_64-linux-gnu/libgcc_s.so.17f445f6c7000-7f445f6c8000 rw-p 00017000 00:00 516559 /lib/x86_64-linux-gnu/libgcc_s.so.17f445f6d0000-7f445f86d000 r-xp 00000000 00:00 516611 /lib/x86_64-linux-gnu/libm-2.27.so7f445f86d000-7f445f870000 ---p 0019d000 00:00 516611 /lib/x86_64-linux-gnu/libm-2.27.so7f445f870000-7f445fa6c000 ---p 000001a0 00:00 516611 /lib/x86_64-linux-gnu/libm-2.27.so7f445fa6c000-7f445fa6d000 r--p 0019c000 00:00 516611 /lib/x86_64-linux-gnu/libm-2.27.so7f445fa6d000-7f445fa6e000 rw-p 0019d000 00:00 516611 /lib/x86_64-linux-gnu/libm-2.27.so7f445fa70000-7f445fc57000 r-xp 00000000 00:00 516391 /lib/x86_64-linux-gnu/libc-2.27.so7f445fc57000-7f445fc60000 ---p 001e7000 00:00 516391 /lib/x86_64-linux-gnu/libc-2.27.so7f445fc60000-7f445fe57000 ---p 000001f0 00:00 516391 /lib/x86_64-linux-gnu/libc-2.27.so7f445fe57000-7f445fe5b000 r--p 001e7000 00:00 516391 /lib/x86_64-linux-gnu/libc-2.27.so7f445fe5b000-7f445fe5d000 rw-p 001eb000 00:00 516391 /lib/x86_64-linux-gnu/libc-2.27.so7f445fe5d000-7f445fe61000 rw-p 00000000 00:00 07f445fe70000-7f445ffe9000 r-xp 00000000 00:00 540399 /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.257f445ffe9000-7f445fff6000 ---p 00179000 00:00 540399 /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.257f445fff6000-7f44601e9000 ---p 00000186 00:00 540399 /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.257f44601e9000-7f44601f3000 r--p 00179000 00:00 540399 /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.257f44601f3000-7f44601f5000 rw-p 00183000 00:00 540399 /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.257f44601f5000-7f44601f9000 rw-p 00000000 00:00 07f4460200000-7f4460226000 r-xp 00000000 00:00 516353 /lib/x86_64-linux-gnu/ld-2.27.so7f4460226000-7f4460227000 r-xp 00026000 00:00 516353 /lib/x86_64-linux-gnu/ld-2.27.so7f4460427000-7f4460428000 r--p 00027000 00:00 516353 /lib/x86_64-linux-gnu/ld-2.27.so7f4460428000-7f4460429000 rw-p 00028000 00:00 516353 /lib/x86_64-linux-gnu/ld-2.27.so7f4460429000-7f446042a000 rw-p 00000000 00:00 07f4460440000-7f4460442000 rw-p 00000000 00:00 07f4460450000-7f4460452000 rw-p 00000000 00:00 07f4460460000-7f4460462000 rw-p 00000000 00:00 07f4460600000-7f4460601000 r-xp 00000000 00:00 205513 /mnt/d/wzq/wzq/util/test/a.out7f4460800000-7f4460801000 r--p 00000000 00:00 205513 /mnt/d/wzq/wzq/util/test/a.out7f4460801000-7f4460802000 rw-p 00001000 00:00 205513 /mnt/d/wzq/wzq/util/test/a.out7fffc7576000-7fffc7597000 rw-p 00000000 00:00 0 [heap]7fffcf186000-7fffcf986000 rw-p 00000000 00:00 0 [stack]7fffcfe84000-7fffcfe85000 r-xp 00000000 00:00 0 [vdso]
上面的我们可以先忽略,看最下面,主要有堆和栈,这两个VMA几乎在所有的进程中都存在,而[vdso]是一个内核模块,程序通过这个模块和内核进行通信。
小总结:
图片来自网络,侵权删
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映射文件的映射成一个VMA,一个进程主要可以分成以下几种VMA区域:
代码VMA:权限只读可执行,有映射文件
数据VMA:权限可读写可执行,有映射文件
堆VMA:权限可读写可执行,无映射文件,匿名,向上扩展
栈VMA:权限可读写不可执行,无映射文件,匿名,向下扩展
Linux如何装载并运行ELF程序
Linux内核装载ELF文件主要有两步:
通过fork系统调用创建一个新的进程
通过execve系统调用执行指定的ELF文件,附带环境变量和参数
检查ELF可执行文件的有效性,比如魔数(通过魔数可以确定文件格式)、Segment的数量等
寻找动态链接的段,设置动态链接器路径
根据ELF可执行文件的程序头表描述,对ELF文件进行映射,比如代码、数据、只读数据
初始化ELF进程环境
将系统调用的返回地址修改为ELF可执行文件的入口地址