二进制兼容性研究
软件中不同模块动态链接库的调用是常见现象。假设有模块A调用到了模块B,而B的代码进行了改动,这个时候B编译出来的dll文件,在A不进行重编的情况下,还能够直接被A调用而不出现异常吗?
一、几种兼容类型
二进制兼容(Binary compatibility)
程序针对其依赖模块的正确加载和运行
源代码兼容(Source compatibility)
源码所依赖的模块在编译时不发生改变
行为兼容(behaviourcompatible)和Bug兼容(bugcompatible)
- 程序表现与其他模块相同
- 上面情况包含Bug表现的扩展
后两种兼容情况这里先不讨论,那么二进制兼容与源代码兼容具体来说是怎么回事呢,二者之间又有什么关系呢?
接着开头说,如果上面提到的B模块改动并编译之后,依赖它的A能继续正常调用B里面的东西,并且正常运行,那么我们说这种兼容情况是二进制兼容;如果A需要重新编一下才能保持正常调用运转,则称这种情况为源代码兼容。
实际工作中开发一般很难察觉到这种细微的差异,因为底层模块被改了以后,在IDE启动运行的时候会检测到B重新生成了dll,这个时候A也会跟着重编去继承新dll的二进制接口。这也导致如果出现二进制兼容问题的时候,我们开发在IDE直接调试自测很难发觉其中的微妙差异。
但当程序部署之后新旧版本之间安装替换,一些公共模块对于上层依赖模块的二进制兼容问题便暴露出来了。比如我们计价程序Bin文件夹中的dll,每个地区版本均会带上,当后出的地区版本安装时路径下已经有了较老的其他地区版本,Bin文件夹中的dll便会被替换。这个时候就出现了老的上层模块在没有重编的情况下调用较新依赖模块的情况,也就是上面说到的二进制依赖所出现的场景。
二、如何保证二进制兼容
那么在修改代码的时候怎么能保障二进制的安全呢?
1.允许的修改方法
* 增加非虚函数,增加signal/slots,构造函数什么的。
* 增加枚举enum或增加枚举中的项目。
* 重新实现在父类里定义过的虚函数 (就是从这个类往上数的第一个非虚基类),理论上讲,程序还是找那个基类要这个虚函数的实现,而不是找你新写的函数要,所以是安全的。但是这可不怎么保准儿,尽量少用。
o 有一个例外: C++有时候允许重写的虚函数改变返回类型,在这种情况下无法保证二进制兼容。
* 修改内联函数,或者把内联函数改成非内联的。这也很危险,尽量少用。
* 去掉一个私有非虚函数。如果在任何内联函数里用到了它,你就不能这么干了。
* 去掉私有的静态成员。同样,如果内联函数引用了它,你也不能这么干。
* 增加私有成员。
* 修改函数参数的缺省值。
* 增加新类。
* 对外开放一个新类。
* 增减类的友元声明。
* 修改保留成员的类型。
* 把原来的成员位宽扩大缩小,但扩展后不得越过边界(char和bool不能过8位界,short不能过16位界,int不过32位界,以此类推)这个也接近闹残:原来没用到的那么几个位我扩来扩去当然没问题,可是这样实在是不让人放心。
2.禁止的修改方法
* 对于已经存在的类:
o 本来对外开放了,现在想收回来不开放
o 改变父类 (增加父类,减少父类,重新给父排序).
* 对于类模板来说:
o 修改任何模板参数(增减或改变顺序)
* 对于函数来说:
o 不再对外开放
o 彻底删掉
o 改成内联的(把代码从类定义外头移到头文件的类定义里头也算改内联)。
o 改变函数特征串:
+ 修改参数,包括增减参数或函数甚至是成员函数的const/volatile描述符。如果一定要这么干,增加一个新函数吧。
+ 把private改成protected或者public。如果一定要这么干,增加一个新函数吧。
+ 对于非成员函数,如果用extern "C"声明了,可以很小心地增减函数参数而不破坏二进制兼容。
* 对于虚成员函数来说:
o 给没虚函数或者虚基类的类增加虚函数
o 修改有别的类继承的基类
o 修改虚函数的前后顺序
o 如果一个函数不是在往上数头一个非虚基类中声明的,覆盖它会造成二进制不兼容。
o 如果虚函数被覆盖时改变了返回类型,不要修改它。
* 对于非私有静态函数和非静态的非成员函数:
o 改成不开放的或者删除
o 修改类型或者const/violate
* 对于非静态成员函数:
o 增加新成员
o 给非静态成员重新排序或者删除
o 修改成员的类型, 有个例外就是修改符号:signed/unsigned改来改去,不影响字节长度。
3.错误方法的分析
取消导出或移除一个类
改前
1 | class KDECORE_EXPORT KUrl |
改后
1 | class KUrl |
原因: 上面类的符号没有加入到生成的库的导出符号列表中,因此其他库或应用不能看见它们。
改变类的继承层级
改前
1 | class MyClass: public BaseClass |
改后
1 | class MyClass: public BaseClass, public OtherBaseClass |
原因:类中成员变量的大小或(和)顺序改变了,引起已有代码执行时分配过多或过少的内存,在错误的偏移位置来读写数据。
改变模版类的模版参数
1 | / GCC mangling before: _Z3foo15MyTemplateClassIiE |
改前
1 | template<typename T1> |
改后
1 | template<typename T1, typename T2 = void> |
原因:与这个模板类型相关的函数,因为它的模板扩展改变也发生了变化。这会同时发生在函数(例如构造函数)以及将其作为参数的函数上。
取消函数的导出
改前
1 | Q_CORE_EXPORT const char *qVersion(); |
改后
1 | const char *qVersion(); |
原因:上面函数的符号没有加入到生成的库的导出符号列表中,因此其他库或应用不能看见它们。
改为内联函数
改前
1 | int square(int n); |
改后
1 | inline int square(int n) { return n * n; } |
原因:当一个函数被声明为内联,并且编译器在它的调用点内联它时,编译器就不需要发送一份离线拷贝(out-of-line copy)。存在并调用此函数的代码将无法再解析该函数。另外,在GCC用-fvisibiliinlines-hidden编译时,如果编译器确实发出了一份离线拷贝,那么它将被隐藏(不添加到导出的符号表中),因此不能从其他库中被访问。
改变函数的参数
改前
1 | // GCC mangling: _Z11doSomethingii |
改后
1 | // GCC mangling: _Z11doSomethingis |
原因:改变一个函数的参数(添加新的或改变现有的)改变了这个函数的名称。这是因为C++允许函数通过具有相同修饰别名,但稍微不同的参数来实现重载的机制所决定的。
上面的示例在SunCC中,没有一个修饰别名,编译器在声明和实现中都强制执行了一致的POD类型。
改变返回类型
改前
1 | // GCC mangling: _Z8positionv |
改后
1 | // GCC mangling: _Z8positionv |
原因:更改返回类型会在一些编译器中改变函数名的名称(GCC明显没有对返回类型进行编码)。然而,即使修饰(mangling)没有改变,关于如何处理返回类型的约定也可能改变。
在上面的第一个例子中,返回类型从64位改为32位整型,这意味着在某些架构上,返回寄存器的上半部分可能包含垃圾。在第二个例子中,返回类型从QByteArray改为QString,这是两种不兼容的类型。
在第三个例子中,返回类型从一个简单的整数(POD)变成了QString——在这种情况下,编译器通常需要传递一个隐藏的隐式第一个参数,而这是不存在的。在这种情况下,由于试图引用不存在的隐式QString参数,调用该函数的现有代码很可能会崩溃。
第四例,返回类型变化从一种POD类型(int)到另一个(enum),这(枚举)也可以看作一个int。他们的调用次序在所有编译器中极有可能相同,然而符号名称的修饰改变了,这意味着调用将因为未知符号而失败。
改变访问权限
改前
1 | class MyClass |
改后
1 | class MyClass |
原因:一些编译器在其修饰别名中对函数的保护类型进行编码。
改变成员函数的const限定符
改前
1 | class MyClass |
改后
1 | class MyClass |
原因:编译器将一个函数的常量(const)性编译进了函数的修饰别名中。他们这样做是因为C++标准允许通过修改常量修饰符来实现重载函数。
改变全局数据类型
改前
1 | // GCC mangling: data (undecorated) |
改后
1 | // GCC mangling: data (undecorated) |
原因:一些编译器将全局数据的类型编译进其修饰别名中。特别要注意的是,一些编译器甚至会对在C中允许的简单数据类型进行处理,这意味着extern "C"
限定符也会产生不同的影响。
即使“mangling”没有改变,改变类型也会改变数据的大小。这意味着访问全局数据的代码可能访问太多或太少字节。
改变全局数据的const限定符
改前
1 | // MSVC mangling: ?data@@3HA |
改后
1 | // MSVC mangling: ?data@@3HB |
原因:一些编译器将全局数据的const限定符编码进了其修饰别名中。特别要注意的是,在类本身中声明的静态const值可以考虑为“内联”——也就是说,编译器不需要为值生成外部符号,因为所有的实现都肯定知道它。
即使对于那些没有对全局数据的const限定符进行编码的编译器,添加const也可能使编译器将该变量放置在只读的内存段中。试图写入的代码很可能会崩溃。
将虚函数添加到类中
改前
1 | struct Data |
改后
1 | struct Data |
原因:没有任何虚拟成员或基础的类肯定与C结构完全相同,这是因为它与该语言的兼容性(即POD结构)。在一些编译器上,他们以及基于其的结构体(类)也是POD结构。然而,只要有一个虚基或虚成员函数,编译器就可以自由地以C++的方式排列结构,这通常意味着在结构的开始或结束时插入一个隐藏的指针,指向该类的虚拟表(virtual table)。这就改变了结构中元素的大小和偏移量。
在非叶子类中添加新的虚函数
改前
1 | class MyClass |
改后
1 | class MyClass |
原因:在一个非末端的类(也就是说,至少有一个类派生从这个类)中添加一个新的虚函数,改变了虚拟表的布局(虚拟表基本上是一个函数指针列表,指向在这类级别活跃的函数)。为了适应新的虚函数,编译器必须向该表添加一个新条目,但是现有的派生类不会知道它,也不会在它们的虚拟表中包含条目。
改变虚函数声明的顺序
改前
1 | class MyClass |
改后
1 | class MyClass |
原因:编译器将指针放置到实现虚函数的函数中,按照它们在类中的声明顺序。通过改变声明的顺序,虚拟表中的条目的顺序也发生了变化。
注意:顺序是从父类继承的,所以覆盖一个虚函数,将会按照父类的顺序分配条目。
覆盖一个非主基类的虚函数
1 | class PrimaryBase |
改前
1 | class MyClass: public PrimaryBase, public SecondaryBase |
改后
1 | class MyClass: public PrimaryBase, public SecondaryBase |
原因:这是一个棘手的案例。当处理带有虚函数表的类的多重继承时,编译器必须创建多个虚表来保证多态性的工作(也就是说,当你的MyClass对象存储在PrimaryBase或中的基指针时)。主基类的虚表与该类的虚表共享,因为它们在开始时具有相同的布局。但是,如果你覆盖了来自非主基类的虚函数,它与添加一个新的虚函数是相同的,因为主基类中没有这个名称的虚函数。
注意:这适用于任何多继承的情况,即使它不是一个直接继承。在上面的例子中,如果我们用MyOtherClass从MyClass派生出来的,同样的约束也是适用的。
使用具有不同顶部地址的协变(covariant)返回来覆盖虚函数
1 | struct Data1 { int i; }; |
改前
1 | class MyClass: public BaseClass |
改后
1 | class MyClass: public BaseClass |
原因:这是另一个棘手的情况,比如上面的例子,也是出于同样的原因:编译器必须向虚表中添加第二个条目,就像添加了一个新的虚函数一样,这会改变虚表的布局并破坏派生类。
当从父类上覆盖一个虚函数返回与父类不同的类时,协变(covariant)调用就会发生(这是由C++标准所允许的,因此上面的代码是完全有效的,并且用BaseClass的p类型调用p-get()将调用MyClass::get)。如果像Complex1和Complex2一样,多派生的类型没有相同的顶部地址,那么编译器需要生成一个存根(stub)函数(通常称为“thunk”)来调整返回的指针的值。它将地址放在与其父在虚表中的虚函数对应的条目中。然而,它同时也返回新的顶部地址(top-address)来增加一个新的调用入口。
三、实际问题探索
1.内核调外壳的情况分析
首先问题是因为软件的新版本Bin文件夹中模块的一处函数被改动(加了一个默认参数),新版本Bin文件夹中模块随着新版地区的安装会覆盖已有的老版本dll,这样当老版本地区模块工作的时候,调用Bin文件夹中对应模块的函数并没有重编,这就造成了二进制兼容问题。
A)问题分析复现
原有
1 | class IBaseDll |
改后
1 | class IBaseDll |
首先看复现问题情况的Demo,复现原有未改动时的调用情形:
接下来是有问题的改动方法的执行现象:
用IDA分析工具查看编译出的dll文件,其修饰别名以及对应的偏移信息:
原有
改后
我们可以看出,前后两个dll在outputA
函数的修饰别名已经不一样了,同时看后面的解析信息,改后参数已经加了一个bool类型的参数。
我们再看调用dll的EXE文件的对应信息(未重编):
可见,exe文件中存的dll信息还是维持了原有版本的内容(很显然是这样的),这样当新版本覆盖了旧版dll文件以后,exe调用时便找不到对应的链接库位置,无法正常运行了。
这也就是上面说到的错误做法中的【改变函数的参数】里面增加默认参数这种情况,也是一定要注意避免的一种情况。
B)多种修改情况的探索
问题出现了,那么我们怎么去修改它,能够达到既能维持二进制兼容性,又能最大限度地减少改动影响呢?
显然在原有函数上直接加参数来实现目的的方法是不可行了,那么大概还可以通过下面几种情况来实现原有目的:
Ⅰ.增加不同名多一个默认参数的函数被原函数调用
1 | class IBaseDll |
1 | void BaseDll::outPutA(int i, QString strName) |
运行结果为:
显然,原有EXE文件里面并没有新增函数的信息,调用dll中原有函数走到新增的函数时,便出现异常终止运行。
Ⅱ.增加不同名多一个默认参数的函数不被原函数调用
1 | class IBaseDll |
1 | void BaseDll::outPutA(int i, QString strName) |
运行结果为:
可见,原有函数不论参数,顺序还是执行内容都没有被改动,所以执行情况正常,能够实现二进制兼容。
Ⅲ.增加多一个默认参数被调用的同名函数
1 | class IBaseDll |
1 | void BaseDll::outPutA(int i, QString strName) |
运行结果为:
根据程序输出情况,可见EXE调用原有函数,执行到调用新增同名函数时,由于没有新增函数的修饰别名和偏移信息,出现了循环调用自己的情况。
Ⅳ.增加多一个默认参数不被调用的同名函数
1 | class IBaseDll |
1 | void BaseDll::outPutA(int i, QString strName) |
运行结果为:
可见,因为两个函数内部没有相互影响,但具有相同的调用特征,由于原EXE并没有新函数的信息,所以新增函数并不会对两者间的二进制兼容造成影响:
- 函数能正常调用
- IDE的代码界面提示调用函数,接口存在歧义
- 重编调用EXE模块,编译不通过
虽然这样不影响模块间的二进制兼容,但最好还是不要这样做。(会影响重编时的代码兼容性)
Ⅴ.增加多一个普通参数被调用的同名函数
1 | class IBaseDll |
1 | void BaseDll::outPutA(int i, QString strName) |
运行结果为:
这种情况与上面相近,EXE调用原有函数,走到调用新增函数的位置,识别成了调用原有函数自身。会将第二个参数转换为原函数的第二个参数(bool->String) ,转换出现异常,程序崩溃。
Ⅵ.增加多一个普通参数不被调用的同名函数
1 | class IBaseDll |
1 | void BaseDll::outPutA(int i, QString strName) |
运行结果为:
这种情况,原有函数没有调用新增函数,EXE执行时没有出现异常,二进制兼容能够得到保证;但是这样就违背了增加默认参数的初衷,适用场景被受到了限制。
2.外壳调内核的情况分析
A)问题场景
同上面情况刚好相反,当出现外壳调用内核的场景时。往往会出现外壳多地区通用,而各地区内核版本不同的情形,这时候如何保证同一个外壳与多个版本的内核兼容就是需要考虑的问题了。
简而言之,当外壳与内核版本A调用时,执行我们希望的A方法;当与另一个版本B调用时,又能够正确的调用B对应的方法。
B)方法探索
这里我们采取的方法是,在外壳中做多一层级的接口集成,通过共有父接口,派生出接口A和B;同时不同版本的方法在其基础上派生出来,这样同一个方法在不同版本中会有不同的父接口A或B。调用的时候通过父接口实例化对象,判断动态转换为子接口的情况来分别调用对应的方法,达到上面场景的兼容目的。
以下是代码情况:
1 | // 外壳实现位置 |
1 | // 内核实现位置 |
当继承A接口时:
当继承B接口时:
3.总结
- 两种情况比较好的方案分别为:
- 内核调外壳:增加不同名多一个默认参数的函数不被原函数调用
- 外壳调内核:判断动态转换派生的不同接口来判断该调用的方法
- 另外:
- 接口给了默认参数的情况下,继承函数不必再重复给默认参数,调用位置如果不给该参数传值,函数将优先采用接口参数,继承参数并不会起作用(某些IDE(Clion、VS等)中会在代码处给出警告提示)
参考文章: