1. 程式人生 > >GCC編譯器原理(二)------編譯原理一:ELF文件

GCC編譯器原理(二)------編譯原理一:ELF文件

過程 外部文件 初始 cati tor 保護功能 編譯原理 外部 comm

二、ELF 文件介紹

2.1 可執行文件格式綜述

相對於其它文件類型,可執行文件可能是一個操作系統中最重要的文件類型,因為它們是完成操作的真正執行者。可執行文件的大小、運行速度、資源占用情況以及可擴展性、可移植性等與文件格式的定義和文件加載過程緊密相關。研究可執行文件的格式對編寫高性能程序和一些黑客技術的運用都是非常有意義的。

可執行鏈接格式 ( Executable and Linking Format)最初是由 UNIX 系統實驗室 ( UNIX System Laboratories, USL)開發並發布的, 作為應用程序二進制接口 ( Application Binary Interface, ABI)的一部分。

不管何種可執行文件格式,一些基本的要素是必須的,顯而易見的,文件中應包含代碼和數據。因為文件可能引用外部文件定義的符號(變量和函數),因此重定位信息和符號信息也是需要的。一些輔助信息是可選的,如調試信息、硬件信息等。基本上任意一種可執行文件格式都是按區間保存上述信息,稱為段(Segment)或節(Section)。不同的文件格式中段和節的含義可能有細微區別,但根據上下文關系可以很清楚的理解,這不是關鍵問題。最後,可執行文件通常都有一個文件頭部以描述本文件的總體結構。

相對可執行文件有三個重要的概念:編譯(compile)、連接(link,也可稱為鏈接、聯接)、加載(load)。源程序文件被編譯成目標文件,多個目標文件被連接成一個最終的可執行文件,可執行文件被加載到內存中運行。

ELF全稱Executable and Linkable Format,可執行連接格式,ELF 格式的文件用於存儲 Linux 程序。

2.2 LINUX平臺下ELF文件加載過程的簡單描述

  1. 內核首先讀 ELF 文件的頭部,然後根據頭部的數據指示分別讀入各種數據結構,找到標記為可加載(loadable)的段,並調用函數 mmap() 把段內容加載到內存中。在加載之前,內核把段的標記直接傳遞給 mmap() ,段的標記指示該段在內存中是否可讀、可寫,可執行。顯然,文本段是只讀可執行,而數據段是可讀可寫。這種方式是利用了現代操作系統和處理器對內存的保護功能。
  2. 內核分析出 ELF 文件標記為 PT_INTERP 的段中所對應的動態連接器名稱,並加載動態連接器。現代 LINUX 系統的動態連接器通常是 /lib/ld-linux.so.2。
  3. 內核在新進程的堆棧中設置一些標記-值對,以指示動態連接器的相關操作。
  4. 內核把控制傳遞給動態連接器。
  5. 動態連接器檢查程序對外部文件(共享庫)的依賴性,並在需要時對其進行加載。
  6. 動態連接器對程序的外部引用進行重定位,通俗的講,就是告訴程序其引用的外部變量/函數的地址,此地址位於共享庫被加載在內存的區間內。動態連接還有一個延遲(Lazy)定位的特性,即只在 "真正" 需要引用符號時才重定位,這對提高程序運行效率有極大幫助。
  7. 動態連接器執行在 ELF 文件中標記為 .init 的節的代碼,進行程序運行的初始化。在早期系統中,初始化代碼對應函數 _init(void)(函數名強制固定),在現代系統中,則對應形式為
1 void __attribute((constructor)) init_function(void)
2 {
3     ……
4 }

其中函數名為任意。

  8. 動態連接器把控制傳遞給程序,從 ELF 文件頭部中定義的程序進入點開始執行。在 a.out 格式和 ELF 格式中,程序進入點的值是顯式存在的,在 COFF 格式中則是由規範隱含定義。

  • 從上面的描述可以看出,加載文件最重要的是完成兩件事情:
    • 加載程序段和數據段到內存;
    • 進行外部定義符號的重定位。

重定位是程序連接中一個重要概念。我們知道,一個可執行程序通常是由一個含有 main() 的主程序文件、若幹目標文件、若幹共享庫(Shared Libraries)組成。(註:采用一些特別的技巧,也可編寫沒有 main 函數的程序)

一個 C 程序可能引用共享庫定義的變量或函數,換句話說就是程序運行時必須知道這些變量/ 函數的地址。在靜態連接中,程序所有需要使用的外部定義都完全包含在可執行程序中,而動態連接則只在可執行文件中設置相關外部定義的一些引用信息,真正的重定位是在程序運行之時。

  • 靜態連接方式有兩個大問題:
    • 如果庫中變量或函數有任何變化都必須重新編譯連接程序;
    • 如果多個程序引用同樣的變量/函數,則此變量/函數會在文件/內存中出現多次,浪費硬盤/內存空間。

比較兩種連接方式生成的可執行文件的大小,可以看出有明顯的區別

三、 文件格式分析

3.1 a.out 文件格式分析

a.out 格式在不同的機器平臺和不同的 UNIX 操作系統上有輕微的不同,下面我們討論的是最 "標準" 的格式。

a.out 文件包含 7 個 section,格式如下:

技術分享圖片

執行頭部的數據結構(/usr/include/x86_64-linux-gnu/a.out.h):

 1 struct exec {
 2     unsigned long   a_info;    /* 魔數和其它信息 */
 3     unsigned long   a_text;      /* 文本段的長度 */
 4     unsigned long   a_data;      /* 數據段的長度 */
 5     unsigned long   a_bss;       /* BSS段的長度 */
 6     unsigned long   a_syms;      /* 符號表的長度 */
 7     unsigned long   a_entry;     /* 程序進入點 */
 8     unsigned long   a_trsize;    /* 文本重定位表的長度 */
 9     unsigned long   a_drsize;    /* 數據重定位表的長度 */
10 };

文件頭部主要描述了各個 section 的長度,比較重要的字段是 a_entry(程序進入點),代表了系統在加載程序並初試化各種環境後開始執行程序代碼的入口。

由 a.out 格式和頭部數據結構我們可以看出,a.out 的格式非常緊湊,只包含了程序運行所必須的信息(文本、數據、BSS),而且每個 section 的順序是固定的。這種結構缺乏擴展性,如不能包含 "現代" 可執行文件中常見的調試信息,最初的 UNIX 黑客對 a.out 文件調試使用的工具是 adb,而 adb 是一種機器語言調試器!

a.out 文件中包含符號表和兩個重定位表,這三個表的內容在連接目標文件以生成可執行文件時起作用。在最終可執行的 a.out 文件中,這三個表的長度都為 0 。

a.out 文件在連接時就把所有外部定義包含在可執行程序中,如果從程序設計的角度來看,這是一種硬編碼方式,或者可稱為模塊之間是強藕和的。

a.out 是早期UNIX系統使用的可執行文件格式,由 AT&T 設計,現在基本上已被 ELF 文件格式代替。a.out 的設計比較簡單,但其設計思想明顯的被後續的可執行文件格式所繼承和發揚。

2.2 COFF 文件格式分析

COFF 格式比 a.out 格式要復雜一些,最重要的是包含一個節段表(section table),因此除了 .text,.data,和 .bss 區段以外,還可以包含其它的區段。另外也多了一個可選的頭部,不同的操作系統可一對此頭部做特定的定義。

COFF 文件格式如下:

技術分享圖片

  • 結構體位於文件:
    • ubuntu中:/usr/include/linux/coff.h
    • Linux 內核中:include/uapi/linux/coff.h

文件頭部的數據結構:

 1 struct COFF_filehdr
 2 {
 3     unsigned short  f_magic[2];    /* 魔數 */
 4     unsigned short  f_nscns[2];    /* 節個數 */
 5     long            f_timdat[4];   /* 文件建立時間 */
 6     long            f_symptr[4];   /* 符號表相對文件的偏移量 */
 7     long            f_nsyms[4];    /* 符號表條目個數 */
 8     unsigned short  f_opthdr[2];   /* 可選頭部長度 */
 9     unsigned short  f_flags[2];    /* 標誌 */
10 }

COFF 文件頭部中魔數與其它兩種格式的意義不太一樣,它是表示針對的機器類型,例如 0x014c 相對於 I386 平臺,而 0x268 相對於 Motorola 68000系列等。當 COFF 文件為可執行文件時,字段 f_flags 的值為 F_EXEC(0X00002),同時也表示此文件沒有未解析的符號,換句話說,也就是重定位在連接時就已經完成。由此也可以看出,原始的 COFF 格式不支持動態連接。為了解決這個問題以及增加一些新的特性,一些操作系統對 COFF 格式進行了擴展。Microsoft 設計了名為 PE(Portable Executable)的文件格式,主要擴展是在 COFF 文件頭部之上增加了一些專用頭部,某些 UNIX 系統也對 COFF 格式進行了擴展,如 XCOFF(extended common object file format)格式,支持動態連接。

緊接文件頭部的是可選頭部,COFF 文件格式規範中規定可選頭部的長度可以為 0,但在 LINUX 系統下可選頭部是必須存在的。下面是 LINUX 下可選頭部的數據結構:

 1 typedef struct 
 2 {
 3     char   magic[2];                /* 魔數 */
 4     char   vstamp[2];               /* 版本號 */
 5     char   tsize[4];                /* 文本段長度 */
 6     char   dsize[4];                /* 已初始化數據段長度 */
 7     char   bsize[4];                /* 未初始化數據段長度 */
 8     char   entry[4];                /* 程序進入點 */
 9     char   text_start[4];           /* 文本段基地址 */
10     char   data_start[4];           /* 數據段基地址 */
11 } COFF_AOUTHDR;

字段 magic 為 0413 時表示 COFF 文件是可執行的,註意到可選頭部中顯式定義了程序進入點,標準的 COFF 文件沒有明確的定義程序進入點的值,通常是從 .text 節開始執行,但這種設計並不好。

前面我們提到,COFF 格式比 a.out 格式多了一個節段表,一個節頭條目描述一個節數據的細節,因此 COFF 格式能包含更多的節,或者說可以根據實際需要,增加特定的節,具體表現在 COFF 格式本身的定義以及稍早提及的 COFF 格式擴展。下面將簡單描述 COFF 文件中節的數據結構,因為節的意義更多體現在程序的編譯和連接上,所以本文不對其做更多的描述。此外,ELF 格式和 COFF 格式對節的定義非常相似,在隨後的 ELF 格式分析中,將省略相關討論。

 1 struct COFF_scnhdr 
 2 {
 3     char    s_name[8];              /* 節名稱 */
 4     char    s_paddr[4];             /* 物理地址 */
 5     char    s_vaddr[4];             /* 虛擬地址 */
 6     char    s_size[4];              /* 節長度 */
 7     char    s_scnptr[4];            /* 節數據相對文件的偏移量 */
 8     char    s_relptr[4];            /* 節重定位信息偏移量 */
 9     char    s_lnnoptr[4];           /* 節行信息偏移量 */
10     char    s_nreloc[2];            /* 節重定位條目數 */
11     char    s_nlnno[2];             /* 節行信息條目數 */
12     char    s_flags[4];             /* 段標記 */
13 };

LINUX 系統中頭文件 coff.h 中對字段 s_paddr 的註釋是 "physical address" ,但似乎應該理解為 "節被加載到內存中所占用的空間長度" 。字段 s_flags 標記該節的類型,如文本段、數據段、BSS 段等。在 COFF 的節中也出現了行信息,行信息描述了二進制代碼與源代碼的行號之間的對映關系,在調試時很有用。

GCC編譯器原理(二)------編譯原理一:ELF文件