博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

二进制兼容性 ABI

Posted on 2020-01-06 10:09  bw_0927  阅读(4705)  评论(0编辑  收藏  举报

https://zhangrunnan.com/cpp-binary-compatibility/

https://community.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B

在深入了解前,首先我们要明确两个概念:二进制兼容和源码兼容。

  • 二进制兼容:在升级库文件的时候,不必重新编译使用此库的可执行文件或其他库文件,并且程序的功能不被破坏。

  • 源码兼容:在升级库文件的时候,不必修改使用此库的可执行文件或其他库文件的源代码只需重新编译应用程序,即可使程序的功能不被破坏。

 

 

主要是动态库存在二进制的兼容问题,其中又主要是c++的虚函数作为接口会产生问题

 

https://blog.csdn.net/Solstice/article/details/6244905

 

比如现在 Graphics 库发布了 1.1.0 和 1.2.0 两个版本,这两个版本可以不必是二进制兼容。

用户的代码从 1.1.0 升级到 1.2.0 的时候要重新编译一下,反正他们要用新功能都是要重新编译代码的。

如果要原地打补丁,那么 1.1.1 应该和 1.1.0 二进制兼容,而 1.2.1 应该和 1.2.0 兼容。

如果要加入新的功能,而新的功能与 1.2.0 不兼容,那么应该发布到 1.3.0 版本。

对于用户可见的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部分,在升级库的时候就不必在意。


————————————————

 

 

 

ABI和API

应用二进制接口(application binary interface,缩写为 ABI)描述了应用程序(或者其他类型)和操作系统之间或其他应用程序的低级接口。ABI涵盖了各种细节,如:数据类型的大小、布局和对齐;调用约定等。

在了解二进制兼容和源码兼容两个定义以后,我们再看与其类似且对应的两个概念:ABIAPIABI不同于API(应用程序接口),API定义了源代码和库之间的接口,因此同样的代码可以在支持这个API的任何系统中编译,然而ABI允许编译好的目标代码在使用兼容ABI的系统中无需改动就能运行。

举个例子,在Qt和Java两种跨平台程序中,API像是Qt的接口,Qt有着通用接口,源代码只需要在支持Qt的环境下编译即可。ABI更像是Jvm,只要支持Jvm的系统上,都可以运行已有的Java程序。

C++的ABI

ABI更像是一个产品的使用说明书,同理C++的ABI就是如何使用C++生成可执行程序的一张说明书。编译器会根据这个说明书,生成二进制代码。C++的ABI在不同的编译器下会略有不同。

C++ABI的部分内容举例:

  • 函数参数传递的方式,比如 x86-64 用寄存器来传函数的前 4 个整数参数
  • 虚函数的调用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 来调用
  • struct 和 class 的内存布局,通过偏移量来访问数据成员

综上所述,如果可执行程序通过以上说明书访问动态链接库A,以及此库的升级版本A+,若按此说明书上的方法,可以无痛的使用A和A+,那么我们就称库A的这次升级是二进制兼容的。

破坏二进制兼容的几种常见方式  【改变了虚函数的offset或者成员的顺序

  • 添加新的虚函数
  • 不导出或者移除一个导出类
  • 改变类的继承
  • 改变虚函数声明时的顺序(偏移量改变,导致调用失败)
  • 添加新的非静态成员变量(类的内存布局改变,偏移量也发生变化)
  • 改变非静态成员变量的声明顺序

不会破坏二进制兼容的几种常见方式

  • 添加非虚函数(包括构造函数)
  • 添加新的类
  • 添加Qt中的信号槽
  • 在已存在的枚举类型中添加一个枚举值
  • 添加新的静态成员变量
  • 修改成员变量名称(偏移量未改变)
  • 添加Q_OBJECT,Q_PROPERTYQ_ENUMS ,Q_FLAGS宏,添加这些宏都是修改了moc生成的文件,而不是类本身

只要我们知道了程序是以什么方式访问动态库的(C++的ABI),那么我们就很好判断,哪些操作会破坏二进制兼容。更多方式请参见Policies/Binary Compatibility Issues With C++

解决二进制兼容问题的相关方法

  • 使用Bitflags即位域
1
2
3
uint m1 : 1;
uint m2 : 3;
uint m3 : 1;
1
2
3
4
uint m1 : 1;
uint m2 : 3;
uint m3 : 1;
uint m4 : 2; // new member without breaking binary compatibility.

C++抽象类和Java的接口

读到这里大家也许会奇怪,作者是不是放错地方了?其实不然,只是在我们了解二进制兼容后,可以更好地理解这组概念。之前我一直认为C++的抽象类就类似于Java的接口,现在发现,如果把一个C++的抽象类作为动态库的接口发布,那将是毁灭的。因为你无法增加虚函数,无法增加成员变量,这使得这个接口变得非常的不友好。这也就是Java接口的优势所在。Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做,便不存在上述二进制兼容的问题。

理解Java二进制兼容的关键是要理解延迟绑定(Late Binding)。延迟绑定是指Java直到运行时才检查类、域、方法的名称,而不象C/C++的编译器那样在编译期间就清除了类、域、方法的名称,代之以偏移量数值——这是Java二进制兼容得以发挥作用的关键。
由于采用了延迟绑定技术, 方法、域、类的名称直到运行时才解析,意味着只要域、方法等的名称(以及类型)一样,类的主体可以任意替换。

参考

 

https://blog.csdn.net/Solstice/article/details/6233478

 

本文主要讨论 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++ 里使用面向接口编程的人,可以拿二进制兼容性考考他。

  • 解决办法
  1. 采用静态链接

这个是王道。在分布式系统这,采用静态链接也带来部署上的好处,只要把可执行文件放到机器上就行运行,不用考虑它依赖的 libraries。目前 muduo 就是采用静态链接。

通过动态库的版本管理来控制兼容性
这需要非常小心检查每次改动的二进制兼容性并做好发布计划,比如 1.0.x 系列做到二进制兼容,1.1.x 系列做到二进制兼容,而 1.0.x 和 1.1.x 二进制不兼容。《程序员的自我修养》里边讲过 .so 文件的命名与二进制兼容性相关的话题,值得一读。

  1. 用 pimpl 技法,编译器防火墙

在头文件中只暴露 non-virtual 接口,并且 class 的大小固定为 sizeof(Impl*),这样可以随意更新库文件而不影响可执行文件。当然,这么做有多了一道间接性,可能有一定的性能损失。见 Exceptional C++ 有关条款和 C++ Coding Standards 101.

  1. Java 是如何应对的

Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做。就不存在“不能增加虚函数”,“不能修改 data member” 等问题。在 Java 里边用面向 interface 编程远比 C++ 更通用和自然,也没有上面提到的“僵硬的接口”问题。

(待续)

https://blog.csdn.net/Solstice/article/details/6244905

虚函数作为接口在二进制兼容性方面有本质困难:“一旦发布,不能修改”

假如我需要给 Graphics 增加几个绘图函数,同时保持二进制兼容性。这几个新函数的坐标以浮点数表示,我理想中的新接口是:

--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:13:30.000000000 +0800
@@ -7,11 +7,14 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
+ virtual void drawLine(double x0, double y0, double x1, double y1);
virtual void drawLine(Point p0, Point p1);

virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
virtual void drawRectangle(Point p0, Point p1);

virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
virtual void drawArc(Point p, int r);
};
受 C++ 二进制兼容性方面的限制,我们不能这么做。其本质问题在于 C++ 以 vtable[offset] 方式实现虚函数调用,而 offset 又是根据虚函数声明的位置隐式确定的,这造成了脆弱性。

我增加了 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列发生了变化,现有的二进制可执行文件无法再用旧的 offset 调用到正确的函数

怎么办呢?有一种危险且丑陋的做法:把新的虚函数放到 interface 的末尾,例如:

--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:58:22.000000000 +0800
@@ -7,11 +7,15 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);

virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);

virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
+
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
};
这么做很丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数没有和原来的 drawLine() 函数呆在一起,造成阅读上的不便。

这么做同时很危险,因为 Graphics 如果被继承,那么新增虚函数会改变派生类中的 vtable offset 变化,同样不是二进制兼容的

另外有两种似乎安全的做法,这也是 COM 采用的办法:

1. 通过链式继承来扩展现有 interface,例如从 Graphics 派生出 Graphics2

--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:58:35.000000000 +0800
@@ -7,11 +7,19 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);

virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);

virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2 : public Graphics
+{
+ using Graphics::drawLine;
+ using Graphics::drawRectangle;
+ using Graphics::drawArc;
+
+ // added in version 2
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
+};
将来如果继续增加功能,那么还会有 class Graphics3 : public Graphics2;以及 class Graphics4 : public Graphics3 等等。

这么做和前面的做法一样丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数位于派生 Graphics2 interace 中,没有和原来的 drawLine() 函数呆在一起,造成割裂

