通透,從源碼到可執行文件
你好,我是雨樂~
C++ 是一種廣泛使用的編程語言,因其高效的性能和靈活的特性被應用於各種類型的項目,從操作系統到遊戲開發。然而,無論你寫的代碼有多麼精巧,它最終都需要被編譯成機器可以理解的可執行文件,才能在計算機上運行。那麼,C++ 源碼是如何從編寫到變成一個可以執行的程序的呢?相信很多人會提到編譯、鏈接等
,但是如果要其講述完整的過程,如果不是專門從事編譯器行業,或者對這塊有所研究,對於大部分人是很困難。
今天,藉助這篇文章,來講解下從源碼到可執行
文件的過程~
PS:本文中的代碼都是在 Linux 下編譯的,中間件文件也是在在該系統查看的,其他平臺可能有所不同,不過其基本原理相似
從一個示例開始
爲了能夠大家加深對這塊的掌握,遂以我們編程時一個經典例子入手:
#include <iostream>
#include <cmath>
#define PI 3.141592653589793
#define AREA(radius) (PI * (radius) * (radius))
int Add(int a, int b) {
return a + b;
}
int main() {
double radius =5.0;
double area =AREA(radius);
std::cout <<"radius: "<< radius <<" area: "<< area << std::endl;
double sqrt_result =sqrt(25.0);
int a =1;
int b =2;
int sum =Add(a, b);
return0;
}
爲了能夠儘量能夠分析整個編譯、鏈接過程,在上述代碼中,設計到了宏 變量定義 函數調用等。
如果要將從源碼到可執行文件分成各個階段的話,那麼大致有如下幾個:
• 預處理:代碼經過預處理器會處理所有以#
開頭的指令,比如#include
和#define
。它會將所有的頭文件內容嵌入到源文件中,並且展開宏定義。預處理後的文件會被傳遞給編譯器,例如,上面的程序中的#include <iostream>
指令會被預處理器替換爲iostream
庫的內容 • 編譯:預處理完成後,編譯器將源代碼轉換成中間代碼或彙編代碼。這個過程將高層次的 C++ 代碼翻譯成較底層的機器語言指令,但這些指令還不完全是計算機能夠直接執行的。編譯器會生成一個目標文件(通常以.o
或.obj
爲擴展名),這個目標文件包含了機器代碼,但它尚未鏈接成一個完整的可執行文件 • 彙編:彙編階段將編譯器生成的彙編代碼轉換爲機器代碼。機器代碼是計算機能夠直接執行的二進制指令。這個過程會生成一個目標文件,這些目標文件包含了程序的所有編譯部分,但還需要鏈接才能成爲一個完整的可執行文件 • 鏈接:鏈接器負責將一個或多個目標文件以及所有必要的庫文件(例如標準庫)合併成一個單一的可執行文件。鏈接器會解決代碼中的外部符號引用,並將它們連接到正確的地址。最終,鏈接器生成的可執行文件就是程序可以被操作系統直接運行的文件
下面,我們針對這幾個階段,進行詳細的說明~
預處理 (Preprocessing)
在 C++ 編譯過程中,預處理階段是一個至關重要的初步步驟,它爲實際編譯做準備。這個階段的主要任務是處理源代碼中的預處理指令,這些指令通過特殊的符號 #
開頭,告訴預處理器執行特定的操作,以修改或影響源代碼的內容,從而在編譯之前進行必要的調整。
預處理階段的目的是在代碼編譯之前對源代碼進行準備,通過處理一些指令來修改代碼。這些指令會在實際編譯成機器語言之前進行處理,主要包含以下幾個階段。
宏展開
在一開始我們示例代碼中,有如下宏定義:
#define PI 3.141592653589793
在預處理階段,所有用到宏 PI 的地方,都會被3.141592653589793
所替換。
文件包含
所謂的文件包含,指的是我們代碼中常見的#include
,預處理器將 #include
指令替換爲被包含文件的實際內容,將必要的聲明和定義整合到源代碼中。(可以把這個步驟理解爲將被包含文件的內容實際插入到你的文件中。)
舉一個簡單的例子:
// test.h
int Add(int a, int b);
// test.c
#include "test.h"
int Add(int a, int b) {
return a + b;
}
請注意上面 test.c 這個文件內容,其中有個頭文件包含#include "test.h"
,使用下面命令看看預處理器在頭文件內容替換後的內容:
gcc -E test.c -o test.i
test.i 內容如下:
# 0 "test.c"
# 0"<built-in>"
# 0"<command-line>"
# 1"/usr/include/stdc-predef.h"134
# 0"<command-line>"2
# 1"test.c"
# 1"test.h"1
int Add(int a, int b);
# 2"test.c"2
int Add(int a, int b) {
return a + b;
}
這就是預處理器在處理頭文件包含時所做的操作~
宏替換
宏擴展指的是定義用於代碼片段的宏會在源代碼中每次使用它們的地方插入宏的內容
,示例如下:
#define SQUARE(x) ((x) * (x))
如果我們在代碼中使用SQUARE(5)
,那麼這段代碼會被替換成((5) * (5))
。
行控制 (Line Control)
行控制這個概念,說實話大部分人沒接觸過,面試中也曾問過候選人,基本聽到這個概念的時候也是一臉懵,當然了,也有可能是這個術語比較模式吧。
預處理階段的行控制(Line Control)是一個重要的機制,用於在源代碼預處理過程中保持對原始源文件的行號和文件名的跟蹤。這對於調試尤其關鍵,原因如下:
- 跟蹤代碼位置:在預處理階段,代碼可能會因爲宏展開、
#include
指令的處理等操作而被修改。這會改變源代碼的結構。行控制信息確保了即使源代碼經過了這些處理,錯誤消息和調試信息仍能準確地反映原始源文件中的位置。2. 調試信息:當編譯器生成錯誤消息或調試信息時,它們會用行控制信息來定位問題的確切位置。如果沒有這些信息,調試工具可能會報告錯誤的位置爲預處理後的代碼位置,而不是原始代碼中的位置,這會導致調試變得非常困難。3. 包含文件:在處理#include
指令時,預處理器會將包含的文件的內容插入到源文件中。行控制信息幫助編譯器識別這些插入的內容的來源,以便能夠正確地記錄錯誤和警告信息的來源。4. 宏展開:當宏被展開時,它們的內容會替代宏的定義。行控制信息允許編譯器追蹤這些宏展開的內容在原始代碼中的位置,從而在調試時能夠顯示準確的源文件和行號。
編譯 (Compilation)
編譯階段,對我們來說再熟悉不過,其目的是將前面預處理器處理之後的代碼生成彙編代碼或者其它中間代碼。
同樣,編譯階段也包含多個步驟。
詞法分析(Lexical Analysis)
詞法分析是編譯過程中的一個重要階段,負責將源代碼轉換成編譯器能夠處理的詞法單元(tokens)。這些詞法單元包括關鍵字、標識符、運算符和字面量。
我們可以使用如下命令查看詞法分析的結果 (此處 gcc 對這塊的支持不是很友好,遂使用 clang 進行演示):
clang -Xclang -dump-tokens -fsyntax-only test.c
如下:
int 'int'[StartOfLine]Loc=<./test.h:1:1>
identifier 'Add'[LeadingSpace]Loc=<./test.h:1:5>
l_paren '('Loc=<./test.h:1:8>
int'int'Loc=<./test.h:1:9>
identifier 'a'[LeadingSpace]Loc=<./test.h:1:13>
comma ','Loc=<./test.h:1:14>
int'int'[LeadingSpace]Loc=<./test.h:1:16>
identifier 'b'[LeadingSpace]Loc=<./test.h:1:20>
r_paren ')'Loc=<./test.h:1:21>
semi ';'Loc=<./test.h:1:22>
int'int'[StartOfLine]Loc=<test.c:3:1>
identifier 'Add'[LeadingSpace]Loc=<test.c:3:5>
l_paren '('Loc=<test.c:3:8>
int'int'Loc=<test.c:3:9>
identifier 'a'[LeadingSpace]Loc=<test.c:3:13>
comma ','Loc=<test.c:3:14>
int'int'[LeadingSpace]Loc=<test.c:3:16>
identifier 'b'[LeadingSpace]Loc=<test.c:3:20>
r_paren ')'Loc=<test.c:3:21>
l_brace '{'[LeadingSpace]Loc=<test.c:3:23>
return'return'[StartOfLine][LeadingSpace]Loc=<test.c:4:3>
identifier 'a'[LeadingSpace]Loc=<test.c:4:10>
plus '+'[LeadingSpace]Loc=<test.c:4:12>
identifier 'b'[LeadingSpace]Loc=<test.c:4:14>
semi ';'Loc=<test.c:4:15>
r_brace '}'[StartOfLine]Loc=<test.c:5:1>
eof '' Loc=<test.c:5:2>
語法分析 (Syntax Analysis)
編譯器將詞法單元(tokens)解析爲語法樹(抽象語法樹或 AST)。AST 代表了根據 C 語言語法規則的代碼的語法結構。
你可以使用以下命令獲取以 json 格式輸出語法樹:
clang -Xclang -ast-dump=json -fsyntax-only test.c
內容如下:
{
"id":"0xc1274c8",
"kind":"TranslationUnitDecl",
"loc":{
},
"range":{
"begin":{
},
"end":{
}
},
"inner":[
{
"id":"0xc127a90",
"kind":"BuiltinType",
"type":{
"qualType":"__int128"
}
},
{
"id":"0xc127d60",
"kind":"TypedefDecl",
"loc":{
},
"range":{
"begin":{
},
"end":{
}
},
"isImplicit":true,
"name":"__uint128_t",
"type":{
"qualType":"unsigned __int128"
}
]
}
語義分析 (Semantic Analysis)
語義分析是編譯過程中的一個重要階段,負責檢查代碼中的語義錯誤。語義分析包括以下幾個方面:
- 類型檢查(Type Checking):確保操作在兼容的數據類型上進行。例如,將整數與字符串相加會被標記爲錯誤,因爲這兩種數據類型不兼容。2. 作用域解析(Scope Resolution):驗證變量和函數是否在其定義的作用域內使用,並且在使用之前已被正確聲明。確保所有的標識符(變量、函數等)在其有效的範圍內被使用。
語義分析階段確保了程序的邏輯正確性,幫助檢測那些在語法分析階段可能不會捕捉到的潛在問題。
中間代碼生成 (Intermediate Code Generation)
中間代碼生成是編譯過程中的一個關鍵階段,編譯器將抽象語法樹(AST)轉換爲中間表示(Intermediate Representation, IR),這是代碼的較低級別表示形式。中間代碼生成的主要目標是將源代碼轉化爲一種介於高級語言和機器語言之間的中間表示,便於進一步優化和生成最終的機器代碼。
使用如下命令生成中間代碼:
gcc -O2 -S -fdump-tree-original test.c
內容如下:
;; Function Add (null)
;; enabled by -tree-original
{
return a + b;
}
優化
經常,在聊到代碼性能的時候,會聊到編譯選項中的-O1 -O2
乃至-O3
,這就是我們通常說的編譯器優化。編譯器通過優化中間代碼來提高性能並減少生成的機器代碼的大小,可以分爲以下幾類:
- 與機器無關的優化(Machine-Independent Optimizations):這些優化不依賴於特定的硬件架構,適用於所有平臺。常見的優化包括:• 循環展開(Loop Unrolling):通過減少循環的迭代次數來減少循環控制的開銷 • 常量摺疊(Constant Folding):在編譯時計算常量表達式的值,從而減少運行時的計算工作量 2. 與機器相關的優化(Machine-Dependent Optimizations):這些優化針對特定的處理器特性進行調整。常見的優化包括:• 使用特定指令(Using Specific Instructions):利用處理器特定的指令集來提高效率 • 寄存器分配(Register Allocation):根據處理器的寄存器資源來優化變量的存儲位置,以減少內存訪問並提高執行速度
在優化完成後,編譯器生成一個 .o
(目標文件)文件。這是一個與平臺相關的文件,符合平臺架構規範。這些目標文件包含了經過優化的機器代碼和其他信息,如符號表和調試信息,準備好用於鏈接生成最終的可執行文件。
彙編
彙編階段主要做兩件事,即:將編譯器生成的中間代碼(IR)轉換爲彙編代碼
以及將彙編代碼轉換爲機器代碼
。
可以使用如下命令生成彙編:
gcc -S test.c
會默認生成相同文件名後綴爲.s
的彙編文件,內容如下:
.file "test.c"
.text
.globl Add
.type Add,@function
Add:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6,-16
movq %rsp,%rbp
.cfi_def_cfa_register 6
movl %edi,-4(%rbp)
movl %esi,-8(%rbp)
movl -4(%rbp),%edx
movl -8(%rbp),%eax
addl %edx,%eax
popq %rbp
.cfi_def_cfa 7,8
ret
.cfi_endproc
.LFE0:
.size Add,.-Add
.ident "GCC: (GNU) 11.2.1 20220127 (Red Hat 11.2.1-9)"
.section .note.GNU-stack,"",@progbits
想必我們經常聽到一個詞反彙編,其指的是通過將彙編代碼反生成源代碼,不過生成的是優化之後的源代碼。
鏈接 (Linking)
經過前面幾個階段,終於來到了最後的階段鏈接
,這個階段是由鏈接器完成的。
隨着時間的推移,我們的項目越來越大,產生了越來越多的 c/c++ 源文件,編譯步驟將爲每個源文件生成一個目標文件(.o
文件)。在這種情況下,如何將一個文件中的函數與另一個文件中的函數連接起來呢?這正是鏈接器(linker)的作用。
爲了理解此階段,我們生成一個新的文件,內容如下:
#include <stdio.h>
#include "test.h"
int main() {
int a =1;
int b =2;
int sum =Add(a, b);
printf("sum is: %d\n", sum);
return0;
}
現在開始進行編譯:
gcc -c test.c -o test.o
gcc -c main.c -o main.o
main.o 內容如下:
U Add
0000000000000000 T main
U printf
可見,Add 和 printf 前面的標記爲 U,代表 “未定義”("undefined")。這表示某個符號(如函數或變量)在目標文件中被聲明,但在該目標文件中沒有定義。相反,這個符號期望在其他地方被解析和定義,通常是在另一個目標文件或庫文件中。
接着,我們看下 test.o 的內容:
0000000000000000 T Add
在前面的內容中,我們瞭解到單獨編譯每個目標文件通常是成功的。但是,如果嘗試直接用 main.c
生成可執行文件呢?
gcc main.o -o main
錯誤提示如下:
main.o: In function `main':
main.c:(.text+0x21): undefined reference to `Add'
collect2: error: ld returned 1 exit status
也就是說如果單獨使用 main.o 編譯生成可執行文件,會因爲找不到 Add 函數而失敗,可以通過使用下面的命令解決:
gcc main.o test.o -o main
至於鏈接的詳細過程,這個不是一言半語能講清楚的,且不是本文的目的~
結語
本文概述了將 C++ 源代碼轉換爲可執行文件的過程。從預處理、編譯到鏈接,每個階段在將高級代碼轉變爲可運行程序中都扮演了至關重要的角色。希望這個概述能幫助你更好地理解源代碼編譯的複雜性,並提升你使用 C/C++ 能力。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/dyak9GNZavWT3ZKdKmNQ5w