天天看點

ABI(Application Binary Interface)知識總結

作者:南陵太守

一、What is ABI?

按照Titus Winters在提案P2028中所解釋的概念,ABI是指在一個翻譯單元中的實體(如函數、類型等)如何互動,平台相關、(編譯器)供應商相關。

原文:ABI is the platform-specific, vendor-specified, not-controlled-by-WG21 specification of how entities (functions, types) built in one translation unit interact with entities from another.

ABI本身并沒有在C++标準中出現過,這導緻C++的ABI問題比較混亂;這也是C++相關提案出現的原因——"not controlled by WG21"。事實上C标準也沒有這個概念。

翻譯單元(TU)在标準中有明确的概念;以筆者的了解,大概可以認為生成的每個object file都是一個翻譯單元。

具體地,C++的ABI可以分為兩個方面,我們也會按兩方面讨論:

  • 語言ABI/編譯器ABI。
  • 庫的ABI(尤其是标準庫的ABI)。
這是筆者之前在reddit的一個文章上看到的分類,覺得很合理,但當時居然沒有标記下來,如果有人确實需要看原帖,筆者可以找找。

自然的,因為庫本身是由語言編寫的,通常情況下語言ABI的改變都會使庫的ABI不相容。

Language ABI / Compiler ABI

C++的ABI由編譯器、作業系統和硬體的體系結構共同決定;按照道理來說C應該也是,但是由于作業系統本身具有了底層的C ABI,是以相應平台上的編譯器都會遵循這個ABI,于是C的ABI一般不由編譯器的諸多選項等決定。

當然,這不意味着不同的C編譯器産生的object file可以一起link。如果兩個編譯器産生可互相辨認的object file(即格式一緻),這應該是可行的;但反之,像MSVC和MinGW的gcc,它們編譯産生的符号表完全不一緻,是以不能連結。如下圖:

ABI(Application Binary Interface)知識總結

MSVC 19.29編譯出的目标檔案

ABI(Application Binary Interface)知識總結

MinGW gcc 8.1.0編譯出的可執行檔案

解析工具見GitHub - gitGNU/objconv。

如果使用相同的庫,clang和gcc的C編譯器應該可以産生可連結的object file。

C的ABI主要包括以下5個方面:

  • 對象布局(Object layout)
  • 資料類型的大小和對齊(Size and default alignment of data types)
  • 函數調用方式(Calling Convention)
  • 寄存器使用(Register usage convention)
  • 目标檔案的格式(這裡的格式指ELF / COFF等,不是産生的内容的格式)

但是對于C++,它的ABI還十分取決于編譯器(我想這也是為什麼Language ABI也稱作compiler ABI)。也就是說,就算兩個目标檔案在以上方面都一緻,而且符号表等也可互相識别,但他們仍可能連結出一個錯誤的可執行檔案。這通常出現在用一個更早版本的編譯器去連結更晚版本的編譯器産生的目标檔案,或者相同版本但選擇了某些改變ABI的編譯器選項的目标檔案。

具體地,C++由編譯器決定的ABI主要包括:

  • 名稱修飾/重整(Name mangling):C++具有函數重載、模闆、名稱空間等,他們在目标檔案中應該具有不同的名稱,來讓可執行檔案可以調用到唯一的函數。将函數的名稱變換為另一個唯一名稱的過程稱為名稱修飾/重整;例如,對于函數 namespace Namespace {int function(int x);} ,在GCC中會修飾為_ZN9NameSpace8functionEi,而在MSVC中會修飾為?function@NameSpace@@YAHH@Z。
  • 異常處理(Exception handling):例如在遇到異常時,棧如何展開(unwind)。
  • 調用構造/析構函數(Invoking ctor & dtor):規定了一個類的成員如何構造/析構,例如如何構造成員中的C數組。
  • class的布局和對齊,例如多繼承中成員變量的排布。
  • 虛表的布局和對齊,例如虛函數在虛表中的順序。

将修飾後的名稱轉化會原名稱的過程稱為demangle;一個demangle的網站是http://demangler.com/

編譯器決定的ABI的分類主要來自于GCC manual about compatibility.

