静态链接中的那点事儿(2):C++二进制兼容性及跨平台初步
C++的一些語(yǔ)言特性使之必須由編譯器和鏈接器共同支持才能完成工作。最主要的有兩個(gè)方面,其一,C++的重復(fù)代碼的消除;其二,全局構(gòu)造與析構(gòu)。此外,由于C++的各種特性,比如虛函數(shù)、函數(shù)重載、繼承、異常等,使得C++背后的數(shù)據(jù)結(jié)構(gòu)異常復(fù)雜。而且最為不幸的是,這些數(shù)據(jù)結(jié)構(gòu)往往在不同的編譯器和鏈接器之間不能相互通用,使得C++程序的二進(jìn)制兼容性成為一個(gè)難題。本篇博客將結(jié)合項(xiàng)目經(jīng)驗(yàn)初步討論C++程序的二進(jìn)制兼容性問(wèn)題。
1.C++中重復(fù)代碼是如何消除的?
C++編譯器在很多時(shí)候會(huì)產(chǎn)生重復(fù)的代碼,比如模板(Templates)、外部?jī)?nèi)聯(lián)函數(shù)(External Inline Function)和虛函數(shù)表(Virtual Function Table)都有可能在不同的編譯單元里生成相同的代碼。最簡(jiǎn)單的情況就是拿模板來(lái)說(shuō)事,模板從本質(zhì)上來(lái)講很像宏,當(dāng)模板在一個(gè)編譯單元里被實(shí)例化時(shí),他并不知道自己是否在別的編譯單元也被實(shí)例化了。所以當(dāng)一個(gè)模板在多個(gè)編譯單元同時(shí)實(shí)例化成相同的類型的時(shí)候,必然會(huì)生成重復(fù)的代碼。當(dāng)然,最簡(jiǎn)單的方式就是不管這些,就將這些重復(fù)的代碼都保留下來(lái)。但這會(huì)存在以下幾個(gè)問(wèn)題:
1.空間浪費(fèi)。可以想象一個(gè)由好幾百個(gè)編譯單元的工程同時(shí)實(shí)例化了許多個(gè)模板,最后鏈接的時(shí)候必須將這些重復(fù)的代碼消除掉,否則最終程序的大小坑定會(huì)膨脹的很厲害。
2.地址容易出錯(cuò)。有可能兩個(gè)指向同一個(gè)函數(shù)的指針會(huì)不想等。
3.指令運(yùn)行效率低。因?yàn)楝F(xiàn)代的CPU都會(huì)對(duì)指令和數(shù)據(jù)進(jìn)行緩存,如果同樣一份指令有多個(gè)副本,那么指令Cache的命中率就會(huì)降低。
一個(gè)比較有效的做法就是將每個(gè)模板的示例代碼都單獨(dú)地存放在一個(gè)段里,每個(gè)段只包含一個(gè)模板實(shí)例。比如有個(gè)模板函數(shù)是add<T>(),某個(gè)編譯單元以int類型【add<int>()】和float類型【add<float>()】實(shí)例化了該模板函數(shù),那么該編譯單元的目標(biāo)文件中就包含了兩個(gè)該模板實(shí)例的段,為了簡(jiǎn)單起見(jiàn),我們假設(shè)這兩個(gè)段的名字分別叫.temp.add.<int>和 .temp.add<float>。這樣,當(dāng)別的編譯單元也以int和float類型實(shí)例化該模板函數(shù)后,也會(huì)生成相同的名字,這樣鏈接器在最終鏈接的時(shí)候可以區(qū)分這些相同的模板實(shí)例段,然后將他們合并入最后的代碼段。
這種重復(fù)代碼消除對(duì)于模板來(lái)說(shuō)是這樣的,對(duì)于外部的內(nèi)聯(lián)函數(shù)和虛函數(shù)表的做法也類似。
函數(shù)級(jí)別鏈接:
由于現(xiàn)在的程序和庫(kù)通常來(lái)講都非常龐大,一個(gè)目標(biāo)文件可能包含成百上千個(gè)函數(shù)或變量。當(dāng)我們需要用到某個(gè)目標(biāo)文件中的任意一個(gè)函數(shù)或變量時(shí),就必須把這個(gè)文件整個(gè)的鏈接起來(lái),也就是說(shuō)那些沒(méi)有用到的函數(shù)也被一起鏈接了進(jìn)來(lái)。這樣導(dǎo)致的直接結(jié)果就是:鏈接輸出文件的輸出結(jié)果會(huì)變得相當(dāng)?shù)凝嫶?#xff0c;所有用到到?jīng)]用到的變量和函數(shù)都一起塞到了輸出文件中。
無(wú)論是VC++編譯器還是GCC編譯器都提供了類似模板代碼冗余消除的方法!具體為:讓所有的函數(shù)氮素保存到一個(gè)段里面,當(dāng)連接器需要用到某個(gè)函數(shù)的時(shí)候,就會(huì)將他直接合并到輸出文件中,對(duì)于那些沒(méi)有用到的函數(shù)進(jìn)行舍棄。該方法很大程度上減小了輸出文件的長(zhǎng)度,減少了空間浪費(fèi)。
2.全局的構(gòu)造函數(shù)與析構(gòu)函數(shù)是如何執(zhí)行的?
我們知道一般一個(gè)C++/C程序是從main函數(shù)開(kāi)始執(zhí)行的,隨著main函數(shù)的結(jié)束而結(jié)束。然而,其實(shí)在main函數(shù)被調(diào)用之前,為了保證程序可以順利地進(jìn)行,首先要初始化進(jìn)程執(zhí)行環(huán)境,比如堆分配初始化(malloc/free)、線程子系統(tǒng)等,C++全局對(duì)象的構(gòu)造函數(shù)就是在該時(shí)期被執(zhí)行的。【實(shí)際上C++的全局對(duì)象的構(gòu)造函數(shù)在main之前被執(zhí)行,C++全局對(duì)象的析構(gòu)函數(shù)在main之后被執(zhí)行】
根據(jù)前面的情境,我們能夠發(fā)現(xiàn),對(duì)于某些場(chǎng)合,程序的一些特定的操作必須在main函數(shù)之前被執(zhí)行,還有一些操作必須在main之后進(jìn)行執(zhí)行。當(dāng)然啦,我最習(xí)慣舉得例子就是,C++中全局對(duì)象的構(gòu)造與析構(gòu)的過(guò)程。為了滿足這一特殊情況,linux系統(tǒng)下的ELF文件結(jié)構(gòu)還定義了兩種特殊的段:
.init: 該段里面保存的是可執(zhí)行指令,他構(gòu)成了進(jìn)程的初始化代碼。在main函數(shù)之前被調(diào)用。
.fini: 該字段保存著進(jìn)程終止代碼指令,在main函數(shù)執(zhí)行結(jié)束之后被調(diào)用。
3.C++的兼容問(wèn)題及跨平臺(tái)編程初步
既然每個(gè)編譯器都能將源代碼編譯成目標(biāo)文件,那么不同編譯器編譯出來(lái)的目標(biāo)文件可以相互鏈接嗎?比如說(shuō):MSVC編譯出來(lái)的目標(biāo)文件和GCC編譯出來(lái)的目標(biāo)文件能否鏈接在一起,從而形成可執(zhí)行文件呢?
對(duì)于上面這些問(wèn)題,首先可以想到的是,如果要將兩個(gè)不同編譯器編譯出來(lái)的編譯結(jié)果鏈接到一起,那么,鏈接器必須同時(shí)支持這兩個(gè)編譯器產(chǎn)生的目標(biāo)文件格式。比如說(shuō),MSVC編譯器的目標(biāo)文件是PE/COFF格式,而GCC編譯的結(jié)果是ELF格式的。只有連接器同時(shí)認(rèn)識(shí)這兩個(gè)格式才行,否則肯定沒(méi)戲!那么鏈接器是不是只要同時(shí)認(rèn)識(shí)目標(biāo)文件的格式就可以了呢?不見(jiàn)得!!!
事情絕對(duì)沒(méi)有這么簡(jiǎn)單,如果我們要使兩個(gè)編譯器編譯出來(lái)的目標(biāo)文件能夠相互鏈接,那這兩個(gè)/多個(gè)目標(biāo)文件必須滿足下面的幾個(gè)條件:
1.采用相同的目標(biāo)文件格式;
2.擁有同樣的函數(shù)符號(hào)修飾標(biāo)準(zhǔn);
3.變量的內(nèi)存分布方式相同;
4.函數(shù)的調(diào)用方式相同。
其中,國(guó)際慣例,把符號(hào)修飾標(biāo)準(zhǔn)、變量?jī)?nèi)存分布布局、函數(shù)調(diào)用方式等這些跟可執(zhí)行代碼二進(jìn)制兼容性相關(guān)的內(nèi)容稱為ABI(Application Binary Interface)。
C++一直為人詬病的一大原因就在于他的二進(jìn)制兼容性不好!或者說(shuō),比起C語(yǔ)言來(lái)說(shuō)更不容易。一方面,不同的編譯器編譯出來(lái)的二進(jìn)制代碼之間無(wú)法相互兼容,甚至有時(shí)候,連同一個(gè)編譯器的不同版本之間的兼容性也不好。最普通的例子:我有一個(gè)庫(kù)A是Company A 用Compiler A編譯的;還有一個(gè)庫(kù)B是Company A 用Compiler B編譯的,當(dāng)我寫(xiě)一個(gè)C++程序來(lái)同時(shí)使用庫(kù)A和庫(kù)B是,就會(huì)相當(dāng)?shù)募?#xff01;!!我的朋友曾和我說(shuō)過(guò),只要我可以把一個(gè)開(kāi)源項(xiàng)目的所有源代碼在同一個(gè)編譯環(huán)境下跑一遍,那不就OK了?!當(dāng)然,這對(duì)于小型項(xiàng)目而言是容易可行的,但是考慮到一些大型程序,逐一進(jìn)行編譯明顯是不合理的,這從某種意義上來(lái)講,就造成軟件開(kāi)發(fā)的效率低下。
此外,很多時(shí)候,庫(kù)廠商往往不希望用戶看到哭的源代碼,所以一般都會(huì)以二進(jìn)制的方式提供給用戶。這樣就會(huì)帶來(lái)一個(gè)問(wèn)題,如果庫(kù)廠商提供產(chǎn)品的編譯器型號(hào)和版本編譯器型號(hào)不同時(shí),代碼基本上就被判了死刑。當(dāng)然,這個(gè)問(wèn)題不僅僅存在用戶和庫(kù)廠商之間;當(dāng)一個(gè)程序由多個(gè)部門或多個(gè)公司聯(lián)合開(kāi)發(fā)時(shí),類似的問(wèn)題更明顯。
所以絕大多數(shù)程序員一直期待能有統(tǒng)一的C++二進(jìn)制兼容標(biāo)準(zhǔn)(C++ ABI),諸多的團(tuán)體以及社區(qū)都在致力于C++ ABI標(biāo)準(zhǔn)的統(tǒng)一,但是前景非常的不樂(lè)觀。基本形成了以微軟的Visual C++和GNU的GCC為首的兩大派系。
4.難兄難弟——API與ABI
很多時(shí)候,我們都會(huì)碰到這兩個(gè)詞,他們長(zhǎng)得太像了,僅僅一字之差。API = Application Programming Interface; ABI = Application Binary Interface,實(shí)際上他們都是應(yīng)用程序接口。只是他們所描述的接口所在的層面不一樣。API往往是指源代碼的應(yīng)用程序接口;而ABI是指二進(jìn)制層面的接口,所以ABI的兼容程度要比API的兼容性嚴(yán)格得多。比如:我們可以說(shuō)C++的對(duì)象內(nèi)存分布(object Memory Layout)是C++ ABI的一部分。相比之下API更關(guān)注源代碼層面。 ABI的概念相比較之下歷史更悠久,因?yàn)槊恳粋€(gè)程序員都希望在不經(jīng)過(guò)任何修改的情況下,重新利用辛辛苦苦寫(xiě)的程序!如果可以,最好的情況就是,二進(jìn)制的指令和數(shù)據(jù)還能夠不加修改地得到重用,但是由于MS和GNU惡性競(jìng)爭(zhēng)的原因,二進(jìn)制級(jí)別的重用任重而道遠(yuǎn)。 下面是百度給的一段理解:正式因?yàn)檫@些挑戰(zhàn)的存在,才有了諸君存在的價(jià)值,開(kāi)源社區(qū)算是MS和GNU之外的第三股力量,希望某天開(kāi)源社區(qū)能夠一統(tǒng)ABI,這樣廣大程序猴們就能夠去做更有意義的事了......
總結(jié)
以上是生活随笔為你收集整理的静态链接中的那点事儿(2):C++二进制兼容性及跨平台初步的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: SEOer必须注意的10种错误SEO做法
- 下一篇: 便利的视频会议