2011-10-24

VC和BCB那点事——DLL导出函数的结构体参数

还是那个项目,VC写一个DLL,导出若干C函数,BCB来调用。实际情况比这个复杂,不过与我这次要讲的这个问题无关,所以在此省略了。

在解决了上次遇到的虚函数表顺序问题后,继续往下调试,又碰到了一个很怪的问题。有一个导出函数,一调用便崩溃。进去一看吧,崩溃点的代码别的导出函数也在用,没什么不对。虽然改改代码,可以做到不抛出异常,但该导出函数返回的值又不正常。反正它就是干不了想干的活。
该函数本身很简单,因此怀疑不是内部逻辑导致的问题。通过对比,发现该函数与其它工作正常的导出函数有一个明显的区别:它返回了一个自定义的结构体作为返回值。函数的声明大概是这样子的:
LONDATEEX __stdcall GetDataDate( USHORT sMarket);
由于别的可能性被一一排除,因此焦点慢慢移到这个情况上来。查了查网上的信息,发现有一篇文章(被墙,由此可见GFW的反动性质)提到了跨模块调用时的内存管理问题,并据此总结了几条规则。其中一条,便是不要在跨模块函数调用中使用类或结构体作为参数或返回值类型。

该文章在这一点上略有阐述:因为内存管理模式不一样,所以类和结构体这种可能会进行内存分配、回收的数据类型,一旦用作跨模块调用的参数或返回值,就会导致不确定的后果。我认为这个解释是合理的。我之前也已经想到了这一点,不过接下来还有。
该文章还提出了改进建议——如果一定要作为参数或返回值传递,那么应该采用所谓的纯结构体,即只包含了简单数据类型的结构体,构造、析构时不涉及内存分配和释放。该文认为,这种纯结构体的数据类型,可以用于DLL导出函数的参数或返回值。碰巧了,我这次这个结构体,就是个纯结构体。那么按照这篇文章的理论,不应该有问题才对。

不弄明白这个问题,始终是不甘心,于是我动手做实验了。先用VC写了一个很简单的DLL,声明了一个最简单的结构体,包含两个int类型的变量。然后用BCB来调用。并且,在做这个实验的时候,我特地注意避免了字节对齐不一致的问题。
嗯,结果是什么呢?调用完成了,可无论传进去的参数值还是传出来的返回值,都不对。同样的函数声明方式,都用了__stdcall的另一个只包含简单数据类型作为参数和返回值的DLL导出函数,工作正常。

两相对照,显然,就算是所谓纯结构体,也不见得能够放心地用于跨模块的函数调用。至少,在VC和BCB5之间,是会有问题的。别人说的,不一定就是对的,至少不一定全对。尽信书不如无书。

解决方案也很简单,指针是一个简单数据类型,把结构体的指针作为参数传进去就行了。比如:
BOOL __stdcall GetDataDate( USHORT sMarket, LONDATEEX* pDate);
还有别的办法也行,比如利用COM,或者通过操作系统级别管理的对象来传递数据。总之别让两种编译器编译出来的东西去分别猜对方是怎么管理内存的就行。

2011-10-21

VC和BCB那点事——虚函数的顺序问题

因为工作需要,编写了一个C++类,供一个VC下的DLL使用。用法类似回调对象,将C++对象的指针传给DLL导出的函数。类里面要被用到的函数全是虚函数,父类是个彻底的抽象类,由DLL的开发人员提供.h头文件。VC和BCB在虚函数表的实现上是一致的,因此可以跨模块调用。

大部分虚函数的调用测试都很顺利,但在一个函数上遇到了麻烦。不,应该说是两个。在测试过程中发现,当DLL想要调用我提供的C++类的FunA函数时,总是错误地调用到了FunB函数。反过来也一样,结果就好像是DLL把A函数和B函数搞反了。
class A
{
    ……
    virtual FunA() = 0;
    virtual FunB() = 0;
    ……
};
既然是按照虚函数表来调用,那就跟函数名无关。这一点很快就得到了证实。现在唯一剩下的就是顺序问题。根据资料,虚函数表里面的顺序是根据函数的声明顺序来排布的,也就是说.h头文件决定了顺序。调换了一下顺序,果然就正常了,可为什么会反呢?
其实不能只看这么一点儿代码,这个问题和上下文有关:
class A
{
    ……
    virtual FunB(int J) = 0;
    virtual FunA() = 0;
    virtual FunB() = 0;
    ……
};
最后是CSDN上一篇博文揭示了这个问题:VC会把重载函数给排在一起,不管中间有没有插队者。参见:http://blog.csdn.net/doudouhuy/article/details/4348531
也就是说,最终的顺序实际上是:
class A
{
    ……
    virtual FunB(int J) = 0;
    virtual FunB() = 0;
    virtual FunA() = 0;
    ……
};
而BCB大概不会做这个改动,因此调用到的函数就和预期的不一样了。

