C的代碼是如何變成程序的
C語言是一門典型的編譯語言,源代碼文件需要編譯成目標代碼文件才能運行。可以認爲程序文件就是編譯好的目標代碼文件。
以GCC的編譯過程爲例。GCC的翻譯過程可以分成四個階段:預處理器、編譯器、彙編器、鏈接器,執行這四個階段的程序一起構成了一個編譯系統。
圖 1 GCC編譯系統(取自《深入理解計算機系統》)
1 預處理器
預處理器(cpp)負責對源代碼進行文本處理。它根據以字符#開頭的命令,修改原始的C代碼。如:
1. #include <stdio.h> 從編譯器的內置查找路徑的根部開始查找stdio.h文件,讀取其內容,並把它直接插入到程序文本中。
2. #include ”my_header.h” 與上條的區別就是查找路徑是從當前代碼文件所在目錄開始。
3. #define MACRO_NAME CONTEXT 將原始代碼中所有的MACRO_NAME文本都替換成CONTEXT,這種替換可能會引起很多難以理解的錯誤。
4. #define FUNC_NAME(PARA_LIST) CONTEXT 與上條類似,區別在於會在查找到FUNC_NAME的地方進行參數匹配,並將CONTEXT中出現的參數名稱用對應的文本進行替換。
5. #define MACRO_NAME #undef MACRO_NAME 前者用於單純的宏定義,後者用於取消宏定義。
6. #ifdef #ifndef #else #endif 這幾個都是用於條件編譯的命令,用於決定被包括的文本是否加入到處理後的文本中。
常用的預處理命令就是這些,處理後就得到了另一個C代碼文件,一般用.i作爲擴展名。
這部分有一個常用的技巧:header guard,用於防止頭文件被重複加載。
假設一個場景,某個工程中的3個文件:main.c、a.h、b.h,其中每個文件的開頭有這樣的文本:
//main.c
#include ”a.h”
#include ”b.h”
...
//a.h
#include ”b.h”
void func_a();
//b.h
void func_b();
上面提到了預處理器在處理#include時是直接的文本插入,處理後的main.i文件的內容是://main.i
void func_b();
void func_a();
void func_b();
...
b.h的內容被載入了兩次!這個例子足夠簡單,出現這種問題不會發生錯誤,但如果b.h文件很大,重複加載後可能會出現很多問題,還會導致編譯時間的延長。這種情況下我們可以使用header guard來防止頭文件被重複加載,中間省略的部分即頭文件的正式內容:#ifndef XXX_YYY_ZZZ
#define XXX_YYY_ZZZ
...
#endif
其中XXX_YYY_ZZZ是你自定義的宏名字。如果爲每個頭文件選擇一個不重複的宏名字,這個宏組合保證了每個頭文件只會被一個代碼文件載入一次,因爲第二次載入時XXX_YYY_ZZZ宏已經定義過了,就直接跳到了#endif的後面。2 編譯階段
編譯器(ccl)將文本文件hello.i翻譯成文本文件hello.s,它包含一個彙編語言程序。彙編語言程序中的每條語句都以一種標準的文本格式確切地描述了一條低級機器語言指令。彙編語言爲不同高級語言的不同編譯器提供了通用的輸出語言,例如C編譯器和Fortran編譯器產生的輸出文件用的都是一樣的彙編語言。
例如,hello.c爲:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("hello world\n");
return 0;
}
運行gcc –S hello.c可以得到hello.s文件,其內容爲: .file "hello.c"
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "hello world\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
LFB6:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
...
所有以字符.開頭的行都是指導彙編器和鏈接器的命令,其它行則是被翻譯成彙編語言的代碼。3 彙編階段
接下來,彙編器(as)將hello.s翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程序的格式,並將結果保存在目標文件hello.o中。hello.o文件是一個二進制文件,它的字節編碼是機器語言指令而不是字符,如果我們在文本編輯器中打開hello.o文件,看到的將是一堆亂碼。
運行gcc –c hello.c可以得到hello.o文件,它是二進制格式,無法直接查看,可以用反彙編器來查看它的編碼:objdump –d code.o
以一種典型的可重定位目標格式ELF爲例。ELF文件的頭部數據包含了:
1. 生成該文件的系統的字的大小和字節順序。
2. 幫助鏈接器語法分析和解釋目標文件信息的數據。
ELF文件中包含的數據可分成幾個節,每個節的位置和大小是由節頭部表描述的:
1. .text 機器代碼
2. .rodata 只讀數據,比如雙引號括起的字符串等。
3. .data 已初始化的全局變量。
4. .bss 未初始化的全局變量。在ELF文件中它只是佔位符,在目標文件中不佔據實際的空間。
5. .symtab 一個符號表,存放在程序中定義和引用的函數和全局變量的信息。
6. .rel.text 一個.text節中位置的列表,當鏈接器進行鏈接時,需要修改這些位置。
7. .rel.data 被引用或定義的全局變量的重定位信息,依賴於其它模塊信息的已初始化的全局變量,其值在鏈接時需要被修改。
8. .debug 調試符號表。
9. .line 機器代碼與源文件行號的對應關係,只有在-g選項時纔會產生。
10. .strtab 一個字符串表,包括.symtab和.debug中的符號表,以及每個節的名字。
圖 2 典型的ELF可重定位目標文件
4 鏈接階段
鏈接器(ld)負責將多個可重定位目標文件(.o文件)合併爲一個可執行文件,如hello程序文件就是由hello.o和printf.o文件合併得來的。合併過程中鏈接器負責解析符號表,並修改不同編譯模塊間的引用信息,如hello.o的main函數調用printf函數時,機器代碼的跳轉位置直到鏈接階段纔會確定,鏈接器會將跳轉位置修改爲printf函數的入口位置。
鏈接器解析本地符號的引用是非常簡單的。編譯器只允許每個模塊中每個本地符號只有一個定義。不過,對全局符號的解析就很複雜。如果鏈接器在所有模塊中都找不到某個符號時,它就輸出”undefined reference”錯誤信息並終止。如果所有符號的解析都順利完成,鏈接器最後會輸出所有符號的引用位置都確定了的可執行文件。