KDE設區--C++的二進位制相容問題

bobbypollo發表於2018-04-10

定義
一個動態庫的二進位制相容性指的是,一個依賴該動態庫的可執行程式,在不重新編譯的情況下,直接替換上該動態庫的更新版本也能正確執行。

譯者解析:C++動態庫的二進位制相容性,從本質上來看,分為如下兩個層次:
1,對外可見的動態符號(函式簽名/符號修飾)的一致性。這個相對容易理解。對於函式簽名不匹配的問題,C++上要比C更容易排查,因為C++中有符號修飾,一般出問題後會直接提示找不到某符號。
2,物件記憶體佈局的一致性。這個最容易被忽視,也比較難排查,因為會導致各種莫名其妙的當機。

關於ABI
本文適用於編譯KDE使用的編譯器所遵循的大多數C++ ABI。主要基於Itanium C++ ABI Draft,該標準被用於所有平臺下的GCC C++編譯器3.4及以上版本。關於微軟 Visual C++的名稱修飾部分的資訊,主要源自 this article on calling conventions (這是目前能找到的關於MSVC ABI名稱修飾的最全面文章)。
本文提及的有些要求,在部分編譯器上不一定適用。本文的目標是為跨平臺的C++程式碼,提出僅可能全面的約束條件,以使得這些程式碼能相容不同的編譯器。

能做的和不能做的
能做的
1,新增新的非虛擬函式,包括建構函式。
2,向類中新增新的列舉型別。
3,向現有列舉型別中新增新的列舉值。
例外:如果這導致編譯器要選擇一個更大的儲存型別來儲存這個列舉,那麼會產生二進位制不相容。由於編譯器有自己的潛在方法來選擇儲存大小,所以一種普遍的做法是,在列舉定義的末尾設定一個足夠大的MAX值,以預留好足夠的擴充套件空間。
4,重新實現第一基類(多重繼承時的第一個基類)中的虛擬函式,前提是連結該動態庫的老版本的程式,能夠安全的訪問基類(而非派生類)中的該函式實現(危險,不建議)。
5,重新實現行內函數,或將行內函數非內聯化。(危險,不建議)
6,刪除一個沒有被任何行內函數呼叫過的私有非虛擬函式。
7,刪除一個沒有被任何行內函數呼叫過的私有靜態成員變數。
8,新增新的靜態成員變數。
9,改變一個函式的預設引數值(但強烈建議重新編譯程式以使得新預設值生效)。
10,新增一個新的類。
11,匯出一個以前沒有被匯出的類。
12,新增或刪除友元類的宣告。
13,重新命名預留成員的型別。
14,擴充套件位域的預留位(不得導致總長度的變化)。
15,如果一個類繼承自QObject,向其新增Q_OBJECT的巨集
不能做的
1,對於已存在的類:
  • 刪除一個已匯出類或不再匯出已匯出類。
  • 改變類的繼承關係(繼承樹)。
2,對於模板類:
  • 修改模板引數。(譯者點評:修改模板引數會導致生成的模板類或模板函式發生變化)
3,對於任何型別的現有函式:
  • 隱藏它(unexport)
  • 刪除它
  • 將其內聯化(這包括將成員函式體移動到類定義中,不管有沒有inline關鍵字,會導致該函式從符號表中消失)
  • 過載一個從沒被過載的函式(BC but bot SC,makes &func ambiguous,導致 “&func“ 語句產生歧義);然而過載一個已經被過載過的函式則沒有問題,因為這種情況下,任何類似 &func 的語句都會要求進行強制型別轉換(cast)。(譯者:這裡SC我猜應該是“semantic compatible“,語義相容)
  • 改變函式的簽名。這包括:修改引數型別,包括const/volatile修飾;改變函式的const/volatile修飾;改變函式的訪問許可權,例如從private改成public(因為有些編譯器會將此作為簽名的一部分);改變成員函式的CV修飾;用追加引數的方式來擴充套件函式,即使該引數有預設值;改變返回值型別;
  • 例外:用extern "C" 來修飾的非成員函式,改變其引數型別(需小心)。
