如何安全地向 DLL 传递对象,特别是 STL 对象?

如何向 C + + DLL 传递类对象,尤其是 STL 对象?

My application has to interact with third-party plugins in the form of DLL files, and I can't control what compiler these plugins are built with. I'm aware that there's no guaranteed ABI for STL objects, and I'm concerned about causing instability in my application.

37001 次浏览

这个问题的简短答案是 不要。因为没有标准的 c + + ABI(应用二进制接口,一个调用约定、数据打包/对齐、类型大小等的标准) ,你将不得不跳过很多环节来尝试和执行在你的程序中处理类对象的标准方法。甚至不能保证在您跳过所有这些环之后它还能正常工作,也不能保证在一个编译器版本中工作的解决方案在下一个编译器版本中也能正常工作。

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


如果您真的希望通过 DLL 边界传递 C + + 对象,那么从技术上讲是可行的。下面是一些你需要考虑的因素:

数据整理/校准

在给定的类中,单个数据成员通常会特别放置在内存中,这样它们的地址就对应于类型大小的倍数。例如,int可能与4字节边界对齐。

如果你的 DLL 是用一个不同于 EXE 的编译器编译的,那么给定类的 DLL 版本可能和 EXE 版本有不同的包装,所以当 EXE 把类对象传递给 DLL 时,DLL 可能无法正确访问该类中的给定数据成员。DLL 将尝试从它自己的类定义(而不是 EXE 的定义)指定的地址读取数据,而且由于所需的数据成员实际上并没有存储在那里,因此会产生垃圾值。

您可以使用 #pragma pack预处理器指令来解决这个问题,该指令将强制编译器应用特定的封装。因此,如果选择一个较大的打包值,类在编译器之间仍然可以有不同的打包。解决这个问题的方法是使用 #pragma pack(1),它将强制编译器在一个字节的边界上对数据成员进行对齐(基本上不会应用打包)。但是,它 威尔确保类的数据成员在内存中对齐的方式的一致性。

会员重新排序

如果类不是 标准布局,则编译器 可以在内存中重新排列其数据成员。对于如何做到这一点没有标准,因此任何数据重排都可能导致编译器之间的不兼容性。因此,将数据来回传递给 DLL 将需要标准布局类。

Calling convention

一个给定的函数可以有多个 召集会议。这些调用约定指定了如何将数据传递给函数: 参数是存储在寄存器中还是存储在堆栈中?将参数推送到堆栈上的顺序是什么?在函数完成之后,谁来清理堆栈上剩下的任何参数?

维护标准调用约定非常重要; 如果将函数声明为 _cdecl(C + + 的默认值) ,并尝试使用 _stdcall 不好的事情会发生调用它。但是,_cdecl是 C + + 函数的默认调用约定,所以除非在一个位置指定 _stdcall,在另一个位置指定 _cdecl,否则这个约定不会中断。

数据类型大小

根据 这份文件,在 Windows 上,不管你的应用是32位还是64位,大多数基本数据类型都有相同的大小。然而,由于给定数据类型的大小是由编译器强制执行的,而不是由任何标准(所有的标准保证都是 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)) ,因此在可能的情况下,使用 固定大小的数据类型来确保数据类型大小的兼容性是一个好主意。

Heap issues

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

为了减轻这种情况,必须将所有内存分配到一个共享堆中,并从同一个堆中释放。幸运的是,Windows 提供了 API 来帮助解决这个问题: GetProcessHeap将允许您访问主机 EXE 的堆,而 HeapAlloc/HeapFree将允许您在这个堆中分配和释放内存。重要的是,你不使用正常的 ABC0/free,因为没有保证他们将工作的方式,你期望。

STL 问题

C++标准程式库有自己的 ABI 问题。有 不能保证表明给定的 STL 类型在内存中以相同的方式布局,也不能保证给定的 STL 类在不同的实现中具有相同的大小(特别是,调试构建可能会将额外的调试信息放入给定的 STL 类型中)。因此,任何 STL 容器在通过 DLL 边界并在另一边重新打包之前都必须解包为基本类型。

名字被毁了

DLL 可能会导出 EXE 要调用的函数。但是,C + + 编译器 没有一种标准的方法来处理函数名。这意味着一个名为 GetCCDLL的函数可能会在 GCC 中被破坏为 _Z8GetCCDLLv,在 MSVC 中被破坏为 ?GetCCDLL@@YAPAUCCDLL_v1@@XZ

You already won't be able to guarantee static linking to your DLL, since a DLL produced with GCC won't produce a .lib file and statically linking a DLL in MSVC requires one. Dynamically linking seems like a much cleaner option, but name mangling gets in your way: if you try to GetProcAddress the wrong mangled name, the call will fail and you won't be able to use your DLL. This requires a little bit of hackery to get around, and is a fairly major reason why passing C++ classes across a DLL boundary is a bad idea.

You'll need to build your DLL, then examine the produced .def file (if one is produced; this will vary based on your project options) or use a tool like Dependency Walker to find the mangled name. Then, you'll need to write your 自己的 .def file, defining an unmangled alias to the mangled function. As an example, let's use the GetCCDLL function I mentioned a bit further up. On my system, the following .def files work for GCC and MSVC, respectively:

海湾合作委员会:

EXPORTS
GetCCDLL=_Z8GetCCDLLv @1

MSVC:

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

重新生成 DLL,然后重新检查它导出的函数。其中应该包括一个未损坏的函数名。未损坏的函数名是由损坏的名称定义的 一个特定的功能过载的别名。还要注意,您需要创建一个新的。每次更改函数声明时,DLL 的 def 文件,因为错误的名称将会更改。最重要的是,通过绕过名称混淆,您将覆盖链接器试图为您提供的任何关于不兼容性问题的保护。

如果使用 create an interface来定义 DLL,那么整个过程会更简单,因为只需要为一个函数定义别名,而不需要为 DLL 中的每个函数创建别名。然而,同样的警告仍然适用。

将类对象传递给函数

This is probably the most subtle and most dangerous of the issues that plague cross-compiler data passing. Even if you handle everything else, 没有关于如何将参数传递给函数的标准. This can cause 没有明显的原因,也没有简单的方法来调试它们的微妙的崩溃. You'll need to pass 所有 arguments via pointers, including buffers for any return values. This is clumsy and inconvenient, and is yet another hacky workaround that may or may not work.


将所有这些变通方法组合在一起并构建在 一些创造性的工作与模板和操作员上,我们可以尝试安全地跨 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类专门用于每个基本数据类型,因此 int将自动被包装到 int32_tuint将被包装到 uint32_t,等等。由于使用了重载的 =()操作符,所有这些都是在幕后进行的。我省略了其余的基本类型专门化,因为除了底层数据类型之外,它们几乎完全相同(bool专门化有一点额外的逻辑,因为它被转换为 int8_t,然后将 int8_t与0进行比较以转换回 bool,但这是相当琐碎的)。

我们也可以用这种方式包装 STL 类型,尽管这需要一些额外的工作:

#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)

现在我们可以创建一个 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();

这只是创建了 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,而不是一个完整的实用程序库。

