高級語言的編譯:鏈接及裝載過程介紹
大龍,志超 2015-01-22 15:00</span> 引言
隨著越來越多功能強大的高級語言的出現,在服務器計算能力不是瓶頸的條件下,很多同學會選擇開發效率高,功能強大的虛擬機支持的高級語言 (Java),或者腳本語言(Python,Php)作為實現功能的首選,而不會選擇開發效率低,而運行效率高的 C/C++ 作為開發語言。而這些語言一般情況下是運行在虛擬機或者解釋器中,而不需要直接跟操作系統直接打交道。
虛擬機和解釋器相當于為高級語言或者腳本語言提供了一個中間層,隔離了與操作系統之間進行交互的細節,這為工程師們減少了很多與系統底層打交道的麻 煩,大大提高了工程師的開發效率。但是這樣也造成了工程師們長期工作在高級語言之上,在有時候需要與鏈接庫,可執行文件,CPU 體系結構這些概念有交互的時候會比較陌生。
因此,本文為了讓讀者可以對源代碼如何編譯到二進制可執行程序有一個整體的了解,將會從一下幾個方面介紹一下程序編譯,鏈接和裝載的基本原理。
- 首先我們介紹一下不同的 CPU 體系結構和不同的操作系統對應的可執行文件的格式。
- 然后以幾個簡單的 C 程序為例子,介紹一下編譯器和鏈接器對程序源代碼所做的處理。
- 最后我們看一下程序執行的時候,裝載器對程序的處理以及操作系統對其的支持。
CPU體系結構
我們現在大部分同學接觸到的 PC 機或者服務器使用的 CPU 都是 X86_64 指令集體系結構,這是一種基于 CISC(復雜指令集體系結構)。我們在計算機組成原理的課程里面都學到,其實CPU指令集類型中除了 CISC,還有另外一種 RISC 類型的 CPU 體系結構,也就是簡單指令集體系結構,比如 SUN 的 SPARC 指令集,IBM 的 PowerPC 指令集都是基于 RISC 指令集的 CPU 體系結構。我們這里不去深究各種體系結構的細節,我們關心的是在其中一種 CPU 體系結構中編譯的代碼能夠在另一種體系結構下面運行么?
答案是否定的,因為所謂的二進制程序,其實都是有一條一條的 CPU 指令組成,二進制程序執行的過程中,也是由 CPU 把這些指令 load 到指令流中一條一條執行。不同的 CPU 體系結構的指令集是不一樣的,指令的長度和組成都有區別。所以讓SPARC的CPU去執行一個編譯成 X86 的 CPU 指令集的二進制程序是不可行的。
操作系統
記得當年 java 語言剛興起的時候,有一個很大的賣點就是跨平臺執行。為什么能夠跨平臺執行呢,這是因為 java 程序經過 javac 編譯之后得到了一種 java 虛擬機可以執行的字節碼文件。只要在不同的操作系統上(Windows,Linux,MacOS)上裝上自己所屬版本的 java 虛擬機之后,就可以執行在另外一種操作系統下面編譯的 java 字節碼程序。那么,為什么經過 gcc/g++ 編譯過的二進制程序不能跨平臺執行呢?
我們剛才說了,java 程序能夠跨平臺執行是因為不同系統平臺上面安裝的 java 虛擬機能夠識別同一種 java 字節碼。那么我們是不是可以推斷,不同的操作系統二進制程序不能跨平臺執行,是因為不同操作系統下面二進制文件的格式不同呢?
事實確實是這樣的。我們都知道的是一個程序編譯成二進制之后,運行的時候是從 main 函數開始執行的。但是這個程序是怎么樣 load 到內存中的,執行流又是如何準確的定位到 main 函數的地址的。其實這些工作都是操作系統替我們做的。我們可以大膽想一下,main 函數之前操作系統都需要做哪些工作。首先,操作系統肯定要分配一塊虛擬地址空間;然后系統需要把二進制程序中的代碼和數據 load 到這個地址空間中,隨后系統會根據某種特定的文件格式,找到其中某一個特定的位置(初始化段),做一些程序運行前的初始化工作,比如環境變量初始化和全局 變量的處理,然后開始執行我們的 main 函數。這里的“某種特定的文件格式”就是為什么二進制程序不能跨平臺運行的原因。
這里我真正想說的是,每一種操作系統有自己的二進制文件格式,操作系統把二進制可執行程序load到內存中之后,會根據默認的這種格式尋找各種數 據,比如代碼段,數據段和初始化段。所以說 Windows 下面的 exe 可執行文件,lib 靜態庫,dll 動態庫是不可以直接運行在 Linux 系統下面的;MacOS 下面的 Mach-O 可執行文件,靜態鏈接庫(a庫),動態鏈接庫(so庫)也是不能夠直接放在 Linux 系統下面運行的。反之亦然,Linux 下面的 ELF 可執行文件,靜態鏈接庫(a庫),動態鏈接庫(so庫)同樣不能夠在 Window 系統下面運行。
源代碼的編譯
說完了 CPU 體系結構和操作系統對二進制文件格式的影響,下面我們從幾個例子看一下從源代碼文件如何經過處理最終變成一個可執行文件。不同的系統下有不同的編譯器,比 如 Windows 下有 vs 自帶的 C++ 編譯器,Linux 和 Unix 下面有 gcc/g++ 編譯器。也有很多不同的編程語言,各自有自己的編譯器把相應的源代碼編譯成二進制可執行程序。盡管有些實現細節不同,這些編譯器的工作原理和過程是一致 的。由于 Linux 和 c語言使用相對廣泛,同時筆者對 Linux 和C/C++相對熟悉,本文剩下的部分都是基于在 Linux 平臺下使用 gcc/g++ 編譯器編譯 c/c++ 源代碼進行說明和解釋。
本文的初衷是讓工程師對程序源代碼如何通過編譯器,鏈接器和裝載器最終成為一個進程運行在系統中的整個過程有一個基本的理解,所以并不會涉及到編譯 器如何通過進行詞法分析,語法分析和語義分析最終得到目標二進制文件。因此本文剩下的部分主要集中在 gcc/g++ 如何形成一個 Linux 認識的 elf 可執行文件的。
C源碼文件
首先我們簡單回顧下 C 源碼程序中變量和函數的基本概念。
我們先來區分一下聲明和定義的概念。在 C 語言程序中,我們可以聲明一個變量和或者一個函數,也可以定義一個變量或者函數。這兩個的區別如下:
- 聲明一個全局變量或者函數是告訴編譯器,在當前的源文件中可能會用到這個變量或者調用這個函數,但是這個變量或者函數不在當前文件中定義,而會在其他的某個文件中定義,請編譯器編譯本文件的時候不要報錯。
- 定義一個變量是告訴編譯器在生成的目標文件中預留一個空間,如果變量有初始值,請編譯器在目標文件中保存這個初始值。
- 定義一個函數是請編譯器在這個文件的目標文件中生成這個函數的二進制代碼。
然后我們需要來看一下 C 源碼程序中的變量類型和函數類型,最基本的 C 程序(不使用 C++ 功能)是比較簡單的,我們可以聲明定義變量和局部變量,全局變量和局部變量可以聲明為 static 變量和非 static 變量。除此之外,我們可以通過 malloc 動態申請變量。他們的區別如下:
- 非 static 全局變量表示這個變量存在于程序執行的整個生命周期,同時可以被本源碼文件之外的其他文件訪問到。
- static 全局變量表示這個變量存在于程序執行的整個生命周期,但是只能被本源碼文件的函數訪問到。
- 非 static 局部變量表示,這個變量只在本變量所在的函數的執行上下文中存在(實際上這種變量是在函數執行棧的函數棧幀中)
- static 局部變量其實屬于全部變量的范疇,它存在于程序執行的整個生命周期,但是作用域被局限在定義這個變量的代碼塊中(大括號包含的范圍)
- 動態申請的變量表明這個變量是在運行過程中,由函數動態的從進程的地址空間中申請一塊空間,并使用這個空間存儲數據。
對于函數而言,我們同樣可以定義 static 和非 static 的函數,區別如下:
- 非 static 函數定義表明這是一個全局的函數,可以被本源碼文件的其他文件訪問到。
- static 函數限制了本函數只能被本源碼文件的函數調用。
我們用下面這個小程序來看看編譯器對上述這些變量都做了什么樣的處理。
int g_a = 1; //定義有初始值全局變量 int g_b; //定義無初始值全局變量 static int g_c; //定義全局static變量 extern int g_x; //聲明全局變量 extern int sub(); //函數聲明 int sum(int m, int n) { //函數定義 return m+n; } int main(int argc, char* argv[]) { static int s_a = 0; //局部static變量 int l_a = 0; //局部非static變量 sum(g_a,g_b); return 0; }
目標文件
在我們用 gcc 編譯這個程序來查看編譯器做了哪些事情之前,其實我們可以簡單梳理一下,為了讓這個程序能夠運行起來,編譯器至少都需要做哪些事情。首先,我們都會默認 CPU 會根據程序的代碼一條一條執行;其實,當遇到了條件判斷或者函數調用,程序就會發生指令流的跳轉;還有,程序代碼執行的過程中需要操作各種變量指向的數 據。從這三個“理所當然”的行為里面,我們可以推斷出編譯器至少需要做哪些事情。第一,CPU 肯定不能理解這些高級語言代碼,編譯器需要把代碼編譯成二進制指令。 第二,指令流跳轉的時候,CPU 怎么能找到要跳轉的位置,編譯器需要為每個定義的函數所在的位置定義一個標簽,每個標簽有一個地址,調用每個函數的時候就相當于跳轉到那個標簽指向的地 址。 第三,CPU 如何能找到那些變量指向的數據,編譯器需要為每一個變量定義一個標簽,每個標簽同樣有一個地址,這個地址指向內存中的數據空間。
我們來實際看一下編譯器的行為,我們先把這個這個編譯成目標文件看一下:
gcc -c test.c -o test.o && nm test.o
0000000000000000 D g_a 0000000000000004 C g_b 0000000000000000 b g_c U g_x 0000000000000014 T main 0000000000000004 b s_a.1597 0000000000000000 T sum
首先我們用 gcc -c 命令把 test.c 源碼文件編譯成 test.o 目標文件,需要注意的是雖然目標文件也是二進制文件,但是和可執行文件是有區別的,目標文件僅僅把當前的源碼文件編譯成二進制文件,并沒有經過鏈接過程, 是不能夠執行的。然后我們用 nm 命令可以查看一下目標文件的 symbol 信息。這里我們看到了nm命令的輸出默認有三列,其中最左邊的一列是變量的相對地址,中間的一列表示變量所在的段的類型,右面表示變量的名字。我們來分別 看一下這三列的意思。
- 首先最左邊這一列是變量在所在段的相對地址,我們看到 g_a 和 g_c 的相對地址是相同的,這并不沖突,因為他們處于不同的段中( D 和 b 表示它們在目標文件中處于不同的段中)。
- 第二列表示變量所處的段的類型,比如我們這里看到了有 D,C,b,T 這些類型的段,實際上編譯器支持的段類型比這個還多。我們同樣不去深究各個段類型的意思,只要明白不同的段存放的是不同的數據即可,比如 D 段就是數據段,專門存放有初始值的全局變量,T 段表示代碼段,所有的代碼編譯后的指令都放到這個段中。在這里我們可以注意到同一個段中的變量相對地址是不能重復的。
- 第三列表示變量的名字,這里我們看到局部的靜態變量名字被編譯器修改為 s_a.1597,我們應該能猜得到編譯器這么做的原因。s_a 是一個局部靜態變量,作用域限制在定義它的代碼塊中,所以我們可以在不同的作用域中聲明相同名字的局部靜態變量,比如我們可以在sum函數中聲明另外一個 s_a。但是我們上面提過,局部靜態變量屬于全局變量的范疇,它是存在于程序運行的整個生命周期的,所以為了支持這個功能,編譯器對這種局部的靜態變量名 字加了一個后綴以便標識不同的局部靜態變量。
細心的讀者應該能看到,為什么這里的變量聲明 g_x 沒有地址呢?我們在C源碼文件部分曾經提到過,變量和函數的聲明本質上是給編譯器一個承諾,告訴編譯器雖然在本文件中沒有這個變量或者函數定義,但是在其 他文件中一定有,所以當編譯器發現程序需要讀取這個變量對應的數據,但是在源文件中找不到的時候,就會把這個變量放在一個特殊的段(段類型為 U)里面,表示后續鏈接的時候需要在后面的目標文件或者鏈接庫中找到這個變量,然后鏈接成為可執行二進制文件。
從上面這些信息的解釋其實我們可以看出來,編譯器沒有那么復雜,它做的任何事情都是為了支持語言級別的功能。
目標文件的鏈接
通過上一個部分的一個小程序,我們討論了 C 源碼文件的基本組成部分,編譯器對這些組成部分的處理以及編譯器這么做背后的原理。同時我們也留下了一個需要在其他目標文件中尋找的變量名。這一小節,我 們討論一下,在編譯器把各個 C 源代碼文件編譯成目標文件之后,鏈接器需要對這些目標文件做什么樣的處理。
首先我們嘗試一下對上一小節得到的目標文件鏈接一下看看有什么結果:gcc test.o -o test
test.o: In function `main': test.c:(.text+0x2c): undefined reference to `g_x' collect2: ld returned 1 exit status
當我們嘗試把這個目標文件進行鏈接成為可執行文件時,鏈接器報錯了。因為我們之前通過變量聲明承諾過的變量并沒有在其他的目標文件或者庫文件中找到,所以鏈接器無法得到一個完整可執行程序。我們嘗試用另外一個 C 程序修復這個問題:
int g_x = 100; int sub() {}
把這個文件編譯成目標文件gcc -c test2.c -o test2.o; nm test2.o
0000000000000000 D g_x 0000000000000000 T sub
現在我們嘗試把這兩個目標文件鏈接成為可執行文件:gcc test.o test2.o -o test; nm test, 這時我們發現輸出了比目標文件多很多的信息,其中定義了很多為了實現不同語言級別的功能而需要的段,在這里我們關心的是源文件中定義的那些變量對應的 symbol 及其地址,如下圖所示:
00000000004005e8 T _fini 0000000000400390 T _init 00000000004003d0 T _start ... 0000000000601018 D g_a 0000000000601038 B g_b 0000000000601030 b g_c 000000000060101c D g_x 00000000004004c8 T main 0000000000601034 b s_a.1597 0000000000400504 T sub 00000000004004b4 T sum
在最終的可以執行文件里面,我們可以看到,首先,之前在第一個源文件中聲明的變量 g_x 和聲明的函數 sub 最終在第二個目標文件中找到了定義;其次,在不同目標文件中定義的變量,比如 g_a, g_x 都會放在了數據段中(段類型為 D);還有,之前在目標文件中變量的相對地址全部變成了絕對地址。
所以我們再一次進行總結一下鏈接器需要對源代碼進行的處理:
- 對各個目標文件中沒有定義的變量,在其他目標文件中尋找到相關的定義。
- 把不同目標文件中生成的同類型的段進行合并。
- 對不同目標文件中的變量進行地址重定位。
這也是鏈接器所需要實現的最基本的功能。
裝載運行
上面的幾個小節中我們討論了編譯器把一個 C 源碼文件編譯成一個目標文件需要做的最基本的處理,也討論了鏈接器把多個目標文件鏈接成可執行文件時需要具備的最基本的功能。在這一個小節我們來討論一下可執行文件如何被系統裝載運行的。
動態鏈接庫
我們都知道,在我們寫程序的過程中,不會自己實現所有的功能,一般情況下會調用我們所需要的系統庫和第三方庫來實現我們的功能。在上面兩個小節的示 例代碼中,為了說明問題的簡單起見,我們僅僅聲明,定義了幾個變量和函數,并沒有使用任何的庫函數。那么現在假設我們需要調用一個庫函數提供的功能,這個 時候可執行文件又是什么樣的呢,我們再看一個小例子:
#include <stdio.h> #include <string.h> int main(int argc, char* argv[]) { char buf[32]; strncpy(buf, "Hello, World\n", 32); printf("%s",buf); }
我們把這個文件編譯成可執行文件并且查看一下它的symbolsgcc test3.c -o test3; nm test3:
00000000004005b4 T main U printf@@GLIBC_2.2.5 U strncpy@@GLIBC_2.2.5
我們應該能看到類似上述的輸出,我們在“目標文件”這一小節曾經看到過這種類型的 symbol。當時是在目標文件中,同樣也是沒有地址,我們說這是編譯器留給鏈接器到后面的目標文件中尋找變量定義的。但是現在我們檢查的是可執行文件, 為什么可執行文件里面仍然有這種沒有地址的 symbols 呢?
我們前面提到過,編譯器沒有什么特別的,它做的所有事情都是為了支持編程語言級別的功能,這里同樣不例外。這里可執行文件中的“未定義”的 symbols 其實是為了支持動態鏈接庫的功能。
我們先來回顧一下動態鏈接庫應該有一個什么樣的功能。所謂動態鏈接庫是指,程序在運行的時候才去定位這個庫,并且把這個庫鏈接到進程的虛擬地址空 間。對于某一個動態鏈接庫來說,所有使用這個庫的可執行文件都共享同一塊物理地址空間,這個物理地址空間在當前動態鏈接庫第一次被鏈接時 load 到內存中。
現在我們看一下二進制文件中對動態鏈接庫中的函數怎么處理的,objdump -D test3 | less,搜索printf我們應該能看到以下內容:
0000000000400490 <strncpy@plt>: 400490: ff 25 6a 0b 20 00 jmpq *0x200b6a(%rip) # 601000 <_GLOBAL_OFFSET_TABLE_+0x18> 400496: 68 00 00 00 00 pushq $0x0 40049b: e9 e0 ff ff ff jmpq 400480 <_init+0x20> ... 00000000004004b0 <printf@plt>: 4004b0: ff 25 5a 0b 20 00 jmpq *0x200b5a(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x28> 4004b6: 68 02 00 00 00 pushq $0x2 4004bb: e9 c0 ff ff ff jmpq 400480 <_init+0x20>
我們看到可執行文件中為 strncpy 和 printf 分別生成了三個代理 symbol,然后代理 symbol 指向的第一條指令就是跳轉到_GLOBAL_OFFSET_TABLE_這個 symbol 對應的代碼段中的一個偏移位置,而在 linux 中,這個_GLOBAL_OFFSET_TABLE_對應的代碼段是為了給“地址無關代碼”做動態地址重定位用的。我們提過,動態鏈接庫可以映射到不同進程的不同的虛擬地址空間,所以屬于“地址無關代碼”,鏈接器把對這個函數的調用代碼跳轉到程序運行時動態裝載地址。
Linux 提供了一個很方便的命令查看一個可執行文件依賴的動態鏈接庫,我們查看一下當前可執行文件的動態庫依賴情況:ldd test3:
linux-vdso.so.1 => (0x00007fff413ff000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe202ae7000) /lib64/ld-linux-x86-64.so.2 (0x00007fe202eb2000)
ldd 命令模擬加載可執行程序需要的動態鏈接庫,但并不執行程序,后面的地址部分表示模擬裝載過程中動態鏈接庫的地址。如果嘗試多次運行 ldd 命令,我們會發現每次動態鏈接庫的地址都是不一樣的,因為這個地址是動態定位的。我們平常工作中,如果某一個二進制可執行文件報錯找不到某個函數定義,可 以用這個命令檢查是否系統丟失或者沒有安裝某一個動態鏈接庫。
我們在上面的小程序最后加一個sleep(1000);,然后查看一下運行時的內存映射分配,cd /proc/21509 && cat maps,應該可以看到下面這一段:
7feeef61f000-7feeef7d4000 r-xp 00000000 fd:01 135891 /lib/x86_64-linux-gnu/libc-2.15.so 7feeef7d4000-7feeef9d3000 ---p 001b5000 fd:01 135891 /lib/x86_64-linux-gnu/libc-2.15.so 7feeef9d3000-7feeef9d7000 r--p 001b4000 fd:01 135891 /lib/x86_64-linux-gnu/libc-2.15.so 7feeef9d7000-7feeef9d9000 rw-p 001b8000 fd:01 135891 /lib/x86_64-linux-gnu/libc-2.15.so
我們可以看到進程運行時,系統為 libc 庫在進程地址空間中映射了四個段,因為每個段權限不同,所以不能合并為一個段。對這些動態鏈接庫的調用最終會跳轉到這里顯示的地址中。
根據以上這些信息,我們在這里繼續總結一下鏈接器需要對動態鏈接庫需要做的最基本的事情:
- 鏈接庫在將目標文件鏈接成可執行文件的時候如果發現某一個變量或者函數在目標文件中找不到,會按照 gcc 預定義的動態庫尋找路徑尋找動態庫中定義的變量或者函數。
- 如果鏈接庫在某一個動態鏈接庫中找到了該變量或者函數定義,鏈接庫首先會把這個動態鏈接庫寫到可執行文件的依賴庫中,然后生成這個當前變量或者函數的代理 symbol.
- 在_GLOBAL_OFFSET_TABLE_代碼中生成真正的動態跳轉指令,并且在庫函數(比如strncpy,printf)代理symbol中跳轉到_GLOBAL_OFFSET_TABLE_中相應的偏移位置。
前面我們一直在討論動態鏈接庫(so庫),其實在各個平臺下面都有靜態鏈接庫,靜態鏈接庫的鏈接行為跟目標文件非常類似,但是由于靜態庫有一些問 題,比如因為每個可執行文件都有靜態庫的一個版本,這導致庫升級的時候很麻煩等問題,現在靜態庫用的非常少,所以這里我們不去深究。
main函數之前
在“操作系統”這一小節中,我們曾簡單提過,在程序的 main 函數執行之前,進程需要做一些初始化工作,然后才會調用 main 函數執行程序邏輯。在“動態鏈接庫”在這一小節中,我們提到了對于動態鏈接庫,我們需要在系統啟動的時候把需要的庫動態鏈接到進程的地址空間。在本節中, 我們綜合這些步驟,從可執行文件的目標代碼中簡單跟蹤一下,Linux 是如何把 elf 文件 load 到內存中并且最終調用到 main 函數的。
在“目標文件的鏈接”這一小節中,我們展示了部分nm test的結果,其中_start這個 symbol 是故意被留下來的,因為對于 elf 文件格式來說,linux 系統在為進程分配完虛擬地址空間并且把代碼 load 到內存之后,是從這_start對應的地址開始執行的。這個地址記錄在 elf 文件的頭中,系統讀取 elf 文件時可以得到這個地址。下面我們就從_start這個 symbol 對應的指令開始并追蹤一下我們感興趣的關鍵點。
0000000000400510 <_start>: ... 400526: 48 c7 c1 70 06 40 00 mov $0x400670,%rcx 40052d: 48 c7 c7 f4 05 40 00 mov $0x4005f4,%rdi 400534: e8 b7 ff ff ff callq 4004f0 <__libc_start_main@plt> /* .start這個段會去執行libc庫中的__libc_start_main的指令, 這里需要注意一下傳給這個函數的兩個參數值“0x400670”和“0x4005f4”, 其中一個是__libc_csu_init的地址,一個是main函數的地址 */ ... 00000000004004f0 <__libc_start_main@plt>: 4004f0: ff 25 22 0b 20 00 jmpq *0x200b22(%rip) # 601018 <_GLOBAL_OFFSET_TABLE_+0x30> 0000000000400670 <__libc_csu_init>: ... 4006b0: e8 e3 fd ff ff callq 400498 <_init> ... 0000000000400498 <_init>: ... 4004a6: e8 65 02 00 00 callq 400710 <__do_global_ctors_aux> ... 00000000004005f4 <main>: ... 400626: e8 95 fe ff ff callq 4004c0 <strncpy@plt> ... 40063f: e8 9c fe ff ff callq 4004e0 <printf@plt> ... 400649: e8 b2 fe ff ff callq 400500 <sleep@plt> ...
我們先來簡單解釋一下上述貼的幾段指令的意思,首先在 _start 對應的指令中,經過一些處理之后,會用__libc_csu_init的地址和main的地址作為參數調用__libc_start_main,這個函數是在libc庫中實現的,也就是linux中所有的可執行程序都共享同一段初始化代碼,篇幅原因我們不去查看__libc_start_main的實現了。我們需要知道的是,在__libc_start_main作為一些處理之后,會先調用__libc_csu_init對應的指令,然后調用main對應的指令。
main對應的指令就是我們自己的main函數了,__libc_csu_init接著會調用_init的指令,然后會調用__do_global_ctors_aux這個 C++ 程序員都應該熟悉的 symbol 對應的指令,__do_global_ctors_aux對應的指令會進行所有的全局變量初始化,或者 C++ 中的全局對象構造等操作。
根據上述信息,我們總結一下當我們通過bash運行一個程序的時候,Linux 做了哪些事情:
- 首先 bash 進行 fork 系統調用,生成一個子進程,接著在子進程中運行 execve 函數指定的 elf 二進制程序( Linux中執行二進制程序最終都是通過 execve 這個庫函數進行的),execve 會調用系統調用把 elf 文件 load 到內存中的代碼段(_text)中。
- 如果有依賴的動態鏈接庫,會調用動態鏈接器進行庫文件的地址映射,動態鏈接庫的內存空間是被多個進程共享的。
- 內核從 elf 文件頭得到_start的地址,調度執行流從_start指向的地址開始執行,執行流在_start執行的代碼段中跳轉到libc中的公共初始化代碼段__libc_start_main,進行程序運行前的初始化工作。
- __libc_start_main的執行過程中,會跳轉到_init中全局變量的初始化工作,隨后調用我們的main函數,進入到主函數的指令流程。
至此,我們討論了從一個 C 語言程序的源代碼,到運行中的進程的全過程。
一個小例子
在明白了編譯器如何把我們的源代碼“轉變”成二進制可執行程序之后,我們就能夠知道怎么樣去看某一段代碼被編譯成二進制之后是一個什么樣子,然后就 可以按照編譯器的“習慣”寫出高效的代碼。 這一小節我們分析一個網上的小例子,下面是一個網友列出的兩段程序,在面試中被問到各有什么優缺點。
程序1: if(k > 8){ for (int h=0;h<100;h++) { //doSomething } } else { for (int h=0;h<100;h++) { //doSomething } } 程序2: for (int h=0;h<100;h++) { if (k>8) { //doSomething } else { //doSomething } }
從編程規范上看,很明顯程序2 是好于程序1 的,因為如果“doSomething”的部分比較復雜,程序2 緊湊而不冗余,而且可以把 if 和 else 分支“doSomething”公共的部分提取出來放在 for 循環下面。但是有經驗的工程師馬上也能看出來,雖然程序1稍顯冗余,但是其執行速度比程序2 是要快的,為什么快我們從編譯器生成的目標文件分析一下,我們的測試程序如下:
程序1: if(type == 0) { for(i=0; i<cnt; i++) { sum += data[i]; } }else if(type == 1) { for(i=0; i<cnt; i++) { sum += (i&0x01)?(-data[i]):data[i]; } } 程序2: for(i=0; i<cnt; i++) { if(type == 0) { sum += data[i]; }else { sum += (i&0x01)?(-data[i]):data[i]; } }
編譯成可執行文件后的片段為:
程序1:
4005d7: 83 7d ec 00 cmpl $0x0,-0x14(%rbp) /* type==0判斷 */ 4005db: 75 29 jne 400606 <calc_1+0x44> /* 條件判斷失敗則跳到else分支 */ 4005dd: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) ------------------------------循環體開始--------------------------- 4005e4: eb 16 jmp 4005fc <calc_1+0x3a> /* 跳到循環條件比較指令 */ 4005e6: 8b 45 fc mov -0x4(%rbp),%eax /* 循環內第一條指令 */ ... 4005f8: 83 45 fc 01 addl $0x1,-0x4(%rbp) 4005fc: 8b 45 fc mov -0x4(%rbp),%eax 4005ff: 3b 45 e8 cmp -0x18(%rbp),%eax /* 循環條件比較 */ 400602: 7c e2 jl 4005e6 <calc_1+0x24> /* 跳到循環開始階段 */ ------------------------------循環體結束---------------------------- 400604: eb 4a jmp 400650 <calc_1+0x8e> 400606: 83 7d ec 01 cmpl $0x1,-0x14(%rbp) /* else分支,type==1判斷 */ ... /* type==1 分支基本與type==0的分支是一致的 */
程序2:
400671: eb 4d jmp 4006c0 <calc_2+0x6b> ------------------------------循環體開始--------------------------- 400673: 83 7d ec 00 cmpl $0x0,-0x14(%rbp) /* type==0 */ 400677: 75 14 jne 40068d <calc_2+0x38> /* 條件判斷失敗則跳到else分支 */ ... 400686: 8b 00 mov (%rax),%eax 400688: 01 45 f8 add %eax,-0x8(%rbp) 40068b: eb 2f jmp 4006bc <calc_2+0x67> 40068d: 8b 45 fc mov -0x4(%rbp),%eax /* else分支 */ 400690: 83 e0 01 and $0x1,%eax 400693: 84 c0 test %al,%al 400695: 74 13 je 4006aa <calc_2+0x55> ... 4006c0: 8b 45 fc mov -0x4(%rbp),%eax 4006c3: 3b 45 e8 cmp -0x18(%rbp),%eax /* 循環條件比較 */ 4006c6: 7c ab jl 400673 <calc_2+0x1e> /* 跳到循環開始階段 */ ------------------------------循環體結束----------------------------
通過對比源程序和匯編指令,我們程序1和程序2 編譯完之后的匯編指令分別進行了對比標注。我們可以對比一下,在程序1 的匯編指令中,經過一次條件判斷之后,執行流會跳到相應的循環指令段中(if/else),然后循環執行整段的指令。而在程序2 的匯編指令中,在每一次執行循環指令段的過程中,都有條件的判斷和跳轉(if/else)。 所以這里我們可以總結一下程序2 比程序1速度快的原因:
- 程序2 中每次循環體的執行都需要執行比較指令和跳轉指令,如果循環次數非常多(比如大于百萬次),就相當于多執行百萬條指令。
- 現代的 CPU 都是流水線模式模式,也有指令預取模塊,也就是說同一時間段內,有多條指令在 CPU 內運行,同時也有預測的指令預取。如果發生了指令跳轉,就很有可能造成后續的指令全部被刷出 CPU,重新跳轉到新地址執行,浪費多個 CPU 周期。
通過我們的分析,我們可以說,如果此程序段處于整個項目中非瓶頸的位置,程序2作為優先選擇的是可以接受的。但是如果此程序段處于速度瓶頸位置,程序1是占有優勢的。
結束語
本文中,我們使用 C 語言為例串講了源代碼程序如何經過編譯,鏈接和裝載最終成功在 Linux 系統運行起來。從代碼細節上看,這是一個漫長復雜的過程,但是只要抓住其中的主線,就會發現其實編譯器和鏈接器所做的時候都是為了滿足我們的功能和需求, 正所謂萬變不離其宗。
另外雖然我們使用 C 語言進行說明的,在同一種系統中,其他的語言編譯得到的二進制文件是一樣的格式的。比如在 Linux 下面使用 go 語言編譯的源代碼,最終編譯出來的二進制文件仍然是elf格式的,我們可以使用同一套工具(比如 nm,objdump,readelf)查看和調試這樣的代碼。由此我們可以得知,雖然go的編譯器和gcc編譯器細節實現上有所不同,但所做的工作基本 是一樣的。
顯然這樣一篇短文不可能很詳盡的把編譯,鏈接和裝載這么復雜的過程描述的很細致。本文的初衷是為了讓同學們對這個過程有一個直觀的了解,有興趣的同學其實還有大把的細節可以去探索,最后的“參考”小節中有幾個不錯的資源,可以為有興趣的同學提供參考。
參考
- 俞甲子,石凡,潘愛民: 程序員的自我修養
- http://www.lurklurk.org/linkers/linkers.html
- John Levine: Linkers and Loaders
來自:http://tech.meituan.com/linker.html