1、漫谈兼容内核之九:ELF 映像的装入(二).txt 我们用一只眼睛看见现实的灰墙,却用另一只眼睛勇敢飞翔,接近梦想。男人喜欢听话的女人,但男人若是喜欢一个女人,就会不知不觉听她的话。漫谈兼容内核之九:ELF 映像的装入(二)align=centersize=4b漫谈兼容内核之九:ELF 映像的装入(二)/b/size/alignalign=center毛德操/align上一篇漫谈介绍了在通过 execve()系统调用启动一个 ELF 格式的目标映像时发生于Linux 内核中的活动。简而言之,内核根据映像头部所提供的信息把目标映像映射到(装入)当前进程用户空间的某个位置上;并且,如果目标映像需要
2、使用共享库的话,还要(根据映像头部所提供的信息)将所需的“解释器”的映像也映射到用户空间的某个位置上,然后在从系统调用返回用户空间的时候就“返回”到解释器的入口,下面就是解释器的事了。如果目标映像不使用共享库,那么问题就比较简单,返回用户空间的时候就直接“返回”到目标映像的入口。现代的应用软件一般都要使用共享库,所以我们把这当作常态,而把不使用共享库的应用软件作为一种简化了的特例。映像装入用户空间的位置有些是固定的、在编译连接时就确定好了的;有些则是“浮动”的、可以在装入时动态决定;具体要看编译时是否使用了-fPIC 选项。一般应用软件主体的映像都是固定地址的,而共享库映像的装入地址都是浮动的
3、。特别地,解释器映像的装入地址也是浮动的。2ELF 映像的结构每个操作系统对于在其内核上运行的可执行程序二进制映像都有特定的要求和规定,包括例如映像的格式,映像在用户空间的布局(程序段、数据段、堆栈段的划分等等),映像装入用户空间的地址是否可以浮动、以及如何浮动,是否支持动态连接、以及如何连接,如何进行系统调用,等等。这些要求和规定合在一起就构成了具体操作系统的“应用(软件)二进制界面(Application Binary Interface)” ,缩写成 ABI。显然,ABI 是二进制映像的“生产者”即编译/连接工具和使用者即映像装入/启动手段之间的一组约定。而我们一般所说的二进制映像格式,
4、实际上并不仅仅是指字面意义上的、类似于数据结构定义那样的“格式” ,还包括了跟映像装入过程有关的其它约定。所以,二进制映像格式是 ABI 的主体。目前的 Linux ABI 是在 Unix 系统 5 的时期(大约在 1980 年代)发展起来的,其主体就是ELF,这是“可执行映像和连接格式(Executable and Lnking Format)”的缩写。读者已经看到,ELF 映像文件的开始是个 ELF 头,这是一个数据结构,结构中有个指针(位移量),指向文件中的一个“程序头”数组(表)。各个程序头表项当然也是数据结构,这是对映像文件中各个“节(Segment)”的(结构性)描述。从映像装入的
5、角度看,一个映像是由若干个 Segment 构成的。有些 Segment 需要被装入、即被映射到用户空间,有些则不需要被装入。在前一篇漫谈中读者已经看到,只有类型为PT_LOAD 的 Segment 才需要被装入。所以,映像装入的过程只“管”到 Segment 为止。而从映像的动态连接、重定位(即浮动)、和启动运行的角度看,则映像是由若干个“段(Section)”构成的。我们通常所说映像中的“代码段” 、 “数据段”等等都是 Section。所以,动态连接和启动运行的过程所涉及的则是 Section。一般而言,一个 Segment 可以包含多个Section。其实,Segment 和 Sect
6、ion 都是从操作/处理的角度对映像的划分;对于不同的操作/处理,划分的方式也就可以不同。所以,读者在后面将会看到,一个 Segment 里面也可以包含几个别的 Segment,这就是因为它们是按不同的操作/处理划分的、不同意义上的Segment。Section 也是一样。在 Linux 系统中,(应用软件主体)目标映像本身的装入是由内核负责的,这个过程读者已经看到;而动态连接的过程则由运行于用户空间的“解释器”负责。这里要注意:第一,“解释器”是与具体的映像相连系的,其本身也有个映像,也需要被装入。与目标映像相连系的“解释器”也是由内核装入的,这一点读者也已看到。第二,动态连接的过程包括了共
7、享库映像的装入,那却是由“解释器”在用户空间实现的。本来,看了内核中与装入目标映像有关的代码以后,应该接着看“解释器”的代码了。但是后者比前者复杂得多,也繁琐得多,原因是牵涉到许多 ELF 和 ABI 的原理和细节,所以有必要先对 ELF 动态连接的原理作一介绍。明白了有关的原理和大致的方法以后,具体的代码实现倒在其次了。前面讲过,Linux 提供了两个很有用的工具,即 readelf 和 objdump。下面就用这两个工具对映像/usr/local/bin/wine 进行一番考察,以期在此过程中逐步对 ELF 和 ABI 有所了解和理解,这也是进一步阅读、理解“解释器”的代码所需要的。我们用
8、命令行“readelf a /usr/local/bin/wine”和“objdump d /usr/local/bin/wine”产生两个文件(把结果重定向到文件中),然后察看这两个文件的部分内容。首先是目标映像的 ELF 头:ELF Header:Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00Class: ELF32Data: 2s complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: EXEC (Execu
9、table file)Machine: Intel 80386Version: 0x1Entry point address: 0x8048750Start of program headers: 52 (bytes into file)Start of section headers: 114904 (bytes into file)Flags: 0x0Size of this header: 52 (bytes)Size of program headers: 32 (bytes)Number of program headers: 6Size of section headers: 40
10、 (bytes)Number of section headers: 36Section header string table index: 33这就是映像文件开头处的 ELF 头,其最初 4 个字节为0x7f 、和E 、 L、 F。从其余字段中我们可以看出: OS 是 Unix、其实是 Linux、而 ABI 是系统 5 的 ABI。ABI 的版本号为 0。 CPU 为 x86。 映像的类型为 EXEC,即带有主函数 main()的应用软件映像(若是共享库则类型为DYN、即动态连接库)。 映像的程序入口地址为 0x8048750。如前所述,EXEC 映像的装入地址是固定的、不能浮动。 程序
11、头数组起点在文件中的位移为 52(字节),而 ELF 头的大小正好也是 52,所以紧接 ELF 头的后面就是程序头数组。数组的大小为 6,即映像中有 6 个 Segment。 Section 头的数组则一直在后面位移位 114904 的地方,映像中有 36 个 Section。于是,我们接下去看程序头数组:Program Headers:Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg AlignPHDR 0x000034 0x08048034 0x08048034 0x000c0 0x000c0 R E 0x4INTERP 0x0000f4 0
12、x080480f4 0x080480f4 0x00013 0x00013 R 0x1Requesting program interpreter: /lib/ld-linux.so.2LOAD 0x000000 0x08048000 0x08048000 0x011cc 0x011cc R E 0x1000LOAD 0x0011cc 0x0804a1cc 0x0804a1cc 0x00158 0x00160 RW 0x1000DYNAMIC 0x0011d8 0x0804a1d8 0x0804a1d8 0x000d8 0x000d8 RW 0x4NOTE 0x000108 0x08048108
13、 0x08048108 0x00020 0x00020 R 0x4一个程序头就是关于一个 Segment 的说明,所以这就是 6 个 Segment。第一个 Segment的类型是 PHDR,在文件中的位移为 0x34、即 52,这就是程序头数组本身。其大小为 0xc0、即 192。前面说每个程序头的大小为 32 字节,而 6 X 32 = 192。第二个 Segment 的类型是INTERP,即“解释器”的文件/路径名,是个字符串,这里说是“/lib/ld-linux.so.2” 。下面是两个类型为 LOAD 的 Segment。如前所述,只有这种类型的 Segment 才需要装入。但是,看
14、一下前者的说明,其起点在文件中的位移是 0,大小是 0x011cc,显然是把 ELF 头和前两个 Segment 也包含在里面了。再看后者,其起点的位移是 0x011cc,所以是和前者连在一起的;其大小为 0x158,这样两个 Segment 合在一起是从 0 到 0x1324。计算一下就可知道,实际上是把所有的 Segment 都包括进去了。所以,对于这个特定的映像,说是只装入类型为 LOAD 的 Segment,实际上装入的却是整个映像。那么,映像中的什么内容可以不必装入呢?例如 bss 段,那是无初始内容的数据段,就不用装入;还有(与动态连接无关的)符号表,那也不需要装入。注意两个 LO
15、AD 类 Segment 的边界(Alignment)都是 0x1000,即 4KB,那正好是存储页面的大小。还有个问题,既然两个 LOAD 类的 Segment 是连续的,那为什么不合并成一个呢?看一下它们的特性标志位就可以知道,第一个 Segment 的映像是可读可执行、但是不可写;第二个则是可读可写、但是不可执行,这当然不能合并。再往下看,下一个 Segment 的类型是 DYNAMIC,那就是跟动态连接有关的信息。如上所述,这个 Segment 其实是包含在前一个 Segment 中的,所以也会被装入。最后一个 Segment的类型是 NOTE,那只是注释、说明一类的信息了。当然,跟动
16、态连接有关的信息是我们最为关心的,所以我们看一下这个 Segment 的具体内容:Dynamic segment at offset 0x11d8 contains 22 entries:Tag Type Name/Value0x00000001 (NEEDED) Shared library: libwine.so.10x00000001 (NEEDED) Shared library: libpthread.so.00x00000001 (NEEDED) Shared library: libc.so.60x0000000c (INIT) 0x80485e80x0000000d (FINI
17、) 0x80490280x00000004 (HASH) 0x80481280x00000005 (STRTAB) 0x80483680x00000006 (SYMTAB) 0x80481d80x0000000a (STRSZ) 301 (bytes)0x0000000b (SYMENT) 16 (bytes)0x00000015 (DEBUG) 0x00x00000003 (PLTGOT) 0x804a2c40x00000002 (PLTRELSZ) 160 (bytes)0x00000014 (PLTREL) REL0x00000017 (JMPREL) 0x80485480x000000
18、11 (REL) 0x80485380x00000012 (RELSZ) 16 (bytes)0x00000013 (RELENT) 8 (bytes)0x6ffffffe (VERNEED) 0x80484c80x6fffffff (VERNEEDNUM) 30x6ffffff0 (VERSYM) 0x80484960x00000000 (NULL) 0x0这个 Segment 中有 22 项数据,开头几项类型为 NEEDED 的数据是我们此刻最为关心的,因为这些数据告诉了我们目标映像要求装入那一些共享库,例如 libwine.so.1。读者已经看过内核怎样装入用户空间映像,解释器只不过是在
19、用户空间做同样的事,所以共享库的装入对于读者并不复杂,问题是怎样实现动态连接,这是我后面要着重讲的。前面说过,Segment 是从映像装入角度考虑的划分,Section 才是从连接/启动角度考虑的划分,现在我们就来看 Section。先看 Section 与 Segment 的对应关系:Section to Segment mapping:Segment Sections.00 01 .interp02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r.rel.dyn .rel.plt .init .
20、plt .text .fini .rodata .eh_frame03 .data .dynamic .ctors .dtors .jcr .got .bss04 .dynamic05 .note.ABI-tagSection 的名称都以.开头,例如.interp;名称中间也可以有. ,例如rel.dyn。这说明,Segment 0 不含有任何 Section,因为这就是程序头数组。Segment 1 只含有一个 Section,那就是.interp,即解释器的文件/路径名。而 Segment 2 所包含的 Section就多了。而且,这个 Segment 还包含了前面两个 Segmrnt,所
21、以.interp 又同时出现在这个Segment 中。余类推。前面 ELF 头中说一共有 36 个 Section,下面就是一份清单: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 080480f4 0000f4 000013 00 A 0 0 1 2 .note.ABI-tag NOTE 08048108 000108 000020 00 A 0 0 4 3 .hash HASH 08048128 0001
22、28 0000b0 04 A 4 0 4 4 .dynsym DYNSYM 080481d8 0001d8 000190 10 A 5 1 4 5 .dynstr STRTAB 08048368 000368 00012d 00 A 0 0 1 6 .gnu.version VERSYM 08048496 000496 000032 02 A 4 0 2 7 .gnu.version_r VERNEED 080484c8 0004c8 000070 00 A 5 3 4 8 .rel.dyn REL 08048538 000538 000010 08 A 4 0 4 9 .rel.plt RE
23、L 08048548 000548 0000a0 08 A 4 b 410 .init PROGBITS 080485e8 0005e8 000017 00 AX 0 0 411 .plt PROGBITS 08048600 000600 000150 04 AX 0 0 412 .text PROGBITS 08048750 000750 0008d8 00 AX 0 0 413 .fini PROGBITS 08049028 001028 00001b 00 AX 0 0 414 .rodata PROGBITS 08049060 001060 000166 00 A 0 0 3215 .
24、eh_frame PROGBITS 080491c8 0011c8 000004 00 A 0 0 416 .data PROGBITS 0804a1cc 0011cc 00000c 00 WA 0 0 417 .dynamic DYNAMIC 0804a1d8 0011d8 0000d8 08 WA 5 0 418 .ctors PROGBITS 0804a2b0 0012b0 000008 00 WA 0 0 419 .dtors PROGBITS 0804a2b8 0012b8 000008 00 WA 0 0 420 .jcr PROGBITS 0804a2c0 0012c0 0000
25、04 00 WA 0 0 421 .got PROGBITS 0804a2c4 0012c4 000060 04 WA 0 0 422 .bss NOBITS 0804a324 001324 000008 00 WA 0 0 423 .stab PROGBITS 00000000 001324 004878 0c 24 0 424 .stabstr STRTAB 00000000 005b9c 014cd4 00 0 0 125 .comment PROGBITS 00000000 01a870 000165 00 0 0 126 .debug_aranges PROGBITS 0000000
26、0 01a9d8 000078 00 0 0 827 .debug_pubnames PROGBITS 00000000 01aa50 000025 00 0 0 128 .debug_info PROGBITS 00000000 01aa75 000a98 00 0 0 129 .debug_abbrev PROGBITS 00000000 01b50d 000138 00 0 0 130 .debug_line PROGBITS 00000000 01b645 000284 00 0 0 131 .debug_frame PROGBITS 00000000 01b8cc 000014 00
27、 0 0 432 .debug_str PROGBITS 00000000 01b8e0 0006be 01 MS 0 0 133 .shstrtab STRTAB 00000000 01bf9e 00013a 00 0 0 134 .symtab SYMTAB 00000000 01c678 000890 10 35 5c 435 .strtab STRTAB 00000000 01cf08 0005db 00 0 0 1Key to Flags:W (write), A (alloc), X (execute), M (merge), S (strings)I (info), L (lin
28、k order), G (group), x (unknown)O (extra OS processing required) o (OS specific), p (processor specific)这是按 Section 的名称列出的,其中跟动态连接有关的 Section 也出现在前面名为Dynamic 的 Segment 中,只是在那里是按类型列出的。例如,前面类型为 HASH 的表项说与此有关的信息在 0x8048128 处,而这里则说有个名为.hash 的 Section,其起始地址为0x8048128。还有,前面类型为 PLTGOT 的表项说与此有关的信息在 0x804a2c
29、4 处,这里则说有个名为.got 的 Section,其起始地址为 0x804a2c4,不过 Section 表中提供的信息更加详细一些,有些信息则互相补充。在 Section 表中,只要类型为 PROGBITS,就说明这个Section 的内容都来自映像文件,反之类型为 NOBITS 就说明这个 Section 的内容并非来自映像文件。有些 Section 名是读者本来就知道的,例如.text、.data、.bss;有些则从它们的名称就可猜测出来,例如.symtab 是符号表、.rodata 是只读数据、还有.comment 和.debug_info 等等。还有一些可能就不知道了,这里择其要
30、者先作些简略的介绍:(1).hash。为便于根据函数/变量名找到有关的符号表项,需要对函数/变量名进行hash 计算,并根据计算值建立 hash 队列。 .dynsym。需要加以动态连接的符号表,类似于内核模块中的 INPORT 符号表。这是动态连接符号表的数据结构部分,须与.dynstr 联用。 .dynstr。动态连接符号表的字符串部分,与.dynsym 联用。 .rel.dyn。用于动态连接的重定位信息。 .rel.plt。一个结构数组,其中的每个元素都代表着 GOP 表中的一个表项 GOTn(见下)。 .init。在进入 main()之前执行的代码在这个 Section 中。 .plt
31、。 “过程连接表(Procedure Linking Table)” ,见后。 .fini。从 main()返回之后执行的代码在这个 Section 中,最后会调用 exit()。 .ctors。表示“Constructor” ,是一个函数指针数组,这些函数需要在程序初始化阶段(进入 main()之前,在.init 中)加以调用。 .dtors。表示“Distructor” ,也是一个函数指针数组,这些函数需要在程序扫尾阶段(从 main()返回之后,在.fini 中)加以调用。 .got。 “全局位移表(Global Offset Table)” ,见后。 .strtab。与符号表有关的字符
32、串都集中在这个 Section 中。其中我们最关心的是“过程连接表(Procedure Linking Table)”PLT 和“全局位移表(Global Offset Table)”GOT。程序之间的动态连接就是通过这两个表实现的。下面我们通过一个实例来说明程序之间的动态连接。目标映像/usr/local/bin/wine 的main()函数中调用了一个库函数 getenv(),这个函数在 C 语言共享库 libc.so.6 中。下面是 main()经编译/连接以后的汇编代码:08048ce0 :8048ce0: 55 push %ebp8048ce1: 89 e5 mov %esp,%eb
33、p. . . . . .8048cef: 68 20 91 04 08 push $0x80491208048cf4: e8 47 f9 ff ff call 8048640 本来,这里 call 指令机器代码的后 4 个字节应该是目标函数 getenv()的入口地址。可是,这个目标函数在共享库 libc.so.6 中,而这个共享库的装入地址是浮动的,要到装入了以后才能知道其地址。怎么办?一个不必很有天分的人就能想到的简单办法是:编译时先让这条 call 指令空着,但是创建一个带有字符串“getenv”的数据结构,并让这个数据结构中有个指针反过来指向这条 call 指令;而在动态连接时,则让“
34、解释器”在共享库的导出符号表中寻找这个符号,找到后根据其装入后的位置计算出应该填入这条 call 指令的数值,再把结果填写到这里的 call 指令中、即地址为 0x8048cf5 的地方。当然,程序中调用getenv()的地方可能不止一个,所以在调用者的映像中需要把所有调用 getenv()的地方都记下来。然而,不幸的是这样的地方可能成百上千,而类似于 getenv()这样由共享库提供的函数也可能成百上千。更何况一个共享库可能还要用到别的共享库,从而形成一个多层次的共享库“图” 。这样一来,动态连接的效率就大成问题了,显然这不是个好办法。那么实际采用的办法是什么样的呢?这里的 call 指令采
35、用的是相对寻址,调用的子程序入口地址为 0x8048640,我们就循着这个地址看过去:8048640: ff 25 dc a2 04 08 jmp *0x804a2dc8048646: 68 18 00 00 00 push $0x18804864b: e9 b0 ff ff ff jmp 8048600 这就已经在 wine 映像的 PLT 表中了,这几条指令就构成 getenv()在 PLT 中的表项,程序中凡是对 getenv()的调用都先来到这里。当然,PLT 表中有许多这样的表项,对应着许多需要通过动态连接引入的函数,凡是这样的表项都以 PLTn 表示之。所有的 PLTn 都是相似的
36、,但是 PLT 表中的第一个表项、即 PLT0、却是特殊的:08048600 :8048600: ff 35 c8 a2 04 08 pushl 0x804a2c88048606: ff 25 cc a2 04 08 jmp *0x804a2cc804860c: 00 00 add %al,(%eax)804860e: 00 00 add %al,(%eax)8048610: ff 25 d0 a2 04 08 jmp *0x804a2d08048616: 68 00 00 00 00 push $0x0804861b: e9 e0 ff ff ff jmp 8048600 8048620:
37、ff 25 d4 a2 04 08 jmp *0x804a2d48048626: 68 08 00 00 00 push $0x8804862b: e9 d0 ff ff ff jmp 8048600 8048630: ff 25 d8 a2 04 08 jmp *0x804a2d88048636: 68 10 00 00 00 push $0x10804863b: e9 c0 ff ff ff jmp 8048600 8048640: ff 25 dc a2 04 08 jmp *0x804a2dc8048646: 68 18 00 00 00 push $0x18804864b: e9 b
38、0 ff ff ff jmp 8048600 . . . . . .可以看出,除 PLT0 以外,所有的 PLTn 的形式都是一样的,而且最后的 jmp 指令都是以0x8048600、即 PLT0 为目标,所不同的只是第一条 jmp 指令的目标和 push 指令中的数据。PLT0 则与之不同,但是包括 PLT0 在内的每个表项都占 16 个字节,所以整个 PLT 就像是个数组。其实 PLT0 只需要 12 个字节,但是为了大小划一而补了 4 个字节的 0。注意每个 PLTn 中的第一条 jmp 指令是间接寻址的。以 getenv()的表项为例,是以地址0x804a2dc 处的内容为目标地址进行
39、跳转。这样,只要把 getenv()装入用户空间后的入口地址填写在 0x804a2dc 处,就可以实现正确的跳转,即实现了与共享库中函数 getenv()的动态连接。这样,对于共享库函数的每次调用,额外的消耗只是执行一条间接寻址的 jmp 指令所需的时间。另一方面,这是不涉及堆栈的跳转指令,堆栈的内容在跳转的过程中保持不变,所以当 getenv()执行 ret 指令返回时就直接回到了调用它的地方,在这里是前面的 main()中。由此可见,解释器的任务就是事先把 getenv()装入用户空间后的入口地址填写在0x804a2dc 处。不仅是 getenv(),共享库提供的库函数可能有很多,对于每个
40、这样的库函数都得保存一个用于间接寻址跳转的指针。保存这些指针的地方就是 GOT。与 PLT 相对应,每个 PLTn 在 GOT 中都有个相应的 GOTn,但是每个 GOTn 只是一个函数指针。同样,GOT0 也是特殊的,而且 GOT0 的大小也不一样,有 12 个字节,相当于三个 GOTn 那么大。在“解释器”ld-linux.so 的代码中把 GOT0 的三个长字表示成 got0、got1、和 got2,注意不要跟GOTn 相混淆。显然、解释器负有正确设置所有 GOTn 的责任。既然如此,每个 PLTn 中只要一条指令就行了,代码中为什么有三条呢?还有,PLT0 和GOT0 又是干什么用的呢
41、?原来,那都是为实现“懒惰式”的动态连接、即“懒连接”而存在的。简而言之, “懒连接”就是解释器并不事先完成对共享库函数的动态连接、即不事先设置 GOTn、而把对具体共享库函数的动态连接拖延到真正要用的时候才来进行,需要用哪一个函数就连接哪一个函数,绝不“积极主动” ,以免劳而无功。然而,要是不事先设置好 GOTn 的内容,PLTn 中的(间接寻址)跳转指令会跳到什么地方去?这要看 GOTn 中的原始内容,这内容来自目标映像(对外进行库函数调用的映像)。我们看 wine 映像所提供的 GOT 原始内容。这个映像的 GOT 起始地址为 0x0804a2c4,跳过 GOT0 的 12 个字节,GO
42、Tn 是从 0x0804a2d0 开始的:Relocation section .rel.plt at offset 0x548 contains 20 entries:Offset Info Type Sym.Value Sym. Name0804a2d0 00000107 R_386_JUMP_SLOT 08048610 strchr0804a2d4 00000207 R_386_JUMP_SLOT 08048620 getpid0804a2d8 00000307 R_386_JUMP_SLOT 08048630 fprintf0804a2dc 00000407 R_386_JUMP_SLOT 08048640 getenv0804a2e0 00000507 R_386_JUMP_SLOT 08048650 pthread_create. . . . . .从这里地址为 0x0804a2dc 的这一表项看,似乎这个指针所指向的是 0x08048640,这正是指令“jmp *0x804a2dc”所在的位置。设想如果 CPU 在尚未完成对 getenv()的