最后说一下。为了避免遇到类似问题,建议在进行跨模块的虚函数调用的时候,彻底避开重载函数出现的情况。把所有函数的名称都声明得不同,就不会轮到编译器来干扰了。

2011-10-08

都是负数惹的祸?

十一长假之后第一天上班,继续调试那位还在婚假当中的同事留下的代码。上午查到的一个Bug让我相当无语。不过鉴于自己如果思想在开小差的话也很可能会犯类似错误,故记录下来警醒自己。

看看这段代码:
#define ST_ERR -1
……
BYTE result = ST_ERR;
……
if (result == ST_ERR)
    return errmsg;
事实上,这个if语句根本就没得到执行。即使result在赋值了ST_ERR之后从未改变,代码在执行时仍然跳过了判断,继续执行下面的代码。也就是说,result明明赋值了ST_ERR,但却不等于ST_ERR。

难道遇到鬼了,result中间被篡改了?溢出过?
好吧,其实result的确不是-1,而是255。看看BYTE的定义就清楚了。BYTE并不是C/C++内建的数据类型。在Win32平台上,它通常都被定义为unsigned char。

到这里就明白了,unsigned当然不可能有负数。而ST_ERR因为用了宏定义,当做常量使用的时候并不会自动转成非负的unsigned形式,这样当然不可能相等了。
说穿了,不是负数惹的祸,而是【宏】这个东西惹的祸。如果用const常量,因为常量的定义上有类型信息存在,就不会有这个问题了。
当然,这里无意评价宏与const常量的好与坏,单纯指出问题而已。目前这个公司的代码,宏用得不少,难保会有菜鸟程序员出这种错,隐患啊!

2011-10-04

初始化,真的很重要

刚花了大半天的时间解决掉一个Bug,再次证明了变量初始化的重要性。照例,还是先来看一小段代码:
map<string, TEOBJ> telist; // 全局对象
……
TEOBJ spte(3);
telist["debug"] = spte;
很简单吧?按道理这点代码应该没有什么问题。不过在最后一句中却抛出了异常,发生了崩溃。负责这部分代码的人昨天就请了假回家了,节后还有婚假,一走要近二十天,只好自己动手找原因。

这里的TEOBJ,是一个自己定义的类。在我的这个案例中,它是一个底层库里面定义的数据对象类,用在了很多地方。基于这个前提,我首先判断这个Bug的故障点在赋值号的左边。
可是左边也是个std::map的相当标准的用法,STL出错的可能性恐怕比我们自己的底层库更低,而且这个用法我以前也用过很多,没有出过什么问题。怀疑的眼光于是又回到了赋值号的右边。

那么到底是哪一边呢?我也拿不准了,于是排除法上马。通过几轮替换,我终于把问题基本定性为「由TEOBJ引起,但需要在赋值到map的[]运算符时产生」。
既然说到赋值,那么就该看看TEOBJ的相关声明了:
class TEOBJ
{
public:
    int GroupID;
private:
    int GroupCode;
public:
    TEOBJ::TEOBJ() {};
    TEOBJ::TEOBJ(int nGroupID)
    {
        GroupID = nGroupID;
        GroupCode = TDataStore::Groups[GroupID]->Code;
    }
    TEOBJ::TEOBJ(const TEOBJ& r)
    {
        GroupID = r.GroupID;
        GroupCode = TDataStore::Groups[GroupID]->Code;
    }
    const TEOBJ& TEOBJ::operator=(const TEOBJ& r)
    {
        GroupID = r.GroupID;
        GroupCode = TDataStore::Groups[GroupID]->Code;
    }
    ……
};
这个TEOBJ重载了拷贝构造函数和赋值运算符,在其中通过查询一个数据字典确保GroupCode有准确的取值。

