Linux 程序編譯過程的來龍去脈
來自公衆號:人人都是極客
作者:佈道師 Peter
大家肯定都知道計算機程序設計語言通常分爲機器語言、彙編語言和高級語言三類。高級語言需要通過翻譯成機器語言才能執行,而翻譯的方式分爲兩種,一種是編譯型,另一種是解釋型,因此我們基本上將高級語言分爲兩大類,一種是編譯型語言,例如 C,C++,Java,另一種是解釋型語言,例如 Python、Ruby、MATLAB 、JavaScript。
本文將介紹如何將高層的 C/C++ 語言編寫的程序轉換成爲處理器能夠執行的二進制代碼的過程,包括四個步驟:
-
預處理(Preprocessing)
-
編譯(Compilation)
-
彙編(Assembly)
-
鏈接(Linking)
GCC 工具鏈介紹
通常所說的 GCC 是 GUN Compiler Collection 的簡稱,是 Linux 系統上常用的編譯工具。GCC 工具鏈軟件包括 GCC、Binutils、C 運行庫等。
GCC
GCC(GNU C Compiler)是編譯工具。本文所要介紹的將 C/C++ 語言編寫的程序轉換成爲處理器能夠執行的二進制代碼的過程即由編譯器完成。
Binutils
一組二進制程序處理工具,包括:addr2line、ar、objcopy、objdump、as、ld、ldd、readelf、size 等。這一組工具是開發和調試不可缺少的工具,分別簡介如下:
-
addr2line:用來將程序地址轉換成其所對應的程序源文件及所對應的代碼行,也可以得到所對應的函數。該工具將幫助調試器在調試的過程中定位對應的源代碼位置。
-
as:主要用於彙編,有關彙編的詳細介紹請參見後文。
-
ld:主要用於鏈接,有關鏈接的詳細介紹請參見後文。
-
ar:主要用於創建靜態庫。爲了便於初學者理解,在此介紹動態庫與靜態庫的概念:
-
如果要將多個. o 目標文件生成一個庫文件,則存在兩種類型的庫,一種是靜態庫,另一種是動態庫。
-
在 windows 中靜態庫是以 .lib 爲後綴的文件,共享庫是以 .dll 爲後綴的文件。在 linux 中靜態庫是以. a 爲後綴的文件,共享庫是以. so 爲後綴的文件。
-
靜態庫和動態庫的不同點在於代碼被載入的時刻不同。靜態庫的代碼在編譯過程中已經被載入可執行程序,因此體積較大。共享庫的代碼是在可執行程序運行時才載入內存的,在編譯過程中僅簡單的引用,因此代碼體積較小。在 Linux 系統中,可以用 ldd 命令查看一個可執行程序依賴的共享庫。
-
如果一個系統中存在多個需要同時運行的程序且這些程序之間存在共享庫,那麼採用動態庫的形式將更節省內存。
-
ldd:可以用於查看一個可執行程序依賴的共享庫。
-
objcopy:將一種對象文件翻譯成另一種格式,譬如將. bin 轉換成. elf、或者將. elf 轉換成. bin 等。
-
objdump:主要的作用是反彙編。有關反彙編的詳細介紹,請參見後文。
-
readelf:顯示有關 ELF 文件的信息,請參見後文瞭解更多信息。
-
size:列出可執行文件每個部分的尺寸和總尺寸,代碼段、數據段、總大小等,請參見後文瞭解使用 size 的具體使用實例。
C 運行庫
C 語言標準主要由兩部分組成:一部分描述 C 的語法,另一部分描述 C 標準庫。C 標準庫定義了一組標準頭文件,每個頭文件中包含一些相關的函數、變量、類型聲明和宏定義,譬如常見的 printf 函數便是一個 C 標準庫函數,其原型定義在 stdio 頭文件中。
C 語言標準僅僅定義了 C 標準庫函數原型,並沒有提供實現。因此,C 語言編譯器通常需要一個 C 運行時庫(C Run Time Libray,CRT)的支持。C 運行時庫又常簡稱爲 C 運行庫。與 C 語言類似,C++ 也定義了自己的標準,同時提供相關支持庫,稱爲 C++ 運行時庫。
準備工作
由於 GCC 工具鏈主要是在 Linux 環境中進行使用,因此本文也將以 Linux 系統作爲工作環境。爲了能夠演示編譯的整個過程,本節先準備一個 C 語言編寫的簡單 Hello 程序作爲示例,其源代碼如下所示:
#include <stdio.h>
//此程序很簡單,僅僅打印一個Hello World的字符串。
int main(void)
{
printf("Hello World! \n");
return 0;
}
編譯過程
- 預處理
預處理的過程主要包括以下過程:
-
將所有的 #define 刪除,並且展開所有的宏定義,並且處理所有的條件預編譯指令,比如 #if #ifdef #elif #else #endif 等。
-
處理 #include 預編譯指令,將被包含的文件插入到該預編譯指令的位置。
-
刪除所有註釋 “//” 和“/* */”。
-
添加行號和文件標識,以便編譯時產生調試用的行號及編譯錯誤警告行號。
-
保留所有的 #pragma 編譯器指令,後續編譯過程需要使用它們。
使用 gcc 進行預處理的命令如下:
$ gcc -E hello.c -o hello.i // 將源文件hello.c文件預處理生成hello.i
// GCC的選項-E使GCC在進行完預處理後即停止
hello.i 文件可以作爲普通文本文件打開進行查看,其代碼片段如下所示:
// hello.i代碼片段
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 942 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 3 "hello.c"
int
main(void)
{
printf("Hello World!" "\n");
return 0;
}
- 編譯
編譯過程就是對預處理完的文件進行一系列的詞法分析,語法分析,語義分析及優化後生成相應的彙編代碼。
使用 gcc 進行編譯的命令如下:
$ gcc -S hello.i -o hello.s // 將預處理生成的hello.i文件編譯生成彙編程序hello.s
// GCC的選項-S使GCC在執行完編譯後停止,生成彙編程序
上述命令生成的彙編程序 hello.s 的代碼片段如下所示,其全部爲彙編代碼。
// hello.s代碼片段
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
- 彙編
彙編過程調用對彙編代碼進行處理,生成處理器能識別的指令,保存在後綴爲. o 的目標文件中。由於每一個彙編語句幾乎都對應一條處理器指令,因此,彙編相對於編譯過程比較簡單,通過調用 Binutils 中的彙編器 as 根據彙編指令和處理器指令的對照表一一翻譯即可。
當程序由多個源代碼文件構成時,每個文件都要先完成彙編工作,生成. o 目標文件後,才能進入下一步的鏈接工作。注意:目標文件已經是最終程序的某一部分了,但是在鏈接之前還不能執行。
使用 gcc 進行彙編的命令如下:
$ gcc -c hello.s -o hello.o // 將編譯生成的hello.s文件彙編生成目標文件hello.o
// GCC的選項-c使GCC在執行完彙編後停止,生成目標文件
//或者直接調用as進行彙編
$ as -c hello.s -o hello.o //使用Binutils中的as將hello.s文件彙編生成目標文件
注意:hello.o 目標文件爲 ELF(Executable and Linkable Format)格式的可重定向文件。
- 鏈接
鏈接也分爲靜態鏈接和動態鏈接,其要點如下:
-
靜態鏈接是指在編譯階段直接把靜態庫加入到可執行文件中去,這樣可執行文件會比較大。鏈接器將函數的代碼從其所在地(不同的目標文件或靜態鏈接庫中)拷貝到最終的可執行程序中。爲創建可執行文件,鏈接器必須要完成的主要任務是:符號解析(把目標文件中符號的定義和引用聯繫起來)和重定位(把符號定義和內存地址對應起來然後修改所有對符號的引用)。
-
動態鏈接則是指鏈接階段僅僅只加入一些描述信息,而程序執行時再從系統中把相應動態庫加載到內存中去。
-
在 Linux 系統中,gcc 編譯鏈接時的動態庫搜索路徑的順序通常爲:首先從 gcc 命令的參數 - L 指定的路徑尋找;再從環境變量 LIBRARY_PATH 指定的路徑尋址;再從默認路徑 / lib、/usr/lib、/usr/local/lib 尋找。
-
在 Linux 系統中,執行二進制文件時的動態庫搜索路徑的順序通常爲:首先搜索編譯目標代碼時指定的動態庫搜索路徑;再從環境變量 LD_LIBRARY_PATH 指定的路徑尋址;再從配置文件 / etc/ld.so.conf 中指定的動態庫搜索路徑;再從默認路徑 / lib、/usr/lib 尋找。
-
在 Linux 系統中,可以用 ldd 命令查看一個可執行程序依賴的共享庫。
由於鏈接動態庫和靜態庫的路徑可能有重合,所以如果在路徑中有同名的靜態庫文件和動態庫文件,比如 libtest.a 和 libtest.so,gcc 鏈接時默認優先選擇動態庫,會鏈接 libtest.so,如果要讓 gcc 選擇鏈接 libtest.a 則可以指定 gcc 選項 - static,該選項會強制使用靜態庫進行鏈接。以 Hello World 爲例:
-
如果使用命令 “gcc hello.c -o hello” 則會使用動態庫進行鏈接,生成的 ELF 可執行文件的大小(使用 Binutils 的 size 命令查看)和鏈接的動態庫(使用 Binutils 的 ldd 命令查看)如下所示:
$ gcc hello.c -o hello $ size hello //使用size查看大小 text data bss dec hex filename 1183 552 8 1743 6cf hello $ ldd hello //可以看出該可執行文件鏈接了很多其他動態庫,主要是Linux的glibc動態庫 linux-vdso.so.1 => (0x00007fffefd7c000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fadcdd82000) /lib64/ld-linux-x86-64.so.2 (0x00007fadce14c000)
-
如果使用命令 “gcc -static hello.c -o hello” 則會使用靜態庫進行鏈接,生成的 ELF 可執行文件的大小(使用 Binutils 的 size 命令查看)和鏈接的動態庫(使用 Binutils 的 ldd 命令查看)如下所示:
$ gcc -static hello.c -o hello $ size hello //使用size查看大小 text data bss dec hex filename 823726 7284 6360 837370 cc6fa hello //可以看出text的代碼尺寸變得極大 $ ldd hello not a dynamic executable //說明沒有鏈接動態庫
鏈接器鏈接後生成的最終文件爲 ELF 格式可執行文件,一個 ELF 可執行文件通常被鏈接爲不同的段,常見的段譬如. text、.data、.rodata、.bss 等段。
分析 ELF 文件
1.ELF 文件的段
ELF 文件格式如下圖所示,位於 ELF Header 和 Section Header Table 之間的都是段(Section)。一個典型的 ELF 文件包含下面幾個段:
-
.text:已編譯程序的指令代碼段。
-
.rodata:ro 代表 read only,即只讀數據(譬如常數 const)。
-
.data:已初始化的 C 程序全局變量和靜態局部變量。
-
.bss:未初始化的 C 程序全局變量和靜態局部變量。
-
.debug:調試符號表,調試器用此段的信息幫助調試。
可以使用 readelf -S 查看其各個 section 的信息如下:
$ readelf -S hello
There are 31 section headers, starting at offset 0x19d8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
……
[11] .init PROGBITS 00000000004003c8 000003c8
000000000000001a 0000000000000000 AX 0 0 4
……
[14] .text PROGBITS 0000000000400430 00000430
0000000000000182 0000000000000000 AX 0 0 16
[15] .fini PROGBITS 00000000004005b4 000005b4
……
- 反彙編 ELF
由於 ELF 文件無法被當做普通文本文件打開,如果希望直接查看一個 ELF 文件包含的指令和數據,需要使用反彙編的方法。
使用 objdump -D 對其進行反彙編如下:
$ objdump -D hello
……
0000000000400526 <main>: // main標籤的PC地址
//PC地址:指令編碼 指令的彙編格式
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: bf c4 05 40 00 mov $0x4005c4,%edi
40052f: e8 cc fe ff ff callq 400400 <puts@plt>
400534: b8 00 00 00 00 mov $0x0,%eax
400539: 5d pop %rbp
40053a: c3 retq
40053b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
……
使用 objdump -S 將其反彙編並且將其 C 語言源代碼混合顯示出來:
$ gcc -o hello -g hello.c //要加上-g選項
$ objdump -S hello
……
0000000000400526 <main>:
#include <stdio.h>
int
main(void)
{
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
printf("Hello World!" "\n");
40052a: bf c4 05 40 00 mov $0x4005c4,%edi
40052f: e8 cc fe ff ff callq 400400 <puts@plt>
return 0;
400534: b8 00 00 00 00 mov $0x0,%eax
}
400539: 5d pop %rbp
40053a: c3 retq
40053b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
……
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Jp9s9j6e2sA4rvevOQEzOQ