机器执行的是机器指令,而机器指令就是一堆二进制的数字。高级语言编写的程序之所以可以在不同的机器上移植就因为有为不同机器设计的编译器的存在。高级语言的编译器就是把高级语言写的程序转换成某个机器能直接执行的二进制代码。以上的知识在我们学习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去掉符号表的话,会导致连接器无法连接。例如:
代码:
$ cat hello.c
main( ) { printf("Hello World\n"); }
代码:
$ gcc -c hello.c
用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为好。
在接下去讨论以前,我们还要来讲讲relocation(重定位)的概念:首先有个简单的程序hello.c
代码:
$ cat hello.c
main( ) { printf("Hello World\n"); }
当我们把hello.c编译为目标文件时,我们并没有在源文件中定义printf这个函数,所以汇编器也不知道printf这个函数的具体的地址,所以在目标文件中就会留下printf这个符号。以下的工作就交给连接器了,连接器会找到这个函数的入口地址然后加以修正最终形成可执行文件。这个过程就叫做relocation。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内部不支持动态连接,用某些技术也是可以实现a.out的动态连接)
要了解elf可执行文件的运行方式,我们有必要讨论一下动态连接技术。很多人对动态连接技术十分熟悉,但是很少有人真正了解动态连接的内部工作方式。回想没有动态连接的日子,程序员写程序时不用什么都从头开始,他们可以调用现成的函数库里的函数,然后再用连接器与函数库连接。这样的话使得程序员更加有效率,但是一个十分重要的问题出现了:这样产生的可执行文件就会很大。因为连接器把程序需要用的所有函数的代码都复制到了可执行文件中去了。这种连接方式就是所谓的静态连接,与之相对的就是动态连接。连接器在可执行文件中标记出程序调用外部函数的位置,并不把代码复制进去,只是标出外部函数在动态连接库(*.so)中的位置。用这样的方式生成的特殊可执行文件就是动态连接的。在运行这种动态程序时,系统在运行时才把该程序调用的外部函数地址映射到真实地址,这就是所谓的动态连接,系统就有一个程序叫做动态连接器,在动态连接的程序执行前都要先把地址映射好。很显然的,必须有一种机制保证动态连接的程序中的函数地址正确地指向了动态连接库的某个函数地址。这就需要讨论一下elf可执行文件格式处理动态连接的机制了。
elf的动态连接库是内存位置无关的,就是说你可以把这个库加载到内存的任何位置都没有影响。这就叫做position independent。而a.out的动态连接库是内存位置有关的,它一定要被加载到规定的内存地址才能工作。在编译内存位置无关的动态连接库时,要给编译器加上 -fpic选项,让编译器产生的目标文件是内存位置无关的其他好处是还会尽量减少对变量引用时使用绝对地址。把库编译成内存位置无关会带来一些花费,编译器会保留一个寄存器(%ebx)来指向全局偏移量表(global offset table (or GOT for short)),这就会导致编译器在优化代码时少了一个寄存器可以使用,但是在最坏的情况下这种性能的减少只有3%,在其他情况下是大大小于3%的。
elf的另一个特点是它的动态连接库是在运行时处理符号地址的,这是通过用符号表和重定位表(relocation)来实现的。可执行文件被载入内存时并不能立即执行,要在处理完符号表把所有的地址都relocation完后才可以执行。这个听起来有点复杂而且可能导致文件运行慢,不过对elf做了很大的优化后,这种减慢已经是微不足道的了。理论上说,不用-fpic选项编译出来的目标文件也可以用作动态连接库,但是在运行时会需要做数目极大的 relocation,这是对运行速度有极大影响的。这样的程序性能是很差的,几乎没有可用性。
当从动态连接库中读一个全局变量时与从非-fpic编译的目标文件读是不同的。读动态连接的库中的变量是通过GOT来寻找到目标变量的,GOT已经由某一个寄存器(%ebx)指向了。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
-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兼容平台。其他的字段可以从其字面上看出它的意义,这里就不一一解释了。
$ readelf -S hello.o -S选项列出节头(section headers)信息
Name字段显示的是各个节的名字,Type显示节的属性,Addr是每个节载入虚拟内存的位置,Off是每个节在目标文件中的偏移位置,Size是每个节的大小,后面的一些字段是表示节的可写,可读,或者可执行。
$ readelf -r hello.o -r可以列出elf文件中的relocation
在.text段中有两个relocation,其中之一就是printf函数的relcation。Offset指出当relocation时要把 printf函数的入口地址贴到离.text段开头00000024处。
下面我们可以看一下连接过后的可执行文件中的内容:
代码:
$ gcc hello.o
$ readelf -S a.out
这里的段比目标文件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文件格式很方便运用动态连接技术,下面我就写一个就简单的动态连接库的例子:
(1) hello1
代码:
$ cat hello1.c
int main(void) { hi(); }
$ cat hi.c
#include
hi() { printf("Hello world\n"); }
两个简单的文件,在main函数中调用hi()函数,下面并不是把两个文件一起编译,而是把hi.c编译成动态连接库。(注意hello1.c中并没有包含任何头文件。)
代码:
$ gcc -fPIC -c hi.c
$ gcc -shared -o libhi.so hi.o
现在在当前目录下有一个名字为libhi.so的文件,这就是仅含有一个函数的动态连接库。
代码:
$ gcc -c hello1.c
$ gcc -o hello1 hello1.o -L. -lhi
在当前目录下有了一个hello1可执行文件,现在就可以执行它了。
代码:
$ ./hello1
./hello1: error while loading shared libraries: libhi.so: cannot open shared object file: No such file or directory
执行不成功,这就表明了这是一个动态连接的程序,连接器找不到libhi.so这个动态连接库。在命令行加上 LD_LIBRARY_PATH=. 指出当前目录作为连接器的搜索目录,就可以了。像这样运行:
代码:
$ LD_LIBRARY_PATH=. ./hello1
Hello world
(2) hello2Elf可执行文件还有一个a.out很难实现的特点,就是对dlopen()函数的支持,这个函数可以在程序中控制动态的加载动态连接库,看下面的一个小程序:
代码:
$ cat hello2.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 hello2.c
$ gcc -o hello2 hello2.o -ldl
运行hello2程序 后面跟上动态连接库名字作参数。
代码:
$ ./hello2 ./libhi.so
Hello world
命令行成功的打印出了Hello world说明我们的动态连接库运用成功了。
在这篇文章中只是讨论了elf可执行文件的执行原理,还有很多方面没有涉及到,要深入了解elf你也许需要对动态连接器hack一下,也要hack一下内核加载程序的loader。但是我想对大多数人来说,这篇文章对elf的介绍已经足够让你可以自己对elf在进行比较深入的研究了。
阅读(695) | 评论(0) | 转发(0) |