1. 前言
本文從減少可執(zhí)行文件大小的角度分析了 ELF 文件,期間通過(guò)經(jīng)典的 ”Hello World” 實(shí)例逐步演示如何通過(guò)各種常用工具來(lái)分析 ELF 文件,并逐步精簡(jiǎn)代碼。由于行文較長(zhǎng),本公眾號(hào)將用一個(gè)系列四個(gè)篇幅呈現(xiàn)給泰曉的讀者。
為了能夠盡量減少可執(zhí)行文件的大小,我們必須了解可執(zhí)行文件的格式,以及鏈接生成可執(zhí)行文件時(shí)的后臺(tái)細(xì)節(jié)(即最終到底有哪些內(nèi)容被鏈接到了目標(biāo)代碼中)。通過(guò)選擇合適的可執(zhí)行文件格式并剔除對(duì)可執(zhí)行文件的最終運(yùn)行沒(méi)有影響的內(nèi)容,就可以實(shí)現(xiàn)目標(biāo)代碼的裁減。因此,通過(guò)探索減少可執(zhí)行文件大小的方法,就相當(dāng)于實(shí)踐性地去探索了可執(zhí)行文件的格式以及鏈接過(guò)程的細(xì)節(jié)。
當(dāng)然,算法的優(yōu)化和編程語(yǔ)言的選擇可能對(duì)目標(biāo)文件的大小有很大的影響,在本文最后我們會(huì)探求一個(gè)打印 “Hello World” 的可執(zhí)行文件能夠小到什么樣的地步。
2. 可執(zhí)行文件格式的選取
可執(zhí)行文件格式的選擇要滿足的一個(gè)基本條件是:目標(biāo)系統(tǒng)支持該可執(zhí)行文件格式,UNIX 平臺(tái)下有三種可執(zhí)行文件格式,這三種格式實(shí)際上代表著可執(zhí)行文件的一個(gè)發(fā)展過(guò)程:
a.out
非常緊湊,只包含了程序運(yùn)行所必須的信息(文本、數(shù)據(jù)、BSS),而且每個(gè) section 的順序是固定的。
coff
雖然引入了一個(gè)節(jié)區(qū)表以支持更多節(jié)區(qū)信息,從而提高了可擴(kuò)展性,但是這種文件格式的重定位在鏈接時(shí)就已經(jīng)完成,因此不支持動(dòng)態(tài)鏈接(不過(guò)擴(kuò)展的coff支持)。
elf
不僅支持動(dòng)態(tài)鏈接,而且有很好的擴(kuò)展性。它可以描述可重定位文件、可執(zhí)行文件和可共享文件(動(dòng)態(tài)鏈接庫(kù))三類文件。
下面來(lái)看看 ELF 文件的結(jié)構(gòu)圖:
1文件頭部(ELF Header)
2程序頭部表(Program Header Table)
3節(jié)區(qū)1(Section1)
4節(jié)區(qū)2(Section2)
5節(jié)區(qū)3(Section3)
6...
7節(jié)區(qū)頭部(Section Header Table)
無(wú)論是文件頭部、程序頭部表、節(jié)區(qū)頭部表還是各個(gè)節(jié)區(qū),都是通過(guò)特定的結(jié)構(gòu)體(struct) 描述的,這些結(jié)構(gòu)在 elf.h
文件中定義。文件頭部用于描述整個(gè)文件的類型、大小、運(yùn)行平臺(tái)、程序入口、程序頭部表和節(jié)區(qū)頭部表等信息。例如,我們可以通過(guò)文件頭部查看該 ELF 文件的類型。
1$ cat hello.c #典型的hello, world程序
2#include <stdio.h>
3
4int main(void)
5{
6 printf('hello, world!n');
7 return 0;
8}
9$ gcc -c hello.c #編譯,產(chǎn)生可重定向的目標(biāo)代碼
10$ readelf -h hello.o | grep Type #通過(guò)readelf查看文件頭部找出該類型
11 Type: REL (Relocatable file)
12$ gcc -o hello hello.o #生成可執(zhí)行文件
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 #生成共享庫(kù)
16$ readelf -h libhello.so.0.0 | grep Type
17 Type: DYN (Shared object file)
那節(jié)區(qū)頭部表(將簡(jiǎn)稱節(jié)區(qū)表)和程序頭部表有什么用呢?實(shí)際上前者只對(duì)可重定向文件有用,而后者只對(duì)可執(zhí)行文件和可共享文件有用。
節(jié)區(qū)表是用來(lái)描述各節(jié)區(qū)的,包括各節(jié)區(qū)的名字、大小、類型、虛擬內(nèi)存中的位置、相對(duì)文件頭的位置等,這樣所有節(jié)區(qū)都通過(guò)節(jié)區(qū)表給描述了,這樣連接器就可以根據(jù)文件頭部表和節(jié)區(qū)表的描述信息對(duì)各種輸入的可重定位文件進(jìn)行合適的鏈接,包括節(jié)區(qū)的合并與重組、符號(hào)的重定位(確認(rèn)符號(hào)在虛擬內(nèi)存中的地址)等,把各個(gè)可重定向輸入文件鏈接成一個(gè)可執(zhí)行文件(或者是可共享文件)。如果可執(zhí)行文件中使用了動(dòng)態(tài)連接庫(kù),那么將包含一些用于動(dòng)態(tài)符號(hào)鏈接的節(jié)區(qū)。我們可以通過(guò) readelf -S
(或objdump -h
)查看節(jié)區(qū)表信息。
1$ readelf -S hello #可執(zhí)行文件、可共享庫(kù)、可重定位文件默認(rèn)都生成有節(jié)區(qū)表
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...
三種類型文件的節(jié)區(qū)可能不一樣,但是有幾個(gè)節(jié)區(qū),例如 .text
, .data
, .bss
是必須的,特別是 .text
,因?yàn)檫@個(gè)節(jié)區(qū)包含了代碼。如果一個(gè)程序使用了動(dòng)態(tài)鏈接庫(kù)(引用了動(dòng)態(tài)連接庫(kù)中的某個(gè)函數(shù)),那么需要 .interp
節(jié)區(qū)以便告知系統(tǒng)使用什么動(dòng)態(tài)連接器程序來(lái)進(jìn)行動(dòng)態(tài)符號(hào)鏈接,進(jìn)行某些符號(hào)地址的重定位。通常,.rel.text
節(jié)區(qū)只有可重定向文件有,用于鏈接時(shí)對(duì)代碼區(qū)進(jìn)行重定向,而 .hash
, .plt
, .got
等節(jié)區(qū)則只有可執(zhí)行文件(或可共享庫(kù))有,這些節(jié)區(qū)對(duì)程序的運(yùn)行特別重要。還有一些節(jié)區(qū),可能僅僅是用于注釋,比如 .comment
,這些對(duì)程序的運(yùn)行似乎沒(méi)有影響,是可有可無(wú)的,不過(guò)有些節(jié)區(qū)雖然對(duì)程序的運(yùn)行沒(méi)有用處,但是卻可以用來(lái)輔助對(duì)程序進(jìn)行調(diào)試或者對(duì)程序運(yùn)行效率有影響。
雖然三類文件都必須包含某些節(jié)區(qū),但是節(jié)區(qū)表對(duì)可重定位文件來(lái)說(shuō)才是必須的,而程序的執(zhí)行卻不需要節(jié)區(qū)表,只需要程序頭部表以便知道如何加載和執(zhí)行文件。不過(guò)如果需要對(duì)可執(zhí)行文件或者動(dòng)態(tài)連接庫(kù)進(jìn)行調(diào)試,那么節(jié)區(qū)表卻是必要的,否則調(diào)試器將不知道如何工作。下面來(lái)介紹程序頭部表,它可通過(guò) readelf -l
(或 objdump -p
)查看。
1$ readelf -l hello.o #對(duì)于可重定向文件,gcc沒(méi)有產(chǎn)生程序頭部,因?yàn)樗鼘?duì)可重定向文件沒(méi)用
2
3There are no program headers in this file.
4$ readelf -l hello #而可執(zhí)行文件和可共享文件都有程序頭部
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 #節(jié)區(qū)和上面類似,這里省略
從上面可看出程序頭部表描述了一些段(Segment),這些段對(duì)應(yīng)著一個(gè)或者多個(gè)節(jié)區(qū),上面的 readelf -l
很好地顯示了各個(gè)段與節(jié)區(qū)的映射。這些段描述了段的名字、類型、大小、第一個(gè)字節(jié)在文件中的位置、將占用的虛擬內(nèi)存大小、在虛擬內(nèi)存中的位置等。這樣系統(tǒng)程序解釋器將知道如何把可執(zhí)行文件加載到內(nèi)存中以及進(jìn)行動(dòng)態(tài)鏈接等動(dòng)作。
該可執(zhí)行文件包含7個(gè)段,PHDR 指程序頭部,INTERP 正好對(duì)應(yīng) .interp
節(jié)區(qū),兩個(gè) LOAD 段包含程序的代碼和數(shù)據(jù)部分,分別包含有 .text
和 .data
,.bss
節(jié)區(qū),DYNAMIC 段包含 .daynamic
,這個(gè)節(jié)區(qū)可能包含動(dòng)態(tài)連接庫(kù)的搜索路徑、可重定位表的地址等信息,它們用于動(dòng)態(tài)連接器。NOTE 和 GNU_STACK 段貌似作用不大,只是保存了一些輔助信息。因此,對(duì)于一個(gè)不使用動(dòng)態(tài)連接庫(kù)的程序來(lái)說(shuō),可能只包含 LOAD 段,如果一個(gè)程序沒(méi)有數(shù)據(jù),那么只有一個(gè) LOAD 段就可以了。
總結(jié)一下,Linux 雖然支持很多種可執(zhí)行文件格式,但是目前 ELF 較通用,所以選擇 ELF 作為我們的討論對(duì)象。通過(guò)上面對(duì) ELF 文件分析發(fā)現(xiàn)一個(gè)可執(zhí)行的文件可能包含一些對(duì)它的運(yùn)行沒(méi)用的信息,比如節(jié)區(qū)表、一些用于調(diào)試、注釋的節(jié)區(qū)。如果能夠刪除這些信息就可以減少可執(zhí)行文件的大小,而且不會(huì)影響可執(zhí)行文件的正常運(yùn)行。
3. 鏈接優(yōu)化
從上面的討論中已經(jīng)接觸了動(dòng)態(tài)連接庫(kù)。ELF 中引入動(dòng)態(tài)連接庫(kù)后極大地方便了公共函數(shù)的共享,節(jié)約了磁盤(pán)和內(nèi)存空間,因?yàn)椴辉傩枰涯切┕埠瘮?shù)的代碼鏈接到可執(zhí)行文件,這將減少了可執(zhí)行文件的大小。
與此同時(shí),靜態(tài)鏈接可能會(huì)引入一些對(duì)代碼的運(yùn)行可能并非必須的內(nèi)容。你可以從《GCC編譯的背后(第二部分:匯編和鏈接)》 了解到 GCC 鏈接的細(xì)節(jié)。從那篇 Blog中似乎可以得出這樣的結(jié)論:僅僅從是否影響一個(gè) C 語(yǔ)言程序運(yùn)行的角度上說(shuō),GC C默認(rèn)鏈接到可執(zhí)行文件的幾個(gè)可重定位文件(crt1.o, rti.o, crtbegin.o, crtend.o, crtn.o)并不是必須的,不過(guò)值得注意的是,如果沒(méi)有鏈接那些文件但在程序末尾使用了 return
語(yǔ)句,main 函數(shù)將無(wú)法返回,因此需要替換為 _exit
調(diào)用;另外,既然程序在進(jìn)入 main 之前有一個(gè)入口,那么 main 入口就不是必須的。因此,如果不采用默認(rèn)鏈接也可以減少可執(zhí)行文件的大小。