如何安全地将对象(尤其是STL对象)与DLL之间传递?


106

如何在C ++ DLL之间传递类对象(尤其是STL对象)?

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


4
如果您在谈论C ++标准库,那么您可能应该称呼它。根据上下文,STL可能意味着不同的含义。(参见stackoverflow.com/questions/5205491/...
米莎Wiedenmann

Answers:


156

对这个问题的简短回答是。因为没有标准的C ++ ABI(应用程序二进制接口,用于调用约定,数据打包/对齐,类型大小等的标准),所以您将不得不跳很多圈来尝试强制执行处理类的标准方法程序中的对象。甚至无法保证在您克服所有这些麻烦之后,它仍然可以工作,也无法保证在一个编译器版本中可用的解决方案在下一个版本中将可用。

只是创建一个普通的C接口使用extern "C",因为C ABI 明确定义的和稳定的。


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

数据打包/对齐

在给定的类中,单个数据成员通常将被专门放置在内存中,因此它们的地址对应于类型大小的倍数。例如,一个int可能与4字节边界对齐。

如果您的DLL是使用不同于EXE的编译器编译的,则给定类的DLL版本可能与EXE的版本具有不同的包装,因此当EXE将类对象传递给DLL时,DLL可能无法正确访问该类中的给定数据成员。DLL将尝试从其自己的类定义而不是EXE的定义所指定的地址中进行读取,并且由于所需的数据成员实际上并未存储在该地址中,因此将导致垃圾值。

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

会员重新订购

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

通话约定

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

保持标准的调用约定很重要;如果将一个函数声明为_cdecl,则为C ++的默认值,并尝试使用_stdcall 不良事件进行调用。_cdecl是C ++函数的默认调用约定,因此,除非您通过_stdcall在一个位置指定a并_cdecl在另一个位置指定a 来故意破坏它,否则这是不会破坏的。

数据类型大小

根据此文档,在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类型)。因此,任何STL容器在穿过DLL边界并在另一侧重新打包之前,都必须解压缩为基本类型。

改名

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

您已经不能保证静态链接到您的DLL,因为用GCC生成的DLL不会生成.lib文件,而在MSVC中静态链接DLL需要一个。动态链接似乎是一个更简洁的选择,但名称重整会妨碍您:如果尝试GetProcAddress使用错误的重整名称,则调用将失败,并且您将无法使用DLL。这需要一些黑客手段来解决,这是一个相当重要的原因,为什么跨DLL边界传递C ++类是一个坏主意。

您将需要构建DLL,然后检查生成的.def文件(如果已生成;此文件会根据您的项目选项而有所不同),或使用诸如Dependency Walker之类的工具来查找错误的名称。然后,您需要编写自己的 .def文件,为错误的函数定义一个未修饰的别名。例如,让我们使用GetCCDLL我在后面提到的功能。在我的系统上,以下.def文件分别适用于GCC和MSVC:

GCC:

EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:

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

重建您的DLL,然后重新检查它导出的功能。函数名称必须是完整的。请注意,您不能以这种方式使用重载函数:未修饰的函数名是一个由重整名称定义的特定函数重载的别名。还要注意,每次更改函数声明时,都需要为DLL创建一个新的.def文件,因为错误的名称将会更改。最重要的是,通过绕过名称修改,您可以覆盖链接器试图为您提供的有关不兼容问题的任何保护。

如果您为DLL 创建一个要遵循的接口,则整个过程会更简单,因为您只需一个函数即可为其定义别名,而不需要为DLL中的每个函数都创建别名。但是,同样的警告仍然适用。

将类对象传递给函数

这可能是困扰交叉编译器数据传递的最微妙和最危险的问题。即使您处理了其他所有事情,也没有关于如何将参数传递给函数的标准。这可能会导致细微的崩溃,没有明显的原因,也没有简单的调试方法。您需要通过指针传递所有参数,包括任何返回值的缓冲区。这是笨拙且不便的,并且是另一种可能无法解决的变通办法。


综合所有这些变通办法,并在使用模板和运算符的某些创造性工作基础上,我们可以尝试跨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专长有一点额外的逻辑,因为将其转换为a 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文件可能无法按预期工作。)

现在让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 ++对象,这就是您的方法。但是,不能保证所有这些都不能与您的设置或任何其他设置一起使用。所有这些都可能随时中断,并且可能会在您的软件计划发布主要版本的前一天中断。这条路充满了我可能应该针对的黑客,风险和普遍的愚蠢行为。如果您选择这条路线,请格外小心。真的……根本不这样做。


