【轉】詳解C程序編譯、鏈接與存儲空間佈局

原文地址:https://www.cnblogs.com/33debug/p/6545136.html

被隱藏了的過程

  現如今在流行的集成開發環境下我們很少需要關注編譯和鏈接的過程,而隱藏在程序運行期間的細節過程可不簡單,即使使用命令行來編譯一個源代碼文件,簡單的一句"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     }