DLL地狱及其解决方案
原作者:Ivan S Zapreev
译者:陆其明
概要
本文将要介绍DLL的向后兼容性问题,也就是著名的“DLL Hell”问题。首先我会列出自己的研究结果,其中包括其它一些研究者的成果。在本文的最后,我还将给出“DLL Hell”问题的一个解决方案。
介绍
我曾经接受过一个任务,去解决一个DLL版本更新的问题————某个公司给用户提供了一套SDK,这个SDK是由一系列DLL组成的;DLL中导出了很多类,用户使用这些类(直接使用或派生新的子类)来继续他们的C++程序开发。用户在使用这些DLL时没有得到很详细的使用说明(比如使用这些DLL中导出的类有什么限制等)。当这些DLL更新为新的版本之后,他们发现他们开发的基于这些DLL的应用程序会经常崩溃(他们的应用程序从SDK的导出类派生了新的子类)。为了解决这个问题,用户必须重新编译他们的应用程序,重新连接新版本的SDK DLL。
我将对这个问题给出我的研究结果,同时还有我从其它地方搜集过来的相关信息。最后,我将来解决这个“DLL Hell”问题。
研究结果
就我个人的理解,这个问题是由SDK DLL中导出的基类改动之后引起的。我查看了一些文章后发现,DLL的向后兼容性问题其实早有人提出。但作为一个实在的研究者,我决定自己做一些试验。结果,我发现如下的问题:
1. 在DLL的导出类中增加一个新的虚函数将导致如下问题:
(1)如果这个类以前就有一个虚函数B,此时在它之前增加一个新的虚函数A。这样,我们改变了类的虚函数表。于是,表中的第一个函数指向了函数A(而不是原来的B)。此时,客户程序(假设没有在拿到新版本的DLL之后重新编译、连接)调用函数B就会产生异常。因为此时调用函数B实际上转向了调用函数A,而如果函数A和函数B的参数类型、返回值类型迥异的话问题就出来了!
(2)如果这个类原本没有虚函数(它的父类也没有虚函数),那么给这个类增加一个新的虚函数(或者在它的父类增加一个虚函数)将导致新增加一个类成员,这个成员是一个指针类型的,指向虚函数表。于是,这个类的尺寸将会被改变(因为增加了一个成员变量)。这种情况下,客户程序如果创建了这个类的实例,并且需要直接或间接修改类成员的值的时候就会有问题了。因为虚函数表的指针是作为类的第一个成员加入的,也就是说,原本这个类定义的成员因为虚函数表指针的加入而都产生了地址的偏移。客户程序对原成员的操作自然就出现异常了。
(3)如果这个类原本就有虚函数(或者只要它的父类有虚函数),而且这个类被导出了,被客户程序当作父类来用。那么,我们不要给这个类增加虚函数!不仅在类声明的开头不能加,即使在末尾处也不能加。因为加入虚函数会导致虚函数表内的函数映射产生偏移;即使你将虚函数加在类声明的末尾,这个类的派生类的虚函数表也会因此产生偏移。
2. 在DLL的导出类中增加一个新的成员变量将导致如下问题:
(1)给一个类增加一个成员变量将导致类尺寸的改变(给原本有虚函数表的类增加一个虚函数将不会改变类的尺寸)。假设这个成员增加在类声明的最后。如果客户程序为创建这个类的实例少分配了内存,那么可能在访问这个成员时导致内存越界。
(2)如果在原有的类成员中间增加一个新的成员,情况会更糟糕。因为这样会导致原有类成员的地址产生偏移。客户程序操作的是一个错误的地址表,对于新成员后面的成员尤其是这样(它们都因为新成员的加入而导致了自己在类中的偏移的变化)。
(注:上述的客户程序就是指使用SDK DLL的应用程序。)
除了上面这些原因外,还有其它操作会导致DLL的向后兼容性问题。下面列出了解决(大部分)这些问题的方法。
DLL编码约定简述
下面是我搜集到的所有的解决方案,其中一些是从网上的文章中拿来的,一些是跟不同的开发者交流后得到的。
下面的约定主要针对DLL开发,而且是为解决DLL的向后兼容性问题:
1. 编码约定:
(1)DLL的每个导出类(或者它的父类)至少包含一个虚函数。这样,这个类就会始终保存一个指向虚函数表的指针成员。这么做可以方便后来新的虚函数的加入。
(2)如果你要给一个类增加一个虚函数,那么将它加在所有其它虚函数的后面。这样就不会改变虚函数表中原有函数的地址映射顺序。
(3)如果你打算以后给一个类扩充类成员,那么现在预留一个指向一个数据结构的指针。这样的话,增加一个成员直接在这个数据结构中修改,而不是在类中修改。于是,新成员的加入不会导致类尺寸的改变。当然,为了访问新成员,需要给这个类定义几个操作函数。这种情况下,DLL必须是被客户程序隐式(implicitly)连接的。
(4)为了解决前一点的问题,也可以给所有的导出类设计一个纯接口的类,但此时,客户程序将无法从这些导出类继续派生,DLL导出类的层次机构也将无法维持。
(5)发布两个版本的DLL和LIB文件(Debug版本和Release版本)。因为如果只发布Release版本,开发者将无法调试他们的程序,因为Release版与Debug版使用了不同的堆(Heap)管理器,因而当Debug版本的客户程序释放Release版本DLL申请的内存时,会导致运行时错误(Runtime failure)。有一种办法可以解决这个问题,就是DLL同时提供申请和释放内存的函数供客户程序调用;DLL中也保证不释放客户程序申请的内容。通常遵守这个约定不是那么简单!
(6)在编译的时候,不要改变DLL导出类函数的默认参数,如果这些参数将被传递到客户程序的话。
(7)注意内联(inline)函数的更改。
(8)检查所有的枚举没有默认的元素值。因为当增加/删除一个新的枚举成员,你可能移动旧枚举成员的值。这就是为什么每一个成员应该拥有一个唯一标识值。如果枚举可以被扩展,也应该对其进行文档说明。这样,客户程序开发者就会引起注意。
(9)不要改变DLL提供的头文件中定义的宏。
2. 对DLL进行版本控制:如果主要的DLL发生了改变,最好同时将DLL文件的名字也改掉,就象微软的MFC DLL一样。例如,DLL文件可以按照如下格式命名:Dll_name_xx.dll,其中xx就是DLL的版本号。有时候DLL中做了很大的改动,使得向后兼容性问题无法解决。此时应该生成一个全新的DLL。将这个新DLL安装到系统时,旧的DLL仍然保留。于是,旧的客户程序仍然能够使用旧的DLL,而新的客户程序(使用新DLL编译、连接)可以使用新的DLL,两者互不干涉。
3. DLL的向后兼容性测试:还有很多很多中可能会破坏DLL的向后兼容性,因此实施DLL的向后兼容性测试是非常必要的!
接下去,我将来讨论一个虚函数的问题,以及对应的一个解决方案。
虚函数与继承
首先来看一下如下的虚函数和继承结构:
/**********DLL导出的类 **********/
class EXPORT_DLL_PREFIX VirtFunctClass{
public:
VirtFunctClass(){}
~VirtFunctClass(){}
virtual void DoSmth(){
//this->DoAnything();
// Uncomment of this line after the corresponding method
//will be added to the class declaration
}
//virtual void DoAnything(){}
// Adding of this virtual method will make shift in
// table of virtual methods
};
/**********客户程序,从DLL导出类派生一个新的子类**********/
class VirtFunctClassChild : public VirtFunctClass {
public:
VirtFunctClassChild() : VirtFunctClass (){}
~VirtFunctClassChild(){};
virtual void DoSomething(){}
};
假设上面的两个类,VirtFunctClass在my.dll中实现,而VirtFunctClassChild在客户程序中实现。接下去,我们做一些改变,将如下两个注释行放开:
//virtual void DoAnything(){}
和
//this->DoAnything();
也就是说,DLL导出的类作了改动!现在如果客户程序没有重新编译,那么客户程序中的VirtFunctClassChild将不知道DLL中VirtFunctClass类已经改变了:增加了一个虚函数void DoAnything()。因此,VirtFunctClassChild类的虚函数表仍然包含两个函数的映射:
1. void DoSmth()
2. void DoSomething()
而事实上这已经不对了,正确的虚函数表应该是:
1. void DoSmth()
2. void DoAnything()
3. void DoSomething()
问题就在于,当实例化VirtFunctClassChild之后,如果调用它的void DoSmth()函数,DoSmth()函数转而要调用void DoAnything()函数,但此时基类VirtFunctClass只知道要调用虚函数表中的第二个函数,而VirtFunctClassChild类的虚函数表中的第二个函数仍然是void DoSomething(),于是问题就出来了!
另外,禁止在DLL的导出类的派生类(上例中的VirtFunctClassChild)中增加虚函数也是于事无补的。因为,如果VirtFunctClassChild类中没有virtual void DoSomething()函数,基类中的void DoAnything()函数(虚函数表中的第二个函数)调用将会指向一个空的内存地址(因为VirtFunctClassChild类维持的虚函数表仅仅维持有一个函数地址)。
现在可以看出,在DLL的导出类中增加虚函数是一个多么严重的问题!不过,如果虚函数是用来处理回调事件的,我们有办法来解决这个问题(下文有介绍)。
COM及其它
现在可以看出,DLL的向后兼容性问题是一个很出名的问题。解决这些问题,不仅可以借助于一些约定,而且可以通过其它一些先进的技术,比如COM技术。因此,如果你想摆脱“DLL Hell”问题,请使用COM技术或者其它一些合适的技术。
让我们回到我接受的那个任务(我在本文开头的地方讲到的那个任务)————解决一个使用DLL的产品的向后兼容性问题。
我对COM有些了解,因此我的第一个建议是使用COM技术来克服那个项目中的所有问题。但这个建议因为如下原因最终被否决了:
1. 那个产品已经在某个内部层中有一个COM服务器。
2. 将一大堆接口类重写到COM的形式,投入比较大。
3. 因为那个产品是DLL库,而且已经有很多应用程序在使用它了。因此,他们不想强制他们的客户重写他们的应用程序。
换句话说,我被要求完成的任务是,以最小的代价来解决这个DLL向后兼容性问题。当然,我应该指出,这个项目最主要的问题在于增加新的成员和接口类上的虚回调函数。第一个问题可以简单地通过在类声明中增加一个指向一个数据结构的指针来解决(这样可以任意增加新的成员)。这种方法我在上面已经提到过。但是第二个问题,虚回调函数的问题是新提出的。因此,我提出了下面的最小代价、最有效的解决方法。
虚回调函数与继承
然我们想象一下,我们有一个DLL,它导出了几个类;客户应用程序会从这些导出类派生新的类,以实现虚函数来处理回调事件。我们想在DLL中做一个很小的改动。这个改动允许我们将来可以给导出类“无痛地”增加新的虚回调函数。同时,我们也不想影响使用当前版本DLL的应用程序。我们期望的就是,这些应用程序只有在不得已的时候才协同新版本的DLL进行一次重新编译。因此,我给出了下面的解决方案:
我们可以保留DLL导出类中的每个虚回调函数。我们只需记住,在任何一个类定义中增加一个新的虚函数,如果应用程序不协同新版本的DLL重新编译,将导致严重的问题。我们所做的,就是想要避免这个问题。这里我们可以一个“监听”机制。如果在DLL导出类中定义并导出的虚函数被用作处理回调,我们可以将这些虚函数转移到独立的接口中去。
让我们来看下面的例子:
// 如果想要测试改动过的DLL,请将下面的定义放开
//#define DLL_EXAMPLE_MODIFIED
#ifdef DLL_EXPORT
#define DLL_PREFIX __declspec(dllexport)
#else
#define DLL_PREFIX __declspec(dllimport)
#endif
/********** DLL的导出类 **********/
#define CLASS_UIID_DEF static short GetClassUIID(){return 0;}
#define OBJECT_UIID_DEF virtual short
GetObjectUIID(){return this->GetClassUIID();}
// 所有回调处理的基本接口
struct DLL_PREFIX ICallBack
{
CLASS_UIID_DEF
OBJECT_UIID_DEF
};
#undef CLASS_UIID_DEF
#define CLASS_UIID_DEF(X) public: static
short GetClassUIID(){return X::GetClassUIID()+1;}
// 仅当DLL_EXAMPLE_MODIFIED宏已经定义的时候,进行接口扩展
#if defined(DLL_EXAMPLE_MODIFIED)
// 新增加的接口扩展
struct DLL_PREFIX ICallBack01 : public ICallBack
{
CLASS_UIID_DEF(ICallBack)
OBJECT_UIID_DEF
virtual void DoCallBack01(int event) = 0; // 新的回调函数
};
#endif // defined(DLL_EXAMPLE_MODIFIED)
class DLL_PREFIX CExample{
public:
CExample(){mpHandler = 0;}
virtual ~CExample(){}
virtual void DoCallBack(int event) = 0;
ICallBack * SetCallBackHandler(ICallBack *handler);
void Run();
private:
ICallBack * mpHandler;
};
很显然,为了给扩展DLL的导出类(增加新的虚函数)提供方便,我们必须做如下工作:
1. 增加ICallBack * SetCallBackHandler(ICallBack *handler);函数;
2. 在每个导出类的定义中增加相应的指针;
3. 定义3个宏;
4. 定义一个通用的ICallBack接口。
为了演示给CExample类增加新的虚回调函数,我在这里增加了一个ICallBack01接口的定义。很显然,新的虚回调函数应该加在新的接口中。每次DLL更新都新增一个接口(当然,每次DLL更新时,我们也可以给一个类同时增加多个虚回调函数)。
注意,每个新接口必须从上一个版本的接口继承。在我的例子中,我只定义了一个扩展接口ICallBack01。如果DLL再下个版本还要增加新的虚回调函数,我们可以在定义一个ICallBack02接口,注意ICallBack02接口要从ICallBack01接口派生,就跟当初ICallBack01接口是从ICallBack接口派生的一样。
上面代码中还定义了几个宏,用于定义需要检查接口版本的函数。例如我们要为新接口ICallBack01增加新函数DoCallBack01,如果我们要调用ICallBack * mpHandler; 成员的话,就应该在CExample类进行一下检查。这个检查应该如下实现:
if(mpHandler != NULL && mpHandler->GetObjectUIID()>=ICallBack01::GetClassUIID()){
((ICallBack01 *) mpHandler)->DoCallBack01(2);
}
我们看到,新回调接口增加之后,在CExample类的实现中只需简单地插入新的回调调用。
现在你可以看出,我们上述对DLL的改动并不会影响客户应用程序。唯一需要做的,只是在采用这种新设计后的第一个DLL版本(为DLL导出类增加了宏定义、回调基本接口ICallBack、设置回调处理的SetCallBackHandler函数,以及ICallBack接口的指针)发布后,应用程序进行一次重编译。(以后扩展新的回调接口,应用程序的重新编译不是必需的!)
以后如果有人想要增加新的回调处理,他就可以通过增加新接口的方式来实现(向上例中我们增加ICallBack01一样)。显然,这种改动不会引起任何问题,因为虚函数的顺序并没有改变。因此应用程序仍然以以前的方式运行。唯一你要注意的是,除非你在应用程序中实现了新的接口,否则你就接收不到新增加的回调调用。
我们应该注意到,DLL的用户仍然能够很容易与它协同工作。下面是客户程序中的某个类的实现例子:
// 如果DLL_EXAMPLE_MODIFIED没有定义,使用以前版本的DLL
#if !defined(DLL_EXAMPLE_MODIFIED)
// 此时没有使用扩展接口ICallBack01
class CClient : public CExample{
public:
CClient();
void DoCallBack(int event);
};
#else // !defined(DLL_EXAMPLE_MODIFIED)
// 当DLL增加了新接口ICallBack01后,客户程序可以修改自己的类
// (但不是必须的,如果他不想处理新的回调事件的话)
class CClient : public CExample, public ICallBack01{
public:
CClient();
void DoCallBack(int event);
// 声明DoCallBack01函数(客户程序要实现它,以处理新的回调事件)
// (DoCallBack01是ICallBack01接口新增加的虚函数)
void DoCallBack01(int event);
};
#endif // defined(DLL_EXAMPLE_MODIFIED)