C++的主流語言ABI應該有兩套:

  • Itanium ABI;可見https://itanium-cxx-abi.github.io/cxx-abi/abi.html
  • MSVC的ABI;根據Herb Sutter的提案N4028,提到MSVC的語言ABI不公開,但是是相對穩定的(盡管标準庫ABI經常變化)。筆者隻找到Name mangling和Exception handling兩個文檔,其他的如果有人可以找到可以在評論區留言。

特别地,Clang好像有一些選項可以盡量(但不完全)相容MSVC的ABI;見https://clang.llvm.org/docs/MSVCCompatibility.html。不知道GCC/MSVC有沒有相容其他ABI的選項?

Library ABI

由于編譯器一般都使用供應商所提供的标準庫實作,是以标準庫的ABI也事實上成為了C++ABI的一部分。具體地,如果一個動态庫在更新後,原來的可執行檔案仍然能正常地使用動态庫的函數,而不需要讓源代碼重新編譯,則稱庫的ABI保持了下去 / 二進制相容。靜态庫本身應該不需要考慮這個問題,因為靜态庫更新之後總是需要重新編譯。

  • MSVC使用的是STL(這裡不是C++98的STL之意,但微軟就起這個名字也沒什麼辦法),具體到檔案上就是msvcprtd。每個主要版本都會具有新的ABI,來盡快更新C++的新特性。根據微軟官方文檔,從VS2015(toolset v140)開始,MSVC保證後來版本的工具鍊總可以使用之前版本的ABI。
  • GCC使用的是libstdc++,根據這個庫的編寫團隊的成員所說,這個庫在5.1/7.1/8,1/9.1/11.1都發生了ABI變化。比較有名的是5.1中std::string和std::list的ABI改變了(為了适應C++11關于COW的規定),造成在新編譯器中連結之前的代碼會運作崩潰(我覺得這是很多公司維持gcc版本在4.9的重要原因,防止老的庫用不了,但似乎有些因噎廢食)。
  • Clang使用的是libc++,根據https://libcxx.llvm.org/DesignDocs/ABIVersioning.html應該是隻用2個ABI版本,可能快到3了。

這給庫程式員造成很大的麻煩,因為C++程式員幾乎不可避免使用标準庫;如果要相容所有版本,保險起見就需要每個ABI break的版本都提供新的庫。如果想跨平台,還要考慮作業系統的問題;甚至可能需要考慮編譯器選項的問題,之前筆者遇到過VS中Release模式編譯的庫在Debug模式使用會報warning。

Maintain library ABI compatibility

如果注意前面提到的幾個方面,那麼我們可以編寫出一個二進制相容的庫。也就是說,在庫更新後,一個實體根據它原來的索引方式仍然能索引到正确的實體:

  • 名稱修飾:注意不要改變函數的名稱,也不要改變const/volatile屬性,因為使用者代碼在編譯時是認為A名稱,會找不到改為B名稱的新符号。
  • 虛表:注意不要改變虛函數在類中的次序或增加基類的虛函數(但單純增加無子類的類的虛函數應該有可能保持,隻是使用者調用不到)。
  • 調用方式:例如__stdcall和__cdecl在Windows中不要混用;這是為了讓語言ABI維持統一。
  • 類的布局:例如class A { public: int a; int b;};變為class A{ public: int b; int a;}; ,由于使用者代碼實際上使用偏移量索引的,改變之後會讓使用者代碼想索引a時索引到b,想索引b時索引到a。或者增加了類的成員,使得棧的配置設定出現問題。std::string就是因為改變了成員造成了不相容。

有兩篇文章詳述了維持庫ABI時需要注意的事項,說的很到位,見

KDE ABI regulation​community.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B

20 ABI breaking changes​www.acodersjourney.com/20-abi-breaking-changes

其次注意一下标準庫的使用版本,也就考慮了标準庫的ABI。

二、ABI常識

有相當一部分程式員弄不清楚API和ABI的差别,甚至根本就沒說過ABI。這并不是ABI不重要,而是在小型開發團隊中ABI問題不容易遇到,當團隊擴大,開發程式使用的元件增多,這個問題就會變得嚴重。我們傾向于從頭說起,就當是科普了。

