深入理解計算機系統:C語言檔案的連結原理
在這篇blog裡,你將瞭解到以下的內容:
1. 一個.c檔案是怎麼變成一個可執行檔案,中間會產生那幾種檔案,在執行的時候又是怎麼被載入進作業系統的?
2. 在形成這些不同檔案的過程中,即連結的過程中,容易誘發那幾種問題,誘發這些問題的原因是什麼,如何避免這些問題?
3. 連結分成那幾種形式?靜態連結庫與動態連結庫是什麼,如何構成靜態連結庫和動態連結庫,靜態連結庫和動態連結庫的利弊?
好的,接下來讓我們一起開始一起解決這些問題:
問題1:
請大家先.c檔案編譯,連結,載入,執行的大概框圖:
這幅圖展示的是一個main2.c的.c檔案和vector.h的標頭檔案編譯執行的過程,該圖表示是動態連結庫的連結過程。首先是,一個main2.c和vector.h一起通過編譯得到可重定位目標檔案main2.o
gcc main2.c vector.h -o main2.o
但是卻包含著編譯的三個階段:
1.利用c語言預處理程式cpp將main2.c翻譯成ASCII碼檔案main2.i。
2.利用cc1(c語言編譯器)將main2,i編譯成main2.s彙編程式。
3.執行assemble彙編器,將main2.s編譯成main2.o可定位目標檔案。
再通過動態連結器(這部分動態連結的內容將在後面說明),連結進libc.so,libvector.so檔案(重定位和符號表),最後得到p2這個可執行檔案,再通過
./p
這個命令進行執行,在執行的過程中動態連結庫會把相關的程式碼和資料鏈接進去。
至於,很關鍵的一點程式是如何被載入進去的,這裡先放出載入時候的linux儲存器映像:
程式通過execve函式執行,然後載入器將目標中的程式碼和檔案從磁碟拷貝到儲存器,再通過程式的入口點來執行該程式。
那麼,程式的入口點,也就是開始執行的地方是符號_start的程式碼,即表示如下的程式碼:
_start表示文字段的入口點,下面的__libc_init_fitst, _init,atexigt,分別初始化和啟動.text,.init,.text,然後對於所有c語言的程式都要呼叫main的入口程式,然後執行_exit退出程式。
看到這裡,提兩個小問題:
1.為什麼所有程式都必須一個main函式。
2.為什麼main函式呼叫exit, 使用return語句返回,不使用return返回,程式都能正常退出?
這兩個問題的答案都在<_start>這個區域裡面,因為程式在載入的時候都需要呼叫main函式最後程式的入口,所以程式碼中的函式必須有main做入口。對於問題2,無論是exit退出,還是有沒有return返回,程式都必然會走到call _exit,程式都可以正常退出,程式退出的原因是呼叫了call _exit,而不是因為main怎麼返回或設計而退出。
接下來討論問題二:
在連結的過程中會出現什麼樣的問題,這裡首先要說一說在連結的過程中有那幾種檔案。
1.可重定位目標檔案:包含二進位制程式碼和資料,在執行的時候可以將和其他可重定位目標檔案合併變成可執行檔案。
2.可執行檔案:可以直接拷貝到儲存器進行執行的檔案。
那麼對於可重定位目標檔案是不是就像我們所寫的程式碼所組織的呢,明顯不是的。這裡放出一個典型的ELF可重定位目標檔案的格式:
這裡就主要說幾個比較重要的section:
ELF header:儲存著一些作業系統和計算機的基本資訊,是為了跨平臺而準備的。
1. .text:程式碼段,我們已編譯的機器程式碼。
2. .rodata: 只讀資料。
3. .data: 表示已經初始化的全域性變數。例如我們在剛開始寫的int a=1;這一類已經初始化的全域性變數。
4. .bss: 表示未初始化的全域性變數。(命名:best save space)
5. .symtab: 表示符號表。
6. .debug: 儲存著關於除錯有關的資訊,只有在含-g選項驅動程式的時候才會用上這塊表。
7. .line: 原始C源程式中的行號和.text節中機器指令之間的對映。
這裡提一下C語言的static這個關鍵詞,首先由static建立的變數或者函式儲存在程式的堆中,我自己認為static為私有的全域性:
私有:體現在可以用static宣告的變數,函式都是屬於該宣告檔案私有的 ,即如果test1.c,說明了static int a;或者static void function1()。那麼在聯合編譯的時候,別的檔案不能呼叫test1的a和function1()。
全域性:體現在static變數儲存在程式的堆中,不會因為函式執行完而出棧清空,可以一直儲存著執行的值,直到不用。這一天放在很多遞迴函式中可以充當全域性的量來用。
既然提到全域性的,那麼就必然要提一下extern關鍵詞:
1. 首先要確定的一件事,extern要用在宣告上,如果有標頭檔案要宣告一個全域性變數,那麼最好不要在標頭檔案進行定義。因為一旦定義之後,如果有兩個檔案include這個標頭檔案,就會產生兩種定義就會發生錯誤。
2. 對於全域性變數,不同檔案引用的時候,變數的宣告需要extern,函式的宣告的extern可以不加,但是全域性變數的extern宣告一定要加,不然會報錯。
關於extern關鍵詞,這裡有一篇很好的blog可以分享一下:
http://www.cnblogs.com/yc_sunniwell/archive/2010/07/14/1777431.html
接下來我們來說一下符號解析的問題,首先這裡要說明的是,連結的時候,連結器對於棧裡面的變數不感興趣,即對區域性變數不感興趣,理由也很好理解,因為區域性變數不會影響連結時候檔案的依賴關係,對於多重定義的全域性變數有著以下是三個原則(強,弱全域性變數原則,函式或者已經初始化的全域性變數是強符號,未初始化的全域性變數是弱符號):
1.規則一:不允許有多個弱符號。
2.規則二:如果有一個強符號和多個弱符號,那麼選擇強符號。
3.規則三:如果有多個弱符號,那麼從弱符號裡面隨機選擇一個。
由於規則二和規則三種很容易產生一些無法意料的後果,請看下面的例子
/* foo3.c */
#include <stdio.h>
void f(void);
int x=15213;
int y=15212;
int main()
{
f();
printf("x=%x y=%x\n",x,y);
return 0;
}
/* bar3.c */
double x;
void f()
{
x=-0.0;
}
一段看起來沒什麼問題的程式,在一起編譯之後,再看一下結果:
明明只對x賦值,卻影響到了y。因為int x是32位的,但是double x是64位的,在bar3.c檔案對x賦值為-0.0就會影響到32位外的y,解決這個問題的辦法就是在bar3檔案的x加上一個static,使x變成私有的。
這裡提一下在C++中有一個特性叫做函式過載,根據函式不同的引數進行過載,以C語言的角度來看,要實現這樣一個函式過載,最開始想到的就是將函式名和量聯合進行區別,這種思想也可以用在其他函式設計上,C++對於函式過載的具體做法如下:
問題三:關於靜態連結庫和動態連結圖的區別,下面兩張圖就一目瞭然:
靜態連結庫和動態連結庫的最主要區別是,在link(連結)成可執行檔案的時候,靜態連結庫是將所需要模組的程式碼和資料全都載入進去,而動態連結庫在link(連結)的時候只進行重定位和載入動態連結庫的符號表,只在執行的時候將相關的程式碼和資料載入進去。很明顯,如果引用像stdio,這樣的庫在很多檔案中是很經常的。如果每個檔案都將程式碼和資料在link的時候就載入進行對空間是巨大的浪費,用動態連結庫,只用使用的時候呼叫相關庫的程式碼和資料,節約了空間。這一點,與虛擬記憶體的概念有異曲同工之妙,虛擬記憶體同樣是對每一個程序假想他們總佔了整塊內容,實際上只有在使用的時候,才會把相關的內容載入在實體記憶體中。
並且在Web高效能伺服器中,利用動態連結庫直接將生成的內容打包成函式放進共享庫中,然後直接通過呼叫共享函式庫的函式,而不是通過execve去執行相關的程式,效率提高了,並且在對伺服器進行更新的時候,也不需要停止伺服器,而是通過修改函式來實現更新的目的。在Linux中,提供介面,允許程式在執行載入和執行共享庫的內容:
關於共享庫的相關函式庫為:dlfcn.h。關於這個函式庫的內容就不贅述了。