现在我们可以构建 DLL 了。别忘了特色菜。使用 def 文件处理链接器的名称错位。(注意: 我实际构建和运行的 CCDLL 结构的函数比我在这里提到的函数多。那个。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。

The image that showing the result afterward.


总而言之,如果您绝对地跨 DLL 边界传递 C + + 对象,那么您就应该这样做。但是,这些都不能保证与您的设置或任何其他人的设置一起工作。所有这些都可能在任何时候中断,并且可能在您的软件计划发布主要版本的前一天中断。这条路上充满了黑客,冒险,还有一些我应该被枪毙的愚蠢行为。如果你选择这条路线,请极其小心地进行测试。真的... 千万别这么做。

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

如果要从 DLL 中公开面向对象的接口,应该公开 C + + 纯接口(类似于 COM 的做法)。考虑一下阅读 CodeProject 上这篇有趣的文章:

如何: 从 DLL 中导出 C + + 类

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

@ Computerfreaker 已经写了一个很好的解释,为什么缺少 ABI 会阻止跨 DLL 边界传递 C + + 对象,即使类型定义在用户控制下,并且两个程序使用完全相同的令牌序列。(有两种情况可行: 标准布局类和纯接口)

对于 C + + 标准中定义的对象类型(包括那些改编自标准模板库的对象类型) ,情况要糟糕得多。定义这些类型的令牌在多个编译器之间并不相同,因为 C + + 标准没有提供完整的类型定义,只有最低要求。此外,出现在这些类型定义中的标识符的名称查找不会解析相同的内容。即使在有 C + + ABI 的系统中,尝试跨模块边界共享这些类型也会因为违反单一定义规则而导致大量的未定义行为。

这是 Linux 程序员不习惯处理的问题,因为 g + + 的 libstdc + + 是事实上的标准,几乎所有程序都使用它,因此满足了 ODR。Clang 的 libc + + 打破了这个假设,然后 C + + 11对几乎所有的标准库类型都进行了强制性更改。

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

这里的一些答案使得传递 C + + 类听起来非常可怕,但是我想分享一个不同的观点。其他一些响应中提到的纯虚拟 C + + 方法实际上比您想象的要简单。我已经围绕这个概念构建了一个完整的插件系统,多年来一直运行良好。我有一个“ PluginManager”类,它使用 LoadLib ()和 GetProcAddress ()从指定目录动态加载 dll (以及 Linux 等价物,使可执行文件能够跨平台运行)。

信不信由你,即使你做了一些古怪的事情,比如在纯虚拟接口的末尾添加一个新函数,并尝试在没有新函数的情况下加载根据接口编译的 dls,这种方法也是可以原谅的——它们会很好地加载。当然... 您必须检查版本号,以确保您的可执行文件只为实现该函数的较新 dls 调用新函数。但是好消息是: 它起作用了!因此,在某种程度上,你有一个粗糙的方法,随着时间的推移演变你的接口。

关于纯虚拟接口的另一个很酷的事情是——您可以继承任意多的接口,而且永远不会遇到菱形问题!

我认为这种方法最大的缺点是必须非常小心地传递参数的类型。任何类或 STL 对象都必须首先用纯虚拟接口包装它们。没有 structs (没有经历过杂注包巫术)。只有主要类型和指向其他接口的指针。此外,你不能过载的功能,这是一个不便,但不是一个展示停止。

好消息是,只需要几行代码,就可以创建可重用的泛型类和接口来包装 STL 字符串、向量和其他容器类。或者,您可以向接口添加函数,如 GetCount ()和 GetVal (n) ,让人们循环访问列表。

为我们构建插件的人们发现这很容易。它们不必是 ABI 边界或其他方面的专家——它们只需继承它们感兴趣的接口,编写它们支持的函数,并为不支持的函数返回 false。

据我所知,所有这些工作的技术都不是基于任何标准的。据我所知,微软决定这样做他们的虚拟表,以便他们可以使 COM,和其他编译器的作者决定效仿。这包括 GCC、 Intel、 Borland 和大多数其他主要的 C + + 编译器。如果您计划使用一个晦涩的嵌入式编译器,那么这种方法可能不适合您。理论上任何编译器公司都可以在任何时候改变他们的虚拟表并破坏它们,但是考虑到多年来编写的依赖于这种技术的大量代码,如果任何一个主要的玩家决定破坏它们的排名,我会非常惊讶。

因此,这个故事的寓意是... ... 除了一些极端情况外,您需要一个负责接口的人,他可以确保 ABI 边界与原始类型保持一致,并避免过载。如果您同意这个规定,那么我不会害怕在编译器之间共享 DLL/SO 中类的接口。直接共享类 = = 麻烦,但是共享纯虚拟接口也不是那么糟糕。