NO IMAGE

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

見 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 頭上。

有哪些情況會破壞庫的 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
  • RTTI 和異常處理的實現(以下本文不考慮異常處理)

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

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

  • 給函式增加預設引數,現有的可執行檔案無法傳這個額外的引數。
  • 增加虛擬函式,會造成 vtbl 裡的排列變化。(不要考慮“只在末尾增加”這種取巧行為,因為你的 class 可能已被繼承。)
  • 增加預設模板型別引數,比方說 Foo 改為 Foo >,這會改變 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,客戶端不需要用到
    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。

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

哪些做法多半是安全的

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

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

  • 增加新的 class
  • 增加 non-virtual 成員函式
  • 修改資料成員的名稱,因為生產的二進位制程式碼是按偏移量來訪問的,當然,這會造成原始碼級的不相容。
  • 還有很多,不一一列舉了。

歡迎補充

反面教材: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 裡使用面向介面程式設計的人,可以拿二進位制相容性考考他。

解決辦法

採用靜態連結

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

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

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

用 pimpl 技法,編譯器防火牆

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

Java 是如何應對的

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