程序的靜態鏈接


程序的靜態鏈接

程序的產生

程序是由程序員編寫,經過編譯鏈接過程,最終能夠在計算機中運行的東西。本質上來說編譯鏈接過程其實就是將由人能看懂的代碼段翻譯成機器能看懂的代碼段,然后指導機器的運行,比起程序在機器中被運行,博主更喜歡程序指導機器運行這樣的說法。

編譯鏈接事實上分為4個過程:預編譯、編譯、匯編、鏈接,在這里我們籠統地將其分為兩個過程:編譯和鏈接,編譯包含預編譯、編譯、匯編。

編譯是將程序員寫的代碼翻譯成機器碼,即機器能夠解析的二進制碼,生成二進制目標文件,既然編譯過程已經生成了機器能夠解析的二進制碼,那為何還需要鏈接過程呢?

在學生時代會經常寫一些基於C語言的小程序,那時候習慣於將代碼的實現和一些聲明同時放在一個文件中,編譯過程也很簡單。

隨着編程水平的提高,自然要嘗試寫一些更復雜的程序,代碼量逐漸增大,這時候就有必要將不同的實現部分放在不同的文件中,第一個是考慮到程序的可讀性,再一個就是程序的移植和維護問題。這時候編譯器就面臨着一個問題:需要編譯多個文件以生成一個可執行文件。

在可執行文件中,每個符號(變量和函數)都將對應程序執行時的唯一地址,那么,在分開編譯多個文件時,怎么解決不同文件中的符號地址定位問題:

  • 第一種方案可以是這樣:將所有程序文件都作為一個整體,在編譯前由編譯器將所有源文件全部放到一起形成一個文件,將這個文件再進行編譯。
  • 第二種方案可以是這樣:所有的源文件分離編譯,生成二進制目標文件,暫時不給每個文件中的符號確定執行時硬件地址,而是指定一個相應的邏輯偏移地址,最后再統一地再給所有編譯時產生的目標文件中的符號分配實際執行時的地址。

如果采用第一種方案,會有什么問題呢?

  • 編譯時間上的問題:因為將所有源文件集中到一起,每次改動一點源文件都要全部重新編譯,大型程序耗時太多。
  • 在引用第三方代碼時,必須以源文件的方式引用,占用大量空間且不利於代碼保護。
  • 程序將不支持動態擴展,每次添加功能都需要重新編譯。
    ...

毫無疑問,現代編譯器采用的是第二種方案,分離式編譯的靈活性完美地解決了第一種方案的缺陷所在,而最后為所有目標中符號分配地址的過程就是程序的鏈接,除此之外,鏈接將指定程序唯一的入口地址以及一些其它操作。

目標文件的格式

目標文件其實有着十分復雜的格式,內部的結構是以段來作區分,如代碼段、數據段、BSS段,為了講解方便,我們暫且只討論目標文件中這三個必要的段。

  • 代碼段:程序代碼

  • 數據段:全局變量,靜態變量

  • BSS段:程序中未初始化或者初始化為0的全局變量,特點是只在代碼文件中占用一個符號位,節省空間,加載時在內存中展開。

問題的開始

鏈接的過程既然是將多個目標文件進行組合,那目標文件中的各個段是以什么樣的方式進行組合?

眾所周知,程序是運行在內存中的,每一個函數每一個變量都會在內存中有相應的地址,在分離式編譯中,每個源文件單獨編譯,生成的二進制目標文件中對符號(函數,變量)地址是怎么確認的?會不會有沖突,如果發生沖突是怎么解決的?

了解編譯流程的朋友都知道,在編譯時,源文件如果引用了到本文件中沒有定義的函數(變量),只要找到了這個函數(變量)的正確聲明,編譯對它的處理就是記錄一個符號,表示這個函數(變量)本文件中無定義,但是編譯過程繼續,將符號處理的工作拋給鏈接器,那么鏈接器又是怎么處理這種符號引用的情況?

鏈接器的作用

對於鏈接器而言,主要就是解決上述的問題,鏈接器提供了三個操作:

  • 空間和地址分配
  • 符號解析
  • 重定位

空間和地址的分配

不妨想一想,如果給你一堆目標文件,里面包含代碼段,數據段,BSS段,讓你將它組合成一個文件,你將怎么做?

最簡單的辦法就是疊加,一個文件緊跟着上一個文件存放,再使用一個總體的文件頭來記錄這些信息,這個文件頭就像一個目錄,可以索引到所有文件。

這種方式當然是可以實現的,鏈接出來的程序在特定環境下也是可以運行的,但是回頭一想,當二進制文件有很多個時,可執行文件中會存在成百上千個零散的段,程序執行的效率顯著降低,而在空間上來說,對單片機而言還好,內存通常以字節或者4字節為單位,而在桌面系統中,例如X86電腦上,內存對其單位是頁,一般為4096字節,當某個段僅有一個字節時,也會占用一頁的空間,這對空間的浪費不言而喻。

當然,不難想到的另一個方法就是將相似的段進行合並,這個看起來更可行,鏈接生成的可執行文件就只有三個連續的段,代碼段、數據段、BSS段,實際應用中這種鏈接方式一直被沿用至今,事情看起來好像確實簡單了很多,將多個文件的段糅合到一起,生成一個新的總段,至少解決了空間上的問題,地址的分配也好說,段與段之間相互疊加就可以了。

符號解析和重定位

