1. 概述
Windows環境下的開發同事,對於“目標文件”概念的理解或多或少有些陌生,因爲大多數都是基於IDE(Integrated Development Environment)集成開發環境進行項目開發。如常用到的 IntelliJ IDEA、Visual Studio、Eclipse 等等。當某函數功能開發完成之後,直接鼠標點擊構建(Build)然後就會生成一個可以直接執行的成果物(Windows下爲文件名.exe),而對於該文件名.exe可執行文件的生成過程卻很模糊和。殊不知構建(Build)背後默默做了一大堆的處理與判斷,比如編譯、連接等過程。如圖1所示,爲Visual Studio集成開發環境界面,當執行快捷鍵F5或是鼠標點擊調試,然後啓動調試時候,所有工作便交給該IDE後臺進行。若代碼沒有語法等錯誤,則會生成一個可執行文件。
圖1 Visual Studio 集成開發環境
作爲一個Linux下的開發同事,有義務、也有必要去弄懂目標文件生成過程的來龍去脈。它能加深我們對編譯和連接過程的理解,同事也能提高我們排查問題的效率和速度。掌握了目標文件生成過程的必須幾個階段過程,遇到問題也能對症下藥。
2. 目標文件生成
從用C/C++/PHP/JAVA等高級語言編寫的源程序(代碼)到最終的可執行程序,中間共經歷了以下重要的4個階段。分別是“預處理、編譯、彙編和連接”。如圖2所示。備註:這裏的“目標文件”是指最終可執行文件(如a.out),而非“彙編”產生的中間成果物“目標文件”。
圖2 編譯和連接原理圖
每個階段的背後,編譯器都會爲我們做大量的工作,特別是編譯部分,編譯器會對預處理生成的xxx.i文件做海量操作,包括:源代碼掃描、語法分析、語義分析、源代碼優化、代碼生成、目標代碼優等等。這其中隨便剖出一個小節,都足以去用一本書的知識和理論才能夠去支撐其原理。因此本章節旨在用言簡意賅的言語來描繪目標文件從源文件代碼到最終的可執行文件中幾個必經階段,後續會有專門的章節來講述編譯器的原理,以及編譯器在背後所擔任的重要角色。圖3給出gcc編譯過程中的4個階段分別對應的gcc編譯指令。
圖3 gcc編譯過程各階段對應的命令
2.1 目標文件之預處理
對源代碼進行預處理,是gcc編譯過程中的第一個階段。本階段中,編譯器負責的主要功能是處理源代碼文件中的那些以“#”開始的預處理命令,如:#include, #define等等。包括但不限於以下幾點(以下六點來着《程序員的自我修養》 P39):
I. 將所有的“#define”刪除,並且展開所有的宏定義
II. 處理所以條件預編譯指令,如“#if”、“#ifdef”、“#else”、“#endif”、“#elif”等等
III. 處理“#include”預編譯指令,將被包含的文件插入到該預編譯指令位置。注意,這個過程是遞歸的,即被包含的文件還可能包含其他文件
IV. 刪除所有的註釋“//”和“/**/”
V. 添加行號和文件名標識,如 #2 “b.c” 2, 以便編譯時編譯器產生調試用的行號信息和編譯時產生編譯錯誤信息或是警告時候能夠顯示行號
VI. 保留所有的“#pragma”編譯器指令因爲編譯器需要使用它們
注意,本階段僅是進行一些預處理宏的簡單文本替換工作,不對語言的語義和語法等進行任何的判斷操作。如例1所示。
1 #include <stdio.h>
2 #define TEST = 10; //錯誤1:#define使用了“=”號; 錯誤2:#define使用了“;”分號
3 int main()
4 {
5 printf("Test...\n");
6 return 0;
7 }
在上面的示例代碼1中,共有2處錯誤,分別是使用了“=”號和使用了“;”分號,但是使用gcc -E b.c -o b.i預處理命令編譯的時候,並不會報錯。相反,是編譯通過,同時生成了b.i文件。
使用 file 命令查看生成的b.i文件內容,可以看到還是ASCII碼的文本文件。
xiaogang5@Cpl-Backend-General-14-115:~/xxx/xxx/xxxx$ file b.i
b.i: ASCII C program text
b.i文件中代碼是經過宏處理/宏替換後的代碼。如下所示。
1 # 1 "b.c"
2 # 1 "<built-in>"
3 # 1 "<command-line>"
4 # 1 "b.c"
5 # 1 "/usr/include/stdio.h" 1 3 4
6 # 28 "/usr/include/stdio.h" 3 4
7 # 1 "/usr/include/features.h" 1 3 4
8 # 324 "/usr/include/features.h" 3 4
9 # 1 "/usr/include/x86_64-linux-gnu/bits/predefs.h" 1 3 4
10 # 325 "/usr/include/features.h" 2 3 4
11 # 357 "/usr/include/features.h" 3 4
12 # 1 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 1 3 4
13 # 378 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 3 4
14 # 1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
15 # 379 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 2 3 4
16 # 358 "/usr/include/features.h" 2 3 4
17 # 389 "/usr/include/features.h" 3 4
18 # 1 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 1 3 4
..........................
839 extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
840
841
842
843 extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
844
845
846 extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
847 # 940 "/usr/include/stdio.h" 3 4
848
849 # 2 "b.c" 2
850
851
852
853 int main()
854 {
855 printf("Test...\n");
856 return 0;
857 }
2.2 目標文件之編譯
gcc中的編譯過程就是將第一步預處理彙總的文件進行一系列的詞法分析、語法分析、語義分析以及代碼優化等等操作。編譯是gcc編譯的整個過程的最爲核心部分,也是最爲複雜部分。後面有專門一章節內容來講述編譯器編譯的過程。感興趣的讀者可以自己閱讀書籍《編譯原理(第2版)》(計算機科學叢書)、《編譯系統透視:圖解編譯原理》。
編譯使用命令:gcc -S xxx.i -o xxx.s
使用 file 命令查看,生成的b.s文件,可以看到是 ASCII碼的彙編文本文件。
lixiaogang5@Cpl-Backend-General-14-115:~/xxx/xxx/xxx$ file b.s
b.s: ASCII assembler program text
b.s文本文件中的內容主要如下所示,可以看到編譯器已經文件中的所有內容都替換爲了彙編代碼。
1 .file "b.c"
2 .section .rodata
3 .LC0:
4 .string "Test..."
5 .text
6 .globl main
7 .type main, @function
8 main:
9 .LFB0:
10 .cfi_startproc
11 pushq %rbp
12 .cfi_def_cfa_offset 16
13 .cfi_offset 6, -16
14 movq %rsp, %rbp
15 .cfi_def_cfa_register 6
16 movl $.LC0, %edi
17 call puts
18 movl $0, %eax
19 popq %rbp
20 .cfi_def_cfa 7, 8
21 ret
22 .cfi_endproc
23 .LFE0:
24 .size main, .-main
25 .ident "GCC: (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3"
26 .section .note.GNU-stack,"",@progbits
2.3 目標文件之彙編
彙編,即彙編器將上一步中生成的彙編代碼逐一翻譯爲對應的機器可以識別和執行的機器指令,翻譯操作碼,格式化每條指令中的比特並且將 標記1
翻譯成地址。對於符號這些東西,計算機是不認識。因此,想要讓計算機執行我們預期的操作,就需要彙編器的介入。本階段相較於編譯,工作和邏輯上面稍微簡單些,沒有太多複雜的處理操作。僅是起到了一個翻譯的作用。使用命令:gcc -c xxx.o -o xxx.o, 也可以使用 as 彙編工具。 但是彙編器匯編出來的成果物還不能執行,儘管在文件存儲格式上面已經可執行程序文件相同(當然,結構上面有些差異),因爲缺少某些庫,以及對某些符號地址的引用加以修正。
若最終的目標文件(可執行程序)僅有一個 .o 鏈接生成,那麼文件中有多少的變量(局部、全局)、函數等都是可以確定的,這個時候可以不需要一些其他輔助內容,如:符號表、重定位表等。然而實際上項目中的目標文件都是由若干個 中間目標文件(.o)鏈接而成。其中每一個目標文件(.o)都有可能調用其他 .o文件的全局變量或是函數,如《C/C++中關鍵字extern詳解》中的 實例1.3 所示。 這樣在單獨 彙編生成每一個.o 文件的過程裏, 是無法去確定所調用的 外部函數或全局變量數的詳細具體地址,若確定不來具體地址,就無法將其加載到內存中去執行。
彙編程序產生一個目標文件(中間目標文件 .o ),目標文件以二進制格式描述指令和數據。可以看到此時的目標文件(稱呼爲“中間目標文件”更易理解)已經和最終的目標文件(可執行程序)的文件格式是一樣的,都爲ELF格式存儲。如下所示。
lixiaogang5@Cpl-Backend-General-14-115:~/xxx/xxx/xxx$ gcc -c b.s -o b.o
lixiaogang5@Cpl-Backend-General-14-115:~/xxx/xxx/xxx$ file b.o
b.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
ELF文件,需使用readelf 命令來讀取。如:readelf -a .bo; 其中b.o文件的內容如下,如何閱讀ELF格式文件,請參考 《揭開“目標文件”背後哪些不爲人知的祕密》
lixiaogang5@Cpl-Backend-General-14-115:~/xxx/xxx/xxx$ readelf -a b.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 304 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 10
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000015 0000000000000000 AX 0 0 4
[ 2] .rela.text RELA 0000000000000000 00000588
.............//省略若干行
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
There are no section groups in this file.
There are no program headers in this file.
Relocation section '.rela.text' at offset 0x588 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000005 00050000000a R_X86_64_32 0000000000000000 .rodata + 0
00000000000a 000a00000002 R_X86_64_PC32 0000000000000000 puts - 4
Relocation section '.rela.eh_frame' at offset 0x5b8 contains 1 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
There are no unwind sections in this file.
Symbol table '.symtab' contains 11 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS b.c
.......//省略若干行
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 21 FUNC GLOBAL DEFAULT 1 main
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
2.4 目標文件之鏈接
實際項目開發中,都是將項目拆分爲若干個子模塊,而每個子模塊中又包含了若干個文件,其中每個文件中的代碼負責處理獨特的功能特性;而不是將所有的邏輯功能代碼放在一個大程序文件中。這樣的好處是“有助於描述出程序的模塊行、提高開發效率、利於問題排查和維護”,因爲不可能大家同時寫一個文件。連接器允許通過幾個程序片段來組成一個程序。它是在由彙編程序生成的目標文件上面操作,且會對彙編程序作出合適且適量的修改以便於各程序文件之間能夠完成連接。如圖4所示。
圖4 目標文件由若干個中間目標文件鏈接而成
連接的過程通常會涉及一些動態庫(xxx.so)、靜態庫(xxx.a)的加載與尋址過程。動態鏈接庫(Dynamically Linked Libray)並不是將一個普通使用的歷程(如文件I/O,網絡I/O等)連接到系統中的每一個可執行程序,而是允許它們在程序執行的開端連接。使用動態庫節省存儲空間,程序更新容易,但是會在程序開始執行之前引入一個延遲。
每個 .o 文件都有一個符號表,該符號表中記錄着函數在代碼中的相對位置或全局變量在數據段中的相對位置。當把一個或多個 .o 鏈接成一個可執行文件時候,通過各個 .o 文件符號表中記錄的相對位置信息,就能夠計算出可執行文件中各個函數和全局函數在代碼段和數據段中的絕對位置信息,即明確了地址值,並且用真實的地址值代替代碼段中的臨時地址值。此時的可執行程序中的代碼和數據就可以直接加載了。如圖5所示。
圖5 每個.o 文件中都有一張符號表
3. 總結
本博客主要描述了C/C++開發中的gcc編譯可執行文件的4個經歷階段。以及每個階段中gcc編譯的命令和編譯出來的文件格式、內容說明。同時也粗略的對每個階段的生成原理作了一個簡介。
參考資料
《嵌入式計算系統設計原理》
《編譯原理》
《編譯系統透視·圖解編譯原理》
《程序員自我修養》
標記(label):給存儲單元提供的名字. 如:
label1 ADR r4, c
, 其中 label1 就是一個標記。 ↩︎