簡介:
本章基於linux主要講解l編輯好的hello.c文件如何從一個存儲介質上的文件編譯為可執行程序,以及加載到內存執行的過程。
第一節講述文本方式的代碼及在介質上的存儲方式(ELF文件),以及關於文本如何編譯成可執行文件的簡單介紹。
第二節講述可執行文件如何加載到內存中,涉及虛擬內存和文件如何加載到內存中並執行的過程。
一:文件方式存儲的代碼
1.1 代碼編寫
本文以如下代碼從文本方式存儲在存儲介質:hello.c
#include<stdio.h> int main() { printf("Hello World!\n"); return 0; }
1.2 代碼編譯
GCC可以很方便的幫我們編譯文件,因為不涉及文件方式的改變,這一部分簡略寫過,想詳細了解的可以參考編譯相關書籍,畢竟這又是另一個很長的故事了。
-
- 源文件預編譯展開頭文件,gcc命令很簡單就是把C文件包含的頭文件都展開到了生成的文件中,輸出太長可以自行實驗。命令: gcc -i hello.c > hello.i
- 利用預編譯后的文件生成匯編文件,可以生成一個叫做hello.s的匯編文件如下。命令: gcc -s hello.i
.file "hello.c" .text .section .rodata .LC0: .string "Hello World!" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 leaq .LC0(%rip), %rdi call puts@PLT movl $0, %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0" .section .note.GNU-stack,"",@progbits
- 生成可重定位目標文件hello.o, 命令:as -o hello.o hello.s 或者gcc -c hello.c
- 生成可執行目標文件hello,命令: gcc -o hello hello.c(本來想用ld命令,不過很多依賴的東西需要找,這目前不在討論范圍所以暫時不提供ld編譯的命令,如果想看ld做了什么可以用這個命令看一下: gcc hello.c -o hello -Wl,-v)
- 總算找出ld的命令了,真是復雜,機器不同需要搜索自己機器上的庫文件對應的位置否則會不匹配,不過.o文件的名字是一樣的,命令如下:ld -static /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib -L/lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
- 實驗結果截圖:
1.3 代碼轉變成目標文件
代碼轉變成目標文件的過程就是上面生成可重定位目標文件的過程,將hello.s匯編文件和庫文件相關的匯編代碼的機器碼抽取,並且添加上目標文件的頭部信息,共同組成了ELF格式(linux為例)的可重定位目標文件。
ELF的目標文件分為三類:
-
-
- 可重定位目標文件(.o)
-
- 其代碼和數據可和其他可重定位文件合並為可執行文件
- 每個 .o 文件由對應的 .c 文件生成
- 每個 .o 文件的代碼和數據地址都是從0開始的偏移
-
-
-
- 可執行目標文件(默認為a.out)
-
- 包含的代碼和數據可以被直接復制到內存並執行
- 代碼和數據的地址是虛擬地址空間中的地址
-
-
-
- 共享的目標文件(.so 共享庫)
-
- 特殊的可重定位目標文件,能在裝載到內存或運行時自動被鏈接,稱為共享庫文件
-
使用objdump -D反匯編最后生成的可重定位目標文件hello.o,得到匯編對應的機器碼,使用二進制方式打開hello.o文件進行對比;
可以觀察反匯編文件中的main函數開始的機器碼開始位置為55 48 80 e5:
二進制方式打開hello。o文件,可以找到對應的機器碼。上方的64字節可以根據零星的文本判斷是ELF文件頭信息。
1.4 鏈接--可重定位目標文件到可執行目標文件
程序連接主要包括兩部分,使連接后的程序可以被加載器加載到內存特定的位置,本文之敘述了基本概念來串聯從代碼到程序執行的過程,詳細的內容后續有機會再重開一篇文章補充(如果感興趣可以參考《鏈接器和加載器》或者《深入理解計算機系統》第三版第七章 或《程序員的自我修養——鏈接、裝載和庫》)。
重定位:編譯器和匯編器通常為每個文件創建程序地址從0開始的目標代碼,但是幾乎沒有計算機會允許從地址0加載你的程序。如果一個程序是由多個子程序組成的,那么所有的子程序必須被加載到互不重疊的地址上。重定位就是為程序不同部分分配加載地址,調整程序中的數據和代碼以反映所分配地址的過程。在很多系統中,重定位不止進行一次。對於鏈接器的一種普遍情景是由多個子程序來構建一個程序,並生成一個鏈接好的起始地址為0的輸出程序,各個子程序通過重定位在大程序中確定位置。當這個程序被加載時,系統會選擇一個加載地址,而鏈接好的程序會作為整體被重定位到加載地址。
符號解析:當通過多個子程序來構建一個程序時,子程序間的相互引用是通過符號進行的;主程序可能會調用一個名為sqrt的計算平方根例程,並且數學庫中定義了sqrt例程。鏈接器通過標明分配給sqrt的地址在庫中來解析這個符號,並通過修改目標代碼使得call指令引用該地址。
1.3章節中可以看到未進行鏈接的文件的各個段的位置都是0,實際上程序一般是不允許從0地址開始運行的,更何況所有代碼段的地址都是0的話加載會前后覆蓋,所以肯定是不行的,這就涉及到了代碼的重定位,使代碼可以加載到能運行的位置。
同樣的我們在main.c中調用了printf這個函數,匯編對應的是 e8 00 00 00 00 callq 10 <main+0x10>, 可是在hello.c中並沒有printf這個函數,它其實是libc中進行的實現我們使用#include<stdio.h>將他的符號擴展過來了,所以我們在hello.o中並不知道這個符號具體含義是什么也不知道它在哪兒,這個就是鏈接器需要做的符號解析的工作,將printf找出來使程序可以正常調用。加黑的部分是匯編的機器碼:e8表示mov指令,后面的是函數地址,可以看到這里的地址是一個無效值00 00 00 00.
1.4.1 ELF文件格式
ELF分為兩種視圖:
-
-
- 鏈接視圖:可重定位文件(Relocatable object files)
-
執行視圖:可執行目標文件(Executable object files)
-
可以看出比較明顯的區別是鏈接視圖有節區(section)執行視圖替換成了段區(segment),這是因為section太過零散所以鏈接的時候把相同性質的section(比如可讀寫屬性的所有section/所有可讀可執行的section)組合到一個段中。這么做的好處是在加載進內存的時候可以有效的減少內存碎片的產生(因為內存加載一般需要按頁對齊,且每個段單獨加載,這樣如果section大小不足一頁4096Byte也需要占一頁的空間就造成了很多內存碎片)。
另外,執行視圖的文件一般是由多個可重定位文件組成的,這就涉及到了這些文件的組合規則,組合的規則一般是使用鏈接腳本進行控制,所以下一節講鏈接腳本。
1.4.1.1 ELF文件頭
我們可以使用readelf -h查看文件的ELF頭,如下圖所示:
可以看出ELF頭中定義了ELF文件魔數、文件機器字節長度、數據存儲方式、版本、運行平台、ABI版本、ELF重定位類型、硬件平台、硬件平台版本、入口地址、程序頭入口和長度、段表的位置和長度、及段的數量等。
ELF格式定義位置在/usr/include/elf.h的Elf32_Ehdr,結構如下
#define EI_NIDENT 16 typedef struct{ unsigned char e_ident[EI_NIDENT]; //目標文件標識信息 Elf32_Half e_type; //目標文件類型 Elf32_Half e_machine; //目標體系結構類型 Elf32_Word e_version; //目標文件版本 Elf32_Addr e_entry; //程序入口的虛擬地址,若沒有,可為0 Elf32_Off e_phoff; //程序頭部表格(Program Header Table)的偏移量(按字節計算),若沒有,可為0 Elf32_Off e_shoff; //節區頭部表格(Section Header Table)的偏移量(按字節計算),若沒有,可為0 Elf32_Word e_flags; //保存與文件相關的,特定於處理器的標志。標志名稱采用 EF_machine_flag的格式。 Elf32_Half e_ehsize; //ELF 頭部的大小(以字節計算)。 Elf32_Half e_phentsize; //程序頭部表格的表項大小(按字節計算)。 Elf32_Half e_phnum; //程序頭部表格的表項數目。可以為 0。 Elf32_Half e_shentsize; //節區頭部表格的表項大小(按字節計算)。 Elf32_Half e_shnum; //節區頭部表格的表項數目。可以為 0。 Elf32_Half e_shstrndx; //節區頭部表格中與節區名稱字符串表相關的表項的索引。如果文件沒有節區名稱字符串表,此參數可以為 SHN_UNDEF。 }Elf32_Ehdr;
1.4.1.2 段表
可重定位目標文件有很多各種各樣的段,段表(section header table)就是保存這些段基本屬性的結構。如:每個段的段名、段的長度、在文件中的偏移、讀寫權限及段的其他屬性。段表在文件中的位置是由ELF文件頭中的“e_shoff”決定。
可以有兩種方式查看段表信息:1. objdump -h hello.o查看ELF中的一些關鍵段。 2. readelf -S hello.o 查看ELF中詳細的段信息
段表是由“Elf32_Shedr"(段描述符)的結構體為元素的數組,段描述符結構如下所示,文件位置/usr/include/elf.h
typedef struct { Elf32_Word sh_name; /* Section name (string tbl index) */ Elf32_Word sh_type; /* Section type, */ Elf32_Word sh_flags; /* Section flags,該段在進程虛擬空間中的屬性,如是否可寫是否可執行等 */ Elf32_Addr sh_addr; /* Section virtual addr at execution */ Elf32_Off sh_offset; /* Section file offset */ Elf32_Word sh_size; /* Section size in bytes */ Elf32_Word sh_link; /* Link to another section */ Elf32_Word sh_info; /* Additional section information */ Elf32_Word sh_addralign; /* Section alignment */ Elf32_Word sh_entsize; /* Entry size if section holds table */ } Elf32_Shdr;
1.4.1.3 段內容
一個段可能包括一到多個節區,但是這並不會影響程序的加載。盡管如此,我們也必須需要各種各樣的數據來使得程序可以執行以及動態鏈接等等。下面會給出一般情況下的段的內容。對於不同的段來說,它的節的順序以及所包含的節的個數有所不同。此外,與處理相關的約束可能會改變對應的段的結構。
如下所示,代碼段只包含只讀的指令以及數據。當然這個例子並沒有給出所有的可能的段。
數據段包含可寫的數據以及以及指令,通常來說,包含以下內容
程序頭部的 PT_DYNAMIC 類型的元素指向指向 .dynamic 節。其中,got 表和 plt 表包含與地址無關的代碼相關信息。盡管在這里給出的例子中,plt 節出現在代碼段,但是對於不同的處理器來說,可能會有所變動。
.bss 節的類型為 SHT_NOBITS,這表明它在 ELF 文件中不占用空間,但是它卻占用可執行文件的內存鏡像的空間。通常情況下,沒有被初始化的數據在段的尾部,因此,p_memsz
才會比 p_filesz
大。
注意:
-
-
- 不同的段來說可能會有所重合,即不同的段包含相同的節。
-
1.4.1.4 程序頭表
可執行目標文件同樣有很多的段Segment信息,這些信息是將可執行文件中屬性相同的段Section組合到了一起以方便加載時進行映射方便節省空間。從段表一節可以看到我們的程序有13個Section,使用readelf -l hello可以查看程序頭表——即Section合並后的Segment信息。
可以看到合並后13個Section只剩下了6個。這里我們主要關注兩個LOAD段,這兩個段是會加載到內存中去的,其余的都是一些輔助段不涉及加載,我們暫不分析。Section和Segment的轉換關系可以參照下圖,VM0和VM1分別表示兩個LOAD,只是他們有不同的屬性(如可讀可執行和可讀可寫)。程序頭表就描述了這寫Segment在虛擬內存和物理存儲中的位置以方便加載進行。
同樣,在內核代碼中程序頭表是以結構體方式定義的,它仍然在/usr/include/elf.h中可以找到
typedef struct { Elf32_Word p_type; /* Segment type,暫只關注LOAD類型,其他還有動態鏈接DYNAMIC等 */ Elf32_Off p_offset; /* Segment file offset, Segment在文件中的偏移 */ Elf32_Addr p_vaddr; /* Segment virtual address, Segment在虛擬地址中第一個字節的位置 */ Elf32_Addr p_paddr; /* Segment physical address, Segment的物理裝載地址 */ Elf32_Word p_filesz; /* Segment size in file, Segment在文件中所占空間的大小 */ Elf32_Word p_memsz; /* Segment size in memory, Segment在虛擬空間中所占用的長度 */ Elf32_Word p_flags; /* Segment flags ,權限屬性,如R W X*/ Elf32_Word p_align; /* Segment alignment , 對齊屬性,如兩字節對齊*/ } Elf32_Phdr;
1.4.1.3 重定位表
在readelf -S獲取的段表中可以看到一個名為.rela.text的段,它的類型為RELA,也就是說這是一個重定位表(Relocation Table)。鏈接器在處理目標文件時,需要對目標文件某些部位進行重定位即代碼段和數據段中那些絕對地址的引用位置。這些重定位信息都保存在重定位表里,每一個需要重定位的段都會有一個對應的重定位表。
重定位表定義如下:
typedef struct { Elf32_Addr r_offset; //給出了重定位動作所適用的位置 Elf32_Word r_info; //給出要進行重定位的符號表索引,以及將實施的重定位類型. } Elf32_Rel; typedef struct { Elf32_Addr r_offset; Elf32_Word r_info; Elf32_Word r_addend; //給出一個常量補齊,用來計算將被填充到可重定位字段的數值。 } Elf32_Rela;
重定位節區會引用兩個其它節區:符號表、要修改的節區。節區頭部的 sh_info 和sh_link 成員給出這些關系。不同目標文件的重定位表項對 r_offset 成員具有略微不同的解釋。
1.4.1.4 字符串表
ELF中使用了很多字符串,比如段名、變量名等。因為字符串長度往往時不固定的,所以用固定的結構表示它們比較困難。一種常見的作法是把字符串集中起來放到一個表,然后使用字符串在表中的偏移來引用字符串。
通過這種方法,ELF文件中引用字符串只需要提供一個下標就可以,不用考慮字符串長度的問題。一般字符串表在ELF文件中存放在段”.strtab“或者”.shstrtab“中。分別表示字符串表(String Table)和段字符串表(Section Header String Table)。
值得一提的是,ELF文件頭最后一個變量”e_shstrndx“-Section Header string table index,存的就是短字符串表的位置,查看readelf -h的最后一個段數值是12,readelf -S的下標12的就是段shstrtbl。解析ELF頭可以得到段表和段表名稱的表位置,從而可以利用段和段名解析整個ELF文件。
1.4.2 鏈接過程控制
由於連接過程由很多內容要確定,使用那些目標文件?使用那些庫文件?是否在最終可執行文件中保留調試信息、輸出文件格式(可執行文件還是動態庫)、還要考慮是否到處某些符號以供調試器或程序本身或其他程序使用。
1.4.2.1 鏈接控制腳本
鏈接器一般提供多種控制整個鏈接過程的方法,用來產生用戶所需要的文件,一般由如下三種方法:
-
-
-
- 使用命令行給鏈接器指定參數,如ld -o指定輸出文件, -e main 指定鏈接后函數入口, -T *.lds指定鏈接腳本
- 將鏈接指令存放在目標文件里面,編譯器經常通過這種方法向鏈接器傳送指令。如visual c++編譯器會把鏈接參數放在放在PE文件的.derectve段用來傳遞參數
- 使用鏈接控制腳本,屬於最為靈活也最為強大的方法
-
-
本節基於linux的鏈接腳本進行講解,1.2節中的ld鏈接命令並沒有指定鏈接腳本,在不指定的情況下將會使用linux默認的鏈接腳本,可以使用ld -verbose進行查看。
默認的鏈接腳本放在/usr/lib/ldscripts目錄下,不同的機器平台和輸出文件都有不同的鏈接腳本。 普通的可執行文件鏈接腳本后綴為 *.x, 共享庫的鏈接腳本后綴為 *.xs等。
1.4.2.2 鏈接腳本語法
ld鏈接器的鏈接腳本的鏈接語法繼承AT&T鏈接器命令語言的語法,它由一系列語句組成,語句分兩種,一種是命令語句,一種是賦值語句。語法與C語言相似處如下:
-
-
-
- 語句間使用“;”作為分隔符, 原則上語句都需要以;作為結束符,不過對於命令語句來說可以以換行作為結束符,賦值語句則必須以“;”作為結束符
- 表達式與運算符 腳本語言允許使用C語言類似的運算符,如: +、-、*、/、+=、-=、*=等,甚至包括&、 |、 >>、<<
- 注釋和字符引用 使用/**/作為注釋,腳本文件中使用到的文件名、格式名或者段名凡是包含“;”或者其他分割符的都要使用雙引號將該名字全稱包含起來,
- 語句間使用“;”作為分隔符, 原則上語句都需要以;作為結束符,不過對於命令語句來說可以以換行作為結束符,賦值語句則必須以“;”作為結束符
-
-
一個簡單的鏈接腳本示例:
ENTRY(main) SESSIONS { .=0X08048000 + SIZEOF_HEADERS; text : { *(.text) *(.data) *(.rodata) } /DISCARD/ : { *(.comment } }
ENTRY(main): 指定程序執行的入接口為main,一般程序入接口為_start。還可以使用ld的-e main,指定函數入口為main,命令行優先級比鏈接腳本高。
.=0X08048000 + SIZEOF_HEADERS : 表示當前的虛擬地址設置為0x08048000 + SIZEOF_HEADERS, SIZEOF_HEADERS為輸出文件文件頭的大小。
text : { *(.text) *(.data) *(.rodata) } : 段轉換規則作用參考上方紅字,含義是所有輸入段中的text、data、rodata依次合並到輸出文件的text段中。
/DISCARD/ : { *(.comment } :特殊關鍵字/DISCARD/, 將輸入中的comment段都丟棄,不放入輸出文件中。
其他一些命令語句:
-
INCLUDE filename
: 包含名為 filename 的鏈接腳本。相當於 c 程序里的 #include 宏指令,用以包含另一個鏈接腳本。 -
INPUT(files)
: 將括號內的文件作為鏈接過程的輸入文件。 -
OUTPUT(FILENAME)
: 定義輸出文件的名字。 -
GROUP(files)
: 指定需要重復搜索符號定義的多個輸入文件, file 必須是庫文件,且 file 文件作為一組被 ld 重復掃描,直到不再有新的未定義的引用出現。 -
SEARCH_DIR(PATH)
: 定義搜索路徑。 -
STARTUP(filename)
: 指定 filename 為第一個輸入文件。 -
PROVIDE
關鍵字: 該關鍵字用於定義這類符號:在目標文件內被引用,但沒有在任何目標文件內被定義的符號。
參考鏈接: https://wiki.x10sec.org/executable/elf/elf_structure/