首頁 > 軟體

C++和C的混合編譯的專案實踐

2022-06-10 14:02:42

簡介

C++ 語言的建立初衷是 “a better C”,但是這並不意味著 C++ 中類似 C 語言的全域性變數和函數所採用的編譯和連線方式與 C 語言完全相同。作為一種欲與 C 相容的語言, C++ 保留了一部分過程式語言的特點(被世人稱為"不徹底地物件導向"),因而它可以定義不屬於任何類的全域性變數和函數。但是, C++ 畢竟是一種物件導向的程式設計語言,為了支援函數的過載, C++ 對全域性函數的處理方式與 C 有明顯的不同。

本文將介紹如何通過 extern “C” 關鍵字在 C++ 中支援 C 語言 和 在C語言中如何支援 C++

某企業曾經給出如下的一道面試題

為什麼標準標頭檔案都有類似以下的結構?

//head.h
#ifndef HEAD_H
#define HEAD_H

#ifdef __cplusplus
extern "C" {
#endif

    /*...*/

#ifdef __cplusplus
}
#endif

#endif /* HEAd_H */

問題分析

  • 這個標頭檔案head.h可能在專案中被多個原始檔包含(#include “head.h”),而對於一個大型專案來說,這些冗餘可能導致錯誤,因為一個標頭檔案包含類定義或inline函數,在一個原始檔中head.h可能會被#include兩次(如,a.h標頭檔案包含了head.h,而在b.c檔案中#include a.h和head.h)——這就會出錯(在同一個原始檔中一個結構體、類等被定義了兩次)。
  • 從邏輯觀點和減少編譯時間上,都要求去除這些冗餘。然而讓程式設計師去分析和去掉這些冗餘,不僅枯燥且不太實際,最重要的是有時候又需要這種冗餘來保證各個模組的獨立

為了解決這個問題,上面程式碼中的

#ifndef HEAD_H
#define  HEAD_H
/*……………………………*/
#endif /* HEAD_H */

就起作用了。如果定義了HEAD_H,#ifndef/#endif之間的內容就被忽略掉。因此,編譯時第一次看到head.h標頭檔案,它的內容會被讀取且給定HEAD_H一個值。之後再次看到head.h標頭檔案時,HEAD_H就已經定義了,head.h的內容就不會再次被讀取了。

那麼下面這段程式碼的作用又是什麼呢?

#ifdef __cplusplus
extern "C" {
#endif
/*.......*/
#ifdef __cplusplus
}
#endif

我們將在後面對此進行詳細說明。

關於 extern “C”

前面的題目中的 __cplusplus 宏,這是C++中已經定義的宏,是用來識別編譯器的,也就是說,將當前程式碼編譯的時候,是否將程式碼作為 C++ 進行編譯。

首先從字面上分析extern “C”,它由兩部分組成:extern關鍵字、“C”。下面我就從這兩個方面來解讀extern "C"的含義。

首先,被它修飾的目標是 extern 的;其次,被它修飾的目標是 C 的。

extern關鍵字

被 extern “C” 限定的函數或變數是 extern 型別的。

extern是C/C++語言中表明函數全域性變數作用範圍(可見性)的關鍵字,該關鍵字告訴編譯器,其宣告的函數和變數可以在本模組或其它模組中使用。通常,在模組的標頭檔案中對本模組提供給其它模組參照的函數和全域性變數以關鍵字extern宣告。例如,如果模組B欲參照該模組A中定義的全域性變數和函數時只需包含模組A的標頭檔案即可。這樣,模組B中呼叫模組A中的函數時,在編譯階段,模組B雖然找不到該函數,但是並不會報錯;它會在連線階段中從模組A編譯生成的目的碼中找到此函數。

被extern修飾的函數,需要在編譯階段去連結該目標檔案,並且與extern對應的關鍵字是 static,被static修飾的全域性變數和函數只能在本模組中使用。因此,一個函數或變數只可能被本模組使用時,其一般是不可能被extern “C”修飾的。

**注意:**例如語句 extern int a; 僅僅是對變數的宣告,其並不是在定義變數 a ,宣告變數並未為 a 分配記憶體空間。定義語句形式為 int a; 變數 a 在所有模組中作為一種全域性變數只能被定義一次,否則會出現連線錯誤。

被 extern “C” 修飾的變數和函數是按照 C 語言方式編譯和連線的。

由於C++和C兩種語言的親密性,並且早期大量的庫都是由C語言實現的,所以不可避免的會出現在C++程式中呼叫C的程式碼、C的程式中呼叫C++的程式碼,但是它們各自的編譯和連結的規則是不同的。

函數名修飾

  1. 由於Windows下vs的修飾規則過於複雜,而Linux下gcc的修飾規則簡單易懂,下面我們使用了gcc演示了這個修飾後的名字。
  2. 通過下面我們可以看出gcc的函數修飾後名字不變。而g++的函數修飾後變成【_Z+函數長度+函數名+型別首字母】。

分別使用C的編譯器和C++的編譯器去編譯並獲得一個可執行檔案

使用C語言(gcc)編譯器編譯後結果

使用objdump -S 命令檢視gcc生成的可執行檔案:

使用C++編譯器(g++)編譯後結果

使用objdump -S 命令檢視g++生成的可執行檔案:

**linux:**修飾後的函數名= _Z + 函數名長度 + 形參型別首字母,Windows下也是相似的,細節上會有所不同,本質上都是通過函數引數資訊去修飾函數名。

C++的編譯和連結方式

採用g++編譯完成後,函數的名字將會被修飾,編譯器將函數的引數型別資訊新增到修改後的名字中,因此當相同函數名的函數擁有不用型別的引數時,在g++編譯器看來是不同的函數,而我們另一個模組中想要呼叫這些函數也就必須使用C++的規則去連結函數(找修飾後的函數名)才能找到函數的地址。

C的編譯和連結方式

對於C程式,由於不支援過載,編譯時函數是未加任何修飾的,而且連結時也是去尋找未經修飾的函數名。

C和C++直接混合編譯時的連結錯誤

在C++程式,函數名是會被引數型別資訊修飾的,這就造成了它們之間無法直接相互呼叫。

例如:

print(int)函數,使用g++編譯時函數名會被修飾為 _Z5printi,而使用gcc編譯時函數名則仍然是print,如果直接在C++中呼叫使用C編譯規則的函數,會連結錯誤,因為它會去尋找 _Z5printi而不是 print。

【C和C++的編譯和連結方式的不同】參考:

C++的函數過載

extern“C”的使用

extern "C"指令非常有用,因為C和C++的近親關係。注意:extern "C"指令中的C,表示的一種編譯和連線規約,而不是一種語言。

並且extern "C"指令僅指定編譯和連線規約,並不影響語意,編譯時仍是一個C++的程式,遵循C++的類別型檢查等規則。

對於下面的程式碼它們之間是有區別的

extern "C" void Add(int a, int b);
//指定Add函數應該根據C的編譯和連線規約來連結
extern void Add(int a, int b);
//宣告在Add是外部函數,連結的時候去呼叫Add函數

如果有很多內容要被加上extern “C”,你可以將它們放入extern “C”{ }中。

通過上面的分析,我們知道extern "C"的真實目的是實現類C和C++的混合程式設計,在C++原始檔中的語句前面加上extern “C”,表明它按照類C的編譯和連線規約來編譯和連線,而不是C++的編譯的連線規約。這樣在類C的程式碼中就可以呼叫C++的函數or變數等。

那麼混合編譯首先要處理的問題就是要讓我們所寫的C++程式和C程式函數的編譯時的修飾規則連結時的修飾規則保持一致。

總共就有下面四種情況,也就是說一個C的庫,應該能同時被C和C++呼叫,而一個C++的庫也應能夠同時相容C和C++。

為了展示如上四種情況,我們分別建立一個C靜態庫和C++靜態庫

C程式呼叫C的庫,C++程式呼叫C++的庫,這是理所應當的,因此我們關注的問題是如何交叉呼叫

用法舉例

靜態庫是什麼

庫是寫好的現有的,成熟的,可以複用的程式碼。現實中每個程式都要依賴很多基礎的底層庫,不可能每個人的程式碼都從零開始,因此庫的存在意義非同尋常

之所以稱為【靜態庫】,是因為在連結階段,會將組合生成的目標檔案.o與參照到的庫一起連結打包到可執行檔案中。因此對應的連結方式稱為靜態連結。

試想一下,靜態庫與組合生成的目標檔案一起連結為可執行檔案,那麼靜態庫必定跟.o檔案格式相似。其實一個靜態庫可以簡單看成是一組目標檔案(.o/.obj檔案)的集合,即很多目標檔案經過壓縮打包後形成的一個檔案。靜態庫特點總結:

  • 靜態庫對函數庫的連結是放在編譯時期完成的。
  • 程式在執行時與函數庫再無瓜葛,移植方便。
  • 浪費空間和資源,因為所有相關的目標檔案與牽涉到的函數庫被連結合成一個可執行檔案。

靜態庫在程式編譯時會被連線到目的碼中,程式執行時將不再需要該靜態庫,因此體積較大

建立C靜態庫

我們以一個棧的靜態庫為例:

首先新建專案Stack_C

新建原始檔和標頭檔案

寫好棧的程式碼

注意一定是C程式,即原始檔字尾為c

更改輸出檔案型別

右鍵專案名稱—>屬性

更改為設定型別為靜態庫

生成靜態庫

檢視是否生成成功

VS一般在專案路徑下的x64Debug路徑下:

至此,靜態庫已經可以成功建立了。

再新建一個專案,寫一個去呼叫該靜態庫實現的棧的程式(以括號匹配問題為例)

不過對於VS我們的靜態庫是預設不去使用的,因此我們需要將靜態庫的路徑和庫的名稱分別新增到庫目錄和依賴項,才能讓程式能去呼叫該靜態庫。

更改連結器設定

右鍵專案名—>點選屬性

“屬性面板“—>”設定屬性”—> “連結器”—>”常規”,附加依賴庫目錄中輸入,靜態庫所在目錄;

增加庫目錄(路徑為我們剛剛生成的靜態庫所在的Debug資料夾)

增加附加依賴項

名稱為Stack_C專案生成的靜態庫名,一般是專案名 + .lib

“屬性面板”—>”設定屬性”—> “連結器”—>”輸入”,附加依賴庫中輸入靜態庫名StaticLibrary.lib。

我們先嚐試使用C程式來呼叫該靜態庫

新建專案

將原始檔字尾改為c;包含上Stack_C專案(靜態庫專案)的標頭檔案;點選生成解決方案;

成功生成,說明成功呼叫。

嘗試使用C++程式呼叫C靜態庫

  • 將原始檔字尾改為cpp;
  • 標頭檔案保持不變;
  • 點選生成解決方法

結果報錯了:

這說明在連結的過程中出現了問題,也就是在我們的程式找不到靜態庫中函數的地址,原因是我們的靜態庫是C語言的,沒有對函數進行修飾,但在我們的呼叫方是C++程式,在連結過程中找的是修飾過的函數名,因此無法找到函數的地址。

既然C語言的靜態庫只能按照C的規則去編譯這些函數(即不修飾函數名),那麼我們只要讓C++程式按照C語言的連結規則(即找未經修飾的函數名)去找到函數名不就解決了?

兩種思路:

  • 改變C庫的編譯和連結方式為C++規則;
  • 改變C++程式呼叫庫函數的編譯和連結方式為C的規則;

方法1是不行的,因為C語言中可沒有extern “C++”這種東西,那麼考慮方法2;

這時我們可以藉助extern“C”改變C++程式的連結規則,讓C++去按照C的規則去找函數名,即未經過任何修飾的函數名,那就一定能找到函數的地址,來去正確呼叫靜態庫。

在原始檔test.cpp使用extern “C”,去改變包含的標頭檔案中的函數的連結規則

//呼叫庫的的模組的標頭檔案包含
extern "C"
{
	#include"....Stack_CStack_Cstack.h"
}
//程式的程式碼
//...

那麼在test.cpp去連結函數時,就會直接去找原函數名。

這樣就解決了。

還有一個一步到位的解決方法,利用條件編譯,根據當前程式的型別,選擇是否去執行extern “C”指令。

  • 呼叫方是C程式,不做處理;
  • 呼叫方是C++程式,需要使用extern“C”將程式改為C的連結規則;
//呼叫庫的的模組的標頭檔案包含
#ifdef __cplusplus//如果是c++程式,就執行extern 「C」,使用C的連結方式,去找未經修飾的函數名
extern "C"{
#endif
#include"....Stack_CStack_Cstack.h"
#ifdef __cplusplus
}
#endif
//程式的程式碼
//...

但是這樣的處理不太好,我們作為呼叫方自然是想可以直接通過標頭檔案包含的方式去使用庫裡的函數,因此採用下列方法,更改庫的標頭檔案函數宣告為:

#ifdef __cplusplus//如果定義了宏__cplusplus就執行#ifdef 到 #endif之間的語句
extern "C"
{
#endif
void StackInit(struct Stack* s);
void StackPush(struct Stack* s, DataType x);
void StackPop(struct Stack* s);
DataType StackTop(struct Stack* s);
int StackSize(struct Stack* s);
void StackDestory(struct Stack* s);
bool StackEmpty(struct Stack* s);
#ifdef __cplusplus
}
#endif

這樣的一段程式碼,無論是C++程式還是C程式都可以直接#include就能去呼叫該靜態庫了。

建立C++靜態庫

步驟和建立C的靜態庫相同,只不過要將專案中的原始檔字尾改為cpp,就會生成一個C++的靜態庫,因此不再闡述。

建立完成後,我們仍使用剛剛的專案,並且新增C++靜態庫路徑到庫目錄,新增C++靜態庫名稱到附加依賴項,仍然以括號匹配問題為例去呼叫該庫。(記得刪除C靜態庫的庫目錄和附加依賴項,否則我們的程式有可能還會去呼叫C的靜態庫,這樣我們就無法探究如何去呼叫C++靜態庫的問題了)

嘗試使用C程式呼叫C++靜態庫

我們不著急呼叫,經過先前的經驗,這裡可以判斷,C++的程式去呼叫C++的庫一定是沒問題的,但是C程式就不好說了,因此我們要搞定C程式呼叫C++庫的情況,先搞清楚它們的差異:

這裡的C++程式去呼叫函數是去尋找修飾後的函數名,C程式是去找未修飾的函數名,要想讓它們保持一致有兩個思路:

改變C程式的編譯和鏈方式為C++的規則;改變C++靜態庫的編譯方式為C的規則;

但是方法1是不行的,之前也說過,C語言中沒有extern “C++”這種東西,那麼考慮方法2;

對庫的標頭檔案中的函數做如下處理:

//用C的規則去搞庫的編譯和連結方式
extern "C"
{
	void StackInit(struct Stack* s);
	void StackPush(struct Stack* s, DataType x);
	void StackPop(struct Stack* s);
	DataType StackTop(struct Stack* s);
	int StackSize(struct Stack* s);
	void StackDestory(struct Stack* s);
	bool StackEmpty(struct Stack* s);
}

那麼現在C++的靜態庫的函數名都是沒有經過修飾的。(C的規則)

但是我們去編譯仍然報錯:

error C2059: 語法錯誤:“字串”

"StackInit”未定義;假設外部返回int

“StackPush”未定義;假設外部返回int

“StackEmpty”未定義;假設外部返回int

“StackTop”未定義;假設外部返回int

“StackPop”未定義;假設外部返回int

這是因為我們使用C程式時也包含了此標頭檔案,但是C語言中無法識別extern“C”,因此報錯。

我們嘗試使用條件編譯來決定是否使用extern“C”,根據呼叫方的不同改變函數連結規則:

  • 呼叫方是C++程式,那麼需要使用extern“C”將C++程式的函數連結規則變為C的;
  • 呼叫方是C程式,不使用extern“C”語句;

因此我們做如下處理,將庫的標頭檔案中的函數宣告加上:

#ifdef __cplusplus//如果定義了宏__cplusplus就執行#ifdef 到 #endif之間的語句
extern "C"
{
#endif
void StackInit(struct Stack* s);
void StackPush(struct Stack* s, DataType x);
void StackPop(struct Stack* s);
DataType StackTop(struct Stack* s);
int StackSize(struct Stack* s);
void StackDestory(struct Stack* s);
bool StackEmpty(struct Stack* s);
#ifdef __cplusplus
}
#endif

總結:C++和C之間的混合編譯,為了消除函數名修飾規則不同的的差別,我們需要使用extern ”C“來改變C++的編譯和連線方式。

但這樣問題也隨之而來:

C++的庫就失去了函數過載的特性,如果庫中有同名函數,那麼就無法正確編譯,因為按照C的方式去編譯,函數名會衝突。

如何解決這個問題呢?

實際上這個問題無法解決,一旦選擇了將某個函數指定了按照C的方式去編譯連結,那麼這個函數就已經失去了過載的特性了,不過Cpp的庫中未被指定按照C的規則去編譯和連結的那些函數,仍然可以被過載,並且具有C++的一切特性。

因此這個問題無解,只有通過避免“一刀切”的方法來保護那些我們想過載的函數,也就是說一部分庫裡的函數那就是實現給C程式呼叫的,我們就通過extern“C”改變它的編譯和連結方式,而對於那些實現給C++程式呼叫的函數介面,我們不做任何處理,並且不暴露給C程式。

想要實現上述過程,我們需要在靜態庫專案中建立兩個標頭檔案libc.hlibcpp.hlibc.h宣告那些需要暴露給C程式的函數介面,並且使用上面介紹的條件編譯和extern“C”,libcpp.h宣告那些暴露給給Cpp程式的函數介面,這樣兩個標頭檔案的函數的連結規範互不相同,也互不干擾。只需要將lic.h在C程式呼叫的地方使用#include 包含,libcpp.h在C++程式呼叫的地方使用#include包含即可使用。

因此C++庫中哪個介面需要暴露給C,我們就用extern“C”修飾哪個介面。

總之,C的庫可以給C程式和C++程式呼叫,而C++庫也可以被C程式和C++程式呼叫

如果要滿足這個庫中所有的函數都能同時被C++和C呼叫,那麼無論是C的庫還是C++的庫,最終這個庫的編譯和連結方式都只能是C的規範,因為C++可以使用C的連結規範但是C不能使用C++的連結規範,也就導致瞭如果庫的連結規範是C++的,那麼無論如何,C程式都無法呼叫。

值得一提的是C++程式中的函數可以使用兩種連結規範,因此我們可以針對函數的使用場景來選擇該函數的編譯和連結規範,使得一部分函數保留C++的特性,但一部分函數就只能為了相容C而犧牲C++的特性,想要既相容C又保留C++的特性,這是做不到的。

到此這篇關於C++和C的混合編譯的專案實踐的文章就介紹到這了,更多相關C++和C混合編譯內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com