編譯和鏈接那點事

留檔

有位學弟想讓我說說編譯和鏈接的簡單過程,我覺得幾句話簡單說的話也沒什麼意思,索性寫篇博文稍微詳細的解釋一下吧。其實詳細的流程在經典的《Linkers and Loaders》和《深入理解計算機系統》中均有描述,也有國產的諸如《程序員的自我修養——鏈接、裝載與庫》等大牛著作。不過,我想大家恐怕很難有足夠的時間去研讀這些厚如詞典的書籍。正巧我大致翻閱過其中的部分章節,乾脆也融入這篇文章作爲補充吧。

我的環境:Fedora 16 i686 kernel-3.6.11-4 gcc 4.6.3

其實MSVC的編譯器在編譯過程中的流程是差不多的,只是具體調用的程序和使用的參數不同罷了。不過爲了描述的流暢性,我在行文中不會涉及MSVC的具體操作,使用Windows的同學可以自行搜索相關指令和參數。但是作爲Linuxer,我還是歡迎大家使用Linux系統。如果大家確實需要,我會擠時間在附言中給出MSVC中相對應的試驗方法。

閒話不多說了,我們進入正題。在正式開始我們的描述前,我們先來引出幾個問題:

  1. C語言代碼爲什麼要編譯後才能執行?整個過程中編譯器都做了什麼?
  2. C代碼中經常會包含頭文件,那頭文件是什麼?C語言庫又是什麼?
  3. 有人說main函數是C語言程序的入口,是這樣嗎?難道就不能把其它函數當入口?
  4. 不同的操作系統上編譯好的程序可以直接拷貝過去運行嗎?

如果上面的問題你都能回答的話,那麼後文就不用再看下去了。因爲本文是純粹的面向新手,所以註定了不會寫的多麼詳細和深刻。如果你不知道或者不是很清楚,那麼我們就一起繼續研究吧。

我們就以最經典的HelloWorld程序爲例開始吧。我們先使用vim等文本編輯器寫好代碼,接着在終端執行命令 gcc HelloWorld.c -o HelloWorld 輸出了可執行文件HelloWorld,最後我們在終端執行 ./HelloWorld,順利地顯示了輸出結果。

可是,簡單的命令背後經過了什麼樣的處理過程呢?gcc真的就“直接”生成了最後的可執行文件了嗎?當然不是,我們在gcc編譯命令行加上參數 –verbose要求gcc輸出完整的處理過程(命令行加上 -v 也行),我們看到了一段較長的過程輸出。

輸出結果我們就不完整截圖了,大家有興趣可以自己試驗然後試着分析整個流程。

一圖勝千言,我們先上一張圖吧。這是gcc編譯過程的分解圖,我在網上找不到滿意的,就自己畫了一張簡單的,大家將就着看吧。

從圖中我們大致可以看出gcc處理HelloWorld.c的大致過程:

預處理(Prepressing)—>編譯(Compilation)—>彙編(Assembly)—>鏈接(Linking)

括號中我註明了各個過程中實際執行任務的程序名稱:預處理器cpp、編譯器cc1、彙編器as以及最後的鏈接器ld。

我們一步一步來看,首先是預處理,我們看看預處理階段對代碼進行了哪些處理。

我們在終端輸入指令 gcc -E HelloWorld.c -o HelloWorld.i,然後我們打開輸出文件。

首先是大段大段的變量和函數的聲明,汗..我們的代碼哪裏去了?我們在vim的普通模式中按下shift+g(大寫G)來到最後,終於在幾千行以後看到了我們可憐兮兮的幾行代碼。

前面幾千行是什麼呢?其實它就是 /usr/include/stdio.h 文件的所有內容,預處理器把所有的#include替換爲實際文件的內容了。這個過程是遞歸進行的,所以stdio.h裏面的#include也被實際內容所替換了。

而且我在HelloWorld.c裏面的所有註釋被預處理器全部刪除了。就連printf語句前的Tab縮進也被替換爲一個空格了,顯得代碼都不美觀了。

時間關係,我們就不一一試驗處理的內容了,我直接給出預處理器處理的大致範圍吧。

  • 展開所有的宏定義並刪除 #define
  • 處理所有的條件編譯指令,例如 #if #else #endif #ifndef …
  • 把所有的 #include 替換爲頭文件實際內容,遞歸進行
  • 把所有的註釋 // 和 / / 替換爲空格
  • 添加行號和文件名標識以供編譯器使用
  • 保留所有的 #pragma 指令,因爲編譯器要使用 ……

基本上就是這些了。在這裏我順便插播一個小技巧,在代碼中有時候宏定義比較複雜的時候我們很難判斷其處理後的結構是否正確。這個時候我們呢就可以使用gcc的-E參數輸出處理結果來判斷了。

前文中我們提到了頭文件中放置的是變量定義和函數聲明等等內容。這些到底是什麼東西呢?其實在比較早的時候調用函數並不需要聲明,後來因爲“筆誤”之類的錯誤實在太多,造成了鏈接期間的錯誤過多,所有編譯器開始要求對所有使用的變量或者函數給出聲明,以支持編譯器進行參數檢查和類型匹配。頭文件包含的基本上就是這些東西和一些預先的宏定義來方便程序員編程。其實對於我們的HelloWorld.c程序來說不需要這個龐大的頭文件,只需要在main函數前聲明printf函數,不需要#include 即可通過編譯。

聲明如下:

1

intprintf(constchar*format, ...);

這個大家就自行測試吧。另外再補充一點,gcc其實並不要求函數一定要在被調用之前定義或者聲明(MSVC不允許),因爲gcc在處理到某個未知類型的函數時,會爲其創建一個隱式聲明,並假設該函數返回值類型爲int。但gcc此時無法檢查傳遞給該函數的實參類型和個數是否正確,不利於編譯器爲我們排除錯誤(而且如果該函數的返回值不是int的話也會出錯)。所以還是建議大家在函數調用前,先對其定義或聲明。

預處理部分說完了,我們接着看編譯和彙編。那麼什麼是編譯?一句話描述:編譯就是把預處理之後的文件進行一系列詞法分析、語法分析、語義分析以及優化後生成的相應彙編代碼文件。這一部分我們不能展開說了,一來我沒有系統學習過編譯原理的內容不敢信口開河,二來這部分要是展開去說需要很厚很厚的一本書了,細節大家就自己學習《編譯原理》吧,相關的資料自然就是經典的龍書、虎書和鯨書了。

gcc怎麼查看編譯後的彙編代碼呢?命令是 gcc -S HelloWorld.c -o HelloWorld.s,這樣輸出了彙編代碼文件HelloWorld.s,其實輸出的文件名可以隨意,我是習慣使然。順便說一句,這裏生成的彙編是AT&T風格的彙編代碼,如果大家更熟悉Intel風格,可以在命令行加上參數 -masm=intel ,這樣gcc就會生成Intel風格的彙編代碼了(如圖,這個好多人不知道哦)。不過gcc的內聯彙編只支持AT&T風格,大家還是找找資料學學AT&T風格吧。

再下來是彙編步驟,我們繼續用一句話來描述:彙編就是將編譯後的彙編代碼翻譯爲機器碼,幾乎每一條彙編指令對應一句機器碼。

這裏其實也沒有什麼好說的了,命令行 gcc -c HelloWorld.c 可以讓編譯器只進行到生成目標文件這一步,這樣我們就能在目錄下看到HelloWorld.o文件了。

Linux下的可執行文件以及目標文件的格式叫作ELF(Executable Linkable Format)。其實Windows下的PE(Portable Executable)也好,ELF也罷,都是COFF(Common file format)格式的一種變種,甚至Windows下的目標文件就是以COFF格式去存儲的。不同的操作系統之間的可執行文件的格式通常是不一樣的,所以造成了編譯好的HelloWorld沒有辦法直接複製執行,而需要在相關平臺上重新編譯。當然了,不能運行的原因自然不是這一點點,不同的操作系統接口(windows API和Linux的System Call)以及相關的類庫不同也是原因之一。

