程序的前世今生——編譯、鏈接和加載簡介

        本篇文章是組內分享的小結,主要介紹源代碼 -> 可執行程序 -> 執行這一過程。也就是源代碼是如何轉化爲可執行程序,然後可執行程序又是如何執行的。在用java或python時,只需要java ClsName或者python a.py就可以執行相應的程序,實際上它們都是依託於底層的虛擬機。本文主要介紹的是操作系統級別的連接、加載、執行等,而不是虛擬機語言的執行。這裏只對鏈接、加載進行一個簡介,詳細內容推薦大家去看《深入理解計算機系統》《程序員的自我修養》,第二本要比第一本講的更加詳細,但稍顯囉嗦,如果只是瞭解建議閱讀第一本的第七章。

        先看兩個示例程序,後續會以它們爲例:

// foo.c
#include <stdio.h>

int a = 10;
int b;

void bar(int c);

int
main(){
    bar(a);
    printf("...");
}

// bar.c

void bar(int c){
    // ...
}

        通常c程序是由多個模塊組成的,每個模塊對應一個c文件,會被編譯成可連接目標文件,然後由連接器將所有的模塊組合成一個可執行程序。可以通過下面命令完成編譯動作:

> gcc -c foo.c
        編譯之後當前目錄會生成foo.o,就是對應的可連接目標文件。實際上由源代碼轉化成目標文件是由多個步驟組成的:


        預處理(cpp):完成宏替換、文件引入,以及去除空行、註釋等爲詞法分析準備。

        編譯(cc):將預處理後的代碼編譯成彙編代碼,由於加入了彙編器這一層,隔離了底層硬件的不同實現,提高了移植性。

        彙編(as):將彙編代碼轉化成機器碼,也就是01序列。

        我們知道,一個程序是由代碼和數據組成的,目標文件必須以某種方式組織這些信息,以便鏈接器和加載器從文件中去識別相應的信息。在Linux下,目標文件的格式是ELF(Executable Linkable Format),可以用來描述可鏈接目標文件、可執行目標文件盒共享目標文件。下面就來看看可鏈接目標文件中主要包含什麼內容。目標文件以節(Section)組織數據,同時具有一個節頭部表(Section Header)用來描述所有的節。主要的節包括:

        .data:已初始化的全局變量和靜態局局變量。foo.c中的全局變量a就是存在.data節中。

        .bss:未初始化的全局變量和靜態局部變量,這個節在載入內存時會被清0,所以未初始化的全局變量和靜態局部變量默認值是0。foo.c中的全局變量b存在.bss節。

        .text:編譯後的機器代碼。所有的函數編譯後的二進制代碼會存在.text節中,比如main函數。

        .string:用來存儲目標文件中用到的字符串以及字符串常量。

        .symtab:符號表。符號就是目標文件中的全局變量和函數,符號表描述目標文件中的所有符號,這個是鏈接器進行鏈接的基礎。符號分爲:

                導入符號:當前模塊引用其他模塊中定義的符號,比如:在foo.c中使用的bar.c中定義的bar函數,那麼foo.o的符號表就包含導入符號bar。

                導出符號:就是當前模塊定義的符號,可以被其他模塊引用。這些導出符號就是模塊中定義的初始化的全局變量和非靜態函數。

        目標文件中其實還有很多個節,這裏只介紹上面幾個主要的節。

        多個c文件分別編譯成可鏈接的目標文件後,要生成可執行文件那麼還需要進行鏈接。鏈接就是解決多個模塊的引用和庫調用,然後進行重定位以便生成可執行文件。鏈接過程最重要的就是符號解析,就是將模塊中的導入符號找到其定義的地方,然後將符號替換爲指針。

        在鏈接時,符號可以分爲強符號和弱符號:

        強符號:就是初始化的全局變量和非靜態函數。比如,foo.c中的全局變量a和函數main以及bar.c中的函數bar。

        弱符號:未初始化的全局變量。比如,foo.c中的全局變量b。

        鏈接時,如果遇到重名的強符號(比如在foo.c和bar.c中都定義了int a = 1;),會報錯“duplicated symbols”,具體名稱記不清了。如果遇到重名的弱符號,鏈接的行爲取決於具體實現,這裏不再深入討論。

        鏈接器需要把多個可鏈接目標文件組合形成一個可執行目標文件,它會收集各個模塊中相同類型的節然後組成可執行文件的對應的節,比如:收集foo.o和bar.o的.data節,然後合併在一起組成可執行文件的.data節。鏈接器還需要完成重定位,因爲在合併節時,原來模塊節的地址會改變,所以重定位就是修改模塊中指針的地址。

        完成連接之後,在磁盤上就會生成可執行目標文件。要執行一個程序時,必須要把可執行目標文件載入內存。我們知道,進程是程序執行的容器,每個運行的程序都有自己的內存地址空間,需要將可執行目標文件中數據和代碼節載入到進程的地址空間。下面看一下進程的地址空間:


        每個進程都有自己私有的虛擬內存地址空間,在32bit機器上,地址空間的大小是4GB,高地址的1GB的內存空間被映射爲內核空間,用於提供內核服務。用戶棧就是函數調用棧用於實現函數調用,在棧上爲局部變量分配空間。共享庫用於實現類似C標準庫的代碼和數據。堆用於動態內存分配。剩下的數據區和代碼區是與可執行文件相關的,需要從磁盤加載。

        加載器載入可執行目標文件時需要虛擬存儲器的支持,通過mmap的文件映射方式將可執行目標文件中的.data節和.bass節映射到進程地址空間中的數據區,將.text節映射到代碼區。棧和堆採用的是mmap的匿名映射,也就是沒有提供文件參數。地址空間中的每個被佔用的區域就是一個VMA(Virtual Memory Area),這些VMA會通過鏈表和紅黑樹組織起來,採用鏈表是爲了便於順序遍歷,紅黑樹是爲了根據地址快速檢索到對應的VMA。當我們通過一個地址p訪問內存時,os會進行地址合法性檢查,第一必須保證p包含在某個VMA中;第二對於每個VMA都有一個讀、寫和執行的權限,進程必須具備相應的權限才能執行操作。如果不滿足上述兩點,就會拋出“Segment Fault”錯誤。

        在完成映射之後,接下來開始執行程序,首先執行的是_start函數這個是屬於glibc的庫函數,完成程序的初始化,爲程序的運行準備,接下來就會調用main函數,這是通過符號表完成main函數入口的定位。經過mmap實際上文件並沒有載入內存,當第一次訪問時會從磁盤加載對應的內容,這是由虛擬存儲器機制完成的,對進程是透明的。

        用戶棧中的元素就是一個個函數調用對應的棧幀,當前棧幀有CPU寄存器%esp和%ebp標識,%esp是棧指針指向棧頂,%ebp是幀指針,在%esp和%ebp之間的區域就是當前函數對應的棧幀。棧幀存放的是函數的實參已經局部變量。

        運行時堆是動態內存分配的區域,指針sbrk指向堆頂,可以通過改變sbrk進行動態內存的分配和釋放。C標準庫中的malloc和free底層就是基於sbrk指針,當然我們也可以通過sbrk實現內存分配器,不過還要設計自己的內存分配算法,通常建議還是使用標準庫進行內存分配。

        上面就是小組分享的全部內容,由於完全裸講,沒有充分準備,想到哪講到哪,所以難免會有紕漏,見諒啊~~


發佈了119 篇原創文章 · 獲贊 69 · 訪問量 126萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章