1
嗯,还不错!您针对使用标准c ++类型与Windows DLL交互使用了相当不错的参数集合,并进行了相应的标记。这些特定的ABI限制不适用于MSVC以外的其他工具链。这应该是甚至提到...
πάνταῥεῖ

12
@DavidHeffernan对。但这是对我进行了数周研究后得出的结果,因此我认为有必要记录一下我所学到的知识,这样其他人就无需进行相同的研究,也无需进行相同的尝试来破解一个可行的解决方案。更是如此,因为这在这里似乎是一个半常见的问题。
cf与Monica站在

@πάνταῥεῖ 这些特殊的ABI的限制不会适用于其他工具链比MSVC。甚至应该提到这一点...我不确定我是否正确理解这一点。您是否表示这些ABI问题是MSVC独有的,例如,使用clang生成的DLL将可以与使用GCC生成的EXE一起成功使用?我有些困惑,因为这似乎与我的所有研究相矛盾……
对比莫妮卡

@computerfreaker不,我是说,PE和ELF使用不同的ABI格式...
πάνταῥεῖ

3
@computerfreaker大多数主要的C ++编译器(GCC,Clang,ICC,EDG等)都遵循Itanium C ++ ABI。MSVC没有。因此,是的,这些ABI问题很大程度上是MSVC特有的,尽管不是排他性的-即使是Unix平台上的C编译器(甚至同一编译器的不同版本!)也都无法达到完美的互操作性。但是,它们通常足够接近,以至于发现您可以成功地将Clang构建的DLL与GCC构建的可执行文件链接起来,我一点都不感到惊讶。
Stuart Olsen 2014年

17

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

对于C ++标准中定义的对象类型(包括从标准模板库改编的对象类型),情况要差得多。定义这些类型的标记在多个编译器中是不同的,因为C ++标准未提供完整的类型定义,仅提供了最低要求。此外,出现在这些类型定义中的标识符的名称查找无法解析相同的内容。 即使在具有C ++ ABI的系统上,由于违反“一个定义规则”,尝试跨模块边界共享此类类型也会导致大量未定义行为。

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

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


16

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

信不信由你,即使您做了一些古怪的事情,例如在纯虚拟接口的末尾添加新功能,并尝试在没有该新功能的情况下加载针对该接口编译的dll,此方法还是可以的。当然...您必须检查版本号,以确保您的可执行文件仅针对实现该函数的较新dll调用新函数。但好消息是:它有效!因此,从某种意义上讲,您有一种粗略的方法来随着时间的推移而发展您的界面。

关于纯虚拟接口的另一件很酷的事情-您可以继承任意数量的接口,并且永远不会遇到钻石问题!

我想说这种方法的最大缺点是,对于作为参数传递的类型,您必须非常小心。没有类或STL对象,必须先用纯虚拟接口包装它们。没有结构(无需通过杂注包伏都教)。只是原始类型和指向其他接口的指针。另外,您不能重载功能,这是一个不便,但不是一个阻碍因素。

好消息是,通过几行代码,您可以制作可重用的泛型类和接口,以包装STL字符串,向量和其他容器类。或者,您可以向接口添加诸如GetCount()和GetVal(n)之类的函数,以使人们可以遍历列表。

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

据我所知,完成所有这些工作的技术并不基于任何标准。据我所知,Microsoft决定以这种方式制作虚拟表,以便可以制作COM,其他编译器作者也决定效仿。这包括GCC,Intel,Borland和大多数其他主要的C ++编译器。如果您打算使用晦涩的嵌入式编译器,则此方法可能对您不起作用。从理论上讲,任何编译器公司都可以随时更改其虚拟表并破坏事物,但是考虑到多年来依赖该技术编写的大量代码,如果任何主要参与者决定打破排名,我将感到非常惊讶。

因此,故事的寓意是……除了一些极端情况外,您需要一个负责接口的人员来确保ABI边界保持原始类型的整洁并避免重载。如果您对此规定表示满意,那么我不会害怕在编译器之间共享DLL / SO中的类的接口。直接共享类==很麻烦,但是共享纯虚拟接口也不错。


很好。我应该说“不要害怕共享类的接口”。我将编辑我的答案。
Ph0t0n

2
嘿,这是一个很好的答案,谢谢!在我看来,将使它变得更好的是一些进一步阅读的链接,这些链接显示了您所提及的内容的一些示例(甚至是一些代码),例如包装STL类等。否则,我正在阅读这个答案,但是我对这些东西的实际外观以及如何搜索感到迷惑。
Ela782

8

您不能跨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接口。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.