API和ABI

API,Application Programming Interface

ABI,Application Binary Interface

從名字上就可以看出,API是面向程式設計人員的,是面向人的;而ABI,程式設計人員是不容易直接看到的,但程式設計人員的行為可以直接影響到ABI,簡單來說,ABI是面向編譯器的。舉個簡單的例子

void print(int tag1, int tag2, int tag3)
{
    printf("%d\n", tag1 + tag2 + tag3);
}
           

這段程式提供一個API,函數原型為

void print(int, int, int)
           

對于開發人員而言,到這裡已經不需要再深入了。對于編譯器而言,卻有很多工作要考慮,例如

  • 參數計算的順序
  • 參數入棧的順序
  • 寄存器傳參如何安排

這些工作發生在二進制層面,是以就稱作ABI,意味着二進制層面的接口規範。編譯器如何處理這些問題和處理器密切相關,一般不足以引起什麼問題,更嚴重的問題發生在語言和編譯器之間,例如:

  • 異常的處理
  • STL代碼的例化
  • 虛函數的實作
  • 結構體的對齊

ABI不相容

既然知道了什麼是ABI,那麼就可以考慮ABI的相容問題了。

假設有一個程式A,依賴一個庫libA中的某個函數,假設為

void __cdecl A_calc(int s1, int s2);
           

A由程式員John維護,libA由程式員CHIV維護,某一天CHIV就突然更新了一下

void __cdecl A_calc(int s1, int s2, int s3);
           

John習慣性的更新了libA,這就出現了問題,這既是典型的ABI不相容問題。有人可能會說了,john重新編譯一下不就能發現問題嗎?顯然,是的。如果随随便便更新某個lib,john就得重新編譯程式,那麼請問把軟體分子產品的意義是不是打了個大折扣。一個很大的程式A,居然因為一個小小的元件libA,就大動幹戈重新編譯一下,這不就是因小失大嗎?

當使用的開發語言成為C++之後,ABI的問題就更嚴重了。主要展現在STL和虛函數上,試想一下,你的開發夥伴給你的頭檔案裡一大堆的STD,你是不是有點發瘋,你們的編譯器都不一樣好嗎?過來一大堆C++11的代碼,你連編譯過去都不可能,VS2013支援的C++11連繼承構造也沒實作你信不?

既然如此,那大家都是用支援C++11的編譯器行嗎?還是不行,因為C++11沒有規定ABI,編譯器如何實作誰也不知道?于是你接口裡的什麼容器之類的就隻能等着崩潰,VC自家的編譯器都不能縱向相容,不同廠家的還能指望嗎?

是不是沒有辦法了?也不是,你隻要給你的合作夥伴提供源代碼就行,讓他直接從源代碼編譯,無限期增加他的Build工作量,讓他996,然後某一天他上班的時候也許會帶把槍(這個梗能get到嗎)什麼的?

說完了STL,然後再說一下虛函數。

class U2B {
public:
    U2B() {}
    
    void Start();
    virtual void Stop();
    virtual void OnCompleted();
    void Shutdown();
};
           

這是CHIV給你的接口,看着是不是很感人?但是沒過多久,CHIV就增加了一個方法

class U2B {
public:
    U2B() {}
    virtual void Log();
    void Start();
    virtual void Stop();
    virtual void OnCompleted();
    void Shutdown();
};
           

就當所有編譯器都一樣吧,把所有的虛函數安排在類執行個體的起始位置,那裡就放了一張虛函數表,表中的函數是個什麼順序?當然是虛函數出現的順序。更新後,很明顯表中的第一項變了,那麼對Stop的調用,就指向了Log,這不就錯了嗎?關鍵是這個函數也能正常執行,于是John根本就找不到問題。更為要命的是,CHIV提供的這個lib是以元件形式管理的,你就算重新編譯一下也找不到問題,john的媽媽每天都得喊他回去吃飯。這個ABI不相容不崩潰、不報錯,程式就是執行不正常。或者這個功能幹脆就不常用,客戶偶爾碰到一次,幹脆一票否決了。

ABI相容的措施