由於本文的讀者定位,我們不能詳細展開說了,有相關需求的同學可以去看《Windows PE權威指南》和《程序員的自我修養》去詳細瞭解。

我們接下來看最後的鏈接過程。這一步是將彙編產生的目標文件和所使用的庫函數的目標文件鏈接生成一個可執行文件的過程。我想在這裏稍微的擴展一下篇幅,稍微詳細的說一說鏈接,一來這裏造成的錯誤通常難以理解和處理,二來使用第三方庫在開發中越來越常見了,想着大家可能更需要稍微瞭解一些細節了。

我們先介紹gnu binutils工具包,這是一整套的二進制分析處理工具包。詳細介紹請大家參考餵雞百科:http://zh.wikipedia.org/wiki/GNU_Binutils

我的fedora已經自帶了這套工具包,如果你的發行版沒有,請自行搜索安裝方法。

這套工具包含了足夠多的工具,我們甚至可以用來研究ELF文件的格式等內容。不過本文只是拋磚引玉,更多的使用方法和技巧還是需要大家自己去學習和研究。

由於時間關係,上篇到此就告一段落了,我們的問題2和3還沒有給出完整的答案,而且鏈接還沒有詳細去解釋和說明。這些內容我們將在下篇中解決,當然,大家也可以先行研究,到時候我們相互學習補充。

上回書我們說到了鏈接以前,今天我們來研究最後的鏈接問題。

鏈接這個話題延伸之後完全可以跑到九霄雲外去,爲了避免本文牽扯到過多的話題導致言之泛泛,我們先設定本文涉及的範圍。我們今天討論只鏈接進行的大致步驟及其規則、靜態鏈接庫與動態鏈接庫的創建和使用這兩大塊的問題。至於可執行文件的加載、可執行文件的運行時儲存器映像之類的內容我們暫時不討論。

首先,什麼是鏈接?我們引用CSAPP的定義:鏈接(linking)是將各種代碼和數據部分收集起來並組合成爲一個單一文件的過程,這個文件可被加載(或被拷貝)到存儲器並執行。

需要強調的是,鏈接可以執行於編譯時(compile time),也就是在源代碼被翻譯成機器代碼時;也可以執行於加載時,也就是在程序被加載器(loader)加載到存儲器並執行時;甚至執行於運行時(run time),由應用程序來執行。

說了這麼多,瞭解鏈接有什麼用呢?生命這麼短暫,我們幹嘛要去學習一些根本用不到的東西。當然有用了,繼續引用CSAPP的說法,如下:

  1. 理解鏈接器將幫助你構造大型程序。
  2. 理解鏈接器將幫助你避免一些危險的編程錯誤。
  3. 理解鏈接將幫助你理解語言的作用域是如何實現的。
  4. 理解鏈接將幫助你理解其他重要的系統概念。
  5. 理解鏈接將使你能夠利用共享庫。 ……

言歸正傳,我們開始吧。爲了避免我們的描述過於枯燥,我們還是以C語言爲例吧。想必大家通過我們在上篇中的描述,已經知道C代碼編譯後的目標文件了吧。目標文件最終要和標準庫進行鏈接生成最後的可執行文件。那麼,標準庫和我們生成的目標文件是什麼關係呢?

其實,任何一個程序,它的背後都有一套龐大的代碼在支撐着它,以使得該程序能夠正常運行。這套代碼至少包括入口函數、以及其所依賴的函數構成的函數集合。當然,它還包含了各種標準庫函數的實現。

這個“支撐模塊”就叫做運行時庫(Runtime Library)。而C語言的運行庫,即被稱爲C運行時庫(CRT)。

CRT大致包括:啓動與退出相關的代碼(包括入口函數及入口函數所依賴的其他函數)、標準庫函數(ANSI C標準規定的函數實現)、I/O相關、堆的封裝實現、語言特殊功能的實現以及調試相關。其中標準庫函數的實現佔據了主要地位。標準庫函數大家想必很熟悉了,而我們平時常用的printf,scanf函數就是標準庫函數的成員。C語言標準庫在不同的平臺上實現了不同的版本,我們只要依賴其接口定義,就能保證程序在不同平臺上的一致行爲。C語言標準庫有24個,囊括標準輸入輸出、文件操作、字符串操作、數學函數以及日期等等內容。大家有興趣的可以自行搜索。

既然C語言提供了標準庫函數供我們使用,那麼以什麼形式提供呢?源代碼嗎?當然不是了。下面我們引入靜態鏈接庫的概念。我們幾乎每一次寫程序都難免去使用庫函數,那麼每一次去編譯豈不是太麻煩了。幹嘛不把標準庫函數提前編譯好,需要的時候直接鏈接呢?我很負責任的說,我們就是這麼做的。

那麼,標準庫以什麼形式存在呢?一個目標文件?我們知道,鏈接的最小單位就是一個個目標文件,如果我們只用到一個printf函數,就需要和整個庫鏈接的話豈不是太浪費資源了麼?但是,如果把庫函數分別定義在彼此獨立的代碼文件裏,這樣編譯出來的可是一大堆目標文件,有點混亂吧?所以,編輯器系統提供了一種機制,將所有的編譯出來的目標文件打包成一個單獨的文件,叫做靜態庫(static library)。當鏈接器和靜態庫鏈接的時候,鏈接器會從這個打包的文件中“解壓縮”出需要的部分目標文件進行鏈接。這樣就解決了資源浪費的問題。

Linux/Unix系統下ANSI C的庫名叫做libc.a,另外數學函數單獨在libm.a庫裏。靜態庫採用一種稱爲存檔(archive)的特殊文件格式來保存。其實就是一個目標文件的集合,文件頭描述了每個成員目標文件的位置和大小。

光說不練是假把式,我們自己做個靜態庫試試。爲了簡單起見我們就做一個只有兩個函數的私有庫吧。

我們在swap.c裏定義一個swap函數,在add.c裏定義了一個add函數。最後還有含有它們聲明的calc.h頭文件。

1

2

3

4

5

6

7

// swap.c

voidswap(int*num1, int*num2)

{

inttmp = *num1;

*num1 = *num2;

*num2 = tmp;

}

1

2

3

4

5

// add.c

intadd(inta, intb)

