如何安全地在动态链接库中传递对象,特别是STL对象?

126

如何传递类对象,特别是STL对象到C++ DLL并从DLL返回?

我的应用程序必须与以DLL文件形式存在的第三方插件交互,我无法控制这些插件使用的编译器。我知道STL对象没有保证的ABI,并且我担心会导致我的应用程序不稳定。


4
如果你在谈论 C++ 标准库,那么最好称其为 C++ Standard Library。STL 的含义可能因上下文不同而有所不同。(另请参见 https://dev59.com/UG435IYBdhLWcg3wyzaa) - Micha Wiedenmann
4个回答

196
这个问题的简短回答是不要。因为没有标准的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将需要标准布局类。 调用约定 给定函数可以有多个调用约定。这些调用约定指定如何将数据传递给函数:参数存储在寄存器还是堆栈中?参数以何种顺序推送到堆栈上?谁清理函数完成后堆栈上剩余的任何参数?
重要的是保持标准的调用约定;如果你将一个函数声明为 C++ 的默认调用约定 _cdecl,并尝试使用 _stdcall 进行调用,那么 会发生糟糕的事情。然而,_cdecl 是 C++ 函数的默认调用约定,所以除非你在一个地方指定了 _stdcall,在另一个地方指定了 _cdecl,否则这一点不会出现问题。 数据类型大小 根据此文档,在Windows上,大多数基本数据类型的大小都相同,无论您的应用程序是32位还是64位。但是,由于给定数据类型的大小是由编译器而不是任何标准强制执行的(所有标准都保证 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)),因此最好使用固定大小的数据类型尽可能确保数据类型大小的兼容性。 堆问题 如果您的DLL链接到与EXE不同版本的C运行时,则两个模块将使用不同的堆。这是一个非常可能的问题,因为这些模块是使用不同的编译器编译的。
为了缓解这个问题,所有的内存都必须分配到一个共享堆中,并从同一堆中释放。幸运的是,Windows 提供了 API 来帮助解决这个问题: GetProcessHeap 将让你访问主机 EXE 的堆,HeapAlloc/HeapFree 则允许你在该堆中分配和释放内存。重要的是,不要使用普通的 malloc/free,因为它们不能保证按照你期望的方式工作。

STL 问题

C++标准库存在自己的ABI问题。不能保证给定的STL类型在内存中的布局方式相同,也不能保证给定的STL类在不同的实现中具有相同的大小(特别是调试构建可能会将额外的调试信息放入给定的STL类型中)。因此,在通过DLL边界传递之前,任何STL容器都必须被解包为基本类型,并在另一侧重新打包。
名称修饰
你的DLL可能会导出你的EXE想要调用的函数。然而,C++编译器没有一种标准的方式来修饰函数名。这意味着一个名为"GetCCDLL"的函数在GCC中可能被修饰为"_Z8GetCCDLLv",而在MSVC中可能被修饰为"?GetCCDLL@@YAPAUCCDLL_v1@@XZ"。
由于使用GCC生成的DLL不会生成.lib文件,因此您已经无法保证对DLL进行静态链接。在MSVC中静态链接DLL需要.lib文件。动态链接似乎是更干净的选择,但名称重整会妨碍您:如果尝试使用错误的重整名称GetProcAddress,则调用将失败,您将无法使用DLL。这需要一些技巧来解决,这也是跨DLL边界传递C++类的一个相当重要的原因。
您需要构建DLL,然后检查生成的.def文件(如果生成了一个;这将根据您的项目选项而异),或者使用Dependency Walker等工具查找重整名称。然后,您需要编写自己的.def文件,定义一个未重整的别名到重整函数。例如,让我们使用我稍早提到的GetCCDLL函数。在我的系统上,以下.def文件适用于GCC和MSVC:
EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:

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

重新构建您的DLL,然后重新检查其导出的函数。其中应该有一个未混淆的函数名称。请注意,您无法以此方式使用重载函数:未混淆的函数名称是由编译器定义的特定函数重载的别名。另请注意,每次更改函数声明时,都需要为您的DLL创建新的.def文件,因为混淆名称将发生变化。最重要的是,通过绕过名称混淆,您正在覆盖链接器试图为不兼容性问题提供的任何保护措施。
如果为您的DLL创建一个接口供其遵循,则整个过程会更简单,因为您只需为一个函数定义一个别名,而不需要为DLL中的每个函数创建一个别名。但是,相同的警告仍然适用。 将类对象传递给函数 这可能是困扰交叉编译器数据传递的问题中最微妙、最危险的一种。即使你处理了其他所有问题,函数参数的传递方式没有标准。这可能导致微妙的崩溃,没有明显的原因和简单的调试方法。你需要通过指针传递所有参数,包括任何返回值的缓冲区。这很笨拙和不方便,是另一个可能有效或无效的hacky解决方法。

结合所有这些解决方法并在模板和运算符的一些创意工作的基础上构建,我们可以尝试安全地通过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)

现在我们可以创建一个使用这些pod类型的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文件可能无法按预期工作。)

现在是调用DLL的EXE:

//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++对象,那么这就是您应该做的。但是,这些都不能保证适用于您的设置或任何其他人的设置。这些中的任何一个都可能随时出现故障,并且可能会在您的软件计划进行重大发布的前一天出现故障。这条路充满了黑客、风险和普遍的愚蠢,我可能因此应该被枪毙。如果您确实选择了这条路,请非常小心地测试。而且...根本不要这样做。

