Linux可執行文件格式-ELF結構詳解

表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+64×13=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文件的每一個位元組

再更…