{

returna + b;

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

// calc.h

#ifndefCALC_H_

#defineCALC_H_

#ifdef_cplusplus

extern"C"

{

#endif

voidswap(int*, int*);

intadd(int, int);

#ifdef_cplusplus

}

#endif

#endif// CALC_H_

我們分別編譯它們得到了swap.o和add.o這兩個目標文件,最後使用ar命令將其打包爲一個靜態庫。

現在我們怎麼使用這個靜態庫呢?我們寫一個test.c使用這個庫中的swap函數吧。代碼如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

#include<stdio.h>

#include<stdlib.h>

#include"calc.h"

intmain(intargc, char*argv[])

{

inta = 1, b = 2;

swap(&a, &b);

printf("%d %dn", a, b);

returnEXIT_SUCCESS;

}

下來是編譯執行,命令行執行gcc test.c ./libcalc.a -o test編譯,執行。如圖,我們輸出了預期的結果。

可能你會問,我們使用C語言標準庫的時候,編譯並不需要加什麼庫名啊。是的,我們不需要。因爲標準庫已經是標準了,所以會被默認鏈接。不過因爲數學函數庫libm.a沒有默認鏈接,所以我們使用了數學函數的代碼在編譯時需要在命令行指定 -lm 鏈接(-l是制定鏈接庫,m是去掉lib之後的庫名),不過現在好多gcc都默認鏈接libm.c庫了,比如我機子上的gcc 4.6.3會默認鏈接的。

正如我們所看到的,靜態鏈接庫解決了一些問題,但是它同時帶來了另一些問題。比如說每一個使用了相同的C標準函數的程序都需要和相關目標文件進行鏈接,浪費磁盤空間;當一個程序有多個副本執行時,相同的庫代碼部分被載入內存,浪費內存;當庫代碼更新之後,使用這些庫的函數必須全部重新編譯……

有更好的辦法嗎?當然有。我們接下來引入動態鏈接庫/共享庫(shared library)。

動態鏈接庫/共享庫是一個目標模塊,在運行時可以加載到任意的存儲器地址,並和一個正在運行的程序鏈接起來。這個過程就是動態鏈接(dynamic linking),是由一個叫做動態鏈接器(dynamic linker)的程序完成的。

Unix/Linux中共享庫的後綴名通常是.so(微軟那個估計大家很熟悉,就是DLL文件)。怎麼建立一個動態鏈接庫呢?

我們還是以上面的代碼爲例,我們先刪除之前的靜態庫和目標文件。首先是建立動態鏈接庫,我們執行gcc swap.c add.c -shared -o libcalc.so 就可以了,就這麼簡單(微軟那個有所區別,我們在這裏只爲說明概念,有興趣的同學請自行搜索)。

順便說一下,最好在gcc命令行加上一句-fPIC讓其生成與位置無關的代碼(PIC),具體原因超出本文範圍,故不予討論。

如何使用呢?我們繼續編譯測試代碼,執行gcc test.c -o test ./libcalc.so即可。運行後我們仍舊得到了預期的結果。

這看起來也沒啥不一樣的啊。其實不然,我們用ldd命令(ldd是我們在上篇中推薦的GNU binutils工具包的組成之一)檢查test文件的依賴。

我們看到這個文件能順利運行需要依賴libcalc.so這個動態庫,我們還能看到C語言的標準庫默認也是動態鏈接的(在gcc編譯的命令行加上 -static 可以要求靜態鏈接)。

好處在哪?第一,庫更新之後,只需要替換掉動態庫文件即可,無需編譯所有依賴庫的可執行文件。第二,程序有多個副本執行時,內存中只需要一份庫代碼,節省空間。

大家想想,C語言標準庫好多程序都在用,但內存只有一份代碼,這樣節省的空間很可觀吧,而且假如庫代碼發現bug,只需要更新libc.so即可,所有程序即可使用新的代碼,豈不是很Cool。

好了,關於庫我們就說到這裏了,再說下去就沒法子結束了。

我們來看看鏈接過程中具體做的事情。鏈接的步驟大致包括了地址和空間分配(Address and Storage Allocation)、符號決議(Symbol Resolution)和重定位(Relocation)等主要步驟。

首先是地址和空間分配,我們之前提到的目標文件其實全稱叫做可重定位目標文件(這只是一種翻譯,叫法很多…)。目標文件的格式已經無限度接近可執行文件了,Unix/Linux下的目標文件的格式叫做ELF(Executable and Linkable Format,可執行連接格式)。詳細的討論可執行文件的格式超出了本文範圍,我們只需要知道可執行文件中代碼,數據,符號等內容分別存儲在不同的段中就可以了,這也和保護模式下的內存分段是有一定關係的,但是這個又會扯遠就不詳談了……

地址和空間分配以及重定位我們簡單敘述一下就好,但是符號決議這裏我想稍微展開描述一下。

什麼是符號(symbol)?簡單說我們在代碼中定義的函數和變量可以統稱爲符號。符號名(symbol name)就是函數名和變量名了。

目標文件的拼合其實也就是對目標文件之間相互的符號引用的一個修正。我們知道一個C語言代碼文件只要所有的符號被聲明過就可以通過編譯了,可是對某符號的引用怎麼知道位置呢?比如我們調用了printf函數,編譯時留下了要填入的函數地址,那麼printf函數的實際地址在那呢?這個空位什麼時候修正呢?當然是鏈接的時候,重定位那一步就是做這個的。但是在修改地址之前需要做符號決議,那什麼是符號決議呢?正如前文所說,編譯期間留下了很多需要重新定位的符號,所以目標文件中會有一塊區域專門保存符號表。那鏈接器如何知道具體位置呢?其實鏈接器不知道,所以鏈接器會搜索全部的待鏈接的目標文件,尋找這個符號的位置,然後修正每一個符號的地址。

這時候我們可以隆重介紹一個幾乎所有人在編譯程序的時候會遇見的問題——符號查找問題。這個通常有兩種錯誤形式,即找不到某符號或者符號重定義。

首先是找不到符號,比如,當我們聲明瞭一個swap函數卻沒有定義它的時候,我們調用這個函數的代碼可以通過編譯,但是在鏈接期間卻會遇到錯誤。形如“test.c:(.text+0x29): undefined reference to ‘swap’”這樣,特別的,MSVC編譯器報錯是找不到符號_swap。咦?那個下劃線哪裏來的?這得從C語言剛誕生說起。當C語言剛面世的時候,已經存在不少用匯編語言寫好的庫了,因爲鏈接器的符號唯一規則,假如該庫中存在main函數,我們就不能在C代碼中出現main函數了,因爲會遭遇符號重定義錯誤,倘若放棄這些庫又是一大損失。所以當時的編譯器會對代碼中的符號進行修飾(name decoration),C語言的代碼會在符號前加下劃線,fortran語言在符號前後都加下劃線,這樣各個目標文件就不會同名了,就解決了符號衝突的問題。隨着時間的流逝,操作系統和編譯器都被重寫了好多遍了,當前的這個問題已經可以無視了。所以新版的gcc一般不會再加下劃線做符號修飾了(也可以在編譯的命令行加上-fleading-underscore/-fno-fleading-underscore開打開/關閉這個是否加下劃線)。而MSVC依舊保留了這個傳統,所以我們可以看到_swap這樣的修飾。

順便說一下,符號衝突是很常見的事情,特別是在大型項目的開發中,所以我們需要一個約定良好的命名規則。C++也引入了命名空間來幫助我們解決這些問題,因爲C++中存在函數重載這些東西,所以C++的符號修飾更加複雜難懂(Linux下有c++filt命令幫助我們翻譯一個被C++編譯器修飾過的符號)。

說了這麼多,該到了我們變成中需要注意的一個大問題了。即存在同名符號時鏈接器如何處理。不是剛剛說了會報告重名錯誤嗎?怎麼又要研究這個?很可惜,不僅僅這麼簡單。在編譯時,編譯器會向彙編器輸出每個全局符號,分爲強(strong)符號和弱符號(weak),彙編器把這個信息隱含的編碼在可重定位目標文件的符號表裏。其中函數和已初始化過的全局變量是強符號,未初始化的全局變量是弱符號。根據強弱符號的定義,GNU鏈接器採用的規則如下:

  1. 不允許多個強符號
  2. 如果有一個強符號和一個或多個弱符號,則選擇強符號
  3. 如果有多個弱符號,則隨機選擇一個

好了,就三條,第一條會報符號重名錯誤的,而後兩條默認情況下甚至連警告都不會有。關鍵就在這裏,默認甚至連警告都沒有。

我們來個實驗具體說一下:

1

2

3

4

5

6

7

8

9

10

11

// link1.c

#include<stdio.h>

intn;

intmain(intargc, char*argv[])

{

printf("It is %dn", n);

return0;

}

1

2

// link2.c

intn = 5;

這兩個文件編譯運行會輸出什麼呢?聰明的你想必已經知道了吧?沒錯,就是5。

初始化過的n是強符號,被優先選擇了。但是,在很複雜的項目代碼,這樣的錯誤很難發現,特別是多線程的……不過當我們懷疑代碼中的bug可能是因爲此原因引起的時候,我們可以在gcc命令行加上-fno-common這個參數,這樣鏈接器在遇到多重定義的符號時,都會給出一條警告信息,而無關強弱符號。如圖所示:

好了,到這裏我們的下篇到此也該結束了,不過關於編譯鏈接其實遠比這深奧複雜的多,我權當拋磚引玉,各位看官自可深入研究。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章