14
@DavidHeffernan 对的。但这是我几周研究的结果,因此我认为值得记录下我所学到的,以便其他人不必进行相同的研究和试图将工作解决方案拼凑在一起。更何况这似乎是一个半常见的问题。 - cf-
2
@πάνταῥεῖ 这些特定的ABI限制不适用于除MSVC之外的其他工具链。这应该甚至被提及... 我不确定我是否正确理解了这个问题。您是在指出这些ABI问题是仅适用于MSVC,比如使用clang构建的DLL将能够成功地与使用GCC构建的EXE一起工作吗?我有点困惑,因为这似乎与我的所有研究都相矛盾... - cf-
1
@computerfreaker 不,我是说PE和ELF使用不同的ABI格式... - πάντα ῥεῖ
1
@πάνταῥεῖ 啊,感谢您的澄清。由于此帖子仅询问Windows DLLs,因此我认为我们不需要关注ELF格式(https://dev59.com/UnI-5IYBdhLWcg3wW3Gp)。Linux上的库可能存在类似的问题,但我还没有进行研究。 - cf-
6
大多数主要的C++编译器(GCC、Clang、ICC、EDG等)遵循Itanium C++ ABI,MSVC则不遵循。因此,是的,这些ABI问题大部分是特定于MSVC的,尽管并非完全如此——即使是Unix平台上的C编译器(甚至是同一编译器的不同版本!),也可能存在互操作性不完美的问题。不过它们通常足够接近,以至于我一点也不会感到惊讶,如果你能将用Clang构建的DLL成功链接到用GCC构建的可执行文件上。 - Stuart Olsen
显示剩余2条评论

23
一些答案让传递C++类听起来非常可怕,但是我想分享一种不同的观点。其他回答提到的纯虚拟C++方法实际上比你想象的更干净。我已经围绕这个概念构建了整个插件系统,多年来一直运行良好。我有一个“PluginManager”类,使用LoadLib()和GetProcAddress()(以及Linux等效工具)从指定目录动态加载dll文件,使其跨平台。

信不信由你,即使你做了一些疯狂的事情,例如在你的纯虚拟接口末尾添加一个新函数,并尝试加载针对没有该新函数的接口编译的dll文件,它们正常加载。当然...您必须检查版本号,以确保您的可执行文件仅对实现该函数的新dll调用新函数。但好消息是:它有效!因此,在某种程度上,您有一个随时间演变您的接口的粗糙方法。
另一个关于纯虚拟接口很酷的事情 - 您可以继承任意数量的接口,永远不会遇到钻石问题!
我认为这种方法最大的缺点是必须非常小心地选择要作为参数传递的类型。没有类或STL对象,必须先使用纯虚拟接口进行包装。没有结构体(不经过pragma pack巫术)。只有原始类型和指向其他接口的指针。另外,您无法重载函数,这是一种不便,但不是阻碍。
好消息是,只需几行代码,您就可以创建可重用的通用类和接口来包装STL字符串、向量和其他容器类。或者,您可以在您的接口中添加GetCount()和GetVal(n)等函数,以让人们遍历列表。
为我们构建插件的人发现这非常容易。他们不需要成为ABI界限方面的专家-他们只需继承他们感兴趣的接口,编写他们支持的功能,并对他们不支持的功能返回false。我所知道的,使所有这些工作的技术基于任何标准都不是。据我所了解,微软决定用这种方式创建他们的虚拟表以便创造COM,而其他编译器的作者也决定效仿。这包括GCC、英特尔、Borland等大多数主要的C++编译器。如果您计划使用一个不太常见的嵌入式编译器,那么这种方法可能行不通。从理论上讲,任何编译器公司都可以随时更改其虚拟表并破坏事物,但考虑到多年来编写的大量代码依赖于此技术,如果任何主要参与者决定打破规则,我会非常惊讶。

所以故事的寓意是...除了极少数例外情况,你需要一位负责接口的人,他可以确保与原始类型保持干净的ABI边界,避免过载。如果您可以接受这个条件,那么共享DLLs/SOs中类的接口就没必要害怕跨编译器。直接共享类==麻烦,但共享纯虚拟接口并不会那么糟糕。


这是一个很好的观点...我应该说“不要害怕共享类的接口”。我会编辑我的答案。 - Ph0t0n
3
嘿,这是一个很棒的答案,谢谢!在我看来,如果能提供一些进一步阅读的链接,展示一些你所提到的事情的例子(甚至是一些代码),那就更好了。例如,关于STL类的封装等等。否则,我虽然看到了这个答案,但是对于这些东西实际上会是什么样子以及如何搜索它们还是有点困惑。 - Ela782

21
@computerfreaker写了一篇很好的解释,说明在通常情况下,即使类型定义在用户控制之下且程序中使用完全相同的标记序列,缺乏ABI也会防止在DLL边界传递C++对象。 (有两种情况可以实现:标准布局类和纯接口)对于在C++标准中定义的对象类型(包括那些从标准模板库中适应的类型),情况要严重得多。 定义这些类型的标记在多个编译器中不相同,因为C++标准没有提供完整的类型定义,只提供最低要求。此外,出现在这些类型定义中的标识符的名称查找不会解析为相同值。即使在存在C++ ABI的系统上,试图在模块边界共享此类类型也会导致巨大的未定义行为,因为它违反了一个定义规则。这是Linux程序员不熟悉的问题,因为g++的libstdc ++是一种事实上的标准,几乎所有程序都使用它,因此满足ODR。clang的libc ++打破了这种假设,然后C ++ 11出现并对几乎所有标准库类型进行强制性更改。不要在模块之间共享标准库类型,这是未定义的行为。

12

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

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

如何:从DLL导出C++类

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


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