机器执行的是机器指令,而机器指令就是一堆二进制的数字。高级语言编写的程序之所以可以在
不同的机器上移植就因为有为不同机器设计的编译器的存在。高级语言的编译器就是把高级语言写的程序转换成某个机器能直接执行的二进制代码。以上的知识在我
们学习CS(Computer
Science)的初期,老师都会这么对我们讲。但是我就产生疑问了:既然机器都是执行的二进制代码,那么是不是说只要硬件相互兼容,不同操作系统下的可
执行文件可以互相运行呢?答案肯定是不行。这就要谈到可执行文件的格式问题。
每个操作系统都会有自己的可执行文件的格式,比如以前的Unix®是用a.out格式的,现代的Unix®类系统使用elf格式,
WindowsNT®是使用基于COFF格式的可执行文件。那么最简单的格式应该是DOS的可执行格式,严格来说DOS的可执行文件没有什么格式可言,就
是把二进制代码安顺序放在文件里,运行时DOS操作系统就把所有控制计算机的权力都给了这个程序。这种方式的不足之处是显而易见的,所以现代的操作系统都
有一种更好的方式来定义可执行文件的格式。一种常见的方法就是为可执行文件分段,一般来说把程序指令的内容放在.text段中,把程序中的数据内容放在.
data段中,把程序中未初始化的数据放在.bss段中。这种做法的好处有很多,可以让操作系统内核来检查程序防止有严重错误的程序破坏整个运行环境。比
如:某个程序想要修改.text段中的内容,那么操作系统就会认为这段程序有误而立即终止它的运行,因为系统会把.text段的内存标记为只读。在.
bss段中的数据还没有初始化,就没有必要在可执行文件中浪费储存空间。在.bss中只是表明某个变量要使用多少的内存空间,等到程序加载的时候在由内核
把这段未初始化的内存空间初始化为0。这些就是分段储存可执行文件的内容的好处。
下面谈一下Unix系统里的两种重要的格式:a.out和elf(Executable and Linking
Format)。这两种格式中都有符号表(symbol
table),其中包括所有的符号(程序的入口点还有变量的地址等等)。在elf格式中符号表的内容会比a.out格式的丰富的多。但是这些符号表可以用
strip工具去除,这样的话这个文件就无法让debug程序跟踪了,但是会生成比较小的可执行文件。a.out文件中的符号表可以被完全去除,但是
elf中的在加载运行是起着重要的作用,所以用strip永远不可能完全去除elf格式文件中的符号表。但是用strip命令不是完全安全的,比如对未连
接的目标文件来说如果用strip去掉符号表的话,会导致连接器无法连接。例如:
代码:
$:gcc -c hello.c $:ls hello.c hello.o
用gcc把hello.c编译成目标文件hello.o
代码:
$:strip hello.o
用strip去掉hello.o中的符号信息。
代码:
$:gcc hello.o /usr/lib/gcc/i686-pc-linux-gnu/3.4.5/../../../crt1.o: In
function `_start': init.c: (.text+0x18) : undefined reference to `main'
collect2: ld returned 1 exit status
再用gcc连接时,连接器ld报错。说明在目标文件中的符号起着很重要的作用,如果要发布二进制的程序的话,在debug后为了减小可执行文件的大小,可以用strip来除去符号信息但是在程序的调试阶段还是不要用strip为好。
在接下去讨论以前,我们还要来讲讲relocations的概念:首先有个简单的程序hello.c
代码:
$:cat hello.c main( ) { printf("Hello World\n"); }
当我们把hello.c编译为目标文件时,我们并没有在源文件中定义printf这个函数,所以汇编器也不知道printf这个函数的具体的地址,所以在
目标文件中就会留下printf这个符号。以下的工作就交给连接器了,连接器会找到这个函数的入口地址然后传递给这个文件最终形成可执行文件。这个过程就
叫做relocations。a.out格式的可执行文件是没有这种relocation的功能的,内核不会执行其中还有未知函数的入口地址的可执行文件
的。在目标文件中当然可以relocation,只不过连接器需要把未知函数的入口地址完全找到,生成可执行文件才行。这样就有一个很尴尬的问题,在
a.out格式中极其难以实现动态连接技术。要知道为什么现在的Unix几乎都是用的elf格式的可执行文件就要了解a.out格式的短处。
a.out的符号是极其有限的,在/usr/include/linux/asm/a.out.h中定义了一个结构exec就是:
代码:
struct exec { unsigned long a_info; /*Use macros N_MAGIC, etc for
access */ unsigned a_text; /* length of text, in bytes */ unsigned
a_data; /* length of data, in bytes */ unsigned a_bss; /* length of
uninitialized data area for file, in bytes*/ unsigned a_syms; /* length
of symbol table data in file, in bytes */ unsigned a_entry; /* start
address */ unsigned a_trsize; /*length of relocation info for text, in
bytes */ unsigned a_drsize; /*length of relocation info for data, in
bytes */ };
在这个结构中更本没有指示每个段在文件中的开始位置,内核加载器具有一些非正式的方法来加载可执行文件的。明显的,a.out 是不支持动态连接的。(在内部不支持动态连接,用某些技术也是可以实现a.out的动态连接)
要了解elf可执行文件的运行方式,我们有必要讨论一下动态连接技术。很多人对动态连接技术十分熟悉,但是很少有人真正了解动态连接的内部工作方式。回想
没有动态连接的日子,程序员写程序时不用什么都从头开始,他们可以调用定义的很好的函数,然后再用连接器与函数库连接。这样的话使得程序员更加有效率,但
是一个十分重要的问题出现了:这样产生的可执行文件就会很大。因为连接器把程序需要用的所有函数的代码都复制到了可执行文件中去了。这种连接方式就是所谓
的静态连接,与之相对的就是动态连接。连接器在可执行文件中标记出程序调用外部函数的位置,并不把代码复制进去,只是标出函数在动态连接库中的位置。用这
样的方式生成的特殊可执行文件就是动态连接的。在运行这种动态程序时,系统在运行时把该程序调用的外部函数地址映射到程序地址,这就是所谓的动态连接,系
统就有一个程序叫做动态连接器,在动态连接的程序执行前都要先把地址映射好。很显然的,必须有一种机制保证动态连接的程序中的函数地址正确地指向了动态连
接库的某个函数地址。这就需要讨论一下elf可执行文件格式处理动态连接的机制了。
elf的动态连接库是内存位置无关的,就是说你可以把这个库加载到内存的任何位置都没有影响。这就叫做position
independent。而a.out的动态连接库是内存位置有关的,它一定要被加载到规定的内存地址才能工作。在编译内存位置无关的动态连接库时,要给
编译器加上
-fpic选项,让编译器产生的目标文件是内存位置无关的还会尽量减少对变量引用时使用绝对地址。把库编译成内存位置无关会带来一些花费,编译器会保留一
个寄存器来指向全局偏移量表(global offset table (or GOT for
short)),这就会导致编译器在优化代码时少了一个寄存器可以使用,但是在最坏的情况下这种性能的减少只有3%,在其他情况下是大大小于3%的。
Elf的另一个特点是它的动态连接库是在运行时处理符号的,这是通过用符号表和再布置(relocation)表来实现的。在载入文件时并不能立即执行,
要在处理完符号表把所有的地址都relocation完后才可以执行。这个听起来有点复杂而且可能导致文件运行慢,不过对elf做了很大的优化后,这种减
慢已经是微不足道的了。理论上说不是用-fpic选项编译出来的目标文件也可以用作动态连接库,但是在运行时会需要做数目极大的
relocation,这是对运行速度有极大影响的。这样的程序性能是很差的,几乎没有可用性。
当从动态连接库中读一个全局变量时与从非-fpic编译的目标文件读是不同的。读动态连接的库中的变量是通过GOT来寻找到目标变量的,GOT已经由某一
个寄存器指向了。GOT本生就是一个指针列表,找到GOT中的某一个指针就可以读到所要的全局变量了,有了GOT我们要读出一个变量只要做一次
relocation。
下面我们来看看elf文件中到底有些什么信息:
代码:
$:cat hello.c main() { printf("Hello World\n"); } $:gcc-elf -c hello.c
还是这个简单的程序,用gcc把它编译成目标文件hello.o。然后用readelf工具来探测一下elf文件的内容。(readelf是在 binutils软件包里的一个工具,大多数Linux发行版都包含它)
代码:
$:readelf -h hello.o ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00
00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian
Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL
(Relocatable file) Machine: Intel 80386 Version: 0x1 Entry point
address: 0x0 Start of program headers: 0 (bytes into file) Start of
section headers: 256 (bytes into file) Flags: 0x0 Size of this header:
52 (bytes) Size of program headers: 0 (bytes) Number of program
headers: 0 Size of section headers: 40 (bytes) Number of section
headers: 11 Section header string table index: 8
-h选项是列出elf文件的头信息。Magic:字段是一个标识符,只要Magic字段是7f 45 4c 46 01 01 01 00 00 00
00 00 00 00 00
00的文件都是elf文件。Class:字段是表示elf的版本,这是一个32位的elf。Machine:字段是指出目标文件的平台信息,这里是
I386兼容平台。其他的字段可以从其字面上看出它的意义,这里就不一一解释了。
下面用-S选项列出段的头信息:
代码:
$:readelf -S hello.o There are 11 section headers, starting at offset
0x100: Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [
0] NULL 00000000 000000 000000 00 0 0 0 [ 1] .text PROGBITS 00000000
000034 00002a 00 AX 0 0 4 [ 2] .rel.text REL 00000000 000370 000010 08
9 1 4 [ 3] .data PROGBITS 00000000 000060 000000 00 WA 0 0 4 [ 4] .bss
NOBITS 00000000 000060 000000 00 WA 0 0 4 [ 5] .rodata PROGBITS
00000000 000060 00000e 00 A 0 0 1 [ 6] .note.GNU-stack PROGBITS
00000000 00006e 000000 00 0 0 1 [ 7] .comment PROGBITS 00000000 00006e
00003e 00 0 0 1 [ 8] .shstrtab STRTAB 00000000 0000ac 000051 00 0 0 1 [
9] .symtab SYMTAB 00000000 0002b8 0000a0 10 10 8 4 [10] .strtab STRTAB
00000000 000358 000015 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)
Name字段显示的是各个段的名字,Type显示段的属性,Addr是每个段载入虚拟内存的位置,Off是每个段在目标文件中的偏移位置,Size是每个段的大小,后面的一些字段是表示段的可写,可读,或者可执行。
用-r可以列出elf文件中的relocation:
代码:
$:readelf -r hello.o Relocation section '.rel.text' at offset 0x370
contains 2 entries: Offset Info Type Sym.Value Sym. Name 0000001f
00000501 R_386_32 00000000 .rodata 00000024 00000902 R_386_PC32
00000000 printf
在.text段中有两个relocation,其中之一就是printf函数的relcation。Offset指出当relocation时要把 printf函数的入口地址贴到离.text段开头00000024处。
下面我们可以看一下连接过后的可执行文件中的内容:
代码:
$:gcc hello.o $:readelf -S a.out There are 32 section headers, starting
at offset 0xbc4: Section Headers: [Nr] Name Type Addr Off Size ES Flg
Lk Inf Al [ 0] NULL 00000000 000000 000000 00 0 0 0 [ 1] .interp
PROGBITS 08048134 000134 000013 00 A 0 0 1 [ 2] .note.ABI-tag NOTE
08048148 000148 000020 00 A 0 0 4 [ 3] .hash HASH 08048168 000168
00002c 04 A 4 0 4 [ 4] .dynsym DYNSYM 08048194 000194 000060 10 A 5 1 4
[ 5] .dynstr STRTAB 080481f4 0001f4 000060 00 A 0 0 1 [ 6] .gnu.version
VERSYM 08048254 000254 00000c 02 A 4 0 2 [ 7] .gnu.version_r VERNEED
08048260 000260 000020 00 A 5 1 4 [ 8] .rel.dyn REL 08048280 000280
000008 08 A 4 0 4 [ 9] .rel.plt REL 08048288 000288 000010 08 A 4 11 4
[10] .init PROGBITS 08048298 000298 000017 00 AX 0 0 4 [11] .plt
PROGBITS 080482b0 0002b0 000030 04 AX 0 0 4 [12] .text PROGBITS
080482e0 0002e0 0001b4 00 AX 0 0 16 [13] .fini PROGBITS 08048494 000494
00001a 00 AX 0 0 4 [14] .rodata PROGBITS 080484b0 0004b0 000016 00 A 0
0 4 [15] .eh_frame PROGBITS 080484c8 0004c8 000004 00 A 0 0 4 [16]
.ctors PROGBITS 080494cc 0004cc 000008 00 WA 0 0 4 [17] .dtors PROGBITS
080494d4 0004d4 000008 00 WA 0 0 4 [18] .jcr PROGBITS 080494dc 0004dc
000004 00 WA 0 0 4 [19] .dynamic DYNAMIC 080494e0 0004e0 0000c8 08 WA 5
0 4 [20] .got PROGBITS 080495a8 0005a8 000004 04 WA 0 0 4 [21] .got.plt
PROGBITS 080495ac 0005ac 000014 04 WA 0 0 4 [22] .data PROGBITS
080495c0 0005c0 00000c 00 WA 0 0 4 [23] .bss NOBITS 080495cc 0005cc
000004 00 WA 0 0 4 [24] .comment PROGBITS 00000000 0005cc 0001b2 00 0 0
1 [25] .debug_aranges PROGBITS 00000000 000780 000058 00 0 0 8 [26]
.debug_info PROGBITS 00000000 0007d8 000164 00 0 0 1 [27] .debug_abbrev
PROGBITS 00000000 00093c 000020 00 0 0 1 [28] .debug_line PROGBITS
00000000 00095c 00015a 00 0 0 1 [29] .shstrtab STRTAB 00000000 000ab6
00010c 00 0 0 1 [30] .symtab SYMTAB 00000000 0010c4 000510 10 31 56 4
[31] .strtab STRTAB 00000000 0015d4 000322 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)
这里的段比目标文件hello.o的段要多的多,这是因为这个程序需要elf的一个动态连接库libc.so.1。在这里需要简单的介绍一下内核加载
elf可执行文件。内核先是把整个文件加载到用户的虚拟内存空间,如果程序是与动态连接库连接的,则程序中就会包含动态连接器的名称,可能是
/lib/elf/ld-linux.so.1。(动态连接器本身也是一个动态连接库)
在文件的尾部的一些段的Addr值是00000000,因为这些都是符号表,动态连接器并不把这些段的内容加载到内存中。.
interp段中只是储存这一个ASCII的字符串,它就是动态连接器的名字(路径)。.hash, .dynsym,
.dynstr这三个段是用于动态连接器执行relocation时的符号表。.hash是一个哈希表,可以让我们很快的从.dynsym中找到所需的符
号。
.plt段中储存着我们调用动态连接库中的函数入口地址,在默认状态下,程序初始化时,.plt中的指针并不是指向正确的函数入口地址的而是指向动态连接
器本身,当你在程序中调用某个动态连接库中的函数时,连接器会找到那个函数在动态连接库中的位置,再把这个位置连接到.plt段中。这样做的好处是如果在
程序中调用了很多动态连接库中的函数,会花费掉连接器很长时间把每个函数的地址连接到.plt段中。所以就可以采用连接器只是把要用的函数地址连接进去,
以后要用的再连接。但是也可以设置环境变量LD_BIND_NOW=1让连接器在程序执行前把所有的函数地址都连接好,这主要是方便调试程序。
readelf工具还有很多选项,具体内容可以查看man手册。在文章的开头就说elf文件格式很方便运用动态连接技术,下面我就写一个就简单的动态连接库的例子:
代码:
$:cat Dyn_hello.c int main(void) { hi(); } $:cat hi.c #include
hi() { printf("Hello world\n"); }
两个简单的文件,在mian函数中调用hi()函数,下面并不是把两个文件一起编译,而是把hi.c编译成动态连接库。(注意Dyn_hello.c中并没有包含任何头文件。)
代码:
$:gcc -fPIC -c hi.c $:gcc -shared -o libhi.so hi.o
现在在当前目录下有一个名字为libhi.so的文件,这就就是仅含有一个函数的动态连接库。
代码:
$:gcc -c Dyn_hello.c $:gcc -o Dyn_hello Dyn_hello.o -L. -lhi
在当前目录下有了一个Dyn_hello可执行文件,现在就可以执行它了。
代码:
$:./Dyn_hello ./Dyn_hello: error while loading shared libraries:
libhi.so: cannot open shared object file: No such file or directory
执行不成功,这就表明了这是一个动态连接的程序,连接器找不到libhi.so这个动态连接库。在命令行加上 LD_LIBRARY_PATH=...就行了。像这样运行:
代码:
$D_LIBRARY_PATH=. ./Dyn_hello Hello world
指出当前目录是连接器的搜索目录,就可以了。
Elf可执行文件还有一个a.out很难实现的特点,就是对dlopen()函数的支持,这个函数可以在程序中控制动态的加载动态连接库,看下面的一个小程序:
代码:
$:cat Dl_hello.c #include int main (int argc, char
*argv[]) { void (*hi) (); void *m; if (argc > 2) exit (0); m =
dlopen (argv[1], RTLD_LAZY); if (!m) exit (0); hi = dlsym (m, "hi"); if
(hi) { (*hi) (); } dlclose (m); }
用一下命令编译:
代码:
$:gcc -c Dl_hello.c $:gcc -o Dl_hello Dl_hello.o -ldl
运行Dl_hello程序加上动态连接库。
代码:
$:./Dl_hello ./libhi.so Hello world
命令行成功的打印出了Hello world说明我们的动态连接库运用成功了。
在这篇文章中只是讨论了elf可执行文件的执行原理,还有很多方面没有涉及到,要深入了解elf你也许需要对动态连接器hack一下,也要hack一下内
核加载程序的loader。但是我想对大多数人来说,这篇文章对elf的介绍已经足够让你可以自己对elf在进行比较深入的研究了。