第一章 溫故而知新
對于下面這些問題,你的腦子里能夠馬上反應(yīng)出一個很清晰又很明確的答案嗎?
程序為什么要被編譯器編譯了之后才可以運行?
編譯器在把C語言程序轉(zhuǎn)換成可以執(zhí)行的機器碼的過程中做了什么,怎么做的?
最后編譯出來的可執(zhí)行文件里面是什么?除了機器碼還有什么?它們怎么存放的,怎么組織的?
#include 是什么意思?把stdio.h包含進來意味著什么?C語言庫又是什么?它怎么實現(xiàn)的?
不同的編譯器(Microsoft VC、GCC)和不同的硬件平臺(x86、SPARC、MIPS、ARM),以及不同的操作系統(tǒng)(Windows、Linux、UNIX、Solaris),最終編譯出來的結(jié)果一樣嗎?為什么?
Hello World程序是怎么運行起來的?操作系統(tǒng)是怎么裝載它的?它從哪兒開始執(zhí)行,到哪兒結(jié)束?main函數(shù)之前發(fā)生了什么?main函數(shù)結(jié)束以后又發(fā)生了什么?
如果沒有操作系統(tǒng),Hello World可以運行嗎?如果要在一臺沒有操作系統(tǒng)的機器上運行Hello World需要什么?應(yīng)該怎么實現(xiàn)?
printf是怎么實現(xiàn)的?它為什么可以有不定數(shù)量的參數(shù)?為什么它能夠在終端上輸出字符串?
Hello World程序在運行時,它在內(nèi)存中是什么樣子的?
其他計算機組成原理和操作系統(tǒng)復(fù)習(xí)略
第二章 編譯和鏈接
Visual Studio等IDE一般都將編譯和鏈接的過程一步完成,通常將這種編譯和鏈接合并到一起的過程稱為構(gòu)建(Build)。
2.1 被隱藏了的過程
在Linux下,當(dāng)我們使用GCC來編譯Hello World程序時,只須使用最簡單的命令(假設(shè)源代碼文件名為hello.c):
`$gcc hello.c`
事實上,上述過程可以分解為4個步驟,分別是預(yù)處理(Prepressing)、 編譯(Compilation)、匯編(Assembly)和鏈接(Linking),如圖所示:
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211113205542339.png" alt="image-20211113205542339" style="zoom:50%;" />
2.1.1 預(yù)編譯
首先是源代碼文件hello.c和相關(guān)的頭文件,如stdio.h等被預(yù)編譯器cpp預(yù)編譯成一個.i文件。對于C++程序來說,它的源代碼文件的擴展名可能是.cpp或.cxx,頭文件的擴展名可能是.hpp,而預(yù)編譯后的文件擴展名是.ii。
命令如下(-E表示只進行預(yù)編譯):
`$gcc –E hello.c –o hello.i`或
`$cpp hello.c > hello.i`
預(yù)編譯過程主要處理那些源代碼文件中的以“#”開始的預(yù)編譯指令。比 如 “#include”、“#define ”等,主要處理規(guī)則如下:
將所有的 “
#define”刪除,并且展開所有的宏定義。處理所有條件預(yù)編譯指令,比如 “
#if”、“#ifdef”、“#elif”、“#else”、“#endif”。處理 “
#include”預(yù)編譯指令,將被包含的文件插入到該預(yù)編譯指令的位置。注意,這個過程是遞歸進行的,也就是說被包含的文件可能還包含其他文件。刪除所有的注釋“//”和“/* */”。
添加行號和文件名標(biāo)識,比如#2“hello.c”2,以便于編譯時編譯器產(chǎn)生調(diào)試用的行號信息及用于編譯時產(chǎn)生編譯錯誤或警告時能夠顯示行號。
-
保留所有的
#pragma編譯器指令,因為編譯器須要使用它們。經(jīng)過預(yù)編譯后的.i文件不包含任何宏定義,因為所有的宏已經(jīng)被展開,并且包含的文件也已經(jīng)被插入到.i文件中。<u>所以當(dāng)我們無法判斷宏定義是否正確或頭文件包含是否正確時,可以查看預(yù)編譯后的文件來確定問題。</u>
2.1.2 編譯
編譯過程就是把預(yù)處理完的文件進行一系列詞法分析、語法分析、語義分析及優(yōu)化后生產(chǎn)相應(yīng)的匯編代碼文件,這個過程往往是我們所說的整個程序構(gòu)建的核心部分,也是最復(fù)雜的部分之一。下一節(jié)簡單介紹,詳見編譯原理
命令如下:
`$gcc –S hello.i –o hello.s`或從.c文件開始
`$gcc –S hello.c –o hello.s`
實際上gcc這個命令只是這些后臺程序的包裝,它會根據(jù)不同的參數(shù)要求去調(diào)用預(yù)編譯譯程序、匯編器、鏈接器。
2.1.3 匯編
匯編器是將匯編代碼轉(zhuǎn)變成機器可以執(zhí)行的指令,每一個匯編語句幾乎都對應(yīng)一條機器指令。
命令如下:
`$as hello.s –o hello.o`或從.c文件開始
`$gcc –c hello.c –o hello.o`
2.1.4 鏈接
本章后續(xù)介紹。命令略
2.2 編譯器做了什么
從最直觀的角度來講,編譯器就是將高級語言翻譯成機器語言的一個工具。編譯過程一般可以分為6步:掃描、語法分析、語義分析、源代碼優(yōu)化、代碼生成和目標(biāo)代碼優(yōu)化。整個過程如圖所示:
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211113205630014.png" alt="image-20211113205630014" style="zoom:50%;" />
下面幾節(jié)由以下C語言代碼為例:
array[index] = (index + 4) * (2 + 6);
2.2.1 詞法分析
首先源代碼程序被輸入到掃描器(Scanner),掃描器運用一種類似于有限狀態(tài)機(Finite State Machine)的算法將源代碼的字符序列分割成一系列的記號(Token)。
詞法分析產(chǎn)生的記號一般可以分為如下幾類:關(guān)鍵字、標(biāo)識符、字面量(包含數(shù)字、字符串等)和特殊符號(如加號、等號)。在識別記號的同時,掃描器也完成了其他工作。比如將標(biāo)識符存放到符號表,將數(shù)字、字符串常量存放到文字表等,以備后面的步驟使用。
2.2.2 語法分析
接下來語法分析器(Grammar Parser)將對由掃描器產(chǎn)生的記號進行語法分析,從而產(chǎn)生語法樹(Syntax Tree)。整個分析過程采用了上下文無關(guān)語法(Context-free Grammar)的分析手段。簡單地講,由語法分析器生成的語法樹就是以表達式(Expression)為節(jié)點的樹。語法樹如圖所示:
[圖片上傳失敗...(image-a7d5f3-1648465105741)]
在語法分析的同時,很多運算符號的優(yōu)先級和含義也被確定下來了。比如乘法表達式的優(yōu)先級比加法高,而圓括號表達式的優(yōu)先級比乘法高,等等。另外有些符號具有多重含義,比如星號*在C語言中可以表示乘法表達式,也可以表示對指針取內(nèi)容的表達式,所以語法分析階段必須對這些內(nèi)容進行區(qū)分。如果出現(xiàn)了表達式不合法,比如各種括號不匹配、表達式中缺少操作符等,編譯器就會報告語法分析階段的錯誤。
2.2.3 語義分析
接下來進行的是語義分析,由語義分析器(Semantic Analyzer)來完成。語法分析僅僅是完成了對表達式的語法層面的分析,但是它并不了解這個語句是否真正有意義。比如C語言里面兩個指針做乘法運算是沒有意義的,但是這個語句在語法上是合法的;比如同樣一個指針和一個浮點數(shù)做乘法運算是否合法等。編譯器所能分析的語義是靜態(tài)語義(Static Semantic),所謂靜態(tài)語義是指在編譯期可以確定的語義,與之對應(yīng)的動態(tài)語義(Dynamic Semantic)就是只有在運行期才能確定的語義。
靜態(tài)語義通常包括聲明和類型的匹配,類型的轉(zhuǎn)換。比如當(dāng)一個浮點型的表達式賦值給一個整型的表達式時,其中隱含了一個浮點型到整型轉(zhuǎn)換的過程,語義分析過程中需要完成這個步驟。比如將一個浮點型賦值給一個指針的時候,語義分析程序會發(fā)現(xiàn)這個類型不匹配,編譯器將會報錯。動態(tài)語義一般指在運行期出現(xiàn)的語義相關(guān)的問題,比如將0作為除數(shù)是一個運行期語義錯誤。
經(jīng)過語義分析階段以后,整個語法樹的表達式都被標(biāo)識了類型,如果有些類型需要做隱式轉(zhuǎn)換,語義分析程序會在語法樹中插入相應(yīng)的轉(zhuǎn)換節(jié)點。上面描述的語法樹在經(jīng)過語義分析階段以后成為如圖所示的形式:
[圖片上傳失敗...(image-3ad280-1648465105741)]
2.2.4 中間語言生成
現(xiàn)代的編譯器有著很多層次的優(yōu)化,往往在源代碼級別會有一個優(yōu)化過程。源碼級優(yōu)化器(Source Code Optimizer)會在源代碼級別進行優(yōu)化,在上例中,(2 + 6)這個表達式可以被優(yōu)化掉,因為它的值在編譯期就可以被確定。類似的還有很多其他復(fù)雜的優(yōu)化過程,我們在這里就不詳細描述了。優(yōu)化后的語法樹如圖所示:
[圖片上傳失敗...(image-f27702-1648465105741)]
我們看到(2 + 6)這個表達式被優(yōu)化成8。其實直接在語法樹上作優(yōu)化比較困難,所以源代碼優(yōu)化器往往將整個語法樹轉(zhuǎn)換成中間代碼(Intermediate Code),它是語法樹的順序表示,其實它已經(jīng)非常接近目標(biāo)代碼了。但是它一般跟目標(biāo)機器和運行時環(huán)境是無關(guān)的,比如它不包含數(shù)據(jù)的尺寸、變量地址和寄存器的名字等。
中間代碼使得編譯器可以被分為前端和后端。編譯器前端負責(zé)產(chǎn)生機器無關(guān)的中間代碼,編譯器后端將中間代碼轉(zhuǎn)換成目標(biāo)機器代碼。這樣對于一些可以跨平臺的編譯器而言,它們可以針對不同的平臺使用同一個前端和針對不同機器平臺的數(shù)個后端。
2.2.5 目標(biāo)代碼生成與優(yōu)化
編譯器后端主要包括代碼生成器(Code Generator)和目標(biāo)代碼優(yōu)化器(Target Code Optimizer)。
<u>代碼生成器</u>將中間代碼轉(zhuǎn)換成目標(biāo)機器代碼,這個過程十分依賴于目標(biāo)機器,因為不同的機器有著不同的字長、寄存器、整數(shù)數(shù)據(jù)類型和浮點數(shù)數(shù)據(jù)類型等。
最后<u>目標(biāo)代碼優(yōu)化器</u>對上述的目標(biāo)代碼進行優(yōu)化,比如選擇合適的尋址方式、使用位移來代替乘法運算、刪除多余的指令等。
2.3 略
2.4 模塊拼裝——靜態(tài)鏈接
人們把每個源代碼模塊獨立地編譯,然后按照需要將它們“組裝”起來,這個組裝模塊的過程就是鏈接 (Linking)。鏈接過程主要包括了地址和空間分配(Address and Storage Allocation)、符號決議(Symbol Resolution)和重定位(Relocation)等這些步驟。
每個模塊的源代碼文件(如.c)文件經(jīng)過編譯器編譯成目標(biāo)文件(Object File,一般擴展名為.o或.obj),目標(biāo)文件和庫(Library)一起鏈接形成最終可執(zhí)行文件。而最常見的庫就是運行時庫(Runtime Library),它是支持程序運行的基本函數(shù)的集合。庫其實是一組目標(biāo)文件的包,就是一些最常用的代碼編譯成目標(biāo)文件后打包存放。關(guān)于庫本書的后面還會再詳細分析。
第三章 目標(biāo)文件里有什么
3.1 目標(biāo)文件的格式
現(xiàn)在PC平臺流行的可執(zhí)行文件格式(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它們都是COFF(Common file format)格式的變種。目標(biāo)文件就是源代碼編譯后但未進行鏈接的那些中間文件(Windows的.obj和Linux下的.o),它跟可執(zhí)行文件的內(nèi)容與結(jié)構(gòu)很相似,所以一般跟可執(zhí)行文件格式一起采用一種格式存儲。
動態(tài)鏈接庫(DLL,Dynamic Linking Library)(Windows的.dll和Linux的.so)及靜態(tài)鏈接庫(Static Linking Library)(Windows的.lib和Linux的.a)文件都按照可執(zhí)行文件格式存儲。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211115211918107.png" alt="image-20211115211918107" style="zoom: 80%;" />
3.2 目標(biāo)文件是什么樣的
程序源代碼編譯后的機器指令經(jīng)常被放在代碼段(Code Section)里,代碼段常見的名字有“.code”或“.text”;全局變量和局部靜態(tài)變量數(shù)據(jù)經(jīng)常放在數(shù)據(jù)段(Data Section),數(shù)據(jù)段的一般名字都叫“.data”。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211115212122076.png" alt="image-20211115212122076" style="zoom:33%;" />
從圖中可以看到,ELF文件的開頭是一個“文件頭”,它描述了整個文件的文件屬性,包括文件是否可執(zhí)行、是靜態(tài)鏈接還是動態(tài)鏈接及入口地址(如果是可執(zhí)行文件)、目標(biāo)硬件、目標(biāo)操作系統(tǒng)等信息,文件頭還包括一個段表(Section Table),段表其實是一個描述文件中各個段的數(shù)組。段表描述了文件中各個段在文件中的偏移位置及段的屬性等,從段表里面可以得到每個段的所有信息。
對照圖1來看,一般C語言的編譯后執(zhí)行語句都編譯成機器代碼,保存在.text段;已初始化的全局變量和局部靜態(tài)變量都保存在. data段;未初始化的全局變量和局部靜態(tài)變量一般放在一個叫.“bss”的段里。bss段只是為未初始化的全局變量和局部靜態(tài)變量預(yù)留位置而已,它并沒有內(nèi)容,所以它在文件中也不占據(jù)空間。
總體來說,程序源代碼被編譯以后主要分成兩種段:程序指令和程序數(shù)據(jù)。代碼段屬于程序指令,而數(shù)據(jù)段和.bss段屬于程序數(shù)據(jù)。
數(shù)據(jù)和指令分段的好處:
一方面是當(dāng)程序被裝載后,數(shù)據(jù)和指令分別被映射到兩個虛存區(qū)域。由于數(shù)據(jù)區(qū)域?qū)τ谶M程來說是可讀寫的,而指令區(qū)域?qū)τ谶M程來說是只讀的,所以這兩個虛存區(qū)域的權(quán)限可以被分別設(shè)置成可讀寫和只讀。這樣可以防止程序的指令被有意或無意地改寫。
另外一方面是對于現(xiàn)代的CPU來說,它們有著極為強大的緩存(Cache)體系。由于緩存在現(xiàn)代的計算機中地位非常重要,所以程序必須盡量提高緩存的命中率。指令區(qū)和數(shù)據(jù)區(qū)的分離有利于提高程序的局部性。現(xiàn)代CPU的緩存一般都被設(shè)計成數(shù)據(jù)緩存和指令緩存分離,所以程序的指令和數(shù)據(jù)被分開存放對CPU的緩存命中率提高有好處。
如何有好處?
第三個原因,其實也是最重要的原因,就是當(dāng)系統(tǒng)中運行著多個該程序的副本時,它們的指令都是一樣的,所以內(nèi)存中只須要保存一份該程序的指令部分。對于指令這種只讀的區(qū)域來說是這樣,對于其他的只讀數(shù)據(jù)也一樣,比如很多程序里面帶有的圖標(biāo)、圖片、文本等資源也是屬于可以共享的。當(dāng)然每個副本進程的數(shù)據(jù)區(qū)域是不一樣的,它們是進程私有的。
3.3 挖掘SimpleSection.o
/*
* SimpleSection.c
*
* Linux:
* gcc -c SimpleSection.c
*
* Windows:
* c1 SimpleSection.c /c /Za
*/
int printf( const char* format, ... );
int global_init_var = 84;
int global_uninit_var;
void func1( int i )
{
printf( "%d\n", i );
}
int main(void)
{
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1( static_var + static_var2 + a + b);
return a;
}
$ gcc –c SimpleSection.c(-c表示只編譯不鏈接)
$ objdump -h SimpleSection.o
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211118210652899.png" alt="image-20211118210652899" style="zoom:67%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211118210707121.png" alt="image-20211118210707121" style="zoom:67%;" />
SimpleSection.o的段除了最基本的代碼段、數(shù)據(jù)段和BSS段以外,還有3個段分別是只讀數(shù)據(jù)段(.rodata)、注釋信息段(.comment)和堆棧提示段(.note.GNU-stack)。
重要的段的屬性:段的長度(Size)和段所在的位置(File Offset),“CONTENTS”表示該段在文件中存在。我們可以看到BSS段沒有“CONTENTS”,表示它實際上在ELF文件中不存在內(nèi)容?!?note.GNU-stack”段雖然 有“CONTENTS”,但它的長度為0,這是個很古怪的段,我們暫且忽略它,認為它在ELF文件中也不存在。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211115212325298.png" alt="image-20211115212325298" style="zoom: 33%;" />
3.3.1 代碼段
$ objdump -s -d SimpleSection.o
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211118210554499.png" alt="image-20211118210554499" style="zoom:67%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211118210614116.png" alt="image-20211118210614116" style="zoom:67%;" />
3.3.2 數(shù)據(jù)段和只讀數(shù)據(jù)段
“.rodata”段存放的是只讀數(shù)據(jù),一般是程序里面的只讀變量(如const修飾的變量)和字符串常量。單獨設(shè)立“.rodata”段有很多好處,不光是在語義上支持了C++的const關(guān)鍵字,而且操作系統(tǒng)在加載的時候可以 將“.rodata”段的屬性映射成只讀,這樣對于這個段的任何修改操作都會作為非法操作處理,保證了程序的安全性。另外在某些嵌入式平臺下,有些存儲區(qū)域是采用只讀存儲器的,如ROM,這樣將“.rodata”段放在該存儲區(qū)域中就可以保證程序訪問存儲器的正確性。
另外值得一提的是,有時候編譯器會把字符串常量放到“.data”段,而不會單獨放在“.rodata”段。
3.3.3 BSS段
.bss段存放的是未初始化的全局變量和局部靜態(tài)變量。
其實我們可以通過符號表(Symbol Table)(后面章節(jié)介紹符號表)看到,只有 static_var2 被存放在了.bss段,而 global_uninit_var 卻沒有被存放在任何段,只是一個未定義的“COMMON符號”。這其實是跟不同的語言與不同的編譯器實現(xiàn)有關(guān)
3.3.4 其他段
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211115212352687.png" alt="image-20211115212352687" style="zoom:80%;" />
3.4 ELF文件結(jié)構(gòu)描述
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211115212421499.png" alt="image-20211115212421499" style="zoom:25%;" />
3.4.1 文件頭
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211118210802380.png" alt="image-20211118210802380" style="zoom:67%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211118210846945.png" alt="image-20211118210846945" style="zoom:67%;" />
以32位版本的文件頭結(jié)構(gòu)“Elf32_Ehdr”作為例,它的定義如下:
typedef struct {
unsigned char e_ident[16];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119201801751.png" alt="image-20211119201801751" style="zoom:80%;" />
ELF魔數(shù) 從上面輸出的結(jié)果可以看到,ELF的文件頭中定義了ELF魔數(shù)、文件機器字節(jié)長度、數(shù)據(jù)存儲方式、版本、運行平臺、ABI版本、ELF重定位類型、硬件平臺、硬件平臺版本、入口地址、程序頭入口和長度、段表的位置和長度及段的數(shù)量等。這些數(shù)值中有關(guān)描述ELF目標(biāo)平臺的部分,與我們常見的32位Intel的硬件平臺基本上一樣。
幾乎所有的可執(zhí)行文件格式的最開始的幾個字節(jié)都是魔數(shù)。這種魔數(shù)用來確認文件的類型,操作系統(tǒng)在加載可執(zhí)行文件的時候會確認魔數(shù)是否正確,如果不正確會拒絕加載。
文件類型 e_type 成員表示ELF文件類型,即前面提到過的3種ELF文件類型,每個文件類型對應(yīng)一個常量。系統(tǒng)通過這個常量來判斷ELF的真正文件類型,而不是通過文件的擴展名。
機器類型 ELF文件格式被設(shè)計成可以在多個平臺下使用。這并不表示同一個ELF文件可以在不同的平臺下使用(就像java的字節(jié)碼文件那樣),而是表示不同平臺下的ELF文件都遵循同一套ELF標(biāo)準。 e_machine成員就表示該ELF文件的平臺屬性,比如3表示該ELF文件只能在Intel x86機器下使用。
3.4.2 段表
段表(Section Header Table)是ELF文件中除了文件頭以外最重要的結(jié)構(gòu),它描述了ELF的各個段的信息,比如每個段的段名、段的長度、在文件中的偏移、讀寫權(quán)限及段的其他屬性。編譯器、鏈接器和裝載器都是依靠段表來定位和訪問各個段的屬性的。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119201910661.png" alt="image-20211119201910661" style="zoom: 67%;" />
段表是一個以“Elf32_Shdr”結(jié)構(gòu)體為元素的數(shù)組。數(shù)組元素的個數(shù)等于段的個數(shù),每個“Elf32_Shdr”結(jié)構(gòu)體對應(yīng)一個段。“Elf32_Shdr”又被稱為段描述符(Section Descriptor)。ELF段表的這個數(shù)組的第一個元素是無效的段描述符,它的類型 為“NULL”。
Elf32_Shdr 段描述符結(jié)構(gòu):
typedef struct
{
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
Elf32_Word sh_size;
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;
Elf32_Shdr的各個成員的含義如表所示:
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211118210940170.png" alt="image-20211118210940170" style="zoom: 80%;" />
3.4.3 重定位表
見下一章
3.4.4 字符串表
因為字符串的長度往往是不定的,所以用固定的結(jié)構(gòu)來表示它比較困難。一種很常見的做法是把字符串集中起來存放到一個表,然后使用字符串在表中的偏移來引用字符串。
[圖片上傳失敗...(image-22a163-1648465105741)]
那么偏移與它們對應(yīng)的字符串如表所示。
[圖片上傳失敗...(image-494fc5-1648465105741)]
一般字符串表在ELF文件中也以段的形式保存,常見的段名為".strtab”或“.shstrtab"。這兩個字符串表分別為字符串表(String Table)和段表字符串表(Section Header String Table)。顧名思義,字符串表用來保存普通的字符串,比如符號的名字;段表字符串表用來保存段表中用到的字符串,最常見的就是段名(sh_name)。
3.5 鏈接的接口——符號
定義和引用:
比如目標(biāo)文件B要用到了目標(biāo)文件A中的函數(shù)“foo”,那么我們就稱目標(biāo)文件A定義(Define)了函數(shù)“foo”,稱目標(biāo)文件B引用(Reference)了目標(biāo)文件A中的函數(shù)“foo”。在鏈接中,我們將函數(shù)和變量統(tǒng)稱為符號(Symbol),函數(shù)名或變量名就是符號名(Symbol Name)。
鏈接過程的本質(zhì)就是要把多個不同的目標(biāo)文件之間相互“粘”到一起。
鏈接過程中很關(guān)鍵的一部分就是符號的管理,每一個目標(biāo)文件都會有一個相應(yīng)的符號表(Symbol Table),這個表里面記錄了目標(biāo)文件中所用到的所有符號。每個定義的符號有一個對應(yīng)的值,叫做符號值(Symbol Value)。
3.5.1 ELF符號表結(jié)構(gòu)
Elf32_Sym的結(jié)構(gòu)定義如下:
typedef struct {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;
3.5.2 特殊符號
當(dāng)我們使用ld作為鏈接器來鏈接生產(chǎn)可執(zhí)行文件時,它會為我們定義很多特殊的符號,這些符號并沒有在你的程序中定義,但是你可以直接聲明并且引用它,我們稱之為特殊符號。具體請參閱本書第7章的“鏈接過程控制”。
3.5.3 符號修飾與函數(shù)簽名
函數(shù)簽名(Function Signature)包含了一個函數(shù)的信息,包括函數(shù)名、它的參數(shù)類型、它所在的類和名稱空間及其他信息。函數(shù)簽名用于識別不同的函數(shù),就像簽名用于識別不同的人一樣,函數(shù)的名字只是函數(shù)簽名的一部分。
在編譯器及鏈接器處理符號時,它們使用某種名稱修飾的方法,使得每個函數(shù)簽名對應(yīng)一個修飾后名稱(Decorated Name)。
3.5.4 extern “C”
extern “C”用法:
#ifdef __cplusplus
extern "C" {
#endif
void *memset (void *, int, size_t);
#ifdef __cplusplus
}
#endif
如果當(dāng)前編譯單元是C++代碼,那么memset會在extern “C”里面被聲明;如果是C代碼,就直接聲明。上面這段代碼中的技巧幾乎在所有的系統(tǒng)頭文件里面都被用到。
3.5.5 弱符號與強符號
對于C/C++語言來說,編譯器默認函數(shù)和初始化了的全局變量為強符號,未初始化的全局變量為弱符號。
針對強弱符號的概念,鏈接器就會按如下規(guī)則處理與選擇被多次定義的全局符號:
- 不允許強符號被多次定義(即不同的目標(biāo)文件中不能有同名的強符號);如果有多個強符號定義,則鏈接器報符號重復(fù)定義錯誤。
- 如果一個符號在某個目標(biāo)文件中是強符號,在其他文件中都是弱符號,那么選擇強符號。
- 如果一個符號在所有目標(biāo)文件中都是弱符號,那么選擇其中占用空間最大的一個。比如目標(biāo)文件A定義全局變量global為int型,占4個字節(jié);目標(biāo)文件B定義global為double型,占8個字節(jié),那么目標(biāo)文件A和B鏈接后,符號global占8個字節(jié)(盡量不要使用多個不同類型的弱符號,否則容易導(dǎo)致很難發(fā)現(xiàn)的程序錯誤)。
3.6 略
第四章 靜態(tài)鏈接
/* a.c */
extern int shared;
int main()
{
int a = 100;
swap(&a, &shared);
}
/* b.c */
int shared = 1;
void swap(int *a, int *b)
{
*a ^= *b ^= *a ^= *b;
}
4.1 空間與地址分配
這里談到的空間分配只關(guān)注于虛擬地址空間的分配
4.1.1 按序疊加
直接將各個目標(biāo)文件依次合并。但是這樣做會造成一個問題,在有很多輸入文件的情況下,輸出文件將會有很多零散的段。比如一個規(guī)模稍大的應(yīng)用程序可能會有數(shù)百個目標(biāo)文件,如果每個目標(biāo)文件都分別有.text段、.data段和.bss段,那最后的輸出文件將會有成百上千個零散的段。這種做法非常浪費空間,因為每個段都須要有一定的地址和空間對齊要求,比如對于x86的硬件來說,段的裝載地址和空間的對齊單位是頁,也就是4096字節(jié)(關(guān)于地址和空間對齊,我們在后面還會有專門的章節(jié)詳細介紹)。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202017107.png" alt="image-20211119202017107" style="zoom: 50%;" />
4.1.2 相似段合并
一個更實際的方法是將相同性質(zhì)的段合并到一起,比如將所有輸入文件的“.text”合并到輸出文件的“.text”段,接著是“.data”段、“.bss”段等,如圖所示:
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202054285.png" alt="image-20211119202054285" style="zoom: 67%;" />
現(xiàn)在的鏈接器空間分配的策略基本上都采用上述方法中的第二種,使用這種方法的鏈接器一般都采用一種叫兩步鏈接(Two-pass Linking)的方法:
- 空間與地址分配 掃描所有的輸入目標(biāo)文件,并且獲得它們的各個段的長度、屬性和位置,并且將輸入目標(biāo)文件中的符號表中所有的符號定義和符號引用收集起來,統(tǒng)一放到一個全局符號表。
- 符號解析與重定位 使用上面第一步中收集到的所有信息,讀取輸入文件中段的數(shù)據(jù)、重定位信息,并且進行符號解析與重定位、調(diào)整代碼中的地址等。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202138962.png" alt="image-20211119202138962" style="zoom: 67%;" />
VMA表示Virtual Memory Address,即虛擬地址,LMA表示Load Memory Address,即加載地址,正常情況下這兩個值應(yīng)該是一樣的,但是在有些嵌入式系統(tǒng)中,特別是在那些程序放在ROM的系統(tǒng)中時,LMA和VMA是不相同的。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202305587.png" alt="image-20211119202305587" style="zoom:50%;" />
鏈接前后的程序中所使用的地址已經(jīng)是程序在進程中的虛擬地址,即我們關(guān)心上面各個段中的VMA(Virtual Memory Address)和Size,而忽略文件偏移(File off)。
為什么鏈接器要將可執(zhí)行文件“ab”的“.text”分配到0x08048094、將“.data”分配0x08049108?而不是從虛擬空間的0地址開始分配呢?這涉及操作系統(tǒng)的進程虛擬地址空間的分配規(guī)則,在Linux下,ELF可執(zhí)行文件默認從地址0x08048000開始分配。關(guān)于進程的虛擬地址分配等相關(guān)內(nèi)容我們將在第6章“可執(zhí)行文件的裝載與進程”這一章進行詳細的分析。
4.1.3 符號地址的確定
通過段內(nèi)相對偏移可計算出虛擬地址
4.2 符號解析與重定位
4.2.1 重定位
$objdump -d a.o
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202332050.png" alt="image-20211119202332050" style="zoom: 80%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202341840.png" alt="image-20211119202341840" style="zoom: 80%;" />
當(dāng)源代碼“a.c”在被編譯成目標(biāo)文件時,編譯器并不知道“shared”和“swap”的地址,因為它們定義在其他目標(biāo)文件中。所以編譯器就暫時把地址0看作是“shared”的地址,我們可以看到這條“mov”指令中,關(guān)于“shared”的地址部分為“0x00000000”。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202415211.png" alt="image-20211119202415211" style="zoom: 33%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202441587.png" alt="image-20211119202441587" style="zoom:33%;" />
跟前面“shared”一樣,“0xFFFFFFFC”只是一個臨時的假地址,因為在編譯的時候,編器并不知道“swap”的真正地址。
編譯器把這兩條指令的地址部分暫時用地址“0x00000000”和“0xFFFFFFFC”代替著,把真正的地址計算工作留給了鏈接器。我們通過前面的空間與地址分配可以得知,鏈接器在完成地址和空間分配之后就已經(jīng)可以確定所有符號的虛擬地址了,那么鏈接器就可以根據(jù)符號的地址對每個需要重定位的指令進行地位修正。我們用objdump來反匯編輸出程序“ab”的代碼段,可以看到main函數(shù)的兩個重定位入口都已經(jīng)被修正到正確的位置:
$objdump –d ab
結(jié)果...
4.2.2 重定位表
重定位表(Relocation Table)的結(jié)構(gòu)專門用來保存這些與重定位相關(guān)的信息。
對于可重定位的ELF文件來說,它必須包含有重定位表,用來描述如何修改相應(yīng)的段里的內(nèi)容。對于每個要被重定位的ELF段都有一個對應(yīng)的重定位表,而一個重定位表往往就是ELF文件中的一個段,所以其實重定位表也可以叫重定位段,我們在這里統(tǒng)一稱作重定位表。比如代碼段“.text”如有要被重定位的地方,那么會有一個相對應(yīng)叫“.rel.text”的段保存了代碼段的重定位表;如果數(shù)據(jù)段“.data”有要被重定位的地方,就會有一個相對應(yīng)叫“.rel.data”的段保存了數(shù)據(jù)段的重定位表。
$ objdump -r a.o
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202517007.png" alt="image-20211119202517007" style="zoom: 67%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202532243.png" alt="image-20211119202532243" style="zoom: 67%;" />
每個要被重定位的地方叫一個重定位入口(Relocation Entry),我們可以看到“a.o”里面有兩個重定位入口。重定位入口的偏移(Offset)表示該入口在要被重定位的段中的位置,“RELOCATION RECORDS FOR [.text]”表示這個重定位表是代碼段的重定位表。
對于32位的Intel x86系列處理器來說,重定位表的結(jié)構(gòu)是一個Elf32_Rel結(jié)構(gòu)的數(shù)組。Elf32_Rel的定義如下:
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
[圖片上傳失敗...(image-73d0a2-1648465105741)]
4.2.3 符號解析
$ readelf -s a.o
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211119202629934.png" alt="image-20211119202629934" style="zoom: 80%;" />
“shared”和“swap”都是“UND”,即“undefined”未定義類型,這種未定義的符號都是因為該目標(biāo)文件中有關(guān)于它們的重定位項。所以在鏈接器掃描完所有的輸入目標(biāo)文件之后,所有這些未定義的符號都應(yīng)該能夠在全局符號表中找到,否則鏈接器就報符號未定義錯誤。
4.2.4 指令修正方式
略
4.3 COMMON塊
多個符號定義類型不一致的情況:
- 兩個或兩個以上強符號類型不一致;
- 有一個強符號,其他都是弱符號,出現(xiàn)類型不一致;
- 兩個或兩個以上弱符號類型不一致。
第一種情況是無須額外處理的,因為多個強符號定義本身就是非法的,鏈接器會報符號多重定義錯誤;鏈接器要處理的就是后兩種情況。
編譯器將未初始化的全局變量定義作為弱符號處理。比如符號global_uninit_var,它在符號表中的各個值為(使用readelf -s):
[圖片上傳失敗...(image-8551c7-1648465105741)]
可以看到它是一個全局的數(shù)據(jù)對象,它的類型為 SHN_COMMON 類型,這是一個典型的弱符號。那么如果我們在另外一個文件中也定義了global_uninit_var 變量,且未初始化,它的類型為double,占8個字節(jié),情況會怎么樣呢?按照COMMON類型的鏈接規(guī)則,原則上講最終鏈接后輸出文件中, global_uninit_var 的大小以輸入文件中最大的那個為準。即這兩個文件鏈接后輸出文件中 global_uninit_var 所占的空間為8個字節(jié)。
當(dāng)然COMMON類型的鏈接規(guī)則是針對符號都是弱符號的情況,如果其中有一個符號為強符號,那么最終輸出結(jié)果中的符號所占空間與強符號相同。值得注意的是,如果鏈接過程中有弱符號大小大于強符號,那么ld鏈接器會報如下警告:
`ld: warning: alignment 4 of symbol `global’ in a.o is smaller than 8 in b.o`
致需要COMMON機制的原因是編譯器和鏈接器允許不同類型的弱符號存在,但最本質(zhì)的原因還是鏈接器不支持符號類型,即鏈接器無法判斷各個符號的類型是否一致。
==在目標(biāo)文件中,編譯器為什么不直接把未初始化的全局變量也當(dāng)作未初始化的局部靜態(tài)變量一樣處理,為它在BSS段分配空間,而是將其標(biāo)記為一個COMMON類型的變量?==
當(dāng)編譯器將一個編譯單元編譯成目標(biāo)文件的時候,如果該編譯單元包含了弱符號(未初始化的全局變量就是典型的弱符號),那么該弱符號最終所占空間的大小在此時是未知的,因為有可能其他編譯單元中該符號所占的空間比本編譯單元該符號所占的空間要大。所以編譯器此時無法為該弱符號在BSS段分配空間,因為所須要空間的大小未知。但是鏈接器在鏈接過程中可以確定弱符號的大小,因為當(dāng)鏈接器讀取所有輸入目標(biāo)文件以后,任何一個弱符號的最終大小都可以確定了,所以它可以在最終輸出文件的BSS段為其分配空間。所以總體來看,未初始化全局變量最終還是被放在BSS段的。
4.4 C++相關(guān)問題
4.4.1 重復(fù)代碼消除
重復(fù)的代碼保留下來的主要問題:
- 空間浪費。可以想象一個有幾百個編譯單元的工程同時實例化了許多個模板,最后鏈接的時候必須將這些重復(fù)的代碼消除掉,否則最終程序的大小肯定會膨脹得很厲害。
- 地址較易出錯。有可能兩個指向同一個函數(shù)的指針會不相等。
- 指令運行效率較低。因為現(xiàn)代的CPU都會對指令和數(shù)據(jù)進行緩存,如果同樣一份指令有多份副本,那么指令Cache的命中率就會降低。
一個比較有效的做法就是將每個模板的實例代碼都單獨地存放在一個段里,每個段只包含一個模板實例。比如有個模板函數(shù)是 add(), 某個編譯單元以int類型和float類型實例化了該模板函數(shù),那么該編譯單元的目標(biāo)文件中就包含了兩個該模板實例的段。為了簡單起見,我們假設(shè)這兩個段的名字分別叫 .temp.add 和 .temp.add。 這樣,當(dāng)別的編譯單元也以int或float類型實例化該模板函數(shù)后,也會生成同樣的名字,這樣鏈接器在最終鏈接的時候可以區(qū)分這些相同的模板實例段,然后將它們合并入最后的代碼段。
4.4.2 全局構(gòu)造與析構(gòu)
- .init 該段里面保存的是可執(zhí)行指令,它構(gòu)成了進程的初始化代碼。因此,當(dāng)一個程序開始運行時,在main函數(shù)被調(diào)用之前,Glibc的初始化部分安排執(zhí)行這個段的中的代碼。
- .fini 該段保存著進程終止代碼指令。因此,當(dāng)一個程序的main函數(shù)正常退出時,Glibc會安排執(zhí)行這個段中的代碼。
這兩個段.init和.fini的存在有著特別的目的,如果一個函數(shù)放到.init段,在main函數(shù)執(zhí)行前系統(tǒng)就會執(zhí)行它。同理,假如一個函數(shù)放到.fint段,在main函數(shù)返回后該函數(shù)就會被執(zhí)行。利用這兩個特性,C++的全局構(gòu)造和析構(gòu)函數(shù)就由此實現(xiàn)。我們將在第11章中作詳細介紹。
4.4.3 C++與ABI
我們把符號修飾標(biāo)準、變量內(nèi)存布局、函數(shù)調(diào)用方式等這些跟可執(zhí)行代碼二進制兼容性相關(guān)的內(nèi)容稱為ABI(Application Binary Interface)。
4.5 靜態(tài)庫鏈接
當(dāng)我們編譯和鏈接一個普通C程序的時候,不僅要用到C語言庫libc.a,而且還有其他一些輔助性質(zhì)的目標(biāo)文件和庫。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211120152937535.png" alt="image-20211120152937535" style="zoom:50%;" />
理想的靜態(tài)庫鏈接圖
為什么靜態(tài)運行庫里面一個目標(biāo)文件只包含一個函數(shù)?比如libc.a里面printf.o只有printf()函數(shù)、strlen.o只有strlen()函數(shù),為什么要這樣組織?
我們知道,鏈接器在鏈接靜態(tài)庫的時候是以目標(biāo)文件為單位的。比如我們引用了靜態(tài)庫中的printf()函數(shù),那么鏈接器就會把庫中包含printf()函數(shù)的那個目標(biāo)文件鏈接進來,如果很多函數(shù)都放在一個目標(biāo)文件中,很可能很多沒用的函數(shù)都被一起鏈接進了輸出結(jié)果中。由于運行庫有成百上千個函數(shù),數(shù)量非常龐大,每個函數(shù)獨立地放在一個目標(biāo)文件中可以盡量減少空間的浪費,那些沒有被用到的目標(biāo)文件(函數(shù))就不要鏈接到最終的輸出文件中。
第五章 Windows PE/COFF
略
第六章 可執(zhí)行文件的裝載與進程
6.1 進程虛擬地址空間
把程序和進程的概念跟做菜相比較的話,那么程序就是菜譜,計算機的CPU就是人,相關(guān)的廚具則是計算機的其他硬件,整個炒菜的過程就是一個進程。
虛擬地址空間 (Virtual Address Space)的大小由計算機的硬件平臺決定,具體地說是由CPU的位數(shù)決定的。硬件決定了地址空間的最大理論上限,即硬件的尋址空間大小,比如32位的硬件平臺決定了虛擬地址空間的地址為 0 到 2^32-1,即0x00000000~0xFFFFFFFF,也就是我們常說的4GB虛擬空間大?。?/p>
進程只能使用那些操作系統(tǒng)分配給進程的地址,如果訪問未經(jīng)允許的空間,那么操作系統(tǒng)就會捕獲到這些訪問,將進程的這種訪問當(dāng)作非法操作,強制結(jié)束進程。
默認情況下,Linux操作系統(tǒng)將進程的虛擬地址空間做了如圖所示的分配。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122204646568.png" alt="image-20211122204646568" style="zoom: 33%;" />
6.2 裝載的方式
覆蓋裝入(Overlay)和頁映射(Paging)是兩種很典型的動態(tài)裝載方法,它們所采用的思想都差不多,原則上都是利用了程序的局部性原理。
6.2.1 覆蓋裝入
覆蓋裝入的方法把挖掘內(nèi)存潛力的任務(wù)交給了程序員,程序員在編寫程序的時候必須手工將程序分割成若干塊,然后編寫一個小的輔助代碼來管理這些模塊何時應(yīng)該駐留內(nèi)存而何時應(yīng)該被替換掉。這個小的輔助代碼就是所謂的覆蓋管理器(Overlay Manager)。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122204759022.png" alt="image-20211122204759022" style="zoom: 50%;" />
由于模塊A和模塊B之間相互調(diào)用依賴關(guān)系,我們可以把模塊A和模塊B在內(nèi)存中“相互覆蓋”,即兩個模塊共享塊內(nèi)存區(qū)域。當(dāng)main模塊調(diào)用模塊A時,覆蓋管理器保證將模塊A從文件中讀入內(nèi)存;當(dāng)模塊main調(diào)用模塊B時,則覆蓋管理器將模塊B從文件中讀入內(nèi)存,由于這時模塊A不會被使用,那么模塊B可以裝入到原來模塊A所占用的內(nèi)存空間。很明顯,除了覆蓋管理器,整個程序運行只需要1 536個字節(jié),比原來的方案節(jié)省了256字節(jié)的空間。覆蓋管理器本身往往很小,從數(shù)十字節(jié)到數(shù)百字節(jié)不等,一般都常駐內(nèi)存。
在多個模塊的情況下, 程序員需要手工將模塊按照它們之間的調(diào)用依賴關(guān)系組織成樹狀結(jié)構(gòu)。
按照下圖的組織關(guān)系,模塊main依賴于模塊A和B,模塊A依賴于C和 D;模塊B依賴于E和F,則它們在內(nèi)存中的覆蓋方式如圖中所示。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122204853494.png" alt="image-20211122204853494" style="zoom:33%;" />
值得注意的是,覆蓋管理器需要保證兩點。
- 這個樹狀結(jié)構(gòu)中從任何一個模塊到樹的根(也就是main)模塊都叫調(diào)用路徑。當(dāng)該模塊被調(diào)用時,整個調(diào)用路徑上的模塊必須都在內(nèi)存中。比如程序正在模塊E中執(zhí)行代碼,那么模塊B和模塊main必須都在內(nèi)存中,以確保模塊E執(zhí)行完畢以后能夠正確返回至模塊B和模塊main。
- 禁止跨樹間調(diào)用。任意一個模塊不允許跨過樹狀結(jié)構(gòu)進行調(diào)用。比如上面例子中,模塊A不可以調(diào)用模塊B、E、F;模塊C不可以調(diào)用模塊D、B、E、F等。因為覆蓋管理器不能夠保證跨樹間的模塊能夠存在于內(nèi)存中。不過很多時候可能兩個子模塊都需要依賴于某個模塊,比如模塊E和模塊C都需要另外一個模塊G,那么最方便的做法是將模塊G并入到main模塊中,這樣G就在E和C的調(diào)用路徑上了。
6.2.2 頁映射
略
6.3 從操作系統(tǒng)角度看可執(zhí)行文件的裝載
6.3.1 進程的建立
創(chuàng)建一個進程,然后裝載相應(yīng)的可執(zhí)行文件并且執(zhí)行。在有虛擬存儲的情況下,上述過程最開始只需要做三件事情:
- 創(chuàng)建一個獨立的虛擬地址空間。
- 讀取可執(zhí)行文件頭,并且建立虛擬空間與可執(zhí)行文件的映射關(guān)系。
- 將CPU的指令寄存器設(shè)置成可執(zhí)行文件的入口地址,啟動運行。
首先是創(chuàng)建虛擬地址空間。一個虛擬空間由一組頁映射函數(shù)將虛擬空間的各個頁映射至相應(yīng)的物理空間,那么創(chuàng)建一個虛擬空間實際上并不是創(chuàng)建空間而是創(chuàng)建映射函數(shù)所需要的相應(yīng)的數(shù)據(jù)結(jié)構(gòu),在i386 的Linux下,創(chuàng)建虛擬地址空間實際上只是分配一個頁目錄(Page Directory)就可以了,甚至不設(shè)置頁映射關(guān)系,這些映射關(guān)系等到后面程序發(fā)生頁錯誤的時候再進行設(shè)置。
讀取可執(zhí)行文件頭,并且建立虛擬空間與可執(zhí)行文件的映射關(guān)系。上面那一步的頁映射關(guān)系函數(shù)是虛擬空間到物理內(nèi)存的映射關(guān)系,這一步所做的是虛擬空間與可執(zhí)行文件的映射關(guān)系。當(dāng)程序執(zhí)行發(fā)生頁錯誤時,操作系統(tǒng)將從物理內(nèi)存中分配一個物理頁,然后將該“缺頁”從磁盤中讀取到內(nèi)存中,再設(shè)置缺頁的虛擬頁和物理頁的映射關(guān)系,這樣程序才得以正常運行。但是很明顯的一點是,當(dāng)操作系統(tǒng)捕獲到缺頁錯誤時,它應(yīng)知道程序當(dāng)前所需要的頁在可執(zhí)行文件中的哪一個位置。這就是虛擬空間與可執(zhí)行文件之間的映射關(guān)系。
由于可執(zhí)行文件在裝載時實際上是被映射的虛擬空間,所以可執(zhí)行文件很多時候又被叫做映像文件(Image)。
由于虛擬存儲的頁映射都是以頁為單位的,在32位的Intel IA32下一般為4 096字節(jié),所以32位ELF的對齊粒度為0x1000(4KB)。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122205149454.png" alt="image-20211122205149454" style="zoom: 33%;" />
這種映射關(guān)系只是保存在操作系統(tǒng)內(nèi)部的一個數(shù)據(jù)結(jié)構(gòu)。Linux中將進程虛擬空間中的一個段叫做虛擬內(nèi)存區(qū)域(VMA, Virtual Memory Area)。
將CPU指令寄存器設(shè)置成可執(zhí)行文件入口,啟動運行。操作系統(tǒng)通過設(shè)置CPU的指令寄存器將控制權(quán)轉(zhuǎn)交給進程,由此進程開始執(zhí)行。這一步看似簡單,實際上在操作系統(tǒng)層面上比較復(fù)雜,它涉及內(nèi)核堆棧和用戶堆棧的切換、CPU運行權(quán)限的切換。不過從進程的角度看這一步可以簡單地認為操作系統(tǒng)執(zhí)行了一條跳轉(zhuǎn)指令,直接跳轉(zhuǎn)到可執(zhí)行文件的入口地址。還記得ELF文件頭中保存有入口地址嗎?沒錯,就是這個地址。
6.3.2 頁錯誤
上面的步驟執(zhí)行完以后,其實可執(zhí)行文件的真正指令和數(shù)據(jù)都沒有被裝入到內(nèi)存中。
虛擬存儲的實現(xiàn)需要依靠硬件的支持,對于不同的CPU來說是不同的。 但是幾乎所有的硬件都采用一個叫MMU(Memory Management Unit) 的部件來進行頁映射,如圖所示。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122205454451.png" alt="image-20211122205454451" style="zoom:80%;" />
CPU將控制權(quán)交給操作系統(tǒng),操作系統(tǒng)有專門的頁錯誤處理例程來處理這種情況。這時候我們前面提到的裝載過程的第二步建立的數(shù)據(jù)結(jié)構(gòu)起到了很關(guān)鍵的作用,操作系統(tǒng)將查詢這個數(shù)據(jù)結(jié)構(gòu),然后找到空頁面所在的VMA,計算出相應(yīng)的頁面在可執(zhí)行文件中的偏移,然后在物理內(nèi)存中分配一個物理頁面,將進程中該虛擬頁與分配的物理頁之間建立映射關(guān)系,然后把控制權(quán)再還回給進程,進程從剛才頁錯誤的位置重新開始執(zhí)行。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122205319256.png" alt="image-20211122205319256" style="zoom: 50%;" />
6.4 進程虛存空間分布
6.4.1 ELF文件鏈接視圖和執(zhí)行視圖
對于相同權(quán)限的段,把它們合并到一起當(dāng)作一個段進行映射。比如有兩個段分別叫“.text”和“.init”,它們包含的分別是程序的可執(zhí)行代碼和初始化代碼,并且它們的權(quán)限相同,都是可讀并且可執(zhí)行的。假設(shè).text為4 097字節(jié),.init為512字節(jié),這兩個段分別映射的話就要占用三個頁面,但是,如果將它們合并成一起映射的話只須占用兩個頁面,如圖所示。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122205840664.png" alt="image-20211122205840664" style="zoom:80%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122205913647.png" alt="image-20211122205913647" style="zoom:80%;" />
ELF可執(zhí)行文件中有一個專門的數(shù)據(jù)結(jié)構(gòu)叫做程序頭表(Program Header Table)用來保存“Segment”的信息。程序頭表也是一個結(jié)構(gòu)體數(shù)組,它的結(jié)構(gòu)體如下:
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122205954258.png" alt="image-20211122205954258" style="zoom:80%;" />
對于“LOAD”類型的“Segment”來說,p_memsz的值不可以小于p_filesz,否則就是不符合常理的。但是,如果 p_memsz 的值大于 p_filesz 又是什么意思呢?如果 p_memsz 大于 p_filesz ,就表示該“Segment”在內(nèi)存中所分配的空 間大小超過文件中實際的大小,這部分“多余”的部分則全部填充為“0”。這樣做的好處是,我們在構(gòu)造ELF可執(zhí)行文件時不需要再額外設(shè)立BSS的“Segment”了
6.4.2 堆和棧
操作系統(tǒng)通過給進程空間劃分出一個個VMA來管理進程的虛擬空間;基本原則是將相同權(quán)限屬性的、有相同映像文件的映射成一個VMA;一個進程基本上可以分為如下幾種VMA區(qū)域:
- 代碼VMA,權(quán)限只讀、可執(zhí)行;有映像文件。
- 數(shù)據(jù)VMA,權(quán)限可讀寫、可執(zhí)行;有映像文件。
- 堆VMA,權(quán)限可讀寫、可執(zhí)行;無映像文件,匿名,可向上擴展。
- 棧VMA,權(quán)限可讀寫、不可執(zhí)行;無映像文件,匿名,可向下擴展。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122210037698.png" alt="image-20211122210037698" style="zoom:50%;" />
6.4.3 堆的最大申請數(shù)量
malloc的最大申請數(shù)量具體的數(shù)值會受到操作系統(tǒng)版本、程序本身大小、用到的動態(tài)/共享庫數(shù)量、大小、程序棧數(shù)量、大小等,甚至有可能每次運行的結(jié)果都會不同,因為有些操作系統(tǒng)使用了一種叫做隨機地址空間分布的技術(shù)(主要是出于安全考慮,防止程序受惡意攻擊),使得進程的堆空間變小。關(guān)于進程的堆的相關(guān)內(nèi)容,在本書的第4部分還會詳細介紹。
6.4.4 段地址對齊
每個段分開映射這種對齊方式在文件段的內(nèi)部會有很多內(nèi)部碎片,浪費磁盤空間。整個可執(zhí)行文件的三個段的總長度只有12 014字節(jié),卻占據(jù)了5個頁,即20 480字節(jié),空間使用率只有58.6%。
[圖片上傳失敗...(image-5fc9a1-1648465105741)]
為了解決這種問題,有些UNIX系統(tǒng)采用了一個很取巧的辦法,就是讓那些各個段接壤部分共享一個物理頁面,然后將該物理頁面分別映射兩次。比如對于SEG0和SEG1的接壤部分的那個物理頁,系統(tǒng)將它們映射兩份到虛擬地址空間,一份為SEG0,另外一份為SEG1,其他的頁都按照正常的頁粒度進行映射。而且UNIX系統(tǒng)將ELF的文件頭也看作是系統(tǒng)的一個段,將其映射到進程的地址空間,這樣做的好處是進程中的某一段區(qū)域就是整個ELF文件的映像,對于一些須訪問ELF文件頭的操作(比如動態(tài)鏈接器就須讀取ELF文件頭)可以直接通過讀寫內(nèi)存地址空間進行。從某種角度看,好像是整個ELF文件從文件最開始到某個點結(jié)束,被邏輯上分成了以4 096字節(jié)為單位的若干個塊,每個塊都被裝載到物理內(nèi)存中,對 于那些位于兩個段中間的塊,它們將會被映射兩次。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122210141518.png" alt="image-20211122210141518" style="zoom:67%;" />
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211122210205848.png" alt="image-20211122210205848" style="zoom:67%;" />
6.4.5 進程棧初始化
一般情況下,是操作系統(tǒng)在進程啟動前將這些信息提前保存到進程的虛擬空間的棧中(也就是 VMA中的Stack VMA)。
6.5 Linux內(nèi)核裝載ELF過程簡介
后續(xù)補充
第七章 動態(tài)鏈接
見https://www.yuque.com/hxfqg9/bin/ug9gx5#5dvaL
7.1 為什么要動態(tài)鏈接
靜態(tài)鏈接使得不同的程序開發(fā)者和部門能夠相對獨立地開發(fā)和測試自己的程序模塊,從某種意義上促進了程序開發(fā)的效率,原先限制程序的規(guī)模也隨之?dāng)U大。但缺點也逐步暴露出來,比如浪費內(nèi)存和磁盤空間、模塊更新困難等問題,使得人們不得不尋找一種更好的方式來組織程序的模塊。
內(nèi)存和磁盤空間
在靜態(tài)鏈接中,C語言靜態(tài)庫是很典型的浪費空間的例子,還有其他數(shù)以千計的庫如果都需要靜態(tài)鏈接,那么空間浪費無法想象。
程序開發(fā)和發(fā)布
如果程序都使用靜態(tài)鏈接,那么通過網(wǎng)絡(luò)來更新程序?qū)浅2槐?,因為一旦程序任何位置的一個小改動,都會導(dǎo)致整個程序重新下載。
動態(tài)鏈接
把鏈接這個過程推遲到了運行時再進行,這就是動態(tài)鏈接(Dynamic Linking)的基本思想。
在內(nèi)存中共享一個目標(biāo)文件模塊的好處不僅僅是節(jié)省內(nèi)存,它還可以減少物理頁面的換入換出,也可以增加CPU緩存的命中率,因為不同進程間的數(shù)據(jù)和指令訪問都集中在了同一個共享模塊上。
程序可擴展性和兼容性
動態(tài)鏈接還有一個特點就是程序在運行時可以動態(tài)地選擇加載各種程序模塊,這個優(yōu)點就是后來被人們用來制作程序的插件(Plug-in)。
動態(tài)鏈接的基本實現(xiàn)
從本質(zhì)上講,普通可執(zhí)行程序和動態(tài)鏈接庫中都包含指令和數(shù)據(jù),這一點沒有區(qū)別。在使用動態(tài)鏈接庫的情況下,程序本身被分為了程序主要模塊(Program1)和動態(tài)鏈接庫(Lib.so),但實際上它們都可以看作是整個程序的一個模塊,所以當(dāng)我們提到程序模塊時可以指程序主模塊也可以指動態(tài)鏈接庫。
7.2 簡單的動態(tài)鏈接例子
[圖片上傳失敗...(image-822e60-1648465105741)]
圖中有一個步驟與靜態(tài)鏈接不一樣,那就是Program1.o被連接成可執(zhí)行文件的這一步。在靜態(tài)鏈接中,這一步鏈接過程會把Program1.o和Lib.o鏈接到一起,并且產(chǎn)生輸出可執(zhí)行文件Program1。但是在這里,Lib.o沒有被鏈接進來,鏈接的輸入目標(biāo)文件只有Program1.o。
關(guān)于模塊(Module)
在靜態(tài)鏈接時,整個程序最終只有一個可執(zhí)行文件,它是一個不可以分割的整體;但是在動態(tài)鏈接下,一個程序被分成了若干個文件,有程序的主要部分,即可執(zhí)行文件(Program1)和程序所依賴的共享對象 (Lib.so),很多時候我們也把這些部分稱為模塊,即動態(tài)鏈接下的可執(zhí)行文件和共享對象都可以看作是程序的一個模塊。
當(dāng)程序模塊Program1.c被編譯成為Program1.o時,編譯器還不不知道 foobar() 函數(shù)的地址,這個內(nèi)容我們已在靜態(tài)鏈接中解釋過了。當(dāng)鏈接器將Program1.o鏈接成可執(zhí)行文件時,這時候鏈接器必須確定Program1.o中所引用的 foobar() 函數(shù)的性質(zhì)。如果foobar() 是一個定義與其他靜態(tài)目標(biāo)模塊中的函數(shù),那么鏈接器將會按照靜態(tài)鏈接的規(guī)則,將Program1.o中的foobar地址引用重定位;如果foobar() 是一個定義在某個動態(tài)共享對象中的函數(shù),那么鏈接器就會將這個符號的引用標(biāo)記為一個動態(tài)鏈接的符號,不對它進行地址重定位,把這個過程留到裝載時再進行。
那么這里就有個問題,鏈接器如何知道foobar的引用是一個靜態(tài)符號還是一個動態(tài)符號?這實際上就是我們要用到Lib.so的原因。Lib.so中保存了完整的符號信息(因為運行時進行動態(tài)鏈接還須使用符號信息),把Lib.so也作為鏈接的輸入文件之一,鏈接器在解析符號時就可以知道:foobar是一個定義在Lib.so的動態(tài)符號。這樣鏈接器就可以對foobar的引用做特殊的處理,使它成為一個對動態(tài)符號的引用。
動態(tài)鏈接程序運行時地址空間分布
查看進程的虛擬地址空間分布:
[圖片上傳失敗...(image-70abb-1648465105741)]
[圖片上傳失敗...(image-b38054-1648465105741)]
整個進程虛擬地址空間中,多出了幾個文件的映射。Lib.so與Program1一樣,它們都是被操作系統(tǒng)用同樣的方法映射至進程的虛擬地址空間,只是它們占據(jù)的虛擬地址和長度不同。Program1除了使用Lib.so以外,它還用到了動態(tài)鏈接形式的C語言運行庫libc-2.6.1.so。另外還有一個很值得關(guān)注的共享對象就是ld-2.6.so,它實際上是Linux下的動態(tài)鏈接器。動態(tài)鏈接器與普通共享對象一樣被映射到了進程的地址空 間,在系統(tǒng)開始運行Program1之前,首先會把控制權(quán)交給動態(tài)鏈接器, 由它完成所有的動態(tài)鏈接工作以后再把控制權(quán)交給Program1,然后開始執(zhí)行。
共享對象的最終裝載地址在編譯時是不確定的, 而是在裝載時,裝載器根據(jù)當(dāng)前地址空間的空閑情況,動態(tài)分配一塊足夠大小的虛擬地址空間給相應(yīng)的共享對象。
7.3 地址無關(guān)代碼
7.3.1 固定裝載地址的困擾
靜態(tài)共享庫的做法就是將程序的各種模塊統(tǒng)一交給操作系統(tǒng)來管理,操作系統(tǒng)在某個特定的地址劃分出一些地址塊,為那些已知的模塊預(yù)留足夠的空間。
靜態(tài)共享庫的目標(biāo)地址導(dǎo)致了很多問題,除了上面提到的地址沖突的問題,靜態(tài)共享庫的升級也很成問題,因為升級后的共享庫必須保持共享庫中全局函數(shù)和變量地址的不變,如果應(yīng)用程序在鏈接時已經(jīng)綁定了這些地址,一旦更改,就必須重新鏈接應(yīng)用程序,否則會引起應(yīng)用程序的崩潰。
為了解決這個模塊裝載地址固定的問題,我們設(shè)想是否可以讓共享對象在任意地址加載?這個問題另一種表述方法就是:共享對象在編譯時不能假設(shè)自己在進程虛擬地址空間中的位置。與此不同的是,可執(zhí)行文件基本可以確定自己在進程虛擬空間中的起始位置,因為可執(zhí)行文件往往是第一個被加載的文件,它可以選擇一個固定空閑的地址,比如Linux下一般都是0x08040000,Windows下一般都是0x0040000。
7.3.2 裝載時重定位
我們前面在靜態(tài)鏈接時提到過重定位,那時的重定位叫做鏈接時重定位(Link Time Relocation),而現(xiàn)在這種情況經(jīng)常被稱為裝載時重定位(Load Time Relocation),在Windows中,這種裝載時重定位又被叫做基址重置(Rebasing),我們在后面將會有專門章節(jié)分析基址重置。
7.3.3 地址無關(guān)代碼
- 第一種是模塊內(nèi)部的函數(shù)調(diào)用、跳轉(zhuǎn)等。
- 第二種是模塊內(nèi)部的數(shù)據(jù)訪問,比如模塊中定義的全局變量、靜態(tài)變量。
- 第三種是模塊外部的函數(shù)調(diào)用、跳轉(zhuǎn)等。
- 第四種是模塊外部的數(shù)據(jù)訪問,比如其他模塊中定義的全局變量。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211205113143966.png" alt="image-20211205113143966" style="zoom:50%;" />
類型一 模塊內(nèi)部調(diào)用或跳轉(zhuǎn)
這種指令是不需要重定位的。
類型二 模塊內(nèi)部數(shù)據(jù)訪問
相對尋址
類型三 模塊間數(shù)據(jù)訪問
ELF的做法是在數(shù)據(jù)段里面建立一個指向這些變量的指針數(shù)組,也被稱為全局偏移表(Global Offset Table,GOT),當(dāng)代碼需要引用該全局變量時,可以通過GOT中相對應(yīng)的項間接引用,它的基本機制如圖所示。
<img src="https://gitee.com/computer-graphics/cpp_primer_learining/raw/master/img/image-20211205132559565.png" alt="image-20211205132559565" style="zoom:50%;" />
類型四 模塊間調(diào)用、跳轉(zhuǎn)
7.6 動態(tài)鏈接的步驟和實現(xiàn)
動態(tài)鏈接的步驟基本上分為3步:先是啟動動態(tài)鏈接器本身,然后裝載所有需要的共享對象,最后是重定位和初始化。
7.6.1 動態(tài)鏈接器自舉
7.6.2 裝載共享對象
完成基本自舉以后,動態(tài)鏈接器將可執(zhí)行文件和鏈接器本身的符號表都合并到一個符號表當(dāng)中,我們可以稱它為全局符號表(Global Symbol Table)。然后鏈接器開始尋找可執(zhí)行文件所依賴的共享對象,鏈接器可以列出可執(zhí)行文件所需要的所有共享對象,并將這些共享對象的名字放入到一個裝載集合中。然后鏈接器開始從集合里取一個所需要的共享對象的名字,找到相應(yīng)的文件后打開該文件,讀取相應(yīng)的ELF文件頭和“.dynamic”段,然后將它相應(yīng)的代碼段和數(shù)據(jù)段映射到進程空間中。 如果這個ELF共享對象還依賴于其他共享對象,那么將所依賴的共享對象的名字放到裝載集合中。如此循環(huán)直到所有依賴的共享對象都被裝載進來為止,當(dāng)然鏈接器可以有不同的裝載順序,如果我們把依賴關(guān)系看作一個圖的話,那么這個裝載過程就是一個圖的遍歷過程,鏈接器可能會使用深度優(yōu)先或者廣度優(yōu)先或者其他的順序來遍歷整個圖,這取決于鏈接器,比較常見的算法一般都是廣度優(yōu)先的。
符號的優(yōu)先級
這種一個共享對象里面的全局符號被另一個共享對象的同名全局符號覆蓋的現(xiàn)象又被稱為共享對象全局符號介入(Global Symbol Interpose)。
關(guān)于全局符號介入這個問題,Linux定義了一個規(guī)則,那就是當(dāng)一個符號需要被加入全局符號表時,如果相同的符號名已經(jīng)存在,則后加入的符號被忽略。
全局符號介入與地址無關(guān)代碼
為了提高模塊內(nèi)部函數(shù)調(diào)用的效率,有一個辦法是把 bar() 函數(shù)變成編譯單元私有函數(shù),即使用“static”關(guān)鍵字定義 bar() 函數(shù),這種情況下,編譯器要確定 bar() 函數(shù)不被其他模塊覆蓋,就可以使用第一類的方法,即模塊內(nèi)部調(diào)用指令,可以加快函數(shù)的調(diào)用速度。
7.6.3 重定位和初始化
第8章 Linux共享庫的組織
libname.so.x.y.z
最前面使用前綴“l(fā)ib”、中間是庫的名字和后綴“.so”,最后面跟著的是三個數(shù)字組成的版本號?!皒”表示主版本號(Major Version Number),“y”表示次版本號(Minor Version Number),“z”表示發(fā)布版本號(Release Version Number)。三個版本號的含義不一樣。
- 主版本號表示庫的重大升級,不同主版本號的庫之間是不兼容的,依賴于舊的主版本號的程序需要改動相應(yīng)的部分,并且重新編譯,才可以在新版的共享庫中運行;或者,系統(tǒng)必須保留舊版的共享庫,使得那些依賴于舊版共享庫的程序能夠正常運行。
- 次版本號表示庫的增量升級,即增加一些新的接口符號,且保持原來的符號不變。在主版本號相同的情況下,高的次版本號的庫向后兼容低的次版本號的庫。
- 發(fā)布版本號表示庫的一些錯誤的修正、性能的改進等,并不添加任何新的接口,也不對接口進行更改。