Linux可執行檔案格式-ELF結構詳解

我叫平沢唯發表於2021-11-19

表1. ELF檔案型別分類

ELF檔案型別 說明 例項
Relocatable File 可重定位檔案 未連結之前的ELF檔案,可用於連結可執行檔案或靜態連結庫 Linux下的".o"檔案,Windows下".obj"檔案
Executable File 可執行檔案 最終的可執行程式 如Linux下"/bin"目錄下檔案,Windows的".exe"檔案
Shared Objected File 共享目標檔案 一種是可用於靜態連結檔案,另一種是程式執行中被動態連結檔案 如Linux下的".a"和".so"檔案,Windows的dll檔案
Core Dump File 核心轉儲檔案 程式意外終止時,儲存程式地址空間的內容以及其他資訊 Linux下的core dump

一、前言

在早期的UNIX中,可執行檔案格式為a.out格式,由於其格式簡單,隨著共享庫概念的出現被COFF格式取代,後來Linux和Windows基於COFF格式,分別制定了ELF和PE格式,我們日常使用的".exe"檔案".lib",".dll"檔案就屬於PE檔案的一種;Linux平臺下的可執行檔案,中間目標檔案".o"以及靜態庫".a"和動態連結庫".so"檔案屬於ELF檔案,本節主要講解中間目標檔案(Relocatable File in ELF)這一ELF型別的檔案結構,因為這是編譯器將原始碼經過預編譯,編譯,彙編後得到的第一層ELF檔案,目標檔案經過連結後才能成為真正在Linux下執行的可執行檔案,這一型別會在後續blog中講解。

本文所有測試結果在一下平臺得出 ,不同的軟體系統與硬體架構在輸出結果上會稍有不同,但原理一致。

1 qi@qi:~$ uname -a
2 Linux qi 5.4.0-89-generic #100~18.04.1-Ubuntu SMP Wed Sep 29 10:59:42 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

 

Reference: 

[1] 《程式設計師的自我修養,連結、裝載與庫》,俞甲子 石凡 潘愛民


二、測試用例

我們以一段具有代表性的C檔案來作為測試原始碼,如下,該程式中包含了各種型別的變數以及函式,這些變數或者函式符號經過彙編生成中間目標檔案後將被儲存在不同的ELF段裡。

 1 int printf( const char* format, ... ); // 外部函式宣告
 2 
 3 int global_init_var = 84; // 全域性已賦值變數
 4 int global_uninit_var;    // 全域性未初始化變數
 5 
 6 void func1( int i ) {
 7     printf("%d\n", i);
 8 }
 9 
10 int main(void) {
11     static int static_var = 85; // 區域性靜態已賦值變數
12     static int static_var2;     // 區域性靜態未初始化變數
13     int a = 1; // 區域性已賦值變數
14     int b;     // 區域性未初始化變數
15 
16     func1( static_var + static_var2 + a + b);
17     return a;
18 }

 我們使用gcc編譯出彙編檔案,中間目標檔案,以及最終可執行檔案,最後使用"file"工具檢視檔案型別,使用"tree"檢視生成的檔案大小 : 

 1 $ ls
 2 SimpleSection.c
 3 $ gcc -S SimpleSection.c && gcc -c SimpleSection.c -o SimpleSection.o && gcc SimpleSection.c -o SimpleSection
 4 $ tree -sp
 5 .
 6 ├── [-rwxrwxr-x        8512]  SimpleSection
 7 ├── [-rw-rw-r--         395]  SimpleSection.c
 8 ├── [-rw-rw-r--        1936]  SimpleSection.o
 9 └── [-rw-rw-r--        1336]  SimpleSection.s
10 
11 $ file *
12 SimpleSection:   ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=da891be2d625e27300a0a9682a57fb6cf6563d82, not stripped
13 SimpleSection.c: C source, ASCII text
14 SimpleSection.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
15 SimpleSection.s: assembler source, ASCII text

 可以看到,檔案"SimpleSection"和"SimpleSection.o"均為ELF-64檔案,但是型別不一樣,"SimpleSection.o"檔案大小為1936個位元組。後文使用sublime text以16進位制檢視ELF目標檔案。

三、目標檔案ELF結構

ELF檔案總體結構可以用圖1表示,圖左為"SimpleSection.o"檔案的前一部分以十六進位制表示的內容,圖中間一層層的欄位(定義:每種欄位儲存不同型別的內容)就是ELF結構的內容層次了,在目標檔案的開頭為一個長度為64(0x40)位元組的ELF頭,只要分析ELF表頭記憶體儲的資訊,可以得出段表"Section Header table"(在圖的最頂層的那個段)在整個目標檔案中的偏移,而段表是一個元素為"Elf64_Shdr"結構體型別的陣列,它的元素的數量正好是圖中間那些欄位的數量,也就是它的每個元素儲存了中間一些欄位如".text",".symtab"等欄位的資訊,這些資訊包括欄位名,大小等等屬性。所以概括的來說,通過讀取ELF頭,可以得到段表,然後通過讀取段表中各個欄位元素,就可以得出各個欄位的資訊了。那麼讀到這裡,ELF結構輪廓已然清晰,接下來就是分析ELF檔案各個欄位的具體用途,以及某些欄位是具體如何關聯才使得連結器能夠完全理解這個檔案。

