图片来自网络
1. 前言
本文从减少可执行文件大小的角度分析了 ELF 文件,期间通过经典的 ”Hello World” 实例逐步演示如何通过各种常用工具来分析 ELF 文件,并逐步精简代码。
为了能够尽量减少可执行文件的大小,我们必须了解可执行文件的格式,以及链接生成可执行文件时的后台细节(即最终到底有哪些内容被链接到了目标代码中)。通过选择合适的可执行文件格式并剔除对可执行文件的最终运行没有影响的内容,就可以实现目标代码的裁减。因此,通过探索减少可执行文件大小的方法,就相当于实践性地去探索了可执行文件的格式以及链接过程的细节。
当然,算法的优化和编程语言的选择可能对目标文件的大小有很大的影响,在本文最后我们会探求一个打印 “Hello World” 的可执行文件能够小到什么样的地步。
2. 可执行文件格式的选取
可执行文件格式的选择要满足的一个基本条件是:目标系统支持该可执行文件格式,UNIX 平台下有三种可执行文件格式,这三种格式实际上代表着可执行文件的一个发展过程:
- a.out
非常紧凑,只包含了程序运行所必须的信息(文本、数据、BSS),而且每个 section 的顺序是固定的。 - coff
虽然引入了一个节区表以支持更多节区信息,从而提高了可扩展性,但是这种文件格式的重定位在链接时就已经完成,因此不支持动态链接(不过扩展的coff支持)。 - elf
不仅支持动态链接,而且有很好的扩展性。它可以描述可重定位文件、可执行文件和可共享文件(动态链接库)三类文件。
下面来看看 ELF 文件的结构图:
1文件头部(ELF Header)
2程序头部表(Program Header Table)
3节区1(Section1)
4节区2(Section2)
5节区3(Section3)
6...
7节区头部(Section Header Table)
无论是文件头部、程序头部表、节区头部表还是各个节区,都是通过特定的结构体(struct) 描述的,这些结构在 elf.h
文件中定义。文件头部用于描述整个文件的类型、大小、运行平台、程序入口、程序头部表和节区头部表等信息。例如,我们可以通过文件头部查看该 ELF 文件的类型。
1$ cat hello.c #典型的hello, world程序
2#include
3
4int main(void)
5{
6 printf("hello, world!n");
7 return 0;
8}
9$ gcc -c hello.c #编译,产生可重定向的目标代码
10$ readelf -h hello.o | grep Type #通过readelf查看文件头部找出该类型
11 Type: REL (Relocatable file)
12$ gcc -o hello hello.o #生成可执行文件
13$ readelf -h hello | grep Type
14 Type: EXEC (Executable file)
15$ gcc -fpic -shared -W1,-soname,libhello.so.0 -o libhello.so.0.0 hello.o #生成共享库
16$ readelf -h libhello.so.0.0 | grep Type
17 Type: DYN (Shared object file)
那节区头部表(将简称节区表)和程序头部表有什么用呢?实际上前者只对可重定向文件有用,而后者只对可执行文件和可共享文件有用。
节区表是用来描述各节区的,包括各节区的名字、大小、类型、虚拟内存中的位置、相对文件头的位置等,这样所有节区都通过节区表给描述了,这样连接器就可以根据文件头部表和节区表的描述信息对各种输入的可重定位文件进行合适的链接,包括节区的合并与重组、符号的重定位(确认符号在虚拟内存中的地址)等,把各个可重定向输入文件链接成一个可执行文件(或者是可共享文件)。如果可执行文件中使用了动态连接库,那么将包含一些用于动态符号链接的节区。我们可以通过 readelf -S
(或objdump -h
)查看节区表信息。
1$ readelf -S hello #可执行文件、可共享库、可重定位文件默认都生成有节区表
2...
3Section Headers:
4 [Nr] Name Type Addr Off Size ES Flg Lk Inf Al
5 [ 0] NULL 00000000 000000 000000 00 0 0 0
6 [ 1] .interp PROGBITS 08048114 000114 000013 00 A 0 0 1
7 [ 2] .note.ABI-tag NOTE 08048128 000128 000020 00 A 0 0 4
8 [ 3] .hash HASH 08048148 000148 000028 04 A 5 0 4
9...
10 [ 7] .gnu.version VERSYM 0804822a 00022a 00000a 02 A 5 0 2
11...
12 [11] .init PROGBITS 08048274 000274 000030 00 AX 0 0 4
13...
14 [13] .text PROGBITS 080482f0 0002f0 000148 00 AX 0 0 16
15 [14] .fini PROGBITS 08048438 000438 00001c 00 AX 0 0 4
16...
三种类型文件的节区可能不一样,但是有几个节区,例如 .text
, .data
, .bss
是必须的,特别是 .text
,因为这个节区包含了代码。如果一个程序使用了动态链接库(引用了动态连接库中的某个函数),那么需要 .interp
节区以便告知系统使用什么动态连接器程序来进行动态符号链接,进行某些符号地址的重定位。通常,.rel.text
节区只有可重定向文件有,用于链接时对代码区进行重定向,而 .hash
, .plt
, .got
等节区则只有可执行文件(或可共享库)有,这些节区对程序的运行特别重要。还有一些节区,可能仅仅是用于注释,比如 .comment
,这些对程序的运行似乎没有影响,是可有可无的,不过有些节区虽然对程序的运行没有用处,但是却可以用来辅助对程序进行调试或者对程序运行效率有影响。
虽然三类文件都必须包含某些节区,但是节区表对可重定位文件来说才是必须的,而程序的执行却不需要节区表,只需要程序头部表以便知道如何加载和执行文件。不过如果需要对可执行文件或者动态连接库进行调试,那么节区表却是必要的,否则调试器将不知道如何工作。下面来介绍程序头部表,它可通过 readelf -l
(或 objdump -p
)查看。
1$ readelf -l hello.o #对于可重定向文件,gcc没有产生程序头部,因为它对可重定向文件没用
2
3There are no program headers in this file.
4$ readelf -l hello #而可执行文件和可共享文件都有程序头部
5...
6Program Headers:
7 Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
8 PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
9 INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
10 [Requesting program interpreter: /lib/ld-linux.so.2]
11 LOAD 0x000000 0x08048000 0x08048000 0x00470 0x00470 R E 0x1000
12 LOAD 0x000470 0x08049470 0x08049470 0x0010c 0x00110 RW 0x1000
13 DYNAMIC 0x000484 0x08049484 0x08049484 0x000d0 0x000d0 RW 0x4
14 NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
15 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
16
17 Section to Segment mapping:
18 Segment Sections...
19 00
20 01 .interp
21 02 .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
22 03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
23 04 .dynamic
24 05 .note.ABI-tag
25 06
26$ readelf -l libhello.so.0.0 #节区和上面类似,这里省略
从上面可看出程序头部表描述了一些段(Segment),这些段对应着一个或者多个节区,上面的 readelf -l
很好地显示了各个段与节区的映射。这些段描述了段的名字、类型、大小、第一个字节在文件中的位置、将占用的虚拟内存大小、在虚拟内存中的位置等。这样系统程序解释器将知道如何把可执行文件加载到内存中以及进行动态链接等动作。
该可执行文件包含7个段,PHDR 指程序头部,INTERP 正好对应 .interp
节区,两个 LOAD 段包含程序的代码和数据部分,分别包含有 .text
和 .data
,.bss
节区,DYNAMIC 段包含 .daynamic
,这个节区可能包含动态连接库的搜索路径、可重定位表的地址等信息,它们用于动态连接器。NOTE 和 GNU_STACK 段貌似作用不大,只是保存了一些辅助信息。因此,对于一个不使用动态连接库的程序来说,可能只包含 LOAD 段,如果一个程序没有数据,那么只有一个 LOAD 段就可以了。
总结一下,Linux 虽然支持很多种可执行文件格式,但是目前 ELF 较通用,所以选择 ELF 作为我们的讨论对象。通过上面对 ELF 文件分析发现一个可执行的文件可能包含一些对它的运行没用的信息,比如节区表、一些用于调试、注释的节区。如果能够删除这些信息就可以减少可执行文件的大小,而且不会影响可执行文件的正常运行。
3. 链接优化
从上面的讨论中已经接触了动态连接库。ELF 中引入动态连接库后极大地方便了公共函数的共享,节约了磁盘和内存空间,因为不再需要把那些公共函数的代码链接到可执行文件,这将减少了可执行文件的大小。
与此同时,静态链接可能会引入一些对代码的运行可能并非必须的内容。你可以从《GCC编译的背后(第二部分:汇编和链接)》 了解到 GCC 链接的细节。从那篇 Blog中似乎可以得出这样的结论:仅仅从是否影响一个 C 语言程序运行的角度上说,GCC默认链接到可执行文件的几个可重定位文件(crt1.o, rti.o, crtbegin.o, crtend.o, crtn.o)并不是必须的,不过值得注意的是,如果没有链接那些文件但在程序末尾使用了 return
语句,main 函数将无法返回,因此需要替换为 _exit
调用;另外,既然程序在进入 main 之前有一个入口,那么 main 入口就不是必须的。因此,如果不采用默认链接也可以减少可执行文件的大小。