ELF 是 Linux 支持的一种程序文件格式,本身包含重定位、执行、共享(动态链接库)三种类型(man elf)。
代码:
/* test.c */
#include
int global = 0;
int main()
{
char local = 'A';
printf("local = %c, global = %d\n", local, global);
return 0;
}
演示:
通过 -c 生成可重定位文件 test.o,这里不会进行链接:
$ gcc -c test.c
$ file test.o
test.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
链接之后才可以执行:
$ gcc -o test test.o
$ file test
test: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), not stripped
也可链接成动态链接库,不过一般不会把 main 函数链接成动态链接库,后面再介绍:
$ gcc -fpic -shared -Wl,-soname,libtest.so.0 -o libtest.so.0.0 test.o
$ file libtest.so.0.0
libtest.so.0.0: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), not stripped
虽然ELF文件本身就支持三种不同类型,不过它有一个统一的结构,这个结构是:
文件头部(ELF Header)
程序头部表(Program Header Table)
节区1(Section1)
节区2(Section2)
节区3(Section3)
...
节区头部表(Section Header Table)
无论是文件头部、程序头部表、节区头部表,还是节区,它们都对应着 C 语言里头的一些结构体(elf.h 中定义)。
1. 文件头部主要描述 ELF 文件的类型,大小,运行平台,以及和程序头部表和节区头部表相关的信息
2. 节区头部表则用于可重定位文件,以便描述各个节区的信息,这些信息包括节区的名字、类型、大小等。
3. 程序头部表则用于描述可执行文件或者动态链接库,以便系统加载和执行它们。
4. 而节区主要存放各种特定类型的信息,比如程序的正文区(代码)、数据区(初始化和未初始化的数据)、调试信息、以及用于动态链接的一些节区,比如解释器(.interp)节区将指定程序动态装载 / 链接器 ld-linux.so 的位置,而过程链接表(plt)、全局偏移表(got)、重定位表则用于辅助动态链接过程。
对于可执行文件除了编译器引入的一些符号外,主要就是用户自定义的全局变量,函数等,而对于可重定位文件仅仅包含用户自定义的一些符号。
$ gcc -c test.c
$ nm test.o
00000000 B global
00000000 T main
U printf
上面包含全局变量、自定义函数以及动态链接库中的函数,但不包含局部变量,而且发现这三个符号的地址都没有确定。
$ gcc -o test test.o
$ nm test | egrep "main$| printf|global$"
080495a0 B global
08048354 T main
U printf@@GLIBC_2.0
经链接,global 和 main 的地址都已经确定了,但是 printf 却还没,因为它是动态链接库 glibc 中定义函数,需要动态链接,而不是这里的“静态”链接。
从上面的演示可以看出,重定位文件 test.o 中的符号地址都是没有确定的,而经过静态链接(gcc 默认调用 ld 进行链接)以后有两个符号地址已经确定了,这样一个确定符号地址的过程实际上就是链接的实质。链接过后,对符号的引用变成了对地址(定义符号时确定该地址)的引用,这样程序运行时就可通过访问内存地址而访问特定的数据。
我们也注意到符号 printf 在可重定位文件和可执行文件中的地址都没有确定,这意味着该符号是一个外部符号,可能定义在动态链接库中,在程序运行时需要通过动态链接器(ld-linux.so)进行重定位,即动态链接。
通过这个演示可以看出 printf 确实在 glibc 中有定义。
$ nm -D /lib/`uname -m`-linux-gnu/libc.so.6 | grep "\ printf$"
0000000000053840 T printf
动态链接就是在程序运行时对符号进行重定位,确定符号对应的内存地址的过程。
Linux 下符号的动态链接默认采用 Lazy Mode 方式,也就是说在程序运行过程中用到该符号时才去解析它的地址。这样一种符号解析方式有一个好处:只解析那些用到的符号,而对那些不用的符号则永远不用解析,从而提高程序的执行效率。
不过这种默认是可以通过设置 LD_BIND_NOW 为非空来打破的(下面会通过实例来分析这个变量的作用),也就是说如果设置了这个变量,动态链接器将在程序加载后和符号被使用之前就对这些符号的地址进行解析。
上面提到重定位的过程就是对符号引用和符号地址进行链接的过程,而动态链接过程涉及到的符号引用和符号定义分别对应可执行文件和动态链接库,在可执行文件中可能引用了某些动态链接库中定义的符号,这类符号通常是函数。
为了让动态链接器能够进行符号的重定位,必须把动态链接库的相关信息写入到可执行文件当中,这些信息是什么呢?
$ readelf -d test | grep NEEDED
0x00000001 (NEEDED) Shared library: [libc.so.6]
通过 LD_LIBRARY_PATH 参数,它类似 Shell 解释器中用于查找可执行文件的 PATH 环境变量,也是通过冒号分开指定了各个存放库函数的路径。该变量实际上也可以通过 /etc/ld.so.conf 文件来指定,一行对应一个路径名。为了提高查找和加载动态链接库的效率,系统启动后会通过 ldconfig 工具创建一个库的缓存 /etc/ld.so.cache 。如果用户通过 /etc/ld.so.conf 加入了新的库搜索路径或者是把新库加到某个原有的库目录下,最好是执行一下 ldconfig 以便刷新缓存。
需要补充的是,因为动态链接库本身还可能引用其他的库,那么一个可执行文件的动态符号链接过程可能涉及到多个库,通过 readelf -d 可以打印出该文件直接依赖的库,而通过 ldd 命令则可以打印出所有依赖或者间接依赖的库。
$ ldd test
linux-gate.so.1 => (0xffffe000)
libc.so.6 => /lib/libc.so.6 (0xb7da2000)
/lib/ld-linux.so.2 (0xb7efc000)
libc.so.6 通过 readelf -d 就可以看到的,是直接依赖的库;而 linux-gate.so.1 在文件系统中并没有对应的库文件,它是一个虚拟的动态链接库,对应进程内存映像的内核部分; 而 /lib/ld-linux.so.2 正好是动态链接器,系统需要用它来进行符号重定位。那 ldd 是怎么知道 /lib/ld-linux.so 就是该文件的动态链接器呢?
那是因为 ELF 文件通过专门的节区指定了动态链接器,这个节区就是 .interp 。
$ readelf -x .interp test
Hex dump of section '.interp':
0x08048114 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so
0x08048124 2e3200 .2.
以看到这个节区刚好有字符串 /lib/ld-linux.so.2,即 ld-linux.so 的绝对路径。
我们发现,与 libc.so 不同的是,ld-linux.so 的路径是绝对路径,而 libc.so 仅仅包含了文件名。原因是:程序被执行时,ld-linux.so 将最先被装载到内存中,没有其他程序知道去哪里查找 ld-linux.so,所以它的路径必须是绝对的;当 ld-linux.so 被装载以后,由它来去装载可执行文件和相关的共享库,它将根据 PATH 变量和 LD_LIBRARY_PATH 变量去磁盘上查找它们,因此可执行文件和共享库都可以不指定绝对路径。
Linux 下 elf 文件的动态链接器是 ld-linux.so,即 /lib/ld-linux.so.2 。通过 man ld-linux 可以获取与动态链接器相关的资料,包括各种相关的环境变量和文件都有详细的说明。
对于环境变量,除了上面提到过的 LD_LIBRARY_PATH 和 LD_BIND_NOW 变量外,还有其他几个重要参数,比如 LD_PRELOAD 用于指定预装载一些库,以便替换其他库中的函数,而环境变量 LD_DEBUG 可以用来进行动态链接的相关调试。
对于文件,除了上面提到的 ld.so.conf 和 ld.so.cache 外,还有一个文件 /etc/ld.so.preload 用于指定需要预装载的库。
从上一小节中发现有一个专门的节区 .interp 存放有动态链接器,但是这个节区为什么叫做 .interp (interpeter)呢?因为当 Shell 解释器或者其他父进程通过 exec 启动我们的程序时,系统会先为 ld-linux 创建内存映像,然后把控制权交给 ld-linux,之后 ld-linux 负责为可执行程序提供运行环境,负责解释程序的运行,因此 ld-linux 也叫做 dynamic loader (或 intepreter)
那么在 exec ()之后和程序指令运行之前的过程是怎样的呢?大体过程如下:
1. 将可执行文件的内存段添加到进程映像中;
2. 把共享目标内存段添加到进程映像中;
3. 为可执行文件和它的共享目标(动态链接库)执行重定位操作;
4. 关闭用来读入可执行文件的文件描述符,如果动态链接程序收到过这样的文件描述符的话;
5. 将控制转交给程序,使得程序好像从 exec() 直接得到控制
关于第 1 步,在 ELF 文件的文件头中就指定了该文件的入口地址,程序的代码和数据部分会相继 map 到对应的内存中。
对于第 2 步,上一节提到的 .dynamic 节区指定了可执行文件依赖的库名,ld-linux (在这里叫做动态装载器或程序解释器比较合适)再从 LD_LIBRARY_PATH 指定的路径中找到相关的库文件或者直接从 /etc/ld.so.cache 库缓冲中加载相关库到内存中。
对于第 3 步,在前面已提到,如果设置了 LD_BIND_NOW 环境变量,这个动作就会在此时发生,否则将会采用 lazy mode 方式,即当某个符号被使用时才会进行符号的重定位。不过无论在什么时候发生这个动作,重定位的过程大体是一样的(在后面将主要介绍该过程)。
对于第 4 步,这个主要是释放文件描述符。
对于第 5 步,动态链接器把程序控制权交还给程序。