编译器之间的DLL兼容性问题

11

有没有办法使用不同的编译器构建的C++动态链接库相互兼容? 类可以拥有创建和销毁的工厂方法,因此每个编译器都可以使用自己的new/delete(因为不同的运行时有它们自己的堆)。

我尝试了以下代码,但在第一个成员方法上崩溃了:

interface.h

#pragma once

class IRefCounted
{
public:
    virtual ~IRefCounted(){}
    virtual void AddRef()=0;
    virtual void Release()=0;
};
class IClass : public IRefCounted
{
public:
    virtual ~IClass(){}
    virtual void PrintSomething()=0;
};

使用VC9编译的test.cpp,生成test.exe

#include "interface.h"

#include <iostream>
#include <windows.h>

int main()
{
    HMODULE dll;
    IClass* (*method)(void);
    IClass *dllclass;

    std::cout << "Loading a.dll\n";
    dll = LoadLibraryW(L"a.dll");
    method = (IClass* (*)(void))GetProcAddress(dll, "CreateClass");
    dllclass = method();//works
    dllclass->PrintSomething();//crash: Access violation writing location 0x00000004
    dllclass->Release();
    FreeLibrary(dll);

    std::cout << "Done, press enter to exit." << std::endl;
    std::cin.get();
    return 0;
}

a.cpp 使用 g++ 编译: g++.exe -shared c.cpp -o c.dll

#include "interface.h"
#include <iostream>

class A : public IClass
{
    unsigned refCnt;
public:
    A():refCnt(1){}
    virtual ~A()
    {
        if(refCnt)throw "Object deleted while refCnt non-zero!";
        std::cout << "Bye from A.\n";
    }
    virtual void AddRef()
    {
        ++refCnt;
    }
    virtual void Release()
    {
        if(!--refCnt)
            delete this;
    }

    virtual void PrintSomething()
    {
        std::cout << "Hello World from A!" << std::endl;
    }
};

extern "C" __declspec(dllexport) IClass* CreateClass()
{
    return new A();
}

编辑: 我向 GCC 的 CreateClass 方法中添加了以下行,文本已正确打印到控制台,因此肯定是函数调用导致问题。

std::cout << "C.DLL Create Class" << std::endl;

我想知道,COM如何在跨语言情况下保持二进制兼容性,因为它基本上是继承的类(尽管仅有单一继承)和虚函数。如果我不能有重载运算符/函数,只要我能够保持基本的面向对象编程(即类和单一继承),我就不会太担心。


COM是如何做到的呢?通过轻量级RPC调用 - 您可以使用dce-rpc构建应用程序,并获得相同的结果。在任何情况下,COM都不会提供指向外部dll内存的指针,而是对该dll进行函数调用。 - gbjbaanb
以下文章这里这里,和这里可能会有所帮助。你的代码示例已经接近完成,除了内联虚析构函数之外。据我所知,抽象接口中的所有方法都必须是纯虚拟=0 - greatwolf
9个回答

11

如果您降低期望并坚持使用简单的函数,那么您应该能够混合使用不同编译器构建的模块。

类和虚拟函数的行为是由C++标准定义的,但实现方式取决于编译器。在这种情况下,我知道VC++会以对象的前4个字节(假设是32位)放置一个指向方法入口点的指针表,从而构建具有虚函数的对象。

因此,这行代码:dllclass->PrintSomething(); 实际上相当于这样的内容:

struct IClassVTable {
    void (*pfIClassDTOR)           (Class IClass * this) 
    void (*pfIRefCountedAddRef)    (Class IRefCounted * this);
    void (*pfIRefCountedRelease)   (Class IRefCounted * this);
    void (*pfIClassPrintSomething) (Class IClass * this);
    ...
};
struct IClass {
    IClassVTable * pVTab;
};
(((struct IClass *) dllclass)->pVTab->pfIClassPrintSomething) (dllclass);
如果g++编译器对虚函数表的实现与MSFT VC++有任何不同(只要符合C++标准),那么就会像您所演示的那样崩溃。VC++代码期望函数指针在内存中的特定位置(相对于对象指针)。
继承使问题变得更加复杂,多重继承和虚拟继承则非常非常复杂。
Microsoft公开了VC++实现类的方式,因此您可以编写依赖于它的代码。例如,由MSFT分发的许多COM对象头文件都具有C和C++绑定。 C绑定公开其类似于上面我的代码的vtable结构。
另一方面,我记得GNU已经留下使用不同实现的选项并保证仅用其编译器构建的程序符合标准行为。
简短的答案是坚持使用简单的C风格函数,POD结构(Plain Old Data;即没有虚函数),以及指向不透明对象的指针。

