GCC編譯基礎

資料準備:

❝ 爲了方便演示和講解,在這裏提前準備好幾個簡單的文件:test.cpp test.h main.cpp 文件內容如下:❞

main.cpp

#include "test.h"

int main (int argc, char **argv)
{
    Test t;
    t.hello();
    return 0;
}

test.h

//test.h
#ifndef _TEST_H_ 
#define _TEST_H_ 

class Test
{
public:
    Test();
    void hello();
    ~Test();
};
#endif  //TEST

test.cpp

//test.cpp
#include "test.h"
#include <iostream>
using namespace std;

Test::Test()
{

}

void Test::hello()
{
    cout << "hello" << endl;
}

Test::~Test()
{

}

C++的編譯過程

一個完整的C++編譯過程(例如g++ a.cpp生成可執行文件),總共包含以下四個過程:

  • 編譯預處理,也稱預編譯,可以使用命令g++ -E執行
  • 編譯,可以使用g++ -S執行
  • 彙編,可以使用as 或者g++ -c執行
  • 鏈接,可以使用g++ xxx.o xxx.so xxx.a執行
❝ 可以通過添加g++ --save-temps參數,保存編譯過程中生成的所有中間文件 下面對這四個步驟進行逐一講解❞

1、編譯預處理階段:主要對包含的頭文件(#include )和宏定義(#define,#ifdef … )還有註釋等進行處理。

可以使用g++ -E 讓g++ 在預處理之後停止編譯過程,生成 *.ii(.c文件生成的是*.i) 文件。 因爲上面寫的main.cpp中沒有任何預編譯指令,所以預編譯生成與源文件幾乎沒有差別。這裏預編譯一下test.cpp文件

g++ -E test.cpp test.h -o test.ii

可以打開test.ii查看,剛剛的main.cpp文件預編譯完成後的內容:

預編譯完成後,#include引入的內容 被全部複製進預編譯文件中,除此之外,如果文件中有使用宏定義也會被替換處理。

預編譯過程最主要的工作,就是宏命令的替換
  • #include命令的工作就是單純的導入,這裏其實並不限制導入的類型,甚至可以導入.cpp.txt等等。
  • 感興趣的同學可以預編譯一個包含Qt中信號的文件,會看到預編譯之後:
    • emit直接成了空。發射信號實質就是一次函數調用;
    • 頭文件中的signals:也被替換成了protected:(Qt5被替換爲public:
    • 以及Qt中其他的宏定義都在預編譯時被處理如:Q_OBJECT Q_INVOKEABLE

2、g++ 編譯階段:C++ 語法錯誤的檢查,就是在這個階段進行。在檢查無誤後,g++ 把代碼翻譯成彙編語言。

可以使用-S 選項進行查看,該選項只進行編譯而不進行彙編,生成彙編代碼。

g++ -S main.ii -o main.s

彙編代碼中生成的是和CPU架構相關的彙編指令,不同CPU架構採用的彙編指令集不同,生成的彙編代碼也不一樣:

3、g++ 彙編階段:生成目標代碼 *.o

有兩種方式:

  • 使用 g++ 直接從源代碼生成目標代碼 g++ -c *.s -o *.o
  • 使用匯編器從彙編代碼生成目標代碼 as *.s -o *.o

到編譯階段,代碼還都是人類可以讀懂的。彙編這一階段,正式將彙編代碼生成機器可以執行的目標代碼,也就是二進制碼。

# 編譯
g++ -c main.s -o main.o
# 彙編器編譯
as main.s -o main.o

也可以直接使用as *.s, 將執行彙編、鏈接過程生成可執行文件a.out, 可以像上面使用-o 選項指定輸出文件的格式。

4、g++ 鏈接階段:生成可執行文件;Windows下生成.exe

修改main.cpp的內容,引用Test

#include "test.h"

int main (int argc, char **argv)
{
    Test t;
    t.hello();
    return 0;
}

生成目標文件:

  • g++ test.cpp -c -o test.o
  • g++ main.cpp -c -o main.o

鏈接生成可執行文件:

g++ main.o test.o -o a.out

鏈接的過程,其核心工作是解決模塊間各種符號(變量,函數)相互引用的問題,更多的時候我們除了使用.o意外,還將靜態庫和動態庫鏈接一同鏈接生成可執行文件。

對符號的引用本質是對其在內存中具體地址的引用,因此確定符號地址是編譯,鏈接,加載過程中一項不可缺少的工作,這就是所謂的符號重定位。本質上來說,符號重定位要解決的是當前編譯單元如何訪問「外部」符號這個問題。

接下來我們先講解如何將源文件編譯成動態庫和靜態庫,然後再講述如何在鏈接時鏈接我們編譯好的庫。

編譯動態庫和靜態庫

大型項目中不可能使用一個單獨的可執行程序提供服務,必須將程序的某些模塊編譯成動態或靜態庫:

編譯生成靜態庫

使用ar命令進行“歸檔”(.a的實質是將文件進行打包)

ar crsv libtest.a test.o 
  • r 替換歸檔文件中已有的文件或加入新文件 (必要)
  • c 不在必須創建庫的時候給出警告
  • s 創建歸檔索引
  • v 輸出詳細信息

編譯生成動態庫

使用g++ -shared 命令指定編譯生成的是一個動態庫

g++ test.cpp -fPIC -shared -Wl,-soname,libtest.so -o libtest.so.0.1
  • shared:告訴編譯器生成一個動態鏈接庫
  • -Wl,-soname:指示生成的動態鏈接庫的別名(這裏是libtest.so
  • -o:指示實際生成的動態鏈接庫(這裏是libtest.so.0.1
  • -fPIC
    • fPIC的全稱是 Position Independent Code, 用於生成位置無關代碼(看不懂沒關係,總之加上這個參數,別的代碼在引用這個庫的時候才更方便,反之,稍不注意就會有各種亂七八糟的報錯)。
    • 使用-fPIC選項生成的動態庫,是位置無關的。這樣的代碼本身就能被放到線性地址空間的任意位置,無需修改就能正確執行。通常的方法是獲取指令指針的值,加上一個偏移得到全局變量/函數的地址。
    • 關於PIC參數的詳細解讀:點此鏈接
❝ 在gcc中,如果指定-shared不指定-fPIC會報錯,無法生成非PIC的動態庫,不過clang可以。❞

庫中函數和變量的地址是相對地址,不是絕對地址,真實地址在調用動態庫的程序加載時形成。 動態庫的名稱有別名(soname),真名(realname)和鏈接名(linker name)。

  • 真名是動態庫的真實名稱,一般總是在別名的基礎上加上一個小的版本號,發佈版本構成 別名由一個前綴lib,然後是庫的名字加上.so構成,例如:libQt5Core.5.7.1
  • 鏈接名,即程序鏈接時使用的庫的名字,例如:-lQt5Core
  • 在動態鏈接庫安裝的時候總是複製庫文件到某個目錄,然後用軟連接生成別名,在庫文件進行更新的時候僅僅更新軟連接即可。
「注意:」
生成的庫文件總是以libXXX開頭,這是一個約定,因爲在編譯器通過-l參數尋找庫時,比如-lpthread會自動去尋找libpthread.solibpthread.a
如果生成的庫並沒有以lib開頭,編譯的時候仍然可以連接到,不過只能以顯示加在編譯命令參數裏的方式鏈接。例如g++ main.o test.so

靜態編譯和動態編譯

編譯C++的程序可以分爲動態編譯和靜態編譯兩種

靜態編譯

鏈接階段,會將彙編生成的目標文件.o與引用到的庫一起鏈接打包到可執行文件中。這種稱爲靜態編譯,靜態編譯中使用的庫就是靜態庫(*.a*.lib)生成的可執行文件在運行時不需要依賴於鏈接庫。

  • 優點:
    • 代碼的裝載速度快,執行速度也比較快
    • 不依賴其他庫執行,移植方便
  • 缺點:
    • 程序體積大
    • 更新不方便,如果靜態庫需要更新,程序需要重新編譯
    • 如果多個應用程序使用的話,會被裝載多次,浪費內存。
g++ main.o libtest.a

編譯完成後可以運行a.out查看效果,通過ldd命令查看運行a.out所需依賴,可以看到靜態編譯的程序並不依賴libtest庫。

動態編譯

動態庫在程序編譯時並不會被連接到目標代碼中,而是在程序運行是才被載入。不同的應用程序如果調用相同的庫,那麼在內存裏只需要有一份該共享庫的實例,規避了空間浪費問題。

動態編譯中使用的庫就是動態庫(*.so*.dll

動態庫在程序運行是才被載入,也解決了靜態庫對程序的更新、部署和發佈頁會帶來麻煩。用戶只需要更新動態庫即可,增量更新。

動態庫在鏈接過程中涉及到加載時符號重定位的問題,感興趣的同學參看鏈接:動態編譯原理分析

  • 優點:
    • 多個應用程序可以使用同一個動態庫,而不需要在磁盤上存儲多個拷貝
    • 動態靈活,增量更新
  • 缺點:
    • 由於是運行時加載,多多少少會影響程序的前期執行性能
    • 動態庫缺失會導致文件無法運行
g++ main.o libtest.so

編譯完成後可以運行a.out查看效果,通過ldd命令查看運行a.out所需依賴

gcc鏈接參數 -L、-l、-rpath、-rpath-link

從上面的截圖中,我們已經看到了剛纔的程序運行報錯,原因是找不到動態鏈接庫libtest.so

這個報錯的解決方案有很多例如:

  • LD_LIBRARY_PATH=. ./a.out

那麼明明編譯成功,運行時爲什麼會找不到庫?爲了弄清這個問題,我們需要對鏈接動態庫的過程有一個更深入的理解。

我們在main.cpp中明確引用到了Test類,所以在編譯進行到最後階段,鏈接的時候。如果在所有參與編譯的文件中沒能檢索到Test這個符號,則會報錯未定義的引用。
所以在編譯過程中必須能夠找到包含Test符號的文件,可以是.o.a、或者.so
如果是.o或者.a,也就是靜態鏈接,那麼它會將.o或者.a中的內容一起打包到生成的可執行文件中,生成的可執行文件可以獨立運行不受任何限制。
而如果是.so這種動態鏈接庫,就比較麻煩了。鏈接器將不會把這個庫打包到生成的可執行文件裏,而僅僅只會在這裏記錄一個地址,告訴程序,如果遇到Test符號,你就去文件libtest.so的第三行第五列(打個比方,實際是一個相對的內存地址)找它的定義。

綜上所述

  • 編譯鏈接main.cpp的時候,必須能夠找到libtest.so的動態庫,記錄下Test符號的偏移地址。
  • 運行的時候,程序必須找到libtest.so,然後尋址找到Test

編譯時鏈接庫

-L-l 鏈接器參數,就是指定鏈接時去(哪裏)找(什麼)庫。

  • -l,代表鏈接哪個庫,會自動檢索lib開頭的對應庫名。 例如-lpthread,-lQt5Core。會自動檢索libpthread.so,libpthread.a,libQt5Core.so,libQt5Core.a
    • 如果靜態庫動態庫同時存在,優先鏈接動態庫
  • -L,指定去哪裏找庫文件。例如指定:-L/home/threedog/test,則在編譯時會優先檢索/home/threedog/test/libpthread.so等文件。
  • 鏈接庫最直接的辦法是不用任何參數,直接寫庫的路徑加載編譯參數裏。
  • 查找順序 :
    • 如果直接寫的庫的全路徑,則會直接去找到庫,不走下面的順序檢索。
    • -L,優先級最高
    • 然後是系統的環境變量LIBRARY_PATH
    • 最後再找內定目錄 /lib /usr/lib /usr/local/lib 這是當初編譯 gcc時寫在程序內的
    • 如果都找不到,會報錯找不到文件或找不到-lxxxx

所以以上的編譯命令,可以通過多種方式通過編譯:

  • g++ main.o libtest.so,或g++ main.o ./libtest.so
  • g++ main.o /home/threedog/test/libtest.so
  • g++ main.o -ltest -L.,或g++ main.o -ltest -L/home/threedog/test/
  • LIBRARY_PATH=. g++ main.o -ltest,或LIBRARY_PATH=/home/threedog/test/ g++ main.o -ltest
  • 或者把libtest.so拷貝到/usr/lib目錄下去。

運行時鏈接庫

通過上面的方法編譯出的a.out,運行會報錯,通過ldd命令查看,發現編譯時鏈接的libtest.so成了not found
這就引出了第二個問題:如何讓程序運行的時候能夠找到對應的庫。

-Wl,-rpath就是做這個事情的:-Wl代表後面的這個參數是一個鏈接器參數,-rpath+庫所在的目錄,會給程序明確指定去哪裏找對應的庫。
手動將一個目錄指定成了ld的搜索目錄。

另外,也可以通過在環境變量LD_LIBRARY_PATH裏添加路徑的方式成功運行

運行時庫的查找順序:

  1. 編譯目標代碼時指定的動態庫搜索路徑(-rpath);
  2. 環境變量LD_LIBRARY_PATH指定的動態庫搜索路徑;
  3. 配置文件/etc/ld.so.conf中指定的動態庫搜索路徑;
  4. 默認的動態庫搜索路徑/lib;
  5. 默認的動態庫搜索路徑/usr/lib.

rpath與rpath-link

❝ 其實rpath和rpath-link都是鏈接器ld的參數,不是gcc的。

rpath-linkrpath只是看起來很像,可實際上關係並不大,rpath-link-L一樣也是在鏈接時指定目錄的。 rpath-link的作用,在我們的這個實例中體現不出來。 例如你上述的例子指定的需要libtest.so,但是如果 libtest.so 本身是需要 xxx.so 的話,這個 xxx.so 我們你並沒有指定,而是 libtest.so 引用到它,這個時候,會先從 -rpath-link 給的路徑裏找。 rpath-link指定的目錄與並運行時無關。

C++頭文件的搜索原則

上面提到了編譯時鏈接庫的查找順序和運行時動態庫的檢索順序,順便再提一下C++編譯時頭文件的檢索順序:

  • #include<file.h>只在默認的系統包含路徑搜索頭文件
  • #include"file.h"首先在當前目錄以及-I指定的目錄搜索頭文件, 若頭文件不位於當前目錄, 則到系統默認的包含路徑搜索

順序:

  1. 先搜索當前目錄
  2. 然後搜索-I指定的目錄
  3. 再搜索gcc的環境變量CPLUS_INCLUDE_PATH(C程序使用的是C_INCLUDE_PATH
  4. 最後搜索gcc的內定目錄
  • /usr/include
  • /usr/local/include
  • /usr/lib/gcc/x86_64-redhat-linux/4.1.1/include

以上,就是對gcc參數的一些詳細總結,下面根據上面的講解解決幾個常遇到的疑問:

問題1:-l鏈接的到底是動態庫還是靜態庫?

  • 如果鏈接路徑下同時有 .so 和 .a 那優先鏈接 .so

問題2:如果路徑下同時有靜態庫和動態庫如何鏈接靜態庫?

  • 最好的辦法,是參數裏直接寫上靜態庫的全路徑。
  • 另一個辦法,可以使用-static參數,會強制鏈接靜態庫。這種方式生成的文件可以執行,但是文件的elf頭會有問題,使用ldd,readelf -d查看會顯示不是動態可執行文件。

問題3:如果文件中沒有使用對應的庫,編譯器是否仍然會進行鏈接?

  • 這個取決於編譯器的類型和版本,我本地gcc5.4,如果沒有用到的庫,即使寫了-l也不會鏈接。而我本地的clang9,則會明確鏈接對應的庫即使我沒有用到它。

參考鏈接:

作者:三級狗
鏈接:https://zhuanlan.zhihu.com/p/151219726
來源:知乎
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

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