2. 通过多重继承来扩展现有 interface,例如定义一个与 Graphics class 有同样成员的 Graphics2,再让实现同时继承这两个 interfaces。

--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:16:45.000000000 +0800
@@ -7,11 +7,32 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);

virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);

virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2
+{
+ virtual void drawLine(int x0, int y0, int x1, int y1);
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawLine(Point p0, Point p1);
+
+ virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(Point p0, Point p1);
+
+ virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
+ virtual void drawArc(Point p, int r);
+};
+
+// 在实现中采用多重接口继承
+class GraphicsImpl : public Graphics, // version 1
+ public Graphics2, // version 2
+{
+ // ...
+};
这种带版本的 interface 的做法在 COM 使用者的眼中看起来是很正常的,解决了二进制兼容性的问题,客户端源代码也不受影响。

在我看来带版本的 interface 实在是很丑陋,因为每次改动都引入了新的 interface class,会造成日后客户端代码难以管理。比如,如果代码使用了 Graphics3 的功能,要不要把现有的 Graphics2 都替换掉?

如果不替换,一个程序同时依赖多个版本的 Graphics,一直背着历史包袱。依赖的 Graphics 版本愈来愈多,将来如何管理得过来?
如果要替换,为什么不相干的代码(现有的运行得好好的使用 Graphics2 的代码)也会因为别处用到了 Graphics3 而被修改?
这种二难境地纯粹是“以虚函数为库的接口”造成的。如果我们能直接原地扩充 class Graphics,就不会有这些屁事,见本文“推荐做法”一节。

 

不要误认为“接口一旦发布就不能更改”是天经地义的,那不过是“以 C++ 虚函数为接口”的固有弊端,如果跳出这个框框去思考,其实 C++ 库的接口很容易做得更好。

为什么不能改?还不是因为用了C++ 虚函数作为接口。

Java 的 interface 可以添加新函数,C 语言的库也可以添加新的全局函数,C++ class 也可以添加新 non-virtual 成员函数和 namespace 级别的 non-member 函数,这些都不需要继承出新 interface 就能扩充原有接口。

偏偏 COM 的 interface 不能原地扩充,只能通过继承来 workaround,产生一堆带版本的 interfaces。有人说 COM 是二进制兼容性的正面例子,某深不以为然。COM 确实以一种最丑陋的方式做到了“二进制兼容”。脆弱与僵硬就是以 C++ 虚函数为接口的宿命。

相反,Linux 系统调用以编译期常数方式固定下来,万年不变,轻而易举地解决了这个问题。在其他面向对象语言(Java/C#)中,我也没有见过每改动一次就给 interface 递增版本号的做法。

还是应了《The Zen of Python》中的那句话,Explicit is better than implicit, Flat is better than nested.

动态库的接口的推荐做法

取决于动态库的使用范围,有两类做法。

  1. 比如现在 Graphics 库发布了 1.1.0 和 1.2.0 两个版本,这两个版本可以不必是二进制兼容。

用户的代码从 1.1.0 升级到 1.2.0 的时候要重新编译一下,反正他们要用新功能都是要重新编译代码的。

如果要原地打补丁,那么 1.1.1 应该和 1.1.0 二进制兼容,而 1.2.1 应该和 1.2.0 兼容。

如果要加入新的功能,而新的功能与 1.2.0 不兼容,那么应该发布到 1.3.0 版本。

对于用户可见的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部分,在升级库的时候就不必在意。

 

  1. 如果库的使用范围很广,用户很多,各家的 release cycle 不尽相同,那么推荐 pimpl 技法[2, item 43],       //还是用类方法的方式来对外暴露接口

并考虑多采用 non-member non-friend function in namespace [1, item 23] [2, item 44 abd 57] 作为接口。                       //或者直接采用c语言的方式,全局函数来暴露接口

这里以前面的 Graphics 为例,说明 pimpl 的基本手法。

1. 暴露的接口里边不要有虚函数,而且 sizeof(Graphics) == sizeof(Graphics::Impl*)。

class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor

void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);

void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);

void drawArc(int x, int y, int r);
void drawArc(Point p, int r);

private:
class Impl;
boost::scoped_ptr<Impl> impl;
};
2. 在库的实现中把调用转发 (forward) 给实现 Graphics::Impl ,这部分代码位于 .so/.dll 中,随库的升级一起变化。

#include <graphics.h>

class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);

void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);

void drawArc(int x, int y, int r);
void drawArc(Point p, int r);
};

Graphics::Graphics()
: impl(new Impl)
{
}

Graphics::~Graphics()
{
}

void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}

void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}

// ...
3. 如果要加入新的功能,不必通过继承来扩展,可以原地修改,且保持二进制兼容性。先动头文件:

--- old/graphics.h 2011-03-12 15:34:06.000000000 +0800
+++ new/graphics.h 2011-03-12 15:14:12.000000000 +0800
@@ -7,19 +7,22 @@
class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor

void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);

void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);

void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);

private:
class Impl;
boost::scoped_ptr<Impl> impl;
};
然后在实现文件里增加 forward,这么做不会破坏二进制兼容性,因为增加 non-virtual 函数不影响现有的可执行文件。

--- old/graphics.cc 2011-03-12 15:15:20.000000000 +0800
+++ new/graphics.cc 2011-03-12 15:15:26.000000000 +0800
@@ -1,35 +1,43 @@
#include <graphics.h>

class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);

void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);

void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);
};

Graphics::Graphics()
: impl(new Impl)
{
}

Graphics::~Graphics()
{
}

void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}

+void Graphics::drawLine(double x0, double y0, double x1, double y1)
+{
+ impl->drawLine(x0, y0, x1, y1);
+}
+
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}
采用 pimpl 多了一道 forward 的手续,带来的好处是可扩展性与二进制兼容性,通常是划算的。pimpl 扮演了编译器防火墙的作用。

pimpl 不仅 C++ 语言可以用,C 语言的库同样可以用,一样带来二进制兼容性的好处,比如 libevent2 里边的 struct event_base 是个 opaque pointer,客户端看不到其成员,都是通过 libevent 的函数和它打交道,这样库的版本升级比较容易做到二进制兼容。

 

为什么 non-virtual 函数比 virtual 函数更健壮?因为 virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加载器 (loader) 会在程序启动时做决议(resolution),通过 mangled name 把可执行文件和动态库链接到一起。就像使用 Internet 域名比使用 IP 地址更能适应变化一样。

 

万一要跨语言怎么办?很简单,暴露 C 语言的接口。Java 有 JNI 可以调用 C 语言的代码,Python/Perl/Ruby 等等的解释器都是 C 语言编写的,使用 C 函数也不在话下。C 函数是 Linux 下的万能接口,C 语言是最伟大的系统编程语言。

 

本文只谈了使用 class 为接口,其实用 free function 有时候更好(比如 muduo/base/Timestamp.h 除了定义 class Timestamp,还定义了 muduo::timeDifference() 等 free function),这也是 C++ 比 Java 等纯面向对象语言优越的地方。留给将来再细谈吧。

参考文献
[1] Scott Meyers, 《Effective C++》 第 3 版,条款 35:考虑 virtual 函数以外的其他选择;条款 23:宁以 non-member、non-friend 替换 member 函数。

[2] Herb Sutter and Andrei Alexandrescu, 《C++ 编程规范》,条款 39:考虑将 virtual 函数做成 non-public,将 public 函数做成 non-virtual;条款 43:明智地使用 pimpl;条款 44:尽可能编写 nonmember, nonfriend 函数;条款 57:将 class 和其非成员函数接口放入同一个 namespace。

[3] 孟岩,《function/bind的救赎(上)》,《回复几个问题》中的“四个半抽象”。

[4] 陈硕,《以 boost::function 和 boost:bind 取代虚函数》,《朴实的 C++ 设计》。

 

================

https://blog.csdn.net/enlyhua/article/details/84561222

 

接口这个词有着很广泛的含义,这里讨论的是二进制接口,即 ABI。共享库的 ABI 跟程序语言有着很大的关系,不同的语言对
接口的兼容性要求不同。ABI 对于不同的语言来说,主要包括一些函数调用的堆栈结构,符号命名,参数规则,数据结构的内存分布等方面的规则。
导致 C 语言的共享库 ABI 改变的行为主要有4个:
1.导出函数的行为发生改变,也就是说调用这个函数以后产生的结果与以前不一样,不再满足旧版本规定的函数行为准则
2.导出函数被删除
3.导出数据的结构发生变化,比如共享库定义的结构体变量的结构发生变化。
4.导出函数的接口发生变化,如函数返回值,参数被修改
很多因素会导致 ABI 的不兼容,比如不同版本的编译器,操作系统和硬件平台等。使用不同版本的编译器或系统库可能会导致结构体
成员对齐方式不一样,从而导致了 ABI 的变化。这种 ABI 不兼容导致的问题可能非常微妙,表面上看可能无关紧要,但是一旦故障,相关的 bug 非常难以定位,这也是共享库的一个问题。

 

 

 

 

 

 

 

 

 

 

  • 5.环境变量
 

LD_LIBRARY_PATH

Linux 系统提供了很多方法来改变动态链接器装载共享库路径的方法,通过使用这些方法,我们可以满足一些特殊的需求,比如
共享库的调试和测试,应用级别的虚拟等。改变共享库查找路径最简单的方法是使用 LD_LIBRARY_PATH 环境变量,这个方法可以
临时改变某个应用程序的共享库查找路径,而不会影响到系统中其他的程序。
在 Linux 中,LD_LIBRARY_PATH 是一个由若干路径组成的环境变量,每个路径之间由冒号隔开。默认情况下, LD_LIBRARY_PATH
为空。如果我们为某个进程设置了 LD_LIBRARY_PATH,那么进程在启动时,动态链接器在查找共享库时,会首先查找由 LD_LIBRARY_PATH
指定的目录。这个环境变量可以很方便的让我们测试新的共享库或者使用非标准的共享库。比如我们希望使用修改过的 lbc.so.6,可以将这个
新版的 libc 放到我们的目录 /home/user 中,然后指定 LD_LIBRARY_PATH:
LD_LIBRARY_PATH=/home/user /bin/ls
Linux中还有一种方法可以实现与 LD_LIBRARY_PATH 类似的功能,那就是直接运行动态链接器来启动程序,比如:
/lib/ld-linux.so.2 -library-path /home/user /bin/ls
可以达到一样的效果。有了LD_LIBRARY_PATH之后,在来总结动态链接器的查找顺序。动态链接器会按照下列程序依次装载或查找共享库对象:
1.又环境变量 LD_LIBRARY_PATH 指定的路径
2.由路径缓存文件 /etc/ld.so.cache 指定的路径
3.默认共享库目录,先 /usr/lib,然后 /lib。
LD_PRELOAD :
系统中另外还有一个环境变量叫做 LD_PRELOAD,这个文件中我们可以指定预先装载的一些共享库或者目标文件。在 LD_PRELOAD 里面指定的
文件会在动态链接器按照固定的规则搜索共享库之前装载,它比 LD_LIBRARY_PATH 里面指定的路面的共享库还有优先。无论是否依赖它们,LD_PRELOAD
里面指定的共享库或者目标文件都会被装载。
由于全局符号介入这个机制的存在,LD_PRELOAD 里面指定的共享库或者目标文件的全局符号就会覆盖后面加载的同名全局符号,这使得我们可以很方便的做到
改写标准C库中的某个或者某个函数而不影响其他函数,对程序的调试或测试非常有用。与 LD_LIBRARY_PATH 一样,正常情况下应该避免使用 LD_PRELOAD。
/etc/ld.so.preload 与 /etc/ld.so.cache 作用一样
LD_DEBUG:
这个变量可以打开动态链接器的调试功能,当我们设置这个变量时,动态链接器会在运行时打印出各种有用的信息。
LD_DEBUG=help ./a.out
Valid options for the LD_DEBUG environment variable are:
libs display library search paths // 显示共享库的查找过程
reloc display relocation processing // 显示重定位过程
files display progress for input file
symbols display symbol table processing // 显示符号表查找过程
bindings display information about symbol binding // 显示动态链接器的符号绑定过程
versions display version dependencies // 显示符号的版本依赖关系
scopes display scope information
all all previous options combined // 显示以上所有信息
statistics display relocation statistics // 显示动态链接过程中的各种统计信息
unused determined unused DSOs
help display this help message and exi
 
 

