如何安全地将对象,尤其是STL对象传递给DLL?

如何将类对象,特别是STL对象传递给C ++ DLL? 我的应用程序必须以DLL的forms与第三方插件进行交互,而且我无法控制这些插件所用的编译器。 我知道STL对象没有保证ABI,我担心导致我的应用程序不稳定。

这个问题的简短答案是 。 由于没有标准的C ++ ABI (应用程序二进制接口,用于调用约定,数据打包/alignment,types大小等的标准),您将不得不跳过一大堆尝试和强制处理类的标准方法对象在你的程序中。 在跳过所有这些环节之后,甚至没有保证它会起作用,也没有保证在一个编译器版本中工作的解决scheme将在下一个工作。

只需使用extern "C"创build一个简单的C接口,因为C ABI定义良好且稳定。


如果你确实想要跨DLL边界传递C ++对象,这在技术上是可行的。 以下是您必须考虑的一些因素:

数据打包/alignment

在一个给定的类中,单个数据成员通常会被专门放置在内存中,因此他们的地址对应于types大小的倍数。 例如,一个int可以alignment到一个4字节的边界。

如果您的DLL使用与EXE不同的编译器进行编译,则DLL的给定类的版本可能与EXE的版本不同,因此,当EXE将类对象传递给DLL时,DLL可能无法正确访问给予该类内的数据成员。 DLL将试图从它自己定义的类所指定的地址读取,而不是EXE的定义,因为所需的数据成员实际上并不存储在那里,所以会产生垃圾值。

您可以使用#pragma pack预处理器指令解决此问题,这将强制编译器应用特定的打包。 编译器仍然会应用默认的打包,如果你select的打包值大于编译器select的打包值 ,那么如果你select一个大的打包值,一个类在编译器之间仍然可以有不同的打包。 解决方法是使用#pragma pack(1) ,这将强制编译器将数据成员在一个字节的边界上alignment(实际上,不会应用打包)。 这不是一个好主意,因为它可能会导致性能问题甚至在某些系统上崩溃。 但是,它将确保类的数据成员在内存中的一致性。

会员重新sorting

如果您的类不是标准布局 ,则编译器可以将其数据成员重新排列在内存中 。 关于如何完成这个任务没有标准,所以任何数据重新排列都会导致编译器之间的不兼容。 因此,将数据传递给DLL将需要标准布局类。

调用约定

给定函数可以有多个调用约定 。 这些调用约定指定了如何将数据传递给函数:是存储在寄存器还是堆栈中的参数? 什么顺序的参数推入堆栈? 谁清除了函数完成后留在堆栈上的任何参数?

保持一个标准的通话习惯是很重要的。 如果你声明一个函数为_cdecl ,C ++的默认值,并尝试使用_stdcall调用它的坏事情会发生 。 _cdecl是C ++函数的默认调用约定,但是,除非有意通过在一个地方指定_stdcall在另一个地方指定_cdecl来破坏它,否则这是不会中断的。

数据types大小

根据本文档 ,在Windows上,无论您的应用程序是32位还是64位,大多数基本数据types都具有相同的大小。 但是,由于给定数据types的大小是由编译器强制执行的,而不是由任何标准执行(所有的标准保证是1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long) ),在可能的情况下使用固定大小的数据types来确保数据types大小的兼容性是个好主意。

堆问题

如果您的DLL链接到不同于您的EXE的C运行时版本,则这两个模块将使用不同的堆 。 考虑到模块正在使用不同的编译器进行编译,这是一个特别可能的问题。

为了减轻这一点,所有的内存将被分配到一个共享的堆,并从同一个堆中释放。 幸运的是,Windows提供了API来帮助解决这个问题: GetProcessHeap可以让你访问宿主的EXE堆,而HeapAlloc / HeapFree会让你在这个堆内分配和释放内存。 不要使用普通的malloc / free是很重要的,因为不能保证它们能按照你期望的方式工作。

STL问题

C ++标准库有自己的一套ABI问题。 不能保证给定的STLtypes在内存中以相同的方式布置,也不能保证给定的STL类从一个实现到另一个实现具有相同的大小(特别是,debugging构build可以将额外的debugging信息放入给定STLtypes)。 因此,任何STL容器都必须在通过DLL边界传递并重新打包到另一端之前解压缩为基本types。

名称捣毁

您的DLL大概会导出您的EXE将要调用的函数。 但是,C ++编译器没有标准的方式来修改函数名称 。 这意味着名为GetCCDLL的函数可能会在GCC中被改为_Z8GetCCDLLv ,而在MSVC中?GetCCDLL@@YAPAUCCDLL_v1@@XZ

您已经无法保证静态链接到您的DLL,因为使用GCC生成的DLL不会生成.lib文件,静态链接MSVC中的DLL需要一个。 dynamic链接似乎是一个非常干净的选项,但是名字变形会影响您的工作:如果您尝试使用GetProcAddress错误的名称,调用将会失败,您将无法使用您的DLL。 这需要一些琐碎的事情才能解决,而且为什么通过DLL边界传递C ++类是一个不好的主意。

您需要构build您的DLL,然后检查生成的.def文件(如果生成的;这将根据您的项目选项而有所不同),或使用像Dependency Walker这样的工具来查找损坏的名称。 然后,你需要编写你自己的 .def文件,为mangled函数定义一个unmangled别名。 作为一个例子,让我们使用我提到的GetCCDLL函数。 在我的系统上,以下.def文件分别适用于GCC和MSVC:

GCC:

 EXPORTS GetCCDLL=_Z8GetCCDLLv @1 

MSVC:

 EXPORTS GetCCDLL=?GetCCDLL@@YAPAUCCDLL_v1@@XZ @1 

重build你的DLL,然后重新检查它输出的函数。 一个unmangled函数名称应该在其中。 请注意,您不能以这种方式使用重载函数未重载函数名称是由重名名称定义的特定函数重载的别名。 另外请注意,每次更改函数声明时,都需要为DLL创build一个新的.def文件,因为重名的名称将会改变。 最重要的是,通过绕过这个名称的修改,您将覆盖链接器试图向您提供的关于不兼容问题的任何保护。

如果你为你的DLL 创build一个接口 ,整个过程就简单了 ,因为你只需要一个函数来定义一个别名,而不需要为你的DLL中的每个函数创build一个别名。 然而,同样的警告仍然适用。

将类对象传递给一个函数

这可能是困扰交叉编译器数据传递的问题中最微妙也是最危险的。 即使你处理了其他所有事情, 对于如何将parameter passing给函数也没有标准 。 这可能导致微妙的崩溃,没有明显的原因,也没有简单的方法来debugging它们 。 您需要通过指针传递所有参数,包括任何返回值的缓冲区。 这是笨拙和不方便,是又一个可能或不可行的解决方法。


综合所有这些变通方法并利用模板和运算符构build一些创造性的工作 ,我们可以尝试安全地通过DLL边界传递对象。 请注意,C ++ 11支持是必需的,支持#pragma pack及其变体也是必须的。 MSVC 2013提供了这种支持,GCC和clang的最新版本也是如此。

 //POD_base.h: defines a template base class that wraps and unwraps data types for safe passing across compiler boundaries //define malloc/free replacements to make use of Windows heap APIs namespace pod_helpers { void* pod_malloc(size_t size) { HANDLE heapHandle = GetProcessHeap(); HANDLE storageHandle = nullptr; if (heapHandle == nullptr) { return nullptr; } storageHandle = HeapAlloc(heapHandle, 0, size); return storageHandle; } void pod_free(void* ptr) { HANDLE heapHandle = GetProcessHeap(); if (heapHandle == nullptr) { return; } if (ptr == nullptr) { return; } HeapFree(heapHandle, 0, ptr); } } //define a template base class. We'll specialize this class for each datatype we want to pass across compiler boundaries. #pragma pack(push, 1) // All members are protected, because the class *must* be specialized // for each type template<typename T> class pod { protected: pod(); pod(const T& value); pod(const pod& copy); ~pod(); pod<T>& operator=(pod<T> value); operator T() const; T get() const; void swap(pod<T>& first, pod<T>& second); }; #pragma pack(pop) //POD_basic_types.h: holds pod specializations for basic datatypes. #pragma pack(push, 1) template<> class pod<unsigned int> { //these are a couple of convenience typedefs that make the class easier to specialize and understand, since the behind-the-scenes logic is almost entirely the same except for the underlying datatypes in each specialization. typedef int original_type; typedef std::int32_t safe_type; public: pod() : data(nullptr) {} pod(const original_type& value) { set_from(value); } pod(const pod<original_type>& copyVal) { original_type copyData = copyVal.get(); set_from(copyData); } ~pod() { release(); } pod<original_type>& operator=(pod<original_type> value) { swap(*this, value); return *this; } operator original_type() const { return get(); } protected: safe_type* data; original_type get() const { original_type result; result = static_cast<original_type>(*data); return result; } void set_from(const original_type& value) { data = reinterpret_cast<safe_type*>(pod_helpers::pod_malloc(sizeof(safe_type))); //note the pod_malloc call here - we want our memory buffer to go in the process heap, not the possibly-isolated DLL heap. if (data == nullptr) { return; } new(data) safe_type (value); } void release() { if (data) { pod_helpers::pod_free(data); //pod_free to go with the pod_malloc. data = nullptr; } } void swap(pod<original_type>& first, pod<original_type>& second) { using std::swap; swap(first.data, second.data); } }; #pragma pack(pop) 

pod类是专门为每一个基本的数据types,所以int会自动被包装到int32_tuint将被包装到uint32_t等。这一切都发生在幕后,感谢过载=()运算符。 我省略了其余的基本types特化,因为它们几乎完全相同,除了底层的数据types( bool有一点额外的逻辑,因为它被转换为int8_t ,然后int8_t被比较为0转换回bool ,但这是相当微不足道的)。

我们也可以用这种方式来包装STLtypes,尽pipe它需要一些额外的工作:

 #pragma pack(push, 1) template<typename charT> class pod<std::basic_string<charT>> //double template ftw. We're specializing pod for std::basic_string, but we're making this specialization able to be specialized for different types; this way we can support all the basic_string types without needing to create four specializations of pod. { //more comfort typedefs typedef std::basic_string<charT> original_type; typedef charT safe_type; public: pod() : data(nullptr) {} pod(const original_type& value) { set_from(value); } pod(const charT* charValue) { original_type temp(charValue); set_from(temp); } pod(const pod<original_type>& copyVal) { original_type copyData = copyVal.get(); set_from(copyData); } ~pod() { release(); } pod<original_type>& operator=(pod<original_type> value) { swap(*this, value); return *this; } operator original_type() const { return get(); } protected: //this is almost the same as a basic type specialization, but we have to keep track of the number of elements being stored within the basic_string as well as the elements themselves. safe_type* data; typename original_type::size_type dataSize; original_type get() const { original_type result; result.reserve(dataSize); std::copy(data, data + dataSize, std::back_inserter(result)); return result; } void set_from(const original_type& value) { dataSize = value.size(); data = reinterpret_cast<safe_type*>(pod_helpers::pod_malloc(sizeof(safe_type) * dataSize)); if (data == nullptr) { return; } //figure out where the data to copy starts and stops, then loop through the basic_string and copy each element to our buffer. safe_type* dataIterPtr = data; safe_type* dataEndPtr = data + dataSize; typename original_type::const_iterator iter = value.begin(); for (; dataIterPtr != dataEndPtr;) { new(dataIterPtr++) safe_type(*iter++); } } void release() { if (data) { pod_helpers::pod_free(data); data = nullptr; dataSize = 0; } } void swap(pod<original_type>& first, pod<original_type>& second) { using std::swap; swap(first.data, second.data); swap(first.dataSize, second.dataSize); } }; #pragma pack(pop) 

现在我们可以创build一个使用这些podtypes的DLL。 首先我们需要一个接口,所以我们只有一个方法来弄清楚。

 //CCDLL.h: defines a DLL interface for a pod-based DLL struct CCDLL_v1 { virtual void ShowMessage(const pod<std::wstring>* message) = 0; }; CCDLL_v1* GetCCDLL(); 

这只是创build一个DLL和任何调用者都可以使用的基本接口。 请注意,我们正在将一个指针传递给一个pod ,而不是一个pod本身。 现在我们需要在DLL端实现:

 struct CCDLL_v1_implementation: CCDLL_v1 { virtual void ShowMessage(const pod<std::wstring>* message) override; }; CCDLL_v1* GetCCDLL() { static CCDLL_v1_implementation* CCDLL = nullptr; if (!CCDLL) { CCDLL = new CCDLL_v1_implementation; } return CCDLL; } 

现在我们来实现ShowMessage函数:

 #include "CCDLL_implementation.h" void CCDLL_v1_implementation::ShowMessage(const pod<std::wstring>* message) { std::wstring workingMessage = *message; MessageBox(NULL, workingMessage.c_str(), TEXT("This is a cross-compiler message"), MB_OK); } 

没有什么太花哨:这只是将传递的pod复制到一个正常的wstring并将其显示在一个消息框中。 毕竟,这只是一个POC ,而不是一个完整的实用程序库。

现在我们可以build立DLL了。 不要忘记特殊的.def文件,以解决链接器名称的问题。 (注意:我实际构build和运行的CCDLL结构比我在这里提供的function要多,.def文件可能无法正常工作。)

现在为一个EXE调用DLL:

 //main.cpp #include "../CCDLL/CCDLL.h" typedef CCDLL_v1*(__cdecl* fnGetCCDLL)(); static fnGetCCDLL Ptr_GetCCDLL = NULL; int main() { HMODULE ccdll = LoadLibrary(TEXT("D:\\Programming\\C++\\CCDLL\\Debug_VS\\CCDLL.dll")); //I built the DLL with Visual Studio and the EXE with GCC. Your paths may vary. Ptr_GetCCDLL = (fnGetCCDLL)GetProcAddress(ccdll, (LPCSTR)"GetCCDLL"); CCDLL_v1* CCDLL_lib; CCDLL_lib = Ptr_GetCCDLL(); //This calls the DLL's GetCCDLL method, which is an alias to the mangled function. By dynamically loading the DLL like this, we're completely bypassing the name mangling, exactly as expected. pod<std::wstring> message = TEXT("Hello world!"); CCDLL_lib->ShowMessage(&message); FreeLibrary(ccdll); //unload the library when we're done with it return 0; } 

这里是结果。 我们的DLL的作品。 我们已经成功地达到了过去的STL ABI问题,过去的C ++ ABI问题,过去的问题,我们的MSVC DLL正在使用GCC EXE。


总之,如果你绝对必须通过DLL边界传递C ++对象,这就是你如何做到的。 但是,这一切都不能保证与您的设置或任何其他人的工作。 任何这些可能会随时中断,并可能会打破你的软件计划有一个主要版本的一天。 这个道路充满了黑客,风险和一般白痴,我可能应该被枪杀。 如果你走这条路线,请慎重testing。 真的…只是不要这样做。

@computerfreaker已经写了一个很好的解释,即在types定义在用户控制之下,并且在这两个程序中使用了完全相同的标记序列时,为什么在一般情况下缺lessABI会阻止跨越DLL边界传递C ++对象。 (有两个工作的例子:标准布局类和纯粹的接口)

对于在C ++标准中定义的对象types(包括从标准模板库中修改的对象types)来说,情况要差得多。 定义这些types的令牌在多个编译器中是不相同的,因为C ++标准没有提供完整的types定义,只有最低要求。 另外,出现在这些types定义中的标识符的名称查找不能解决相同的问题。 即使在存在C ++ ABI的系统上,尝试跨模块边界共享这些types也会由于一个定义规则违规而导致大量未定义的行为。

这是Linux程序员不习惯处理的事情,因为g ++的libstdc ++是一个事实上的标准,几乎所有的程序都使用它,因此满足了ODR。 铿锵的libc ++打破了这个假设,然后C ++ 11伴随着几乎所有标准库types的强制性更改。

只是不要在模块之间共享标准库types。 这是未定义的行为。

除非所有的模块(.EXE和.DLL)都使用相同的C ++编译器版本和CRT的相同设置和风格构build,这是高度约束的,显然不是您的情况,否则不能安全地跨DLL边界传递STL对象。

如果你想从你的DLL中公开一个面向对象的接口,你应该暴露C ++的纯接口(这与COM相似)。 考虑阅读CodeProject上这个有趣的文章:

如何:从DLL导出C ++类

您可能还想考虑在DLL边界处暴露一个纯C接口,然后在调用者站点构build一个C ++包装器。
这类似于Win32中发生的事情:Win32实现代码几乎是C ++,但是很多Win32 API公开了纯粹的C接口(也有暴露COM接口的API)。 然后ATL / WTL和MFC用C ++类和对象包装这些纯粹的C接口。

这里的一些答案使传递的C ++类听起来真的很可怕,但我想分享一个替代的观点。 在其他一些响应中提到的纯虚拟C ++方法实际上变得比您想象的要干净。 我已经围绕这个概念构build了一个完整的插件系统,并且多年来一直运行良好。 我有一个“PluginManager”类,它使用LoadLib()和GetProcAddress()(以及与之相当的可执行文件,使其跨平台)从指定目录dynamic加载dll。

信不信由你,这种方法是宽容的,即使你做了一些古怪的东西,比如在你的纯虚拟接口的末尾添加一个新的函数,并尝试加载没有这个新函数的接口编译的dll,它们会加载得很好。 当然,你必须检查一个版本号,以确保你的可执行文件只为实现这个函数的较新的dll调用新的函数。 但好消息是:它的工作原理! 所以从某种意义上说,你有一个粗略的方法来演变你的界面。

纯虚拟接口的另一个很酷的东西 – 你可以inheritance尽可能多的接口,你永远不会遇到钻石问题!

我认为这种方法最大的缺点是你必须非常小心你传递的参数types。 没有任何类或STL对象没有先包装纯虚拟接口。 没有结构(没有通过编译包巫术)。 只是主要types和其他接口的指针。 此外,你不能超载的function,这是一个不便,但不是一个表演停止。

好消息是,通过一些代码行,可以创build可重用的generics类和接口来包装STLstring,向量和其他容器类。 或者,您可以像GetCount()和GetVal(n)一样将函数添加到您的界面,以便让用户循环访问列表。

为我们build设插件的人觉得很容易。 他们不必是ABI边界上的专家或任何东西 – 他们只是inheritance他们感兴趣的接口,编码他们支持的function,并为他们不支持的function返回false。

据我所知,使这一切工作的技术并不是以任何标准为基础的。 从我所收集的内容来看,微软决定以这样的方式制作虚拟表格,这样他们就可以制作COM,而其他编译器作者也决定效仿。 这包括GCC,Intel,Borland和大多数其他主要的C ++编译器。 如果你打算使用一个模糊的embedded式编译器,那么这种方法可能不适合你。 从理论上讲,任何一家编译器公司都可以随时改变他们的虚拟表格,但是考虑到多年来依赖这种技术编写的大量代码,如果任何主要玩家决定打破排名,我会感到非常惊讶。

因此,故事的寓意是…除了极端的情况之外,您需要一个负责接口的人来确保ABI边界保持原始types的清洁,避免重载。 如果你确定了这个规定,那么我不会害怕在编译器之间共享DLLs / SOs中的类。 直接共享类==麻烦,但共享纯虚拟接口并不是那么糟糕。