说实话,我宁愿不要到处使用纯函数,而是告诉每个人在采取这一步骤之前必须使用VC9编译dll文件... - Fire Lancer
你也许可以用CORBA做你想做的事情,但我对它不是很了解。 - Die in Sente

6

如果你这样做,几乎可以肯定会遇到麻烦 - 尽管其他评论者正确地指出,在某些情况下C++ ABI可能是相同的,但两个库使用不同的CRT、不同版本的STL、不同的异常抛出语义、不同的优化...你正在走向疯狂的道路。


6

您可以通过在应用程序和dll中使用类,并将两者之间的接口保持为extern "C"函数来组织代码。这是我使用C++ dll被C#程序集调用的方式。导出的DLL函数用于操作通过静态class* Instance()方法访问的实例,例如:

__declspec(dllexport) void PrintSomething()
{
    (A::Instance())->PrintSometing();
}

对于多个对象实例,可以使用dll函数创建实例并返回标识符,然后将其传递给Instance()方法以使用所需的特定对象。如果您需要在应用程序和dll之间进行继承,请在应用程序侧创建一个类来包装导出的dll函数,并从该类派生其他类。按照这种方式组织代码将使DLL接口简单且可在编译器和语言之间轻松移植。


好的,有没有某种部分自动化的方法可以做到这一点,而不是为每个方法编写大约4个步骤(加载C方法以便接口可以找到它,将对接口的调用转换为C方法,从C方法转到dll中的oop方法,最后是dll方法)? - Fire Lancer
你可以部分自动化这个过程。你需要保持一个类和方法的文件列表。一个脚本可以处理这个文件,生成需要包含的文件。但请记住,C dll函数将无法直接接受C++实例*参数。 - Tim Butterfield

5
只要使用extern "C"函数,就可以实现。这是因为"C" ABI 得到了明确定义,而C++ ABI则故意没有定义。因此,每个编译器都允许定义自己的ABI。在某些编译器中,不同版本的编译器甚至以不同的标志生成不兼容的ABI。

@Shy: 应用程序二进制接口。 - Die in Sente

3

我认为您会发现这篇MSDN文章很有用。

无论如何,从您的代码快速浏览中,我可以告诉您,在接口中不应声明虚析构函数。相反,当引用计数降至零时,您需要在A :: Release()中执行delete this


2

您在IT技术方面确实需要依赖VC和GCC之间的v-table布局兼容性。这有一定的可能性是可以的。确保调用约定匹配是您应该检查的事情(COM:__stdcall,您:__thiscall)。

值得注意的是,您在写入时遇到了AV错误。当您进行方法调用本身时,没有任何内容被写入,因此很可能是operator<<出现了问题。当使用LoadLibrary()加载DLL时,std::cout是否会被GCC运行时正确初始化?调试器应该可以告诉您。


我该如何检查在使用VC时GCC dll中的cout是否被正确创建?此外,COM是如何通过__stdcall工作的,因为我认为即使是基本类也必须在VC下通过__thiscall工作? - Fire Lancer

1
您的代码导致崩溃的问题在于接口定义中的虚析构函数:
virtual ~IRefCounted(){}
    ...
virtual ~IClass(){}

删除它们,一切都会没事的。问题是由于虚函数表的组织方式引起的。 MSVC编译器忽略了析构函数,但GCC将其添加为表中的第一个函数。 看看COM接口。它们没有任何构造函数/析构函数。永远不要在接口中定义任何析构函数,这样就没问题了。

0

你的问题是维护ABI。虽然使用相同编译器但不同版本,你仍然想要维护ABI。COM是解决这个问题的一种方式。如果你真的想了解COM如何解决这个问题,请查看这篇文章CPP to COM in msdn,它描述了COM的本质。

除了COM之外,还有其他(最古老的)解决ABI的方法,例如使用Plain old data和opaque pointers。看看Qt/KDE库开发人员解决ABI的方式。


0

有趣...如果您也在VC++中编译dll,或者在CreateClass()中放置一些调试语句会发生什么呢?

我认为可能是您的两个不同运行时版本的cout冲突而不是方法调用 - 但我相信返回的函数指针/dllclass不是0x00000004吧?


不,VC调试器显示指针的值是合理的,就我所知(我以前从未尝试过使用VC调试非VC二进制文件),它实际上从未进入PrintSomething方法,至少堆栈帧表明它在这一点上从未进入dll。 - Fire Lancer
当你调试没有使用VC构建的代码时,甚至当你使用VC构建但没有调试符号时,你不能完全相信调试器关于调用堆栈的提示。 - Die in Sente

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接