6.1 共享库的创建

创建共享库的过程跟创建一般共享对象的过程基本一致,最关键的是使用 gcc 的2个参数,即 '-shared' 和 '-fPIC'。'-shared'
表示输出结果是共享库类型的;'-fPIC' 表示使用地址无关代码技术来产生输出文件。还有另外一个参数是 '-Wl' 参数,这个参数可以指定
参数传递给链接器,比如我们使用 '-Wl,-soname,my_soname'时,gcc 会将 '-soname my_soname' 传递给链接器,用来指定输出共享库
的 so-name,所以我们可以使用如下命令来生成一个共享库:
gcc -shared -W1,-soname,my_soname -o library_name source_file library_files
注意:如果我们不适用 -soname 来指定共享库的 so-name,那么该共享库默认就没有 so-name,即使用 ldconfig 更新 so-name 的软连接
时,对该共享库也没有效果。
不要把输出共享库中的符号和调试信息去掉,也不要使用 gcc 的 '-fomit-frame-pointer'选项,这样做虽然不会导致共享库停止运行,但会
影响共享库的调试。
在开发过程中,你可能需要测试新的共享库,但是你又不希望影响现有的程序正常运行,我们前面提到的 LD_LIBRARY_PATH 是个很好的方法,用
它指定共享库的查找路径,还有一种方法是使用链接器的 '-rpath' 选项(或者使用 gcc 的 -Wl,-rpath),这种方法可以指定连接产生的目标程序
的共享库查找路径。
ld -rpath /home/mylib -o program.out program.o -lsomelib
这样产生的输出可执行文件 program.out 在被动态链接器装载时,动态链接器会首先在 '/home/mylib' 查找共享库。
6.2 清除符号信息
我们可以使用一个叫做 'strip' 的工具清除掉共享库或可执行文件的所有符号和调试信息
strip libfoo.so
6.3 共享库的安装
创建共享库以后我们必须将它按照在系统中,以便于各种程序都可以共享它。最简单的办法就是将共享库复制到某个标准的共享库目录,比 /lib,/usr/lib等,
然后运行 ldconfig 即可。
不过上述方法往往需要 root 权限。
6.4 共享库的构成和析构函数
gcc 提供了一种共享库的构造函数,只要在函数声明时加上 '__attribute__((constructor))' 的属性,即指定该函数为共享库的构造函数,拥有这种属性
的函数会在共享库加载时被执行,即在程序的 main 函数之前运行。如果我们使用 dlopen() 打开共享库,共享库构造函数会在 dlopen() 返回之前被执行
与共享库构造函数相对应的析构函数,我们可以使用在函数声明之前加上 '__attribute__((destructor))' 的属性,这种函数会在 main() 函数执行完毕之后
执行(或者是程序调用 exit()时执行)。如果共享库是运行时加载的,那么我们使用 dlclose() 卸载共享库时,析构函数会在 dlclose()返回之前执行。声明构造和
析构函数的格式如下:
void __attribute__((constructor)) init_function(void);
void __attribute__((destructor)) fini_function(void);
指的注意的是,如果我们使用了这种析构或构造函数,那么必须使用系统默认的标准运行库和启动文件,即不可以使用 gcc 的 'nostartfiles' 或 '-nostdlib' 这
2个参数。因为这些构造和析构函数是在系统默认的标准运行库或启动文件被运行的,如果没有这些辅助结构,它们可能不会被运行。
另外还有一个问题,如果我们又多个构造函数,那么默认情况下,它们被执行的顺序是没有规定的。如果我们希望构造和曦哥函数能够按照一定的顺序执行,gcc 为我们提供了
一个参数叫做优先级,我们可以指定某个析构或者构造函数的优先级:
void __attribute__((constructor(5))) init_function1(void);
void __attribute__((constructor(10))) init_function2(void);
对于构造函数来说,属性中优先级数字越小的函数将会在优先级大的函数之前运行;而对于析构函数来讲,刚好相反。这种安排有利于对构造函数和析构函数能够匹配,比如某一
对构造函数和析构函数分来用来申请和释放讴歌资源,那么它们可以拥有一样的优先级。这样做的结果往往是先申请的资源后释放,符合资源释放的一般原则