1、进程的虚拟地址空间
每个程序被运行起来后,都将拥有自己的独立虚拟地址空间,虚拟地址空间的大小由 CPU 的位数决定的。比如 32 位的硬件平台决定了虚拟地址空间为 4 GB 大小。一般来说,C 语言指针大小的位数与虚拟空间的位数相同,如 32 位平台下的指针为 32 位, 即 4 字节。64 位平台下的指针为 64 位,即 8 字节。
操作系统会提供一种机制,能够将不同进程的虚拟地址空间与不同内存的物理地址映射起来,这样所有进程都不能直接访问物理内存,都只能访问自己的虚拟内存空间。那 Linux 操作系统下 32 位平台下的 4 GB 虚拟地址空间是怎么分配的呢?
整个 4 GB 的空间,操作系统用了 1 GB,从地址 0XC0000000 到 0XFFFFFFFF, 剩余 3 GB留给用户空间。也就是说整个进程在执行的时候,所有的代码、数据包括申请的堆栈总和不能超过 3 GB。对于占用内存空间较大的进程,3 GB的内存空间实在是偏小,现在大部分的 App 也都支持 64 位系统。当然,早期的时候,并不是人人都能用上 64 位处理器,更何况有很多现在的程序只能运行在32位处理器下。那么 32 位 CPU 的平台能不能使用超过 4GB 的空间呢?这个问题在 “PAE” 一节中有介绍。
上文提到的这3GB的空间“原则上”是可以给进程使用的,但令人遗憾的事,进程并不能完全使用这3GB的虚拟空间,其中有一部分是预留给其他用途的,我们后面还会提到。
2、装载的方式
程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的方法就是将程序运行所需要的指令和数据全都装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的方法。但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决办法就是添加内存,但是内存是稀有资源。人们总是想尽各种办法,在不添加内存的情况下让更多的程序运行起来。后来发现程序运行时是有局部性原理,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想就是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
2.1 覆盖装入
在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了。覆盖装入的方法把挖掘内存嵌入的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉。这个小的辅助代码就是所谓的覆盖管理器(Overlay Manager)。覆盖装入是典型的利用时间换取空间的方法。
如一个程序有主模块 main(1024byte) 会调用到模块 A(512byte) 和模块 B(256byte),但A、B之间不会互调。不考虑内存对齐和装载地址限制,理论上运行这个程序需要1792个byte。如果采用覆盖装入,只需要1536个byte。模块A和B共享内存,main 调 A 时覆盖管理器将 A 从文件中读入内存,调 B 时将 B 从文件读入内存。
2.3 页映射
程序执行时需要的指令和数据必须在内存中才能正常运行,最简单的办法是将程序运行时所需的指令和数据全部加载到内存中,这是最简单的静态载入的办法,但是多数情况下进程所需的内存空间远大于物理内存的空间容量。
目前都是采用动态载入的方式,利用程序局部性原理,用到哪个模块,就将哪个模块装入内存,如果不用就暂不装入,存放在磁盘中。页映射是一种典型的动态装载方法,它不是一下子将程序的所有数据和指令都装入内存,而是将内存和地址空间中的所有数据和指令按照 页(Page)为单位划分为若干页,以后所有的装载和操作都是以页为单位,并且页面的大小一般为 4KB 。
下面我们分析下如何通过页映射将虚拟地址空间映射成物理地址的。
假设程序为 32KB,那么程序总共被分为 8 个页。如上图所示,16KB 的内存无法将 32KB 的程序同时装入。如果程序刚开始执行时的入口地址在 P0,这时装载管理器发现程序 P0 不在内存中,于是将内存 F0 分配给 P0,并且将 P0 的内容装入 F0;运行一段时间后,程序需要用到 P5,于是装载管理器将 P5 装入F1;同样的,当使用到 P3 和 P6 时,它们被分别装入到 F2 和 F3,映射管理即如上图所示。
此时,内存已经被装满,但是若这时程序需要访问 P4,那么装载管理器就必须根据相关的策略来放弃目前正在使用的 4 个内存页中的一个来装载 P4,即页面置换算法如 FIFO、LUR等。
当然上述的装载管理器即是操作系统的存储管理器。目前主流操作系统都是按照这种方式装载可执行文件的。
3、从操作系统角度看可执行文件的装载
3.1 进程的建立
事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间。很多时候一个程序被执行同时都伴随着一个新的进程的创建,进程的创建流程主要为:
- 创建一个独立的虚拟地址空间
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
- 将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行
创建一个独立的虚拟地址空间。我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,创建一个虚拟空间实际上并不是创建空间,而是创建映射函数所需要的相应的数据结构。
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面第一步的页映射关系函数是虚拟空间到物理内存的映射管理,这一步所建立的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才能正常运行。但是当操作系统捕获到缺页错误时,它应该知道程序当前所需要的页在可执行文件中的位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。(由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image))
将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行。 第三步其实也是最简单的一部,操作系统通过设置 CPU 的指令寄存器将控制权交给进程,由进程开始执行。这一步看似简单,实际上在操作系统层面比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU 运行权限的切换。不过从进程的角度看这一步可以简单的认为操作系统执行了一条跳转指令,跳转到可执行文件的入口地址——ELF 文件头中保存的入口地址
3.2 页错误
上面的步骤执行完完,其实可执行文件的真正指令和数据都没有被装入到内存中。操作系统只是通过可执行文件的头部信息建立起可执行文件和进程虚存之间的映射关系而已。
- 当 CPU 试图执行可执行文件的入口地址的指令时,发现该指令所属页面是一个空页面,于是就触发一次页错误。
- 这时候 CPU 将控制权交给操作系统,操作系统有专门的页错误处理例程。这时候我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统将查询这个数据结构,然后找到空页面所在的 VMA,计算出相应页面在可执行文件中的偏移。然后在物理内存中分配一个物理页面,并将可执行文件中的该缺页读取到内存中(刚分配的物理页面)。
- 将进程中该虚拟页与分配的物理页之间建立映射关系,然后操作系统把控制权再还给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断产生,操作系统也会为进程分配相应的物理页面来满足进程的执行需求。当然有可能进程所需的内存会超过可用的内存数量,特别时在有多个进程同时执行的时候,这时候操作系统就需要精心组织和分配物理内存,甚至有时候将已经分配给进程的物理内存暂时回收等,这就涉及了操作系统的虚拟内存管理。这里就不作详细介绍了,有兴趣的读者可以参考相应的操作系统方面的资料。
4、进程虚存空间分布
4.1 ELF文件链接视图和执行视图
当需要映射的段数量增多时,就会产生空间浪费的问题。因为我们知道,ELF 文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余部分也将占用一个页。有没有办法尽量减少这种内存浪费呢?
当我们站在操作系统装载可执行文件的角度看问题,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。ELF 文件中,段的权限往往只有为数不多的几种组合,基本上是三种:
- 以代码段为代表的权限为可读可执行
- 以数据段和 BSS 段位代表的权限为可读可写
- 以只读数据段为代表的权限为只读
那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射。
ELF可执行文件引入了一个概念叫做 “Segment” ,一个 “Segment” 包含一个或多个属性类似的 “Section”。如果将 “.text” 和 “.init” 段合并在一起看作是一个 “Segment”,那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚拟内存空间中只有一个相应的 VMA(Virtual Memory Area),而不是两个,这样做的好处是可以很明显的减少内存碎片,从而节省了内存空间。
我们很难将“Segment”和“Section”这两个词从中文翻译上加以区分。但是很明显,从链接的角度看,ELF文件是按“Section”存储的,事实也的确如此;从装载的角度看,ELF文件又可以按照“Segment”划分。这一点,读者要注意,我们后面不会对 Segment 作翻译。
“Segment” 的概念实际上是从装载的角度重新划分了 ELF 的各个段。在将多个目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。在 ELF 文件中把这些属性相似、又连在一起的段叫做一个 “Segment”,而系统正是按照 “Segmen” 而不是 “Section” 来映射可执行文件的。
下面以一个例子来讲解。
#include <stdlib.h>
int main()
{
while(1) {
sleep(1000);
}
return 0;
}
我们以静态链接的方式将其编译成可执行文件。
liang@liang-virtual-machine:~/cfp$ gcc -static SectionMapping.c -o SectionMapping.elf
使用 readelf 可以看到,这个可执行文件中总共有33个段(Section)
$readelf -S SectionMapping.elf
There are 33 section headers, starting at offset 0x74594:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .note.ABI-tag NOTE 080480d4 0000d4 000020 00 A 0 0 4
[ 2] .init PROGBITS 080480f4 0000f4 000017 00 AX 0 0 4
[ 3] .text PROGBITS 08048110 000110 055948 00 AX 0 0 16
[ 4] __libc_freeres_fn PROGBITS 0809da60 055a60 000a8b 00 AX 0 0 16
[ 5] .fini PROGBITS 0809e4ec 0564ec 00001c 00 AX 0 0 4
[ 6] .rodata PROGBITS 0809e520 056520 0169e8 00 A 0 0 32
[ 7] __libc_subfreeres PROGBITS 080b4f08 06cf08 00002c 00 A 0 0 4
[ 8] __libc_atexit PROGBITS 080b4f34 06cf34 000004 00 A 0 0 4
[ 9] .eh_frame PROGBITS 080b4f38 06cf38 003a0c 00 A 0 0 4
[10] .gcc_except_table PROGBITS 080b8944 070944 0000a1 00 A 0 0 1
[11] .tdata PROGBITS 080b99e8 0709e8 000010 00 WAT 0 0 4
[12] .tbss NOBITS 080b99f8 0709f8 000018 00 WAT 0 0 4
[13] .ctors PROGBITS 080b99f8 0709f8 000008 00 WA 0 0 4
[14] .dtors PROGBITS 080b9a00 070a00 00000c 00 WA 0 0 4
[15] .jcr PROGBITS 080b9a0c 070a0c 000004 00 WA 0 0 4
[16] .data.rel.ro PROGBITS 080b9a10 070a10 00002c 00 WA 0 0 4
[17] .got PROGBITS 080b9a3c 070a3c 000008 04 WA 0 0 4
[18] .got.plt PROGBITS 080b9a44 070a44 00000c 04 WA 0 0 4
[19] .data PROGBITS 080b9a60 070a60 000720 00 WA 0 0 32
[20] .bss NOBITS 080ba180 071180 001ad4 00 WA 0 0 32
[21] __libc_freeres_pt NOBITS 080bbc54 071180 000014 00 WA 0 0 4
[22] .comment PROGBITS 00000000 071180 002df0 00 0 0 1
[23] .debug_aranges PROGBITS 00000000 073f70 000058 00 0 0 8
[24] .debug_pubnames PROGBITS 00000000 073fc8 000025 00 0 0 1
[25] .debug_info PROGBITS 00000000 073fed 0001ad 00 0 0 1
[26] .debug_abbrev PROGBITS 00000000 07419a 000066 00 0 0 1
[27] .debug_line PROGBITS 00000000 074200 00013d 00 0 0 1
[28] .debug_str PROGBITS 00000000 07433d 0000bb 01 MS 0 0 1
[29] .debug_ranges PROGBITS 00000000 0743f8 000048 00 0 0 8
[30] .shstrtab STRTAB 00000000 074440 000152 00 0 0 1
[31] .symtab SYMTAB 00000000 074abc 007ab0 10 32 898 4
[32] .strtab STRTAB 00000000 07c56c 006e68 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
我们可以使用 readelf 命令来查看 ELF 的 “Segment”。正如描述 “Section” 属性的结构叫做段表,描述 “Segment” 的结构叫做程序头(Program Header),它描述了 ELF 文件该如何被操作系统映射到进程的虚拟空间:
$ readelf -l SectionMapping.elf
Elf file type is EXEC (Executable file)
Entry point 0x8048110
There are 5 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x709e5 0x709e5 R E 0x1000
LOAD 0x0709e8 0x080b99e8 0x080b99e8 0x00798 0x02280 RW 0x1000
NOTE 0x0000d4 0x080480d4 0x080480d4 0x00020 0x00020 R 0x4
TLS 0x0709e8 0x080b99e8 0x080b99e8 0x00010 0x00028 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00 .note.ABI-tag .init .text __libc_freeres_fn .fini .rodata __libc_subfreeres __libc_atexit .eh_frame .gcc_except_table
01 .tdata .ctors .dtors .jcr .data.rel.ro .got .got.plt .data .bss __libc_freeres_ptrs
02 .note.ABI-tag
03 .tdata .tbss
04
我们可以看到,这个可执行文件中共有5个 Segment。从装载的角度看,我们目前只关心两个 “LOAD” 类型的 Segment,因为只有它是需要被映射的,其他的诸如 “NONE”、“TLS” 都是在装载时起辅助作用的,我们这里不再展开。可以用下图来表示 “SectionMapping.elf” 可执行文件的段与进程虚拟空间的映射关系,这里主要是根据可执行文件中各个 section 的 offset,以及程序头中各个的 segment 的 offset 来判断的。
所以总的来说,“Segment” 和 “Section” 是从不同角度来划分同一个 ELF 文件。这个在 ELF 中被称为不同的视图(view)。从“Section”角度来看 ELF 文件就是链接视图(Linking View),从“Segment”角度来看就是执行试图(Execution View)。
ELF 可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table),用来保存 Segment 的信息。因为 ELF 目标文件不需要被装载,所以它没有程序头表,而 ELF 的可执行文件和共享库文件都有。跟段表结构一样,程序头表也是一个结构体数组,它的结构体如下:
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
typedef struct {
Elf64_Word p_type;
Elf64_Word p_flags;
Elf64_Off p_offset;
Elf64_Addr p_vaddr;
Elf64_Addr p_paddr;
Elf64_Xword p_filesz;
Elf64_Xword p_memsz;
Elf64_Xword p_align;
} Elf64_Phdr;
成员 | 含义 |
---|---|
p_type | 此数组元素描述的段类型,基本上我们在这里只关注 “LOAD” 类型的 “Segment”。“LOAD”类型的常量为1。还有几个类型诸如“DYNAMIC” 、“INTERP” 等我们在介绍 ELF 动态链接时还会碰到 |
p_offset | “Segment” 内容在文件中的偏移 |
p_vaddr | “Segment” 的第一个字节在进程虚拟地址空间的起始位置(只是一个偏移)。整个程序头表中,所有 “LOAD” 类型的元素按照 p_vaddr 从小到大排列。 |
p_paddr | “Segment” 的物理装载地址。p_paddr 一般和 p_vaddr 是一样的 |
p_filesz | “Segment” 内容在ELF文件中所占空间的长度。它的值可能是0,因为有可能这个“Segment”在ELF文件中不存在内容 |
p_memsz | “Segment” 在进程虚拟地址空间所占用的长度。它的值也可能为0 。 |
p_flags | “Segment” 的权限属性,比如可读“R”、可写“W”和可执行“X”。 |
p_align | “Segment” 的对齐属性。实际对齐字节等于 2的 p_align 次。 |
对于 LOAD 类型的 Segment 来说,p_memsz 的值不可以小于 p_filesz,否则就是不符合常理的。如果 p_memsz 大于p_filesz,表示该 Segment 在内存中所分配的空间大小超过文件中实际的大小,多余的部分全部填充为0。这样在构造 ELF 可执行文件时不需要再额外设立 BSS 的 Segment,可以把 数据 Segment 的 p_memsz 扩大,额外的部分就是 BSS。如前面例子中,BSS 就已经被合并到了数据类型的段中。
4.2 堆和栈
操作系统使用 VMA 管理进程的地址空间。例如程序的堆(heap),栈(stack)在虚拟空间中也是以 VMA 的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的 VMA。在 Linux 下可以通过/proc来查看进程的虚拟空间分布:
$ ./SectionMapping.elf &
[1] 21963
$ cat /proc/21963/maps
08048000-080b9000 r-xp 00000000 08:01 2801887 ./SectionMapping.elf
080b9000-080bb000 rwxp 00070000 08:01 2801887 ./SectionMapping.elf
080bb000-080de000 rwxp 080bb000 00:00 0 [heap]
bf7ec000-bf802000 rw-p bf7ec000 00:00 0 [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]
上面的输出结果中:第一列是 VMA 的地址范围;第二列是 VMA 的权限,“r”表示可读,“w”表示可写,“x”表示可执行,p表示私有(COW, Copy on Write),s表示共享。第三列是偏移,表示 VMA 对应的 Segment 在映像文件中的偏移;第四列表示映像文件所在设备的主设备号和次设备号;第五列表示映像文件的节点号。最后一列是映像文件的路径。
前两个 VMA 映射到可执行文件中的 Segment。另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,表示它们没有映射到文件中,这种 VMA 叫做匿名虚拟内存区域。其中两个区域分别是堆(Heap)和栈(Stack)。还有一个 VMA 叫vdso,地址位于内核空间(即大于0xc0000000),是一个内核模块,进程可以通过访问这个 VMA 跟内核进行通信。
操作系统通过给进程空间划分出一个个 VMA 来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几种 VMA 区域:
- 代码VMA,权限只读、可执行;有映像文件。
- 数据VMA,权限可读写、可执行;有映像文件。
- 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
- 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。
/proc目录里面看到的 VMA2 的结束地址跟预测的不一样,按照 readelf -S 的输出计算应该是0x080bc000,但实际上是0x080bb000。这是因为 Linux 在装载 ELF 文件时实现了一种 Hack 的做法,因为 Linux 的进程虚拟空间管理的 VMA 的概念并非与 Segment 完全对应,Linux 规定一个 VMA 可以映射到某个文件的一个区域,或者是没有映射到任何文件;而我们这里的第二个 Segment 要求是,前面部分映射到文件中,而后面一部分不映射到任何文件,直接为0,也就是说前面的从 .tdata 段到 .data 段部分要建立从虚拟空间到文件的映射,而 .bss 和 __libcfreeres_ptrs 部分不要映射到文件。这样这两个概念就不完全相同了,所以 Linux 实际上采用了一种取巧的办法,它在映射完第二个 Segment 之后,把最后一个页面的剩余部分清0,然后调用内核中的 do_brk(),把 .bss 和 __libcfreeres_ptrs 的剩余部分放到堆段中。有兴趣的读者可以阅读位于 Linux 内核源代码 fs/Binfmt_elf.c 中的 load_elf_interp() 和 elf_map() 两个函数。
4.3 段地址对齐
可执行文件最终是要被操作系统装载运行的,这个装载过程一般是通过虚拟内存的页映射机制完成的。对于Intel 80x86系列处理器来说,默认的页大小为4096字节。在物理内存和进程虚拟地址空间之间建立映射关系时,内存空间的长度必须是4096的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是4096的整数倍。由于有着长度和起始地址的限制,对于可执行文件来说,它应该尽量地优化自己的空间和地址的安排,以节省空间。
假设有一个ELF可执行文件,它有三个段(Segment)需要装载。如下表:
最简单的映射办法是每个段分开映射,对于长度不足一个页的部分则占一个页。按照这样的映射方式,各个段的虚拟地址和长度如下图
三个段的总长度只有12014字节,却占据了5个页,即20480字节,空间使用率只有58.6%。
为了解决这种问题,UNIX系统让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次。而且将 ELF 的文件头也看作是系统的一个段,将其映射到进程的地址空间,这样做的好处是进程中的某一段区域就是整个 ELF 文件的映像,对于一些须访问 ELF 文件头的操作(比如动态链接器就须读取 ELF 文件头)可以直接通过读写内存地址空间进行。从某种角度看,好像是整个 ELF 文件从文件最开始到某个点结束,被逻辑上分成了以4096字节为单位的若干个块,每个块都被装载到物理内存中,对于那些位于两个段中间的块,它们将会被映射两次。如下图所示:
因为段地址对齐的关系,各个段的虚拟地址就往往不是系统页面长度的整数倍了。比如在 SectionMapping.elf 的例子中,为什么 VMA1 的起始地址是0x080B99E8?而不是0x080B89E8或干脆是0x080B9000?
VMA0 的起始地址是0x08048000,长度是0x709E5,所以它的结束地址是0x080B89E5。而 VMA1 因为跟 VMA0 的最后一个虚拟页面共享一个物理页面,并且映射两遍,所以它的虚拟地址应该是0x080B99E5,又因为段必须是4字节的倍数(为什么是4字节的倍数呢?),则向上取整至0x080B99E8。
这里插一句,为什么段必须是4字节的倍数呢?假设计算机的字大小为4个字节,CPU只能对4的倍数的地址进行读取。合理的内存对齐可以高效的利用硬件性能。
根据上面的段对齐方案推算出一个规律:在ELF文件中,对于任何一个可装载的Segment,p_vaddr 与 p_offset 关于对齐属性(页大小)同余。
4.4 进程栈初始化
操作系统在进程启动前会将跟进程运行环境相关的信息提前保存到进程的虚拟空间的栈中(也就是 VMA 中的 Stack VMA),如系统环境变量和进程的运行参数。 假设系统中有两个环境变量:HOME=/home/user;PATH=/usr/bin。运行该程序的命令行是:$ prog 123并且假设堆栈段底部地址为0xBF802000,进程初始化后的堆栈如图所示:
栈顶寄存器 esp 指向的位置是初始化以后堆栈的顶部,最前面的4个字节表示命令行参数的数量,紧接的就是指向参数字符串的指针;后面跟了一个0;接着是两个指向环境变量字符串的指针,后面紧跟一个0表示结束。进程启动以后,系统库会把堆栈里的初始化信息中的参数信息传递给main()函数,即argc和argv。
5、Linux内核装载ELF过程简介
当我们在 Linux 系统的 bash 下输入一个命令执行某个 ELF 程序时,Linux 系统是怎样装载这个 ELF 文件并且执行它的呢?
首先在用户层面,bash 进程会调用 fork() 系统调用创建一个新的进程,然后新的进程调用 execve() 系统调用执行指定的 ELF文件,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。execve()系统调用被定义在unistd.h,它的原型如下:
int execve(const char *filename, char *const argv[], char *const envp[]);
它的三个参数分别是被执行的程序文件名、执行参数和环境变量。
在进入 execve() 系统调用之后,Linux 内核就开始进行真正的装载工作。在内核中,execve() 系统调用相应的入口是sys_execve(),它被定义在 arch\i386\kernel\Process.c。sys_execve() 进行一些参数的检查复制之后,调用 do_execve()。do_execve() 会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节,其目的是判断文件的格式,比如a.out、JAVA程序和以 “#!” 开始的脚本程序等。这里的 do_execve() 读取文件的前128个字节的目的是判断文件的格式,没有可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称为魔数,通过对魔数的判断可以确定文件的格式和类型。比如 ELF 的可执行文件的头4个字节为0x7F、‘e’、‘l’、‘f’;而 Java 的可执行文件格式的头4个字节为‘c’、‘a’、‘f’、‘e’;如果被执行的是 Shell 脚本或 perl、python 等这种解释型语言脚本,那么它的第一行往往是“#!/bin/sh”或“#!/usr/bin/perl”或“#!/usr/bin/python”,这时候,前两个字节“#”和“!”就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序路径。
当 do_execve() 读取文件的前128个字节文件头部后,然后调用 search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程。Linux 中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle() 会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如 ELF 可执行文件的装载处理过程叫做load_elf_binary();a.out可执行文件的装载处理过程叫做 load_aout_binary();而装载可执行脚本程序的处理过程叫做 load_script()。load_elf_binary() 被定义在fs/Binfmt_elf.c,这个函数的代码比较长,它的主要步骤是:
- 检查 ELF 可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
- 寻找动态链接的 “.interp” 段,设置动态链接器路径。
- 根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射,比如代码、数据、只读数据。
- 初始化 ELF 进程环境,比如进程启动时 EDX 寄存器的地址应该是 DT_FINI 的地址(参照动态链接)。
- 将系统调用的返回地址修改成 ELF 可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的 ELF 可执行文件,这个程序入口就是 ELF 文件的文件头中 e_entry 所指的地址;对于动态链接的 ELF 可执行文件,程序入口点是动态链接器。
当 load_elf_binary() 执行完毕,返回至 do_execve() 再返回至 sys_execve() 时,上面的第5步中已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址了。所以当 sys_execve() 系统调用从内核态返回到用户态时,EIP 寄存器直接跳转到了 ELF 程序的入口地址,于是新的程序开始执行,ELF 可执行文件装载完成。