1. 程式人生 > >二進位制相容ABI-C++庫注意事項

二進位制相容ABI-C++庫注意事項

相關連結:https://blog.csdn.net/knowledgebao/article/details/85076661


 

目錄

1,什麼是二進位制相容?

2,有哪些情況會破壞庫的 ABI?

3, 哪些做法多半是安全的

4, 反面教材:COM

4,解決辦法

4.1 採用靜態連結

4.2 通過動態庫的版本管理來控制相容性

4.3 用 pimpl 技法,編譯器防火牆

4.4  使用d-pointer

4.5  對於忘記設計d-pointer的類,最標準的彌補做法是:

4.6 如何覆蓋已實現過的虛擬函式?

4.7 使用全域性函式作為介面,內部還是按照C++的方式來實現。


1,什麼是二進位制相容?

C/C++ 的二進位制相容性 (binary compatibility) 有多重含義,本文主要在“標頭檔案和庫檔案分別升級,可執行檔案是否受影響”這個意義下討論,我稱之為 library (主要是 shared library,即動態連結庫)的 ABI (application binary interface)。至於編譯器與作業系統的 ABI 留給下一篇談 C++ 標準與實踐的文章。

在解釋這個定義之前,先看看 Unix/C 語言的一個歷史問題:open() 的 flags 引數的取值。open(2) 函式的原型是int open(const char *pathname, int flags);其中 flags 的取值有三個: O_RDONLY,  O_WRONLY,  O_RDWR。與一般人的直覺相反,這幾個值不是按位或

 (bitwise-OR) 的關係,即 O_RDONLY | O_WRONLY != O_RDWR。如果你想以讀寫方式開啟檔案,必須用 O_RDWR,而不能用 (O_RDONLY | O_WRONLY)。為什麼?因為 O_RDONLY, O_WRONLY, O_RDWR 的值分別是 0, 1, 2。它們不滿足按位或。那麼為什麼 C 語言從誕生到現在一直沒有糾正這個不足之處?比方說把 O_RDONLY, O_WRONLY, O_RDWR 分別定義為 1, 2, 3,這樣 O_RDONLY | O_WRONLY == O_RDWR,符合直覺。而且這三個值都是巨集定義,也不需要修改現有的原始碼,只需要改改系統的標頭檔案就行了。因為這麼做會破壞二進位制相容性。對於已經編譯好的可執行檔案,它呼叫 open(2) 的引數是寫死的,更改標頭檔案並不能影響已經編譯好的可執行檔案。比方說這個可執行檔案會呼叫 open(path, 1) 來
檔案,而在新規定中,這表示檔案,程式就錯亂了。

以上這個例子說明,如果以 shared library 方式提供函式庫,那麼標頭檔案和庫檔案不能輕易修改,否則容易破壞已有的二進位制可執行檔案,或者其他用到這個 shared library 的 library。作業系統的 system call 可以看成 Kernel 與 User space 的 interface,kernel 在這個意義下也可以當成 shared library,你可以把核心從 2.6.30 升級到 2.6.35,而不需要重新編譯所有使用者態的程式。

所謂“二進位制相容性”指的就是在升級(也可能是 bug fix)庫檔案的時候,不必重新編譯使用這個庫的可執行檔案或使用這個庫的其他庫檔案,程式的功能不被破壞。見 QT FAQ 的有關條款:http://developer.qt.nokia.com/faq/answer/you_frequently_say_that_you_cannot_add_this_or_that_feature_because_it_woul在 Windows 下有惡名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll, mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,這是動態連結庫的本質問題,怪不到 MFC 頭上。

2,有哪些情況會破壞庫的 ABI?

到底如何判斷一個改動是不是二進位制相容呢?這跟 C++ 的實現方式直接相關,雖然 C++ 標準沒有規定 C++ 的 ABI,但是幾乎所有主流平臺都有明文或事實上的 ABI 標準。比方說 ARM 有 EABI,Intel Itanium(英特爾安騰)  有 http://www.codesourcery.com/public/cxx-abi/abi.html,x86-64 有仿 Itanium 的 ABI,SPARC 和 MIPS 也都有明文規定的 ABI,等等。x86 是個例外,它只有事實上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI 還有多個版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 編譯器也得按照 Visual C++ 或 G++ 的 ABI 來生成程式碼,否則就不能與系統其它部件相容。

C++ ABI 的主要內容:

  • 函式引數傳遞的方式,比如 x86-64 用暫存器來傳函式的前 4 個整數引數
  • 虛擬函式的呼叫方式,通常是 vptr/vtbl 然後用 vtbl[offset] 來呼叫
  • struct 和 class 的記憶體佈局,通過偏移量來訪問資料成員
  • name mangling(mangling的目的就是為了給過載的函式不同的簽名,以避免呼叫時的二義性呼叫)
  • RTTI 和異常處理的實現(以下本文不考慮異常處理)

C/C++ 通過標頭檔案暴露出動態庫的使用方法,這個“使用方法”主要是給編譯器看的,編譯器會據此生成二進位制程式碼,然後在執行的時候通過裝載器(loader)把可執行檔案和動態庫綁到一起。如何判斷一個改動是不是二進位制相容,主要就是看標頭檔案暴露的這份“使用說明”能否與新版本的動態庫的實際使用方法相容。因為新的庫必然有新的標頭檔案,但是現有的二進位制可執行檔案還是按舊的標頭檔案來呼叫動態庫。

這裡舉一些原始碼相容但是二進位制程式碼不相容例子

1,對於已經存在的類:
   1.1,本來對外開放了,現在想收回來不開放.
   1.2, 換爹 (加爹,減爹,重新給爹排座次). 
2,對於類模板來說: 
   2.1,修改任何模板引數(增減或改變座次),比方說 Foo<T> 改為 Foo<T, Alloc=alloc<T> >,這會改變 name mangling
3,對於函式來說:
   3.1,不再對外開放.
   3.2,徹底刪掉
   3.3,改成內聯的(把程式碼從類定義外頭移到標頭檔案的類定義裡頭也算改內聯)。
   3.4,改變函式特徵串:
      3.4.1,修改引數,包括增減引數或函式甚至是成員函式的const/volatile描述符。如果一定要這麼幹,增加一個新函式吧。
      3.4.2,把private改成protected或者public。如果一定要這麼幹,增加一個新函式吧。
      3.4.3,對於非成員函式,如果用extern "C"聲明瞭,可以很小心地增減函式引數而不破壞二進位制相容。
4,對於虛成員函式來說:
   4.1,給沒虛擬函式或者虛基類的類增加虛擬函式,這會造成 vtbl 裡的排列變化。(不要考慮“只在末尾增加”這種取巧行為,因為你的 class 可能已被繼承。)
   4.2,修改有別的類繼承的基類
   4.3,修改虛擬函式的前後順序
   4.4,如果一個函式不是在往上數頭一個非虛基類中宣告的,覆蓋它會造成二進位制不相容。
   4.5,如果虛擬函式被覆蓋時改變了返回型別,不要修改它。
5,對於非私有靜態函式和非靜態的非成員函式:
   5.1,改成不開放的或者刪除
   5.2,修改型別或者const/violate
6,對於非靜態成員函式:
   6.1,增加新成員
   6.2,給非靜態成員重新排序或者刪除
   6.3,修改成員的型別, 有個例外就是修改符號:signed/unsigned改來改去,不影響位元組長度。
7,改變 enum 的值,把 enum Color { Red = 3 }; 改為 Red = 4。這會造成錯位。當然,由於 enum 自動排列取值,新增 enum 項也是不安全的,除非是在末尾新增。

8,給 class Bar 增加資料成員,造成 sizeof(Bar) 變大,以及內部資料成員的 offset 變化,這是不是安全的?通常不是安全的,但也有例外。
   8.1,如果客戶程式碼裡有 new Bar,那麼肯定不安全,因為 new 的位元組數不夠裝下新 Bar。相反,如果 library 通過 factory 返回 Bar* (並通過 factory 來銷燬物件)或者直接返回 shared_ptr<Bar>,客戶端不需要用到 sizeof(Bar),那麼可能是安全的。同樣的道理,直接定義 Bar bar; 物件(無論是函式區域性物件還是作為其他 class 的成員)也有二進位制相容問題。
   8.2,如果客戶程式碼裡有 Bar* pBar; pBar->memberA = xx;,那麼肯定不安全,因為 memberA 的新 Bar 的偏移可能會變。相反,如果只通過成員函式來訪問物件的資料成員,客戶端不需要用到 data member 的 offsets,那麼可能是安全的。
   8.3:如果客戶呼叫 pBar->setMemberA(xx); 而 Bar::setMemberA() 是個 inline function,那麼肯定不安全,因為偏移量已經被 inline 到客戶的二進位制程式碼裡了。如果 setMemberA() 是 outline function,其實現位於 shared library 中,會隨著 Bar 的更新而更新,那麼可能是安全的。

那麼只使用 header-only 的庫檔案是不是安全呢?不一定。如果你的程式用了 boost 1.36.0,而你依賴的某個 library 在編譯的時候用的是 1.33.1,那麼你的程式和這個 library 就不能正常工作。因為 1.36.0 和 1.33.1 的 boost::function 的模板引數型別的個數不一樣,其中一個多了 allocator。

這裡有一份黑名單,列在這裡的肯定是二級制不相容,沒有列出的也可能二進位制不相容,見 KDE 的文件:http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B

3, 哪些做法多半是安全的

前面我說“不能輕易修改”,暗示有些改動多半是安全的,這裡有一份白名單,歡迎新增更多內容。

只要庫改動不影響現有的可執行檔案的二進位制程式碼的正確性,那麼就是安全的,我們可以先部署新的庫,讓現有的二進位制程式受益。

1 增加非虛擬函式(成員函式),增加signal/slots,建構函式什麼的。
2 增加列舉enum或增加列舉中的專案。
3 重新實現在父類裡定義過的虛擬函式 (就是從這個類往上數的第一個非虛基類),理論上講,程式還是找那個基類要這個虛擬函式的實現,而不是找你新寫的函式要,所以是安全的。但是這可不怎麼保準兒,儘量少用。(好多廢話,結論是少用) 有一個例外: C++有時候允許重寫的虛擬函式改變返回型別,在這種情況下無法保證二進位制相容。
4 修改行內函數,或者把行內函數改成非內聯的。這也很危險,儘量少用。
5 去掉一個私有非虛擬函式。如果在任何行內函數裡用到了它,你就不能這麼幹了。
6 去掉私有的靜態成員。同樣,如果行內函數引用了它,你也不能這麼幹。
7 增加私有成員。
8 增加新類。
9 對外開放一個新類。
10 增減類的友元宣告。
11 修改保留成員的型別。
12 把原來的成員位寬擴大縮小,但擴充套件後不得越過邊界(char和bool不能過8位界,short不能過16位界,int不過32位界,以此類推)這個也接近鬧殘:原來沒用到的那麼幾個位我擴來擴去當然沒問題,可是這樣實在是不讓人放心。
13 修改資料成員的名稱,因為生產的二進位制程式碼是按偏移量來訪問的,當然,這會造成原始碼級的不相容。

4, 反面教材:COM

在 C++ 中以虛擬函式作為介面基本上就跟二進位制相容性說拜拜了。具體地說,以只包含虛擬函式的 class (稱為 interface class)作為程式庫的介面,這樣的介面是僵硬的,一旦釋出,無法修改。

比方說 M$ 的 COM,其 DirectX 和 MSXML 都以 COM 元件方式釋出,我們來看看它的帶版本介面 (versioned interfaces):

  • IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
  • IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3

換話句話說,每次釋出新版本都引入新的 interface class,而不是在現有的 interface 上做擴充。這樣一樣不能相容現有的程式碼,強迫客戶端程式碼也要改寫。

回過頭來看看 C 語言,C/Posix 這些年逐漸加入了很多新函式,同時,現有的程式碼不用修改也能執行得很好。如果要用這些新函式,直接用就行了,也基本不會修改已有的程式碼。相反,COM 裡邊要想用 IXMLDOMDocument3 的功能,就得把現有的程式碼從 IXMLDOMDocument 全部升級到 IXMLDOMDocument3,很諷刺吧。

tip:如果遇到鼓吹在 C++ 裡使用面向介面程式設計的人,可以拿二進位制相容性考考他。

4,解決辦法

4.1 採用靜態連結

這個是王道。在分散式系統這,採用靜態連結也帶來部署上的好處,只要把可執行檔案放到機器上就行執行,不用考慮它依賴的 libraries。目前 muduo 就是採用靜態連結。

4.2 通過動態庫的版本管理來控制相容性

這需要非常小心檢查每次改動的二進位制相容性並做好釋出計劃,比如 1.0.x 系列做到二進位制相容,1.1.x 系列做到二進位制相容,而 1.0.x 和 1.1.x 二進位制不相容。《程式設計師的自我修養》裡邊講過 .so 檔案的命名與二進位制相容性相關的話題,值得一讀。

4.3 用 pimpl 技法,編譯器防火牆

參考http://www.cnblogs.com/Solstice/archive/2011/03/13/1982563.html

在標頭檔案中只暴露 non-virtual 介面,並且 class 的大小固定為 sizeof(Impl*),這樣可以隨意更新庫檔案而不影響可執行檔案。當然,這麼做有多了一道間接性,可能有一定的效能損失。見 Exceptional C++ 有關條款和 C++ Coding Standards 101。

Java 是如何應對的

Java 實際上把 C/C++ 的 linking 這一步驟推遲到 class loading 的時候來做。就不存在“不能增加虛擬函式”,“不能修改 data member” 等問題。在 Java 裡邊用面向 interface 程式設計遠比 C++ 更通用和自然,也沒有上面提到的“僵硬的介面”問題。

4.4  使用d-pointer

d-pointer是Qt開發者發明的一個保護二進位制相容的辦法,也是Qt如此成功的原因之一。
假如你要宣告一個類Foo的話,先宣告一個它的私有類,用向前引用的方法,在類Foo裡,宣告一個指向FooPrivate的指標,FooPrivate類本身在實現檔案.cpp裡定義,不需要標頭檔案,在類Foo的建構函式裡,建立一個FooPrivate的例項,當然別忘記在解構函式裡刪掉它,還有一個技巧,在大部分環境下,把d-pointer宣告成const是比較明智的。這樣可以避免意外修改和拷來拷去,避免記憶體洩露,這樣,你可以修改d指向的內容,但是不能修改指標本身。

.h檔案

class FooPrivate;
class Foo
{
publish:
    Foo();
    ~Foo();

private:
    FooPrivate* const d;
}

.cpp檔案

class FooPrivate {
public:
    FooPrivate()
        : m1(0), m2(0)
    {}
privite:
    int m1;
    int m2;
};

Foo()
{
    d = new FooPrivate;
}
~Foo()
{
    delete d;
    d = NULL;
}

4.5  對於忘記設計d-pointer的類,最標準的彌補做法是:

    * 設計一個私有類FooPrivate.
    * 建立一個靜態的哈西表 static QHash<Foo *, FooPrivate>.
    * 很不幸的是大部分編譯器都是鬧殘,在建立動態連結庫的時候都不會自動建立靜態物件,所以你要用Q_GLOBAL_STATIC巨集來宣告這個哈西表才行:

//為了二進位制相容: 增加一個真正的d-pointer
Q_GLOBAL_STATIC(QHash<Foo *,FooPrivate>, d_func);
static FooPrivate* d( const Foo* foo )
{
    FooPrivate* ret = d_func()->value( foo, 0 );
    if ( ! ret ) {
        ret = new FooPrivate;
        d_func()->insert( foo, ret );
    }
    return ret;
}
static void delete_d( const Foo* foo )
{
    FooPrivate* ret = d_func()->value( foo, 0 );
    delete ret;
    d_func()->remove( foo );
}

這樣你就可以在類裡自由增減成員物件了,就好像你的類擁有了d-pointer一樣,只要呼叫d(this)就可以了:

d(this)->m1 = 5;

* 解構函式也要加入一句:

delete_d(this);

* 記得加入二進位制相容(BCI)的標誌,下次大版本釋出的時候趕緊修改過來。
    * 下次設計類的時候,別再忘記加入d-pointer了。

4.6 如何覆蓋已實現過的虛擬函式?

前文說過,如果爹類已經實現過虛擬函式,你覆蓋是安全的:老的程式仍然會呼叫父類的實現。假如你有如下類函式:

void C::foo()
{
    B::foo();
}

B::foo()被直接呼叫。如果B繼承了A,A中有foo()的實現,B中卻沒有foo()的實現,則C::foo()會直接呼叫A::foo()。如果你加入了一個新的B::foo()實現,只有在重新編譯以後,C::foo()才會轉為呼叫B::foo()。
一個善解人意的例子:

B b;                // B 繼承 A
b.foo();

如果B的上一版本連結庫根本沒B::foo()這個函式,你呼叫foo()時一般不會訪問虛擬函式表,而是直接呼叫A::foo()。
如果你怕使用者重新編譯時造成不相容,也可以把A::foo() 改為一個新的保護函式 A::foo2(),然後用如下程式碼修補:

void A::foo()
{
    if( B* b = dynamic_cast< B* >( this ))
        b->B::foo(); // B:: 很重要
    else
        foo2();
}
void B::foo()
{
    // 新的函式功能
    A::foo2(); // 有可能要呼叫父類的方法
}

所有呼叫B型別的函式foo()都會被轉到 B::foo().只有在明確指出呼叫A::foo()的時候才會呼叫A::foo()。

4.7 使用全域性函式作為介面,內部還是按照C++的方式來實現。

這樣的庫既可以給C++使用者呼叫,也可以給C程式設計師呼叫,並且也容易封裝成被其他語言呼叫的庫。比如python或者java等。

 


參考資料:

1,https://blog.csdn.net/willon_tom/article/details/5499259

2,https://www.jianshu.com/p/f3728924835c?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation

3,http://www.cnblogs.com/Solstice/archive/2011/03/09/1978024.html

 

 


如果有任何問題,請聯絡:[email protected]