圖1 ELF檔案結構圖

1 .text欄位:用於儲存程式中的程式碼片段

2 .data欄位:用於儲存已經初始化的全域性變數和區域性變數

3 .bss欄位:用於儲存未初始化的全域性變數和區域性變數

4 .rodata:顧名思義,儲存只讀的變數

5 .comment:儲存編譯器版本資訊

6 .symtab:符號表,各個目標檔案連結的介面

7 .strtab:字串表,儲存符號的名字,因為各個字串大小不一,所以統一把所有字串放到這個段裡,後續其他段通過某個符號在字串標中的偏移可以取到符號。

8 .rela.text:因為程式宣告使用了未在程式內部定義的函式或者變數,所以需要等到連結時(定義在別的目標檔案或者庫裡)對這個符號的地址進行重新定位,不然會引用到錯誤的地址。

9 .shstrtab:和strtab類似,不過儲存是段名,也就是說裡面儲存的字串是所有段的名字

10 Section Header Table:段表,儲存了所有段的資訊,本身通過Elf頭找到,可以解析出所有段的位置。

在你還沒掌握肉身解碼ELF檔案之前,你可能需要一些工具才能得出目標檔案中有什麼欄位,每個欄位有多少位元組等等資訊,我們可以藉助"objdump"和"readelf"來檢視目標檔案內的細節 : 

  • 使用readelf工具,我們可以很容易得到 "ELF Header前16個位元組(可用sublime檢視檔案實際內容驗證)" 為 "7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00" ,這16個位元組被稱為為Elf檔案的魔數。其中前四個位元組為所有ELF固定的格式,第5個位元組為0x02代表的是64位Elf,如果你的機器是32位的話,那麼這個位元組就是0x01,第6個位元組為位元組序,0x01為little endian,0x02為big endian,第7個位元組為Elf檔案的主版本號,後面9個位元組一般為0。
  • 另外,這個shell工具還給我們輸出了各個欄位的資訊,和圖1中各個欄位一一對應,我們可以看到各個欄位在檔案中的偏移和佔用的位元組,如欄位".text"的offset為0x40(這裡同時驗證了Elf檔案頭為0x40個位元組大小,和readelf返回的"Size of this header: 64 (bytes)"契合),佔用了0x57個位元組。其他欄位的位置和大小以此類推。
  • 除此之外,我們還可以看到 "Start of section headers: 1104 (bytes into file)" 這一資訊,這表示段表的位置在檔案的1104(0x450)個位元組的偏置的地方,而我們的Elf一共有13個段,因為段表中的每個元素表示的各個段的屬性而不是內容,所以段表中每個元素的大小應該一樣,事實上,每個元素都是一個 "Elf64_Shdr" 結構體型別的變數,該結構體和其它如表示Elf頭的結構體 "Elf64_Ehdr" 都可以在檔案 "/usr/include/elf.h" 中找到,每一個結構體有64個位元組(也可以從readelf的輸出得出: Size of section headers: 64 (bytes)),那麼段表將在1104+64x13=1936 Bytes結束,正好是我們用 "ls -l"得出來的目標檔案大小,這表明在我這個平臺下,段表位於目標檔案的最後。

段表元素型別:Elf64_Shdr 

 1 typedef struct
 2 {
 3     Elf64_Word    sh_name;        /* Section name (string tbl index) */
 4     Elf64_Word    sh_type;        /* Section type */
 5     Elf64_Xword   sh_flags;       /* Section flags */
 6     Elf64_Addr    sh_addr;        /* Section virtual addr at execution */
 7     Elf64_Off sh_offset;      /* Section file offset */
 8     Elf64_Xword   sh_size;        /* Section size in bytes */
 9     Elf64_Word    sh_link;        /* Link to another section */
10     Elf64_Word    sh_info;        /* Additional section information */
11     Elf64_Xword   sh_addralign;       /* Section alignment */
12     Elf64_Xword   sh_entsize;     /* Entry size if section holds table */
13 } Elf64_Shdr;

 Elf檔案頭型別:Elf64_Ehdr

 1 typedef struct
 2 {
 3     unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
 4     Elf64_Half    e_type;         /* Object file type */
 5     Elf64_Half    e_machine;      /* Architecture */
 6     Elf64_Word    e_version;      /* Object file version */
 7     Elf64_Addr    e_entry;        /* Entry point virtual address */
 8     Elf64_Off e_phoff;        /* Program header table file offset */
 9     Elf64_Off e_shoff;        /* Section header table file offset */
10     Elf64_Word    e_flags;        /* Processor-specific flags */
11     Elf64_Half    e_ehsize;       /* ELF header size in bytes */
12     Elf64_Half    e_phentsize;        /* Program header table entry size */
13     Elf64_Half    e_phnum;        /* Program header table entry count */
14     Elf64_Half    e_shentsize;        /* Section header table entry size */
15     Elf64_Half    e_shnum;        /* Section header table entry count */
16     Elf64_Half    e_shstrndx;     /* Section header string table index */
17 } Elf64_Ehdr;
  • 上文中我們說到可以通過Elf頭得到段表在檔案中的位置從而可以找到段表,儲存這個偏置的資訊就儲存在結構體 "Elf64_Ehdr"中的成員 "e_shoff" 中,我們回過頭來看Elf表頭的內容,使用sublime text開啟目標檔案,觀察檔案頭前64個位元組也就是Elf檔案頭的大小:
1 7f45 4c46 0201 0100 0000 0000 0000 0000
2 0100 3e00 0100 0000 0000 0000 0000 0000
3 0000 0000 0000 0000 5004 0000 0000 0000
4 0000 0000 4000 0000 0000 4000 0d00 0c00

可以看到,在第41個位元組開始的8個位元組(成員e_shoff為Elf64_Off型別,是uint64_t的typedef)為 "50040000",由於是little endian,所以轉化成十進位制就是0x0450=1104,正好和readelf輸出的偏移一樣。

  • 我們再來觀察由Elf頭得到的段表,也就是檔案從第1105個位元組開始的段,前128個位元組(包含了NULL和.text欄位的資訊)如下:
1 0000 0000 0000 0000 0000 0000 0000 0000
2 0000 0000 0000 0000 0000 0000 0000 0000
3 0000 0000 0000 0000 0000 0000 0000 0000
4 0000 0000 0000 0000 0000 0000 0000 0000
5 
6 2000 0000 0100 0000 0600 0000 0000 0000
7 0000 0000 0000 0000 4000 0000 0000 0000
8 5700 0000 0000 0000 0000 0000 0000 0000
9 0100 0000 0000 0000 0000 0000 0000 0000

因為第一個欄位為NULL,所以64個位元組全為0,接下來的一個欄位就是.text欄位,對比一下 "Elf64_Shdr"結構體的定義,剛好是64個位元組,其中成員 "sh_offset" 表示該段在檔案中的偏移,該結構體的第25-32這8個位元組為0x40,是"sh_offset"的值,而該結構體的第23-40這8個位元組為0x57,是 "sh_size" 的值,正好是偏置0x40,大小0x57,和readelf工具的輸出一致。

等等,我為什麼知道這個欄位是叫".text",請看 "Elf64_Ehdr" 結構體的第一個成員,該成員的值表示欄位名在段表字串表中的下標,為0x20,我們根據0x20,到欄位 ".shstrtab" 中找到第33個位元組開始,將16進位制碼轉化為ASCII碼,就可以知道該段的名字了,說幹就幹,但是現在每一個欄位的名字都還不知道,也就不知道這12個欄位裡哪一個才是 ".shstrtab", Elf Header結構體 "Elf64_Ehdr" 的最後一個變數 "e_shstrndx" 告訴了我們該段在段表陣列中的下標,我們看目標檔案前64個位元組中的最後兩個位元組為0x000c=12,也就是說,段表陣列的最後一個元素就是我們要找的 ".shstrtab" 欄位了,這個元素應該是目標檔案的最後64個位元組,而這64個位元組中的第25-32這8個位元組為0x03e8,也就是說 ".shstrtab" 欄位的內容從第0x3e8+1個位元組開始,我們用sublime text開啟目標檔案,找到第1001個位元組,往後走0x20=32個位元組:

1 **** **** **** **** 002e 7379 6d74 6162
2 002e 7374 7274 6162 002e 7368 7374 7274
3 6162 002e 7265 6c61 2e74 6578 7400 2e64
4 6174 6100 2e62 7373 002e 726f 6461 7461
5 002e 636f 6d6d 656e 7400 2e6e 6f74 652e
6 474e 552d 7374 6163 6b00 2e72 656c 612e
7 6568 5f66 7261 6d65 00** **** **** ****

後面的5個位元組為 "2e 74 65 78 74",轉換為ASCII正好為 ".text",OK,大功告成。

  • 使用objdump工具,我們可以看到各個欄位的內容,上文中objdump輸出了".text"等欄位的內容,如".text"欄位輸出了0x57個位元組,與readelf的輸出一致。

 

 

四、不放過ELF檔案的每一個位元組

再更...

相關文章