可执行目标文件
我们已经知道连接器是如何将多个目标模块合并成一个可执行目标文件的。
此时,我们的 C 程序已经从一组 ASCII 文件文本,转化为一个二进制文件,而且这个二进制文件中包含加载程序到存储器并运行它所需的所有信息。
ELF 可执行目标文件格式
我们可以发现,可执行目标文件类似于可重定位目标文件的格式。
在 ELF 头部描述文件的总体格式,包括程序的入口点(程序运行时要执行的第一条指令的地址)。
init 段定义了一个 _init 函数,程序的初始化代码会调用它。
ELF 可执行文件被设计的很容易加载到存储器,可执行文件的连续的片被映射到连续的存储器段。段头部表描述了这种映射关系。
在这里我们给出一个简单的程序
#include <stdio.h>
int main( void ){
int a = 10;
int b = 20;
int c = a + b;
printf("%d\n", c);
return 0;
}
编译之后我们通过 readelf -S 查看可执行文件的段信息
There are 30 section headers, starting at offset 0x9f8:
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 0000000000400200 00000200 000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.ABI-tag NOTE 000000000040021c 0000021c 0000000000000020 0000000000000000 A 0 0 4
[ 3] .note.gnu.build-i NOTE 000000000040023c 0000023c 0000000000000024 0000000000000000 A 0 0 4
[ 4] .gnu.hash GNU_HASH 0000000000400260 00000260 000000000000001c 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 0000000000400280 00000280 0000000000000060 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 00000000004002e0 000002e0 000000000000003f 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 0000000000400320 00000320 0000000000000008 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000400328 00000328 0000000000000020 0000000000000000 A 6 1 8
[ 9] .rela.dyn RELA 0000000000400348 00000348 0000000000000018 0000000000000018 A 5 0 8
[10] .rela.plt RELA 0000000000400360 00000360 0000000000000030 0000000000000018 A 5 12 8
[11] .init PROGBITS 0000000000400390 00000390 0000000000000018 0000000000000000 AX 0 0 4
[12] .plt PROGBITS 00000000004003a8 000003a8 0000000000000030 0000000000000010 AX 0 0 4
[13] .text PROGBITS 00000000004003e0 000003e0 0000000000000208 0000000000000000 AX 0 0 16
[14] .fini PROGBITS 00000000004005e8 000005e8 000000000000000e 0000000000000000 AX 0 0 4
[15] .rodata PROGBITS 00000000004005f8 000005f8 0000000000000014 0000000000000000 A 0 0 8
[16] .eh_frame_hdr PROGBITS 000000000040060c 0000060c 0000000000000024 0000000000000000 A 0 0 4
[17] .eh_frame PROGBITS 0000000000400630 00000630 000000000000007c 0000000000000000 A 0 0 8
[18] .ctors PROGBITS 00000000006006b0 000006b0 0000000000000010 0000000000000000 WA 0 0 8
[19] .dtors PROGBITS 00000000006006c0 000006c0 0000000000000010 0000000000000000 WA 0 0 8
[20] .jcr PROGBITS 00000000006006d0 000006d0 0000000000000008 0000000000000000 WA 0 0 8
[21] .dynamic DYNAMIC 00000000006006d8 000006d8 0000000000000190 0000000000000010 WA 6 0 8
[22] .got PROGBITS 0000000000600868 00000868 0000000000000008 0000000000000008 WA 0 0 8
[23] .got.plt PROGBITS 0000000000600870 00000870 0000000000000028 0000000000000008 WA 0 0 8
[24] .data PROGBITS 0000000000600898 00000898 0000000000000004 0000000000000000 WA 0 0 4
[25] .bss NOBITS 00000000006008a0 0000089c 0000000000000010 0000000000000000 WA 0 0 8
[26] .comment PROGBITS 0000000000000000 0000089c 0000000000000059 0000000000000001 MS 0 0 1
[27] .shstrtab STRTAB 0000000000000000 000008f5 00000000000000fe 0000000000000000 0 0 1
[28] .symtab SYMTAB 0000000000000000 00001178 0000000000000600 0000000000000018 29 46 8
[29] .strtab STRTAB 0000000000000000 00001778 00000000000001f2 0000000000000000 0 0 1
加载可执行目标文件
要运行一个可执行目标文件, ./+文件名 。
因为这个文件名不是一个内置的命令,所以会认为它是一个可执行目标文件。然后通过调用某个驻留在存储器中称为加载器的操作系统代码来运行它。
任何程序都可以通过调用加载器 execve 函数来调用加载器。
加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令或入口来运行该程序。
这个将程序拷贝到存储器并运行的过程叫做加载。
每个程序都有一个运行时存储器映像,在 32 位 Linux 系统中,代码段总是从地址 0x08048000 处开始。
- 数据段是在接下来的一个 4KB 对齐的地址处。
- 运行时堆在读写段之后接下来的第一个 4KB 对齐的地址处。
- 还有一个段是为共享库保留的。
- 用户栈从最大的 合法用户地址开始,向下增长。
- 从栈的上部开始的段是为操作系统驻留存储器的部分的代码和数据保留的。
当加载器运行时,它将创建如图的存储器映像。在段头部表的指导下,将可执行文件的相关内容拷贝到代码和数据段。
随后,加载器跳转到程序入口点,也就是符号 _start 的地址。
在从 .text段和.init段中调用了初始化例程后。
启动代码调用 atexit 例程,这个程序附加了一系列在应用程序正常终止时应该调用的程序。
exit 函数运行 atexit 注册的函数,然后通过调用 _exit 将控制返回给操作系统。
接着 启动代码调用应用程序的 main 程序,它会开始执行我们的 C 代码,在应用程序返回之后,启动代码调用 _exit 程序,他将控制返回给操作系统。
启动例程的伪码
0x08048000 <_start>:
call __libc_init_first
call _init
call atexit
call main
call _exit