今日碰到一个 bug ,调试很久才发现是由于链接库与头文件不匹配造成的。实际问题当然很复杂,做个小例子来说明当时的情况吧。
--------------------------------------------------- 问题发现与再现 ------------------------------------------------------------
先用 VS2005 创建两个项目,第一个为 MFC Dll 项目,名称 my_lib 在项目属性 , “Configuration Properties”, “General” 中将 ”Configuration Type” 设为 ”Static Library(.lib)”
然后添加一个类 (CBaseExport) : ( BaseExport.h)
class CBaseExport
{
public :
CBaseExport(void );
~CBaseExport(void );
int get_x();
private :
int _x;
};
其实现:( BaseExport.cpp )
CBaseExport::CBaseExport(void ) : _x(0)
{
}
CBaseExport::~CBaseExport(void )
{
}
int CBaseExport::get_x()
{
return _x;
}
然后创建第二个项目,并在项目属性, “ Configuration Properties ” , “ Linker ” , “ input ” , 为 ” Additional Dependencies ” 加上前一个项目生成的Lib 文件 ” my_lib.lib ” 。
然后添加类:
class CDerive : public CBaseExport
{
public :
CDerive(void );
~CDerive(void );
private :
int _x2;
};
CDerive::CDerive(void ) : _x2(10)
{
}
CDerive::~CDerive(void )
{
}
当我们在项目2 中使用类CDerive ,例如:
CDerive *pd = new CDerive;
int x = pd->get_x();
毫无疑问,这时x 的值是0 。
但假设某个不幸的时刻发生了,就像我今天碰到的这样:
项目 1 中的 CBaseExport 类发生了变化,比如添加了一个成员变量:
class CBaseExport
{
public :
CBaseExport(void );
~CBaseExport(void );
int get_x();
private :
int _new_x; // 我是新来的
int _x;
};
然后在项目 2 中,我们获取了最新的 lib 文件,但是很不幸,头文件没更新,还是以前的(这种情况应该很有可能发生吧),这时再 使用类CDerive ,例如:
CDerive *pd = new CDerive;
int x = pd->get_x();
这时 x 的值会是什么呢?还会是 0 吗?
No, 它变成了 10 。
为什么会这样?咱们一步一步调试看看。
--------------------------------------------------- 问题原因 ------------------------------------------------------------
首先第一步,调用 new 分配内存。
CDerive *pd = new CDerive;
004024C0 push 8
004024C2 call operator new (401398h)
004024C7 add esp,4
004024CA mov dword ptr [ebp-1Ch],eax
004024CD cmp dword ptr [ebp-1Ch],0
004024D1 je CUseDllView::CUseDllView+60h (4024E0h)
004024D3 mov ecx,dword ptr [ebp-1Ch]
004024D6 call CDerive::CDerive (4023E0h)
……
可以看到编译器认为 CDerive 的大小是8 ,因为在编译器看来,CBaseExport 只有一个整型变量,大小是4 ,而CDerive 增加了一个整型变量,大小就是8 了。而实际上CBaseExport 的大小是8 ,这样在调用CBaseExport 的构造函数时,肯定会发生内存越界。
然后进入CDerive 的构造函数:
CDerive::CDerive(void) : _x2(10)
{
004023E0 push ebp
004023E1 mov ebp,esp
004023E3 push ecx
004023E4 mov dword ptr [ebp-4],ecx
004023E7 mov ecx,dword ptr [this]
004023EA call CBaseExport::CBaseExport (4023B0h)
004023EF mov eax,dword ptr [this]
004023F2 mov dword ptr [eax+4],0Ah
}
004023F9 mov eax,dword ptr [this]
004023FC mov esp,ebp
004023FE pop ebp
004023FF ret
从这里可以看出CDerive 的构造函数先调用基类CBaseExport 的构造函数,然后通过初始化列表初始化所有成员变量(事实上没在初始化列表中的类变量也会调用默认构造函数初始化),最后在执行函数体内的函数(当然这里是空函数体)。
然后CBaseExport 的构造函数:
CBaseExport::CBaseExport(void) : _x(0), _new_x(0)
{
004023B0 push ebp
004023B1 mov ebp,esp
004023B3 push ecx
004023B4 mov dword ptr [ebp-4],ecx
004023B7 mov eax,dword ptr [this]
004023BA mov dword ptr [eax],3
004023C0 mov ecx,dword ptr [this]
004023C3 mov dword ptr [ecx+4],0
}
004023CA mov eax,dword ptr [this]
004023CD mov esp,ebp
004023CF pop ebp
004023D0 ret
其中mov eax,dword ptr [this] 把CBaseExport 的地址存入寄存器eax 中,然后mov dword ptr [eax],3 其实就是给变量_new_x 赋值。这里要注意变量的赋值顺序并不是按照变量初始化列表的顺序,而是按照他在类中声明的顺序。然后mov ecx,dword ptr [this] 和mov dword ptr [ecx+4],0 时给变量_x 赋值。
在这时,我们记录一下ecx 的值是0x003AA750 (每次运行时会不一样),也就是说_x 的内存地址是0x003AA754 。
CBaseExport 的构造函数完成后,回到CDerive 的构造函数中:
CDerive::CDerive(void) : _x2(10)
{ ……
004023EA call CBaseExport::CBaseExport (4023B0h)
004023EF mov eax,dword ptr [this]
004023F2 mov dword ptr [eax+4],0Ah
} ……
这里再给_x2 赋值。注意eax 的值同样是0x003AA750, 也就是编译器认为_x2 的内存地址是0x003AA754 ,和基类CBaseExport 中的_x 完全一样,结果_x 的值就被覆盖了。
--------------------------------------------------- 引申思考 ------------------------------------------------------------
以前经常听到Dll 地狱这个名词,但对其没有太大感觉,今天通过这个例子一下理解了Dll 地狱的说法。我想,之所以这种事情发生,还是由于dll 在头文件中暴露了太多的细节,而这些没必要的细节暴露就在不经意间引发了很多问题,例如当头文件和lib 或dll 不匹配时编译器根本没能力发现,因为毕竟没改动任何接口,也就没法给出警告,这样只能在运行时靠运气和经验来解决问题了。
我想,解决方法就是不要向用户暴露实现细节,例如使用PROXY 代理设计模式:
给用户提供封装的代理类,其中只有公共接口和一个指向实际对象的指针:
class CBaseExportImpl;
class CBaseExport
{
public :
CBaseExport(void );
~CBaseExport(void );
int get_x();
private :
CBaseExportImpl *_impl;
};
在公共接口中,直接将调用转给实际的对象处理。
int CBaseExport::get_x()
{
return _impl->get_x();
}
这样无论底层的实现如何变化,对用户都没有任何影响了(当然接口变化除外,这时编译器会给出错误警告)。
另一种方式是使用COM 。由于COM 具有自我描述,只向外提供接口的特点,所以像Dll 地狱的问题很容易避免。
终归到底,就是小心设计,不要随便对外提供实现细节,否则,会死得很惨!