首先,赋值运算符重载这里的返回值有点小问题。const引用类型的返回值与习惯上的non-const引用类型的返回值有所区别。不过这个最多造成编译时语法检查上的问题,不至于引起指针错误。
其次,就是这个TDataStore::Groups[GroupID]的数组的使用值得怀疑了。

实际案例比这个情况要复杂一些,而且我对于BCB的使用还不算熟悉,调试基本靠OutputDebugString。问题就诡异在,往TEOBJ::operator=()里面只要任意加一行OutputDebugString,故障就消失了。搞得好像是野指针一样。
我在此浪费了很多时间,走了不少弯路。后来,一咬牙用上了windbg,配合map2dbg,好歹是得到了CallStack。但是map2dbg之后得到的符号名称又和我以前看到的有些不同,于是又错判了位置。
最后,终于在windbg+排除法的协助下找到了问题。问题根本不在赋值运算符重载函数中。尽管这里代码中明显是用到了它,但它并没有出问题。抛出异常的是拷贝构造函数。这个就要从map::operator[]的实现说起了。

Borland C++ Builder 5里面的std::map的实现在map.h里面,是一个inline函数,挖出来一看,也很简单:
mapped_type& operator[] (const key_type& k)
{
    value_type tmp(k,T()); return (*((insert(tmp)).first)).second;
}
可以看到,这里先生成了一个临时变量(其实本例中就是pair<string, TEOBJ>)。在生成这个临时变量的时候,采用了无参数的TEOBJ构造函数。此时TEOBJ::GroupID还是没有初始化的,在Release版本中它可能是任意值(TEOBJ所在的底层库就是Release编译)。实际使用时,TEOBJ都通过TEOBJ::TEOBJ(int nGroupID)来初始化,因此不会产生这种未被正确初始化的实例。但在map::operator[]中,由于是「先插入空值,再传出引用用于赋值」,这个时候就使得拷贝构造函数被调用,于是产生了类似的访问野指针的效果,导致故障的出现。通过在拷贝构造函数中用OutputDebugString输出GroupID的取值,确认了这一点。

至此,真相大白。总结经验教训如下:
  1. 变量的初始化很重要,一个也不能放松,不能因为眼下不初始化也不会出问题,就放松警惕。做底层库的人尤其应该重视——你的代码不只是你一个人在用!
  2. 数组的下标,或者说指针的偏移量,这种变量与指针基本上是同样的性质。要提防野指针,它们也要算上。
  3. 调试手段无所谓牛刀不牛刀,好用就用,不要因为感觉问题小、简单,就先用笨办法尝试,直到一筹莫展了之后才考虑别的办法。如果我一开始就用windbg,时间上可能会节省更多一些。

在STL容器中删对象一定要小心

先来看一段代码:
typedef map<string, string> Dict;
Dict eventmap;
for (Dict::iterator pIter = eventmap.begin(); pIter != end(); ++pIter)
{
    if (pIter->second == "no")
        eventmap.erase(pIter);
}
熟悉STL的人都知道,这段代码是错的。出问题的可能性不是100%,但是相当大,而且可能会是那种不一定能稳定重现的问题,往往会搞得人很恍惚。

自己很早以前就吃过这个亏,那时候还不太会用STL。当定位到这一段代码后,凭直觉也觉得如果这样删除对象,那删除之后的pIter是不是正确的?可能很难讲。毕竟学过「数据结构」,大概能猜到各种容器里面的信息是如何组织的。

昨天又看见同事在这样写代码。可能他对STL也不熟。至少我目前对于erase这个单词已经产生了足够的警戒,一旦要用的时候就会想起吃过的亏来。

正确的代码应该是:
typedef map<string, string> Dict;
Dict eventmap;
for (Dict::iterator pIter = eventmap.begin(); pIter != end();)
{
    if (pIter->second == "no")
        pIter = eventmap.erase(pIter);
    else
        ++pIter;
}
另外,昨天发现Borland C++ Builder提供的STL和VC有点区别,map::erase没有返回值,因此不支持这种写法。那么还有另一种办法:
eventmap.erase(pIter++);
这样就可以了。相比之下,这种写法可能适应性更强一些。