程序執行簡述
由下圖編譯的過程可以看出,源程序經過預處理,編譯,彙編等步驟形成可重定位目標文件,再由多個可重定位目標文件鏈接成可執行目標文件。
每個步驟做的事情:
(1)預處理(預編譯),處理源代碼中以“#”開頭的預處理指令,如#include,#define。具體工作如下:
- 將所有#define刪除,並展開所有宏定義
- 處理所有條件預編譯指令,如#if,#ifndef,#endif等
- 處理#include預編譯指令,將所包含的文件插入到該指令的位置,這個過程是遞歸進行的
- 刪除註釋,添加行號和文件名標識
(2)編譯過程是將預處理完成的文本進行詞法分析,語法分析,語義分析,優化後生成相應的彙編程序。
- 詞法分析,源代碼被輸入掃描器,掃描器將源代碼的字符序列分割成一系列的記號(記號分爲關鍵字,標識符,字面量和特殊符號)
- 語法分析,利用語法分析器對掃描器產生的符號進行語法分析並生成語法樹
- 語義分析,編譯器只能做靜態語義分析,主要是聲明和類型的匹配,類型的轉換
- 編譯器對源碼和目標代碼進行優化
(3)彙編器將彙編代碼轉換成二進制機器指令,彙編語句與機器指令一一對應。
(4)鏈接是將各種代碼和數據片段收集並組合成一個單一可執行目標文件的過程,可執行文件可被加載到內存中直接執行。鏈接可以執行於編譯時(源代碼被翻譯成機器碼)、加載時(程序被加載器加載到內存並執行)、運行時(由應用程序來執行)
鏈接的好處在於①模塊化(可構建共享庫)②效率高,時間上可分開編譯(只需要重新編譯修改過的文件即可),空間上可執行文件運行時內存只需包含所調用函數所在目標文件的代碼(是以目標文件爲基本單位)。
鏈接操作步驟
現代的鏈接器一般分成兩步,第一步空間和地址分配,分配虛擬地址空間,掃描所有的可重定位目標文件,獲取各個段的長度、屬性、位置,並將符號表中所有符號定義和符號引用收集起來統一放到全局符號表中。linux下,ELF可執行文件默認從地址0x8048000開始分配,確定好每個符號的地址,後面的步驟就容易了。
第二步符號解析和重定位,具體如下,
- 確定符號引用關係
- 合併相關.O文件
- 確定每個符號的地址
- 在指令中填入新地址
同時也可以分成兩個步驟,符號解析(步驟1,即把每個符號引用和一個符號定義關聯起來)和重定位(步驟2\3\4)。
符號解析:連接器解析符號引用的方法就是將每個引用與他輸入的可重定位目標文件的符號表中的符號定義關聯起來。每個可重定位目標模塊m都有一個符號表,其包含了m定義和引用的符號信息,符號表中有三種不同的符號:
- 由模塊m定義並能被其他模塊引用的全局符號(非靜態函數和非靜態全局變量)
- 由其他模塊定義並被模塊m引用的全局符號,也成爲外部符號(其他模塊中定義的非靜態函數和非靜態全局變量)
- 只能被模塊m定義和引用的局部符號(靜態函數和靜態變量)
注:函數內部局部變量不在符號表中,在函數棧幀中
對於多重定義的全局符號,在編譯時編譯器會向彙編器輸出每個全局符號,分爲強符號(函數和已初始化的全局變量)和弱符號(未初始化的全局變量),彙編器則會把這個信息放在可重定位文件的符號表中,鏈接器根據以下規則處理多重定義的符號名:
- 不允許有多個同名的強符號
- 如果有一個強符號和多個弱符號同名,那麼選擇強符號
- 如果由多個弱符號同名,那麼從這些弱符號中任意選擇一個。
重定位:合併輸入模塊併爲每個符號分配運行時地址。有兩步驟組成:①重定位節和符號定義,將所有相同類型的節合併爲同一類型的新的聚合節②重定位節中的符號引用,鏈接器修改代碼節和數據節中對每個符號的引用,使其指向正確的運行地址(依賴於重定位條目)
可重定位目標文件
圖中是一個ELF可重定位目標文件格式。
首先,爲什麼要把ELF中指令與數據分開放?原因是
(1)權限問題,數據大部分是可讀可寫的,代碼指令只是可讀的
(2)局部性問題,指令段與代碼段的分離,有利於提高程序的局部性,現代cpu的緩存大部分都分爲指令緩存和數據緩存
(3)當系統運行多個該程序副本時,內存中只需保存一份該程序的代碼段,可通過內存映射實現,而數據段是每個進程私有且不同的
接下來對一些ELF文件中的關鍵節(段)做具體分析。
ELF文件頭描述了整個ELF文件的基本屬性,主要包含系統字大小,字節順序,ELF頭大小、目標文件類型(如可重定位、可執行或共享的)、機器類型、節頭部表的文件偏移、以及節頭部表中條目的大小和數量。
節頭部表描述不同節的位置和大小。比如每個段的段名,段長度,在文件中的偏移,讀寫權限以及段的其他屬性等
- .text已編譯程序的機器代碼
- .rodata只讀數據
- .data已初始化的全局和靜態C變量
- .bss未初始化的全局和靜態C變量
- .symtal符號表,存放程序中引用和定義的函數和全局變量的信息
- .rel.text存放代碼的重定位條目,.rel.data存放已初始化數據重定位條目
- .debug是一個調試符號表,.line是原始C源程序中行號和.text節中機器指令之間的映射,
- .strtab一個字符串表,用來保存普通的字符串
可執行目標文件
段頭部表(程序頭部表)是將連續的文件節映射到運行時內存段,即節與段的映射幷包含每個段的信息。可執行目標文件的ELF頭和可重定位文件的ELF頭類似,還包括程序的入口點(即當程序運行時候執行的第一條指令的地址),其他.text節等已重定位到他們最終運行時內存地址。
裝載可執行目標文件
Linux系統中每個程序都運行在一個進程上下文中,有自己的虛擬地址空間,shell運行一個可執行目標文件的過程:
- 讀入命令(可執行文件名稱)及參數
- 構造argv和envp
- 調用fork系統調用,從父進程生成一個子進程
- 調用execve系統調用啓動加載器,加載器刪除子進程現有的虛擬內存段,並創建一組新的代碼段、數據段、堆和棧,新的堆和棧初始化爲零,通過虛擬地址空間中的頁映射到可執行文件的頁大小的片,新的代碼段和數據段就會被初始化爲可執行文件的內容
- 加載器跳轉到_start地址,最終會調用應用程序的main函數
靜態庫與動態鏈接共享庫
所有的相關目標模塊(.o文件)打包成一個單獨的文件即爲靜態庫(.a文件)。靜態庫的缺點:①靜態庫函數被包含在每個進程的代碼段,造成主存的浪費②靜態庫函數被合併在可執行目標中,磁盤中空間浪費③更新困難
動態鏈接共享庫是一個目標模塊,在運行或加載時,可以加載到任意內存地址,並和一個在內存中的程序鏈接起來。Linux下用.so後綴表示,Windows下用.dll表示。
所有引用共享庫的可執行目標文件共享這個.so文件中的代碼和數據,而不是像靜態庫中被複制到引用他們的可執行文件中,除此之外,在內存中一個共享庫的.text節的一個副本可以被不同的正在運行的進程共享。
linux > gcc -shared -fpic -o lib.so add1.c add2.c
linux > gcc -o a.out main.c ./lib.so
//main.c中引用add1.c或者add2.c
參考《CSAPP》《TLPI》《程序員的自我修養》