多個目標文件合成一個目標文件時,地址分配是比較好解決的,但是當多個目標文件編譯成一個可執行文件,就沒那么簡單了。

我們來看下面的例子:

有兩個源文件:test1.c 和 test2.c,程序代碼分別為:
test1.c:

void func1(void)
{
    printf("hello world1\r\n");
}

test2.c:

void func2(void)
{
    printf("hello world2\r\n");
}

我們將這兩個源文件編譯成目標文件:

gcc -c test1.c
gcc -c test2.c

分別生成了test1.o和test2.o,我們可以通過linux下的nm指令來查看目標文件中的符號表(nm的用法可以查看我另一篇博客):

nm -n test1.o  

輸出:

                 U puts
0000000000000000 T func1  

在查看test2.o中符號表:

nm -n test2.o  

輸出

                 U puts
0000000000000000 T func2  

(同時也可以使用objdump -h命令)

簡單解釋一下,上述符號表就是在編譯成二進制文件時函數和變量產生的對應的符號

第一列是地址,第二列是當前目標所在段,第三列是對象。

可以看到,在test1.c中,func1放在test段(即代碼段),地址為0;

在test2.c中,func2放在test段(即代碼段),地址同樣為0;

那么問題來了,兩個不同文件的目標函數地址都是0,我們都知道,在同一個程序中,函數在運行時需要在內存中確定唯一地址,如果程序同時引用這兩個文件,這明顯會產生地址沖突。

這就是分離式編譯帶來的問題所在,每個源文件都彼此獨立編譯,在編譯時編譯器根本不知道這個源文件中的函數及變量將被加載到內存的何處。

那就退而求其次,編譯器假設這個源文件中的符號地址就是從某個地址(一般是0)開始,結果是每個目標文件編譯出來都是在0地址處進行疊加放置,做完這些工作之后,編譯器就事了拂身去,告訴鏈接器:符號相對的地址偏移我給你算好了,怎么去安排這些符號的實際內存我就不管啦!

鏈接器只好接下這個爛攤子,收集好所有目標文件之后,開始一個個地為這些文件中的符號分配地址,對於這些符號的重新定址就被稱為重定位。

事實上編譯器的偷懶行為並非這一個,很明顯的,在分離式編譯中,假如源文件a需要引用源文件b中的函數func,a中沒有此函數的定義,通常就只有兩個行為:

  • 編譯器在編譯a時,發現func沒有定義,編譯中斷

  • 編譯器在編譯a時,發現func沒有定義,但是有個函數聲明,在這里做個記號,繼續編譯

正確的處理方式當然是第二個,如果引用的每個函數都必須在本函數內有定義,那么分離編譯的意義也就不存在了。

結果就是,編譯器遇到未定義的函數或變量,只要有相應聲明,就記錄下來,編譯完成之后,同樣告訴鏈接器:我把這些只有聲明沒有定義的函數和變量記錄下來了,你幫我去找吧,找不找得到我不管啦!

鏈接器如果找不到,就報出我們非常熟悉的錯誤:

    undefined reference to `XXX'

沒辦法,編譯器只好又接下這個爛攤子,對於這些符號的處理被稱為符號解析或者說符號決議,主要是在其他文件中找到那些聲明而在其他文件中定義的符號,並建立聯系。

單片機下的地址重定位

對於單片機而言,程序直接操作物理地址,因為沒有MMU(內存管理單元)且有其他資源的限制,不存在多進程的概念,重定位時直接將目標文件中的邏輯地址根據鏈接腳本的設置轉換成物理地址,直接下載到flash中運行。

桌面系統下的地址重定位

在桌面系統中,情況就不一樣了,由於MMU的存在,應用程序操作虛擬地址而非真實的物理地址,重定位的過程就是將目標文件中的邏輯地址根據鏈接腳本的設置轉換成虛擬地址,當程序被加載進內存時,MMU動態地將虛擬地址映射到相應的物理內存。

這篇文章旨在建立一個鏈接過程的概念,鏈接過程的細節且聽下回分解。

好了,關於程序的靜態鏈接 的討論就到此為止啦,如果朋友們對於這個有什么疑問或者發現有文章中有什么錯誤,歡迎留言

原創博客,轉載請注明出處!

祝各位早日實現項目叢中過,bug不沾身.
(完)

鏈接器如果找不到,就報出我們非常熟悉的錯誤:

    undefined reference to `XXX'

沒辦法,編譯器只好又接下這個爛攤子,對於這些符號的處理被稱為符號解析或者說符號決議。

單片機下的地址重定位

對於單片機而言,程序直接操作物理地址,因為沒有MMU(內存管理單元)且有其他資源的限制,不存在多進程的概念,重定位時直接將目標文件中的邏輯地址根據鏈接腳本的設置轉換成物理地址,直接下載到flash中運行。

桌面系統下的地址重定位

在桌面系統中,情況就不一樣了,由於MMU的存在,應用程序操作虛擬地址而非真實的物理地址,重定位的過程就是將目標文件中的邏輯地址根據鏈接腳本的設置轉換成虛擬地址,當程序被加載進內存時,MMU動態地將虛擬地址映射到相應的物理內存。

這篇文章旨在建立一個鏈接過程的概念,鏈接過程的細節且聽下回分解。

好了,關於程序的靜態鏈接 的討論就到此為止啦,如果朋友們對於這個有什么疑問或者發現有文章中有什么錯誤,歡迎留言

原創博客,轉載請注明出處!

祝各位早日實現項目叢中過,bug不沾身.
(完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM