天天看點

C++ 工程實踐(4):二進制相容性什麼是二進制相容性有哪些情況會破壞庫的 ABI哪些做法多半是安全的反面教材:COM解決辦法

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

本文主要讨論 Linux x86/x86-64 平台,偶爾會舉 Windows 作為反面教材。

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)庫檔案的時候,不必重新編譯使用這個庫的可執行檔案或使用這個庫的其他庫檔案,程式的功能不被破壞。

在 Windows 下有惡名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll, mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,這是動态連結庫的本質問題,怪不到 MFC 頭上。

C++ ABI 的主要内容:

函數參數傳遞的方式,比如 x86-64 用寄存器來傳函數的前 4 個整數參數

虛函數的調用方式,通常是 vptr/vtbl 然後用 vtbl[offset] 來調用

struct 和 class 的記憶體布局,通過偏移量來通路資料成員

name mangling

RTTI 和異常處理的實作(以下本文不考慮異常處理)

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

這裡舉一些源代碼相容但是二進制代碼不相容例子

給函數增加預設參數,現有的可執行檔案無法傳這個額外的參數。

增加虛函數,會造成 vtbl 裡的排列變化。(不要考慮“隻在末尾增加”這種取巧行為,因為你的 class 可能已被繼承。)

增加預設模闆類型參數,比方說 Foo<T> 改為 Foo<T, Alloc=alloc<T> >,這會改變 name mangling

改變 enum 的值,把 enum Color { Red = 3 }; 改為 Red = 4。這會造成錯位。當然,由于 enum 自動排列取值,添加 enum 項也是不安全的,除非是在末尾添加。

給 class Bar 增加資料成員,造成 sizeof(Bar) 變大,以及内部資料成員的 offset 變化,這是不是安全的?通常不是安全的,但也有例外。

如果客戶代碼裡有 new Bar,那麼肯定不安全,因為 new 的位元組數不夠裝下新 Bar。相反,如果 library 通過 factory 傳回 Bar* (并通過 factory 來銷毀對象)或者直接傳回 shared_ptr<Bar>,用戶端不需要用到 sizeof(Bar),那麼可能是安全的。同樣的道理,直接定義 Bar bar; 對象(無論是函數局部對象還是作為其他 class 的成員)也有二進制相容問題。

如果客戶代碼裡有 Bar* pBar; pBar->memberA = xx;,那麼肯定不安全,因為 memberA 的新 Bar 的偏移可能會變。相反,如果隻通過成員函數來通路對象的資料成員,用戶端不需要用到 data member 的 offsets,那麼可能是安全的。

如果客戶調用 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。

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

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

增加新的 class

增加 non-virtual 成員函數

修改資料成員的名稱,因為生産的二進制代碼是按偏移量來通路的,當然,這會造成源碼級的不相容。

還有很多,不一一列舉了。

歡迎補充

在 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++ 裡使用面向接口程式設計的人,可以拿二進制相容性考考他。

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

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

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

Java 是如何應對的

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

(待續)

    本文轉自 陳碩  部落格園部落格,原文連結:http://www.cnblogs.com/Solstice/archive/2011/03/09/1978024.html,如需轉載請自行聯系原作者