关于C++:我如何安全地将对象,特别是STL对象传递给DLL?

How do I safely pass objects, especially STL objects, to and from a DLL?

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

我的应用程序必须以dll文件的形式与第三方插件交互,我无法控制这些插件是用什么编译器构建的。我知道STL对象没有可靠的ABI,我担心在我的应用程序中引起不稳定。


对这个问题的简短回答是不要。因为没有标准的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来回传递数据需要标准的布局类。好的。

呼叫约定好的。

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

保持一个标准的调用约定是很重要的;如果你声明一个函数为EDCOX1(4),默认为C++,试着用EDCOX1调用它。5个坏的事情将会发生。EDOCX1 4是一个C++函数的默认调用约定,但是,除非在一个地方指定EDCOX1×5个,另一个EDCOX1×4个,否则这是不会中断的。好的。

Datatype尺寸好的。

根据此文档,在Windows上,无论应用程序是32位还是64位,大多数基本数据类型的大小都相同。但是,由于给定数据类型的大小是由编译器强制执行的,而不是由任何标准强制执行的(所有标准保证都是1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)),因此最好使用固定大小的数据类型,以确保尽可能兼容数据类型的大小。好的。

堆问题好的。

如果您的dll链接到的C运行时版本与您的exe不同,那么这两个模块将使用不同的堆。这是一个特别可能的问题,因为模块是用不同的编译器编译的。好的。

为了缓解这种情况,必须将所有内存分配到一个共享堆中,并从同一堆中释放。幸运的是,Windows提供了API来帮助实现这一点:getprocessheap允许您访问主机exe的堆,heapalloc/heapfree允许您在此堆中分配和释放内存。重要的是不要使用普通的mallocfree,因为不能保证它们按您期望的方式工作。好的。

STL问题好的。

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

名字命名好的。

您的dll可能会导出您的exe想要调用的函数。但是,C++编译器没有一个标准的函数名。这意味着在gcc中名为GetCCDLL的函数可能被破坏为_Z8GetCCDLLv,在msvc中则被破坏为?GetCCDLL@@YAPAUCCDLL_v1@@XZ。好的。

您已经不能保证静态链接到您的dll,因为用gcc生成的dll不会生成.lib文件,并且在msvc中静态链接dll需要一个。动态链接似乎是一个更干净的选择,但名称管理会妨碍您的方法:如果您尝试使用错误的管理名称GetProcAddress,调用将失败,您将无法使用您的dll。这需要一点黑客来绕过,这是为什么在一个DLL边界上传递C++类是一个坏主意的一个相当重要的原因。好的。

您需要构建您的dll,然后检查生成的.def文件(如果生成了一个;这将根据您的项目选项而变化),或者使用类似依赖关系查询器的工具来查找损坏的名称。然后,您需要编写自己的.def文件,为损坏的函数定义一个未混合的别名。作为一个例子,让我们使用我前面提到过的GetCCDLL函数。在我的系统中,以下.def文件分别适用于gcc和msvc:好的。

海湾合作委员会:好的。

1
2
EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:好的。

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

重新生成DLL,然后重新检查它导出的函数。其中应包含未组合的函数名。请注意,不能这样使用重载函数:未合并的函数名是由已损坏的名称定义的特定函数重载的别名。另外请注意,每次更改函数声明时,都需要为DLL创建一个新的.def文件,因为损坏的名称将更改。最重要的是,通过绕过名称管理,您将覆盖链接器在不兼容问题上试图为您提供的任何保护。好的。

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

将类对象传递给函数好的。

这可能是困扰跨编译器数据传递的最微妙和最危险的问题。即使处理了所有其他事情,也没有标准来规定如何将参数传递给函数。这可能导致细微的崩溃,没有明显的原因,也没有简单的调试方法。您需要通过指针传递所有参数,包括任何返回值的缓冲区。这是笨拙和不方便的,而且是另一个可能或可能不起作用的黑客解决方案。好的。

将所有这些解决方法放在一起,并利用模板和运算符构建一些创造性的工作,我们可以尝试安全地将对象通过DLL边界。请注意,C++ 11的支持是强制性的,就像对EDCOX1和7的变体及其变体的支持一样,MSVC 2013提供了这种支持,就像GCC和CLAN的最新版本一样。好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
//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_t中,uint将包装到uint32_t中,等等。由于=()操作符过载,这些都发生在后台。我省略了其余的基本类型专门化,因为它们几乎完全相同,除了底层数据类型(bool专门化有一点额外的逻辑,因为它被转换为int8_t,然后int8_t被比较为0以转换回bool,但这是相当小的)。好的。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#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)

现在我们可以创建一个使用这些pod类型的dll。首先,我们需要一个接口,所以我们只有一个方法来解决管理问题。好的。

1
2
3
4
5
6
7
//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端实现它:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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功能:好的。

1
2
3
4
5
6
7
#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:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//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++对象,你就是这样做的。但是,这些都不能保证与您的安装程序或其他任何人一起工作。任何一个可能在任何时候中断,并且可能会在您的软件计划有一个主要版本的前一天中断。这条路充满了我可能会被枪毙的黑客、风险和一般的愚蠢行为。如果你走这条路,请极其小心地测试。真的…别这么做。好的。好啊。


"计算机FrieCK"写了一个很好的解释,说明为什么ABI的缺乏在一般情况下阻止传递C++对象跨越DLL边界,即使当类型定义在用户控制之下,并且在两个程序中都使用相同的令牌序列。(有两种情况可以工作:标准布局类和纯接口)

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

这是Linux程序员不习惯处理的问题,因为G++的libstdc++实际上是一个标准,几乎所有程序都使用它,从而满足了ODR。CLAN的LBC++打破了这种假设,然后C++ 11伴随着几乎所有标准库类型的强制更改。

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


这里的一些答案使传递C++类听起来非常可怕,但我想分享另一种观点。在其他一些反应中提到的纯虚C++方法实际上比你想象的更干净。我围绕这个概念构建了一个完整的插件系统,它已经运行了很多年了。我有一个"pluginmanager"类,它使用loadlib()和getprocAddress()动态地从指定的目录加载DLL(以及Linux等价物,使其跨平台运行)。

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

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

我想说,这种方法最大的缺点是,您必须非常小心地将哪些类型作为参数传递。没有类或STL对象,如果不首先用纯虚拟接口包装它们。没有结构(不经过pragma pack voodoo)。只是指向其他接口的主要类型和指针。另外,你不能超载的功能,这是一个不便,但不是一个显示阻止。

好消息是,使用少量的代码行,您可以创建可重用的通用类和接口来包装STL字符串、向量和其他容器类。或者,您可以向接口中添加诸如getcount()和getval(n)之类的函数,让人们循环访问列表。

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

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

所以这个故事的寓意是…除了一些极端情况外,您需要一个负责接口的人来确保ABI边界与原始类型保持干净,并避免重载。如果您同意这个规定,那么我不会害怕在编译器之间共享dlls/sos中的类的接口。直接共享类==trouble,但是共享纯虚拟接口并没有那么糟糕。


不能安全地通过STL对象跨越DLL边界,除非所有的模块(.exe和.dll)都用相同的C++编译器版本和CRT的相同设置和味道构建,这是高度约束的,而且显然不是你的情况。

如果您想从DLL公开面向对象接口,则应该公开C++纯接口(类似于COM)。考虑阅读这篇关于代码项目的有趣文章:

HowTo: Export C++ classes from a DLL

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