被隱藏了的過程
現如今在流行的集成開發環境下我們很少需要關注編譯和鏈接的過程,而隱藏在程序運行期間的細節過程可不簡單,即使使用命令行來編譯一個源代碼文件,簡單的一句"gcc hello.c"命令就包含了非常復雜的過程。
1 #include<stdio.h> 3 int main() 4 { 5 printf("Hello word\n"); 6 return 0; 7 }
在Linux系統下使用gcc編譯程序時只須簡單的命令:
$gcc hello.c
$/a.out
Hello word
不管哪種編輯器,以上過程可分為4個步驟,分別是預編譯(Prepressing)、編譯(Compilation)、匯編(Assembly)、鏈接(Linking)。
GCC 編譯過程分解
預編譯
首先是將源代碼文件hello.c和相關的頭文件,如stdio.h等被編譯器Cpp預編譯成一個.i文件。主要處理那些源文件中以“#”開始的預編譯指令,如“#include"、”#define“等,主要規則如下:
•宏定義展開:將所有的”#define“刪除,並且展開所有的宏定義;
•處理所有條件預編譯指令,比如”#if”、”#ifdef“、”#elif“等;
•頭文件展開:處理”#include“預編譯命令,將被包含的文件插入到該預編譯指令的位置。注意,這個過程是遞歸進行的,也就是說被包含的文件可能還包含其他文件;
•去注釋:刪除所有的注釋”//“和”/**/“;
•添加行號和文件名標識,比如#2”hello.c“2,以便於編譯器產生調試用時的行號信息及用於編譯時產生編譯錯誤或警告時能顯示行號;
•保留所有的#pragma編譯器指令,因為預編譯器需要用他們。
在Linux系統下使用gcc預編譯程序時命令:$gcc -E hello.c -o hello.i
編譯
編譯過程就是把預處理完的文件進行一系列詞法分析、語法分析、語義分析、生成匯編文件,這個過程是是整個程序構建的核心部分,也是最復雜的部分之一。編譯過程相當於如下過程命令:
$gcc -S hello.i -o hello.s
gcc將預編譯和編譯合並成一個步驟,使用如下命令:
$gcc -S hello.c -o hello.s
可得到會變輸出文件 hello.s 。實際上gcc這個命令只是這些后台程序的包裝,它會根據不同的參數要求去調用預編譯編譯程序cc1、匯編器as、鏈接器ld。
編譯器職責
詞法分析 經過預編譯的源代碼程序被輸入到掃描器(Scanner),掃描器對其進行簡單的詞法分析,運用一種類似於有限狀態機的算法將源代碼的字符列分割成一系列的記號。如:關鍵字、標識符、字面量(包含數字、字符串等)和特殊符號(如加號、等號)。在標別記號的同時掃描器也完成了其他如將標識符存放到符號表,將數字、字符串常量存放到文件表等的工作,以備后面的步驟使用。(lex程序可實現詞法掃描,按照一定的詞法規則完成標別記號等功能,所以無需為每個編譯器開發一個獨立開發掃描器,而是根據需要改變語法規則即可。)
語法分析 語法分析器采用上下文無關語法的分析手段對掃描器產生的記號(Token)進行語法分析,從而生成語法樹,即以表達式為節點的樹。同時很多運算符的含義和優先級也被確定下來。編譯器也會報告出語法分析階段的錯誤。(如詞法分析有像lex一樣語法分析有現成工具ycc程序,它可根據語法規則對輸入的記號序列構建出一顆語法樹。對不同的編程語言只須改變語法規則即可。)
語義分析 語義分析由語義分析器完成,它所能分析的語義是靜態語義,即編譯期間可以確定的語義,運行期間才能確定的語義是指動態語義,比如將0作為除數是一個運行期間的語義錯誤。靜態語義通常包括聲明和類型匹配,類型轉換,如浮點型到整型轉換。經過語義分析以后整個語法樹都被標識了類型,如果有些類型需要做隱式轉換,語義分析程序會在語法樹中插入相應的轉換節點。語義分析器對符號表里的符號類型也做了更新。語法分析僅僅完成對表達式語法層面的分析, 該語句是否有意義不進行檢測。
生成中間代碼和目標代碼 語義分析完成后,源碼優化器會在源代碼級別進行優化,它往往將整個語法樹轉換成中間代碼,它是語法樹的順序表示,已非常接近目標代碼。中間代碼有多種類型,常見的有三地址碼,P-代碼。中間代碼使得編譯器可分成前端和后端,前段即產生中間代碼,后端將中間代碼轉換成目標機器代碼。后端編輯器主要包括代碼生成器和目標代碼優化器。代碼生成器將中間代碼轉換成目標機器代碼。目標代碼優化器再對其進行優化,如選擇合適的尋址方式、使用位移來代替乘法運算、刪除多余指令等。
匯編
匯編器是將匯編代碼變成機器可以執行的指令,每一條匯編語句幾乎都對應一條機器指令,根據匯編指令和機器指令對照表一一翻譯即可。目標文件中還包括鏈接是所需要的一些調試信息: 比如符號表、 調試信息、 字符串等。前述匯編過程可以可調用匯編器as來完成:
$as hello.s -o hello.o
或者使用gcc匯編程序命令:$gcc -c hello.s -o hello.o
或者使用gcc命令從C源代碼文件開始,經過預編譯、編譯、匯編、直接輸出目標文件:
$gcc -c hello.c -o hello.o
目標文件:就是源代碼編譯后,但未進行鏈接的那些中間文件,它與鏈接之后形成的可執行文件在內容和結構上非常相似,按一種格式存儲,且動態鏈接庫與靜態鏈接庫都按照可執行文件格式存儲(Linux下為ELF格式)。
鏈接
人們把每個源代碼模塊獨立的進行編譯,然后按照需要將它們組裝起來,這個組裝的過程就是鏈接(Linking)。其主要內容就是把各個模塊之間相互引用的部分都處理好,使得各個模塊之間能夠正確地銜接。鏈接過程主要包括地址空間分配、符號決議和重定位。每個模塊的源代碼文件經編譯器編譯生成目標文件(.o或.obj),目標文件和庫一起鏈接形成可執行文件。
靜態鏈接是指在編譯階段直接把靜態庫加入到可執行文件中去,這樣可執行文件會比較大。
動態鏈接則是指鏈接階段僅僅只加入一些描述信息,而程序執行時再從系統中把相應動態庫加載到內存中去。
靜態鏈接
兩步鏈接:1、空間與地址分配。掃描輸入的目標文件,獲得各個段長度、屬性、位置,合並符號表、合並相似段(為合並的“bss”段分配虛擬地址空間),計算輸出文件中各個段合並后的長度與位置,並建立映射關系;
可使用鏈接器 ld 將“hello1.o”與“hello2.o”鏈接起來:
$ ld hello1.o hello2.o -e main -o hello
"-e mian"將main函數作為程序入口,ld 鏈接器默認為_start。
"-o hello"表示鏈接輸出文件名為hello 默認為a.out。
使用 objdump 可查看鏈接前后虛擬地址空間分配情況(Linux下ELF可執行文件默認從地址0x08048000開始分配)。
2、符號解析與重定位
首先,符號解析。解析符號就是將每個符號引用與它輸入的可重定位目標文件中的符號表中的一個確定的符號定義聯系起來。若找不到,則出現編譯時錯誤。
其次是重定位;不同的處理器指令對於地址的格式和方式都不一樣。我們這里采用的是32位的x86處理器,介紹兩種尋址方式。絕對尋址修正與相對尋址修正。
靜態庫可以簡單看作是一組可目標文件的集合。與靜態庫鏈接的過程是這樣的:ld鏈接器自動查找全局符號表,找到那些為決議的符號,然后查出它們所在的目標文件,將這些目標文件從靜態庫中“解壓”出來,最終將它們鏈接在一起成為一個可執行文件。也就是說只有少數幾個庫和目標文件被鏈接入了最終的可執行文件,而非所有的庫一股腦地被鏈接進了可執行文件。
動態鏈接
1、為什么要有動態鏈接?
第一,考慮內存和磁盤空間。靜態鏈接極大地浪費內存空間。因為在靜態鏈接的情況下,假設有兩個程序共享一個模塊,那么在靜態鏈接后輸出的兩個可執行文件中各有一個共享模塊的副本。如果同時運行這兩個可執行文件,那么這個共享模塊將在磁盤和內存中都有兩個副本,對磁盤和內存造成極大地浪費;第二,程序的更新。一旦程序中的一個模塊被修改,那么整個程序都要重新鏈接、發布給用戶。如果這個程序相當的大,那么后果就會更加嚴重!
2、動態鏈接做了什么?
務必知道,動態鏈接是相對於共享對象而言的。動態鏈接器將程序所需要的所有共享庫裝載到進程的地址空間,並且將程序匯總所有為決議的符號綁定到相應的動態鏈接庫(共享庫)中,並進行重定位工作。
對於共享模塊來說,要實現共享,那么其代碼對數據的訪問必須是地址無關(就是代碼中的地址是固定的,這里用的相對地址)的,如何做到地址無關,編譯器是這么干的,每一個共享模塊,都會在其代碼段有一個GOT(global offset table)段,如上圖所示,Got是一個指針數組,用來存儲外部變量的地址,而代碼相對於Got的距離是固定的,當對外部模塊變量數據和函數進行訪問時,就去訪問變量在GOT中的位置。
共享模塊對於數據的訪問方式:
本模塊的全局變量和函數------相對地址
外模塊的全局變量和函數-------GOT段
動態鏈接重定位時修改GOT中的值就實現了對變量的正確訪問。
3、動態鏈接基本分為三步:先是啟動動態鏈接器本身,然后裝載所有需要的共享對象,最后重定位和初始化。
(1)啟動動態鏈接器本身
動態鏈接器有其自身的特殊性:首先,動態鏈接器本身不可以依賴其他任何共享對象(人為控制);其次動態鏈接器本身所需要的全局和靜態變量的重定位工作由它自身完成(自舉代碼)。
在Linux下,動態鏈接器ld.so實際上也是一個共享對象,操作系統同樣通過映射的方式將它加載到進程的地址空間中。操作系統在加載完動態鏈接器之后,就將控制權交給動態鏈接器。動態鏈接器入口地址即是自舉代碼的入口。動態鏈接器啟動后,它的自舉代碼即開始執行。自舉代碼首先會找到它自己的GOT(全局偏移表,記錄每個段的偏移位置)。而GOT的第一個入口保存的就是“.dynamic”段的偏移地址,由此找到動態鏈接器本身的“.dynamic”段。通過“.dynamic”段中的信息,自舉代碼便可以獲得動態鏈接器本身的重定位表和符號表等,從而得到動態鏈接器本身的重定位入口,然后將它們重定位。完成自舉后,就可以自由地調用各種函數和全局變量。
(2)裝載共享對象
完成自舉后,動態鏈接器將可執行文件和鏈接器本身的符號表都合並到一個符號表當中,稱之為“全局符號表”。然后鏈接器開始尋找可執行文件所依賴的共享對象:從“.dynamic”段中找到DT_NEEDED類型,它所指出的就是可執行文件所依賴的共享對象。由此,動態鏈接器可以列出可執行文件所依賴的所有共享對象,並將這些共享對象的名字放入到一個裝載集合中。然后鏈接器開始從集合中取出一個所需要的共享對象的名字,找到相應的文件后打開該文件,讀取相應的ELF文件頭和“.dynamic”,然后將它相應的代碼段和數據段映射到進程空間中。如果這個ELF共享對象還依賴於其他共享對象,那么將依賴的共享對象的名字放到裝載集合中。如此循環,直到所有依賴的共享對象都被裝載完成為止。當一個新的共享對象被裝載進來的時候,它的符號表會被合並到全局符號表中。所以當所有的共享對象都被裝載進來的時候,全局符號表里面將包含動態鏈接器所需要的所有符號。
(3)重定位和初始化
當上述兩步完成以后,動態鏈接器開始重新遍歷可執行文件和每個共享對象的重定位表,將表中每個需要重定位的位置進行修正,原理同前。
重定位完成以后,如果某個共享對象有“.init”段,那么動態鏈接器會執行“.init”段中的代碼,用以實現共享對象特有的初始化過程。
此時,所有的共享對象都已經裝載並鏈接完成了,動態鏈接器的任務也到此結束。同時裝載鏈接部分也將告一段落!接下來便是程序的執行了。。。
4、靜態庫與動態庫的區別:
庫: 指由標准常用函數編譯而成的文件,旨在提高常用函數的可重用性,減輕開發人員負擔。常用的sdtio.h,math.h等 庫便是C函數庫的冰山一角。
(1)靜態庫:指編譯鏈接階段將整個庫復制到可執行文件
優點:靜態鏈接的程序不依賴外界庫支持,具有良好的可移植性。
缺點: 每次庫更新都需要重新編譯程序,即使更新很小或只是局部。
缺點:每個靜態鏈接的程序都有一份庫文件,存儲時增加了硬盤空間消耗,運行時則增加了內存消耗。
(2).動態庫:指直道運行時才將庫鏈接到可執行程序
優點: 動態鏈接方式的程序不需要包含庫(編輯鏈接時節省時間),占用的空間小很多。
優點: 運行時系統內存只需提供一個共享庫給所有程序動態鏈接,內存消耗減少。
缺點: 需要系統中動態庫支持才可運行,可能有動態庫不兼容問題
小結:在linux系統中:靜態庫 .a , 動態庫 .so
在windows中:靜態庫 .lib , 動態庫 .dll
未解決的符號表: 列出本單元里有引用但是不在本單元定義的符號以及地址。導出符號表: 本單元中定義的一些符號(全局、靜態變量和函數) 和地址的映射表。地址重定向表: 提供了本編譯單元所有對自身地址的引 用記錄連接器的工作順序:當連接器鏈接的時候, 首先決定各個目標文件在最終可執行文件里的位置。然后訪問所有目標文件的地址重定義表, 對其中記錄的地址進行重定向 (加上一個偏移量, 即該編譯單元在可執行文件上的起始地址) 。然后遍歷所有目標文件的未解決符號表, 並且在所有的導出符號表里查找匹配的符號, 並在未解決符號表中所記錄的位置上填寫實際地址。最后把所有的目標文件的內容寫在各自的位置上,和庫(Library)一起鏈接,形成最終的可執行文件。
總結:
C程序的存儲空間分配
如下圖所示:
靜態數據區還分為“data”段與“bss”段,分別存放已初始化全局變量和局部靜態變量與未初始化全局變量和局部靜態變量。未初始化全局變量和局部靜態變量默認初始化為0,沒有必要在“data”分配空間存放0,而在程序運行期間它們的確要占內存的,且可執行文件需要記錄未初始化全局變量和局部靜態變量的大小總和記為“.bss”段,所以目標文件和可執行文件中的".bss"段只是為未初始化全局變量和局部靜態變量預留位置,並沒有內容,也不占據空間,只是在鏈接裝載時占用地址空間。
代碼區存放程序指令,靜態區存放數據,為什么要將指令與數據分開呢?
1、程序被裝載后數據與指令分別映射到兩個虛存區域,進程對數據區可讀可寫,而對指令區只讀,所以對兩個虛存區的權限分別設置為可讀寫和只讀,防止指令被改寫。
2、CPU緩存被設置為數據緩存與指令緩存分離,程序指令與數據分開放可提高CPU緩存命中率。
3、最重要的原因是共享指令。當系統中運行着大量該程序副本時只需,內存中只需存一份該程序的指令部分。而數據區域不一樣,為進程私有。可以節省大量的內存。
示例代碼如下:
1 #include<stdio.h> 2 #include<stdlib.h> 3 #include<string.h> 4 int a = 0; // 全局初始化區(④區) 5 char *p1; // 全局未初始化區(③區) 6 int main() 7 { 8 int b; // 棧區 9 char s[] = "abc"; // 棧區 10 char *p2; // 棧區 11 char *p3 = "123456"; // 123456\0 在常量區(②),p3在棧上,體會與 char s[]="abc"; 的不同 12 static int c = 0; // 全局初始化區 13 p1 = (char *)malloc(10), // 堆區 14 p2 = (char *)malloc(20); // 堆區 15 // 123456\0 放在常量區,但編譯器可能會將它與p3所指向的"123456"優化成一個地方 16 strcpy(p1, "123456"); 17 }