【无标题】

6 篇文章 0 订阅

作者:Aman
链接:https://www.zhihu.com/question/381069847/answer/1094118331
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
 

当一个库作者以二进制形式发布他的库的时候,需要对这个问题有所了解。如果只是开发应用程序,或者以源码形式发布一个库,则大体上这个问题是透明的。

当我们的应用程序引用了一个以二进制形式发布的库时,在源代码层面,我们使用了这个库的 API;而在编译、链接之后,在运行时,我们的应用程序通过 ABI 在与这个库通信。从这个角度看来,ABI 只是 API 的底层实现,所以多数时候我们才不需要去关心这个问题。

那么当人们提到 ABI 的时候,到底在说什么?以我个人的经验来看,当人们提及 ABI 时,一般主要是在说 Binary-compatible 即二进制兼容性

什么是二进制兼容性呢?这个问题牵扯的比较广。举例来说,假设你的应用程序引用的一个库某天更新了,虽然 API 和调用方式基本没变,但你需要重新编译你的应用程序才能使用这个库,那么一般说这个库是 Source compatible;反之,如果不需要重新编译应用程序就能使用新版本的库,那么说这个库跟它之前的版本是二进制兼容的。再举个例子:一个库在 VC9 上完成编译并以 DLL 形式发布,如果该库要求使用它的应用程序也必须在 VC9 上编译,那么说这个库不是二进制兼容的;反之,如果任意版本的 VC 乃至其它编译器例如 gcc、clang 都可以使用这个库,那么说这个库是二进制兼容的。

之后的问题就是如何开发具有(和保持)二进制兼容性的库,这需要注意很多方面。

首先说 Calling convention,它其实是关于函数调用的约定。C 语言调用一个函数时(API 都是函数对吧),调用者首先需要将参数压栈,接着调用函数,之后再将参数从栈里弹出,例如:

/* example of __cdecl */
push arg1
push arg2
push arg3
call function
add  sp,12        // effectively "pop; pop; pop"

这是最传统的约定。可以看出,它要求在所有调用一个函数的地方,都要插入清理栈的指令。在 Windows 平台上这种调用约定叫做 __cdecl。除了 __cdecl 之外,还有 __stdcall 和 __fastcall。__stdcall 是由函数自己清理栈,于是调用一个函数就变成:

/* example of __stdcall */
push arg1
push arg2
push arg3
call function
// no stack cleanup - callee does this

不需要在每个调用函数的地方插入清理代码。__stdcall 在 Windows 平台上是所有 API 默认使用的约定,所以如果我们要在 Windows 平台上开发具有二进制兼容的库,往往需要使用 __stdcall 约定。

接着我们说 Name mangling。Name mangling 是 C++ 引入的概念,其核心思想是把函数的名字、参数等信息(或者叫函数签名)编码成一个具有唯一性的字符串,用作链接符号;这样就能在编译期完成检查,从而避免运行时出问题。例如:

namespace wikipedia 
{
   class article {
   public:
      std::string format (void);
      ...
   }
}

format 函数经过 Name mangling 之后变成了:

_ZN9wikipedia7article6formatEv

(这里要提及一个很方便的在线工具 GCC and MSVC C++ Demangler,当你搞不清一个符号对应的函数是什么的时候,用这个工具可以 de-mangling 得到函数原来的样子)

由于不同的 C++ 编译器、甚至不同版本的 C++ 编译器的 Name mangling 算法可能有所不同,所以开发二进制兼容的库的时候,一般使用 extern "C" 来抑制 Name mangling:

#ifdef __cplusplus 
extern "C" {
#endif

// your code here

#ifdef __cplusplus
}
#endif

这样产生的符号被称作是 unmangled 的。

另外,上面使用 std::string 只是为了演示;在开发二进制兼容的库的时候,一定要避免使用 STL,因为不同的 C++ 编译器、不同版本的 C++ 编译器携带的 STL 不具备二进制兼容性,甚至同一个版本的 C++ 编译器用户也可能使用不同的 STL 替代自带的 STL。或者说,二进制兼容的接口应该只使用 int32、double 等基础数据类型,使用确定的 struct 甚至完全不使用 struct、只提供抽象的 handle,或者纯抽象接口。

有关二进制兼容性的另一个重要问题是内存管理。基本上,内存分配和释放不应该跨越 DLL Boundary。也就是说,一个模块创建的对象应该由它自己摧毁。这是因为我们无法确定不同的模块内部使用的内存分配器是否相同 —— 实际上它们 99% 是不同的。这就要求我们在设计 API 时,要特别注意对象生命期和所有权问题。

那么,C++ 的面向对象编程思想在 ABI 问题下是否能够得以保存?幸运的是,在实践中,答案是肯定的。

一些平台的 C++ 编译器产生的二进制是具有兼容性的,否则就失去了复用性。就 C++ 编译器众多的 Windows 平台来说,这些编译器为了能够兼容 COM 接口,通常会在 vtable 上遵循其规范(如果你不清楚什么是 vtable,推荐阅读《深入探索 C++ 对象模型》)。所以,只要注意到有关问题,完全可以提供具有二进制兼容接口的库。举例来说:

#ifdef WIN32
  #define CALL __stdcall
#else
  #define CALL
#endif

class Window {
public:
  virtual void CALL destroy() = 0;
  virtual void CALL setTitle(const char* title) = 0;
  virtual const char* CALL getTitle() = 0;
};

extern "C" Window* CALL CreateWindow(const char* title);

值得注意的几点:1)在 Windows 平台上使用 __stdcall;2)使用 extern "C" 来产生 unmangled 的基础函数(该函数将被放到 DLL 导出表里);3)只使用基础数据类型(不使用 std::string 等);4)对象的创建和摧毁都由库自己完成(不能直接 delete 对象,内存分配释放不跨越 DLL 边界;使用 destroy 函数摧毁对象;或者像 COM 一样使用 RC 管理对象生命);5)使用纯抽象接口(vtable 兼容性);6)不重载函数(相同名称的函数 vtable 里的排序可能有所不同)。

很多时候,由于版本的更新,对接口的修改是不可避免。如果 API 已经改变,则 ABI 是 100% 无法保持兼容性的。这个问题有时候可以使用对接口的版本管理来解决。例如:

Interface* queryInterface(int version);

提供一套 queryInterface 接口,允许以版本号为参数获取对应版本的接口。对于库作者来说,这意味着,在新版本中提供版本号为 2 的接口(当然,还有实现)的同时,版本号为 1 的接口仍然要保留,以提供向后兼容性。如果可能,版本号为 1 的接口在库的内部使用某种 adapter 转接到新的实现。这样一来,即便库更新了,使用旧接口的应用程序仍然能够正常工作,而新的应用程序又可以采用新的接口。


参考资料:

Using Win32 calling conventions

https://en.wikipedia.org/wiki/Name_mangling

Binary-compatible C++ Interfaces

Exporting C++ classes from a DLL

你们说的ABI,Application Binary Interface到底是什么东西? - Aman的回答 - 知乎 https://www.zhihu.com/question/381069847/answer/1094118331

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值