C++編譯連結的那些小事 .

yangxi_001發表於2016-11-17

最近,有同事向我多次問及C++關於編譯連結方面的問題,包括如下:

1:什麼樣的函式以及變數可以定義在標頭檔案中

2:extern "C"的作用

3:防止重複包含的巨集的作用

4:函式之間是怎麼連結起來的

我認為,這些問題不難,書上基本上都有,但要是沒有真正思考過,就憑死記硬背,也就是隻能“嘴上說說”而已,遇到問題還真棘手,所以我覺得有必要說一下。


C/C++的編譯連結過程

其實,“編譯”這個詞大多數時候,我們指的是由一堆.h,.c,.cpp檔案生成連結庫或者可執行檔案的過程。但是拿C/C++來說,其實這是很模糊的,由一堆C/C++檔案生成應用程式包括預處理---編譯檔案---連結(寫的比較粗糙,不影響本文論述)。

首先,要明白什麼是編譯單元,一個編譯單元可以認為是一個.c或者.cpp檔案,每一個編譯單元首先會經過預處理得到一個臨時的編譯單元,這裡稱為tmp.cpp,預處理會把.c或者.cpp直接或者間接包含的其它檔案(不只侷限於.h檔案,只要是#include即可)的內容替換進來,並展開巨集呼叫等。

下面首先看一個例子:

a.h

#ifndef A_H_
#define A_H_                                                                                                          
                                                                                                                      
static int a = 1;
void fun();                                                                                                           

#endif
a.cpp

#include "a.h"


static void hello_world()
{
}
只有a.h和a.cpp這兩個檔案,及其簡單。首先通過g++的-E引數得到a.cpp預處理之後的內容

coderchen@coderchen:~/c++$ g++ -E a.cpp > tmp.cpp
檢視tmp.cpp

# 1 "a.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "a.cpp"
# 1 "a.h" 1



static int a = 1;
void fun();
# 2 "a.cpp" 2


static void hello_world()
{
}
tmp.cpp就是隻經過預處理得到的檔案,這個檔案才是編譯器能夠真正看到的檔案。這個過程就是預處理。

其中#define A_H_的作用是防止重複包含a.h這個標頭檔案,很多人都知道這一點,但是再仔細問,我見過大多數人都說不清楚。

這種巨集是為了防止一個編譯單元(cpp檔案)重複包含同一個標頭檔案。它在預處理階段起作用,前處理器發現a.cpp內已經定義過A_H_這個巨集的話,在a.cpp中再次發現#include "a.h"的時候就不會把a.h的內容替換進a.cpp了。
編譯器看到tmp.cpp的時候,會編譯成一個obj檔案,最後由連結器對這一個對obj檔案進行連結,從而得到可執行程式。


編譯錯誤和連線錯誤

編譯錯誤指的是一個cpp編譯單元在編譯時發生的錯誤,這種錯誤一般都是語法錯誤,拼寫錯誤,引數不匹配等。

以main.cpp為例(只有一個main函式)

int main()                                                                                                            
{                                                                                                                     
  hello_world();                                                                                                      
}    
編譯(加-c參數列示只編譯不連結)

coderchen@coderchen:~/c++$ g++ -c -o main.o main.cpp
main.cpp: In function ‘int main()’:
main.cpp:4: error: ‘hello_world’ was not declared in this scope
這種錯誤就是編譯,原因是hello_world函式未宣告,把void hello_world();這條語句加到main函式前面,再次編譯

coderchen@coderchen:~/c++$ g++ -c -o main.o main.cpp
coderchen@coderchen:~/c++$ 
編譯成功,雖然我們呼叫了hello_world函式,卻沒有定義這個函式。好,接下來,我們把這個main.o檔案連結下,

coderchen@coderchen:~/c++$ g++ -o main main.o
main.o: In function `main':
main.cpp:(.text+0x7): undefined reference to `hello_world()'
collect2: ld returned 1 exit status
看到了吧,連結器ld報出了連結錯誤,原因是hello_world這個函式找不到。這個例子很簡單,基本上可以區分出編譯錯誤和連結錯誤。我們再新增一個hello_world.cpp

void hello_world()
{ 
}
編譯

coderchen@coderchen:~/c++$ g++ -c -o hello_world.o hello_world.cpp
連結

coderchen@coderchen:~/c++之所以$ g++ -o main main.o hello_world.o
ok,我們的main程式已經生成了,我們經歷了預處理---編譯---連結的過程。

有的人說為什麼不需要寫一個hello_world.h的標頭檔案,宣告hello_world函式,然後再讓main.cpp包含hello_world.h呢?這樣寫自然是標準的做法,不過預處理過後,和我們現在寫的一樣的,預處理會把hello_world.h的內容替換到main.cpp中。


問題:在連結的時候,main.o怎麼知道hello_world函式定義在hello_world.o中呢?

答案:main.o不知道hello_world函式定義在那個obj檔案中,每個obj檔案都有一個匯出符號表,對於這個例子,hello_world.o的匯出符號表中有hello_world這個函式,而main.o需要用到這個函式,可以想象就像幾個插槽一樣。連結器通過掃描obj檔案發現這個函式定義在hello_world.o中,然後就可以連結了。

問題:為什麼函式不能定義在標頭檔案中?

這個問題是不恰當的,因為用inline和static修飾的函式可以定義在標頭檔案中,而inline修飾的函式必須定義在標頭檔案中。

如果函式定義在標頭檔案中,並且有多個cpp檔案都包含了這個標頭檔案的話,那麼這些cpp檔案生成的obj檔案的匯出符號表中都有這個標頭檔案中定義的函式,單檔案編譯的時候是不會出錯的,但是連結的時候就會報錯。連結器發現了多個函式實體,但卻無法確定應該使用哪一個。這是一個連結錯誤。

inline修飾的函式,通常都不會存在函式實體,即便編譯器沒有對其內聯,那麼obj檔案也不會匯出inline函式,所以連結不會出錯。

static修飾的函式,只能由定義它的編譯單元呼叫,也不會匯出。如果標頭檔案中頂一個static修飾的函式,就相當於多個obj檔案中都頂一個了一個一模一樣的函式,大家各用各的,互補干擾。

問題:什麼樣的變數可以定義在標頭檔案中?

其實變數於函式很類似,由static或const修飾的變數可以定義在標頭檔案中。

static修飾的變數於static修飾的函式一樣,道理同上。

const修飾的變數預設是不會進入匯出符號表的,相當於每個obj中都定義了一個一模一樣的const變數,各用各的。而const可以再用extern修飾,如果用extern const修飾的變數定義在標頭檔案中,那麼就會出現連結錯誤,原因就是“想一想extern是幹嘛的”

問題:extern "C"是幹嘛的?

如果有人回答“相容C和C++”,我只能說“這是一個正確答案,但我不知道你是否真的知道”。

首先要知道C不支援過載,C++支援過載,C++為了支援過載,引入了函式重新命名的機制,就像下面這樣:

int hello_world(type1 param);
int hello_world(type2 param);
通常第一個函式會被編譯成hello_world_type1這樣子,第二個函式會被編譯成hello_world_type2這樣子。不管是定義的地方還是呼叫的地方,都會把函式改成同樣的名字,所以連結器可以正確的找到函式實體。

而我們寫C++程式的時候,通常會引入由c編寫的庫(gcc編譯的c檔案),而c不支援過載,自然不會對函式重新命名。而我們在C++中呼叫的地方很可能會重新命名,這就造成了呼叫的地方(C++編譯)和定義的地方(C編譯)函式名不一致的情況,這也是一種連結錯誤。

所以我們經常會看到在C++中用extern "C" { #include "some_c.h" }這種程式碼。這就是告訴c++編譯器,some_c.h中的函式要按照c的方式編譯,不要重新命名,這樣在連結的時候就ok了。

相關文章