首先說結論,沒有根本的辦法防止ABI不相容的問題。隻能通過規範管理、增強安全程式設計的意識、提高對與程式的運作原理的認識才能降低這種機率,從程式設計技巧上,前輩們已經總結出了一些,這一節主要就介紹Pimpl方法。

在開發中不使用STL或者不使用虛函數确實能避免不少ABI問題,如果這樣的話,還是用C++幹啥。是以前輩們就想出了Pimpl方法,它的中心思想就是,給合作夥伴的接口裡面沒有STL,也沒有虛函數,所有能引起ABI相容問題的特征都被封死在庫的本身範圍之内。這需要用到C++的前置聲明,還是舉例說明。

class U2BImpl;
class U2B {
public:
    U2B() {}
    void Start();
    void Stop();
    void OnCompleted();
    void Shutdown();
private:
    U2BImpl* mImpl;
};
           

這就是給合作夥伴的接口,U2BImpl是真正的實作,U2B是這個實作的封裝,U2BImpl并不需要使用者直接通路,進而隔離了ABI不相容的問題。

三、ABI問題的産生

一般的軟體為了子產品分割,思路都是這樣的:

子產品寫在so/dll檔案中,使用exe加載并執行功能,更新隻用更新dll、so就可以了。不用重新編譯exe

這是一篇ABI相容的文章 https://www.jianshu.com/p/895451c7b678

ABI相容的目的就是為了保證改變了dll,so以後,不用重新編譯exe就可以直接使用。

當ABI不相容,或者ABI出錯的時候,會發生什麼呢?我們來看一個例子

先給出一個繼承圖:

vclass -> obj

//interface.h

#include<string>

class vclass{

public:

std::string name;

virtual std::string get_name()=0;

};

//lib.h,繼承自interface,重寫虛函數

#include"interface.h"

#include<string>

class obj : public vclass{

public:

std::string get_name();//獲得name

obj(){

name="子類";

}

};

extern "C" vclass* get_obj(){ //使用C格式的函數命名

return new obj;

}

//lib的實作

#include"lib.h"

std::string obj::get_name(){

return name;

}

很簡單的例子吧,就是實作了一個接口,然後傳回name的值,随後我們在main中調用dlopen,來打開這個動态連結庫:

#include<iostream>

#include<memory>

#include<dlfcn.h>

#include"interface.h"

using namespace std;

int main(){

void *handle=dlopen("./lib.so",RTLD_NOW);//加載so檔案

using func= vclass*(*)(void);

func get_obj;

get_obj=(func)dlsym(handle,"get_obj");//從so中擷取函數

vclass *obj1=(get_obj()); //擷取一個對象

cout<<obj1->get_name()<<endl; //能夠成功得到子類的對象

return 0;

}

運作的結果很顯而易見,直接會輸出動态庫中的"子類“

#makefile

lib:lib.h lib.cpp

g++ -g -fPIC -shared -o lib.so lib.cpp

main:main.cpp

g++ -g main.cpp -o main -ldl

ok,現在問題來了,現在有另外一個同僚,由于某種需求,在父類加了一個虛函數xxxx(),并且在get_name()虛函數前(這個順序非常重要,因為虛函數表):

#include<string>

class vclass{

public:

std::string name;

virtual std::string xxxx(){

return "哈哈哈哈";

}

virtual std::string get_name()=0;

};

那麼現在保持子類不動,main也不編譯,直接重新編譯 lib

#makefile

lib:lib.h lib.cpp

g++ -g -fPIC -shared -o lib.so lib.cpp

現在運作main,你就會發現,我的get_name()呢?去哪了?怎麼變成哈哈哈哈了?

這就是ABI的一個特點。

因為加入了新的一個虛函數,使得原來虛函數表的順序由:

get_name;

變成了:

xxxx

get_name

但是main.cpp 中的obj->get_name(),在彙編中隻是通路虛函數表中特定偏移量的函數,在這個例子中,通路的是虛函數表中第一個函數。 那麼我們通過修改虛函數表的大小,就會導緻函數運作出錯。是以,一旦涉及到需要動态運作so的,就要考慮到abi了。業界比較常用的,保持C++ ABI相容的方法是Q指針和D指針。

繼續閱讀