4,對於虛成員函式:
  • 向一個沒有任何虛擬函式或虛基類的類中新增一個虛擬函式(這會導致本來沒有虛表的類產生一個虛表,其物件的記憶體佈局發生變化)。
  • 向一個非葉子(non-leaf)類中新增虛擬函式(譯者解析:會導致應用中該類的虛表和新動態庫中該類的虛表的大小不匹配,在x86_64上測試,程式執行時loader會報警告,但還能執行成功)。
  • 如果該動態庫要考慮在windows系統中的二進位制相容,則甚至不能在葉子(leaf)類中新增虛擬函式,因為這會導致虛表內現有函式的排列順序發生變化。
  • 改變虛擬函式之間的宣告順序。(譯者解析:會導致應用呼叫介面錯亂,crash)
  • 當子類是多重繼承時,子類中重寫(override)不在第一基類(primary base class)(第一個非虛基類)中定義的虛擬函式,會導致二進位制不相容。(因為此時會將子類的該函式放入第1個虛表中,並且會破壞子類的對應非第1基類的虛表的原內容!詳見我另一篇文章分析:)
  • override an existing virtual function if the overriding function has a covariant return type for which the more-derived type has a pointer address different from the less-derived one (usually happens when, between the less-derived and the more-derived ones, there's multiple inheritance or virtual inheritance).
  • 刪除一個虛擬函式,即使該虛擬函式是對基類虛擬函式的重新實現。(譯者:場景?當該虛擬函式是重寫的基類的話,PC測試沒問題呢)
5,對於靜態非私有成員(static non-private members)或者 非靜態非類內成員的公共資料(non-static non-member public data)
  • 刪除或不再匯出(unexport)
  • 改變其型別
  • 改變其CV修飾符(const/volatile)
6,對於非靜態成員(non-static members)
  • 向現有類中新增新的成員變數
  • 改變非靜態成員變數之間的宣告順序
  • 改變成員變數的型別(無符號除外)
  • 刪除已有的非靜態成員變數

如果你向將某個類匯出,那麼你應該遵循如下規則:
  • 新增“d指標“,詳見下文。
  • 新增非內聯的虛解構函式,即便函式體為空實現(譯者:目的應該是提前在虛表內佔位,並保證delete 指向子類物件的基類指標時,不產生記憶體洩漏)。
  • 在QObject的派生類中重新實現event,即使函式體內僅僅是呼叫基類的event函式。
  • 讓所有建構函式非內聯化。
  • 以非內聯的形式來實現拷貝建構函式(copy constructor)和賦值運算子,除非該類不支援值拷貝(copy by value)。

動態庫開發的相關技術
在開發動態庫時,最大的難題就是,對類成員的修改是不能確保安全的,因為這可能導致類物件的記憶體佈局發生變化,甚至會影響到子類。
位域標誌(bitflags)
使用位域標誌的方法,一定程度上可以保證二進位制相容。位域標誌可以用來儲存列舉或布林型別,你可以在保證二進位制相容的情況下,將位域變數擴充套件到下一個位元組減一的位置。例如,
擴充套件前:
uint m1 : 1;uint m2 : 3;uint m3 : 1;
擴充套件後:
uint m1 : 1;uint m2 : 3;uint m3 : 1;uint m4 : 2; // new member

需要注意的是,對於一個位元組的空間來說,請儘量只使用前7 bits(或者如果它原來就超過了8 bits,請僅使用前15bits)。因為對於一些編譯器來說,使用最後一個bit可能會引起問題。

D指標
位域標誌和預留定義變數的方法,雖然用起來很簡便,但卻是遠遠不能滿足需求的。所以D指標登場了。D指標源於Trolltech's Arnt Gulbrandsen,是他最初把這項技術引入了QT,使得QT成為最先滿足二進位制相容的C++ GUI庫之一,即便是重大的版本釋出也不例外。這項技術在KDE庫中很快就成為一個通用的程式設計模式。這是一個非常巧妙的能在確保二進位制相容的情況下向現有類中新增私有成員資料的方法。
備註:截止目前,D指標模式也多次被用一些其它命名方式來描述,如pimpl、handle/body或者cheshire cat
舉例,假設你要定義一個對外介面類 class Foo,
常規的做法是,在一個標頭檔案中定義如下:
class Foo
{
public:
Foo();
virtual ~Foo();
virtual void func(void);
private:
int m1;
int m2;
QString s;
};
這種定義方式,按照上文中我們的分析,若要保證二進位制相容,將來就無法再擴充套件成員變數。
下面是D指標的方式:
你應先宣告一個“私有“類 class FooPrivate,然後在Foo的私有成員中新增一個FooPrivate型別的指標,如下:
class FooPrivate;
class Foo
{
public:
Foo();
virtual ~Foo();
virtual void func(void);
private:
FooPrivate* d;
};
然後,要在一個“cpp“檔案內部來定義class FooPrivate,將class Foo中的介面所有使用到的成員變數都放到FooPrivate中,如下所示,
class FooPrivate
{
public:
FooPrivate():m1(0),m2(0){}
private:
int m1;
int m2;
QString s;
};
然後要在Foo.cpp中實現Foo的構造解構函式(不得在對外標頭檔案中new FooPrivate):
Foo::Foo()
{
d = new FooPrivate;
}
Foo::~Foo()
{
delete d;
}

由於d指標指向的物件,對於Foo的外部使用者來說是不可見的,如果讓從外部訪問FooPrivate中的成員變數,那麼在Foo中新增訪問介面就好了:
QString Foo::string() const{ return d->s;}void Foo::setString( const QString& s ){ d->s = s;}

還有一個需要注意的是,class FooPrivate對於動態庫的使用者來說,應該是不可見的,但預設情況下,FooPrivate會繼承Foo的可見性,導致FooPrivate也會被放到動態庫的動態符號表中,這在一些情況下,可能會引起全域性符號介入問題,因此,最好將FooPrivate類隱藏起來,方法如下:
#define _SYM_HIDDEN_ __attribute__ ((visibility("hidden")))
class _SYM_HIDDEN_ FooPrivate
{
......
};
問題解答
向沒有D指標的類中新增新成員變數
假設class Foo中沒有bitflags、reserved members或者d-pointer,而你又不得不向該類中新增私有成員資料,那怎麼辦?現在提供一個比較通用的方法。QT中有一個基於指標的字典類叫做QHash。
對class Foo修改如下。
  • 建立一個私有類 FooPrivate
  • 建立一個靜態雜湊表 static QHash<Foo *, FooPrivate *>.
  • 需要注意的是,對於大多數編譯器/連結器來說,無法在動態庫中建立靜態物件,因為它們“忘了“呼叫相關建構函式了。因此你需要使用Q_GLOBAL_STATIC這個巨集來建立和訪問該靜態物件:

// BCI: Add a real d-pointertypedef QHash<Foo *, FooPrivate *> FooPrivateHash;Q_GLOBAL_STATIC(FooPrivateHash, d_func)static FooPrivate *d(const Foo *foo){ FooPrivate *ret = d_func()->value(foo); 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); delete ret; d_func()->remove(foo);}
  • 現在你就可以在Foo類中瀟灑自如的使用D指標了,如下,
d(this)->m1 = 5;
  • 在Foo的解構函式中新增如下一行:
delete_d(this);
  • 不要忘了新增一個BCI備註(Binary Compatible I?),以便在下一個動態庫版本中刪除這種臨時方法。
  • 不要忘了在以後的新類中新增D指標!

譯者解析:這裡的例子是基於QT的,對於普通的C++類來說,我們可以改用容器hash_map(甚至map)來實現。

新增一個重新實現的虛擬函式
As already explained, you can safely reimplement a virtual function defined in one of the base classes only if it is safe that the programs linked with the prior version call the implementation in the base class rather than the derived one. This is because the compiler sometimes calls virtual functions directly if it can determine which one to call.
之前已經解釋過了,如果連結老版本動態庫的程式,能夠安全的訪問基類(而非派生類)中某個虛擬函式,你才可以在派生類中重寫這個虛擬函式。(如何理解?)這是因為有時候,如果編譯器能判斷出該調哪個虛擬函式的話,它會直接調直接呼叫這個虛擬函式(而不是通過虛表呼叫),舉個例子,
void C::foo(){ B::foo();}

這時B::foo()就是被直接呼叫的。如果class B是繼承自class A,並且A中有foo()的實現而B中沒有重寫foo(),那麼C::foo()實際上就會直接呼叫A::foo(). 假設在新版本的動態庫中新增了B::foo()的實現,那麼如果不重新編譯C類,那C::foo()仍然是調不到B::foo()的。

另一個更常見的例子:
B b; // B derives from Ab.foo();
這時對foo的呼叫並不是通過虛表實現的。這也就意味著,如果老版本庫中沒有B::foo()的實現但新版本庫中有了,那麼按老版本庫編譯的程式,換上新版本庫後調到的實際上仍然是A::foo(),除非重新編譯程式。
在僅替換動態庫但不重新編譯使用者程式的情況下,如果你無法保證程式能否按預期執行,那麼你需要將A::foo()中的功能程式碼移動到一個新的proected函式A::foo2()中,並將原A::foo()修改成如下:

void A::foo(){ if( B* b = dynamic_cast< B* >( this )) b->B::foo(); // B:: is important else foo2();}void B::foo(){ // added functionality A::foo2(); // call base function with real functionality}
這樣一來,所有B*型別的物件指標,呼叫A::foo()時就會轉呼叫至B::foo()。唯一無法滿足期望的情況是,程式中就是明確的想呼叫A::foo(),但這裡B::foo()中呼叫了A::foo2(),其它地方應該是沒有這麼做的了。

使用一個新類
擴充套件一個類時,相對簡單的方法就是寫一個新類,新類裡實現新的功能(出於程式碼複用的考慮,新類當然要繼承老類)。這當然需要重新修改和編譯應用程式,否則無法將新的功能擴充套件到依賴老庫的應用程式上。然而,對於那些體量比較小或者效能要求非常高的類來說,擴充套件新類並且修改和重新編譯應用程式,反倒是一個更簡單的方法。

向葉子類中新增虛擬函式
This technique is one of cases of using a new class that can help if there's a need to add new virtual functions to a class that should stay binary compatible and there is no class inheriting from it that should also stay binary compatible (i.e. all classes inheriting from it are in applications). In such case it's possible to add a new class inheriting from the original one that will add them. Applications using the new functionality will of course have to be modified to use the new class.
該項技術適用的條件:
  • 需要向 class A 中新增一個虛擬函式。
  • 要保證class A的二進位制相容。
  • 沒有A的派生類需要保持二進位制相容(或者說,所有A的派生類都在應用程式中)。
在這條件下,可以新建一個A的派生類 class B,然後將新的虛擬函式放到B中實現。如果應用程式需要用到這個新的虛擬函式,那麼應用程式需要重新修改和編譯。

class A {public: virtual void foo();};class B : public A { // newly added classpublic: virtual void bar(); // newly added virtual function};void A::foo(){ // here it's needed to call a new virtual function if( B* this2 = dynamic_cast< B* >( this )) this2->bar();}
It is not possible to use this technique when there are other inherited classes that should also stay binary compatible because they'd have to inherit from the new class.
如果class A的派生類中有需要保持二進位制相容的,那就不能用此技術,因為這種派生類只能重新繼承這個新類 class B。

譯者解析:
假設應用中之前這麼呼叫:
A* a = new A();
a->foo();
如果應用要呼叫新函式,則需要改成:
A* a = new B();
a->foo();

用訊號取代虛擬函式
QT相關的內容,暫不翻譯了。

不相容問題舉例

相關文章