如何安全地将对象(尤其是STL对象)传入DLL或从DLL传递?

时间:2014-04-01 21:40:10

标签: c++ windows dll stl abi

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

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

4 个答案:

答案 0 :(得分:141)

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

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


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

数据打包/对齐

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

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

您可以使用#pragma pack预处理程序指令解决此问题,该指令将强制编译器应用特定打包。 The compiler will still apply default packing if you select a pack value bigger than the one the compiler would have chosen,所以如果你选择一个大的包装值,一个类在编译器之间仍然可以有不同的包装。对此的解决方案是使用#pragma pack(1),这将强制编译器在一个字节的边界上对齐数据成员(实质上,不会应用打包)。 这不是一个好主意,因为它可能会导致某些系统出现性能问题甚至崩溃。但是,它确保您的课程数据的一致性成员在记忆中保持一致。

会员重新排序

如果您的班级不是standard-layout,则编译器can rearrange its data members in memory。没有关于如何完成此操作的标准,因此任何数据重新排列都可能导致编译器之间的不兼容。因此,将数据来回传递到DLL将需要标准布局类。

调用约定

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

保持标准的召唤惯例非常重要;如果您将函数声明为_cdecl(C ++的默认值),请尝试使用_stdcall bad things will happen调用它。 _cdecl是C ++函数的默认调用约定,所以这是一件不会中断的事情,除非你故意通过在一个地方指定一个_stdcall和一个{{1}来打破它在另一个。

数据类型

根据this documentation,在Windows上,无论您的应用是32位还是64位,大多数基本数据类型都具有相同的大小。但是,由于给定数据类型的大小是由编译器强制执行的,而不是由任何标准强制执行(所有标准保证都是_cdecl),因此最好使用fixed-size datatypes来确保数据类型尺寸兼容性尽可能。

堆问题

如果您的DLL链接到与EXE不同的C运行时版本the two modules will use different heaps。考虑到使用不同的编译器编译模块,这是一个特别可能的问题。

为了缓解这种情况,必须将所有内存分配到共享堆中,并从同一堆中释放。幸运的是,Windows提供了API来帮助解决这个问题:GetProcessHeap将允许您访问主机EXE的堆,而HeapAlloc / HeapFree将允许您在此堆中分配和释放内存。 重要的是,您不能使用普通1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long) / malloc,因为无法保证它们会按照您的预期运作。

STL问题

C ++标准库有自己的一组ABI问题。有no guarantee给定的STL类型在内存中以相同的方式布局,也不保证给定的STL类从一个实现到另一个实现具有相同的大小(特别是,调试版本可能会进行额外的调试信息到给定的STL类型)。因此,在通过DLL边界并在另一端重新打包之前,任何STL容器都必须被解压缩为基本类型。

名称修改

您的DLL可能会导出您的EXE想要调用的函数。但是,C ++编译器do not have a standard way of mangling function names。这意味着名为free的函数可能会被绑定到GCC中的GetCCDLL和MSVC中的_Z8GetCCDLLv

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

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

GCC

GetCCDLL

MSVC:

EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

重建您的DLL,然后重新检查它导出的函数。应该是一个未编码的函数名称。 请注意,您不能以这种方式使用重载函数:unmangled函数名称是一个特定函数重载的别名,由错位名称定义。另请注意,每次更改函数声明时,您都需要为DLL创建一个新的.def文件,因为受损的名称会发生​​变化。最重要的是,通过绕过名称修改,您可以覆盖链接器试图为您提供的有关不兼容问题的任何保护。

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

将类对象传递给函数

这可能是困扰交叉编译器数据传递的最微妙和最危险的问题。即使你处理其他一切,there's no standard for how arguments are passed to a function。这可能会导致subtle crashes with no apparent reason and no easy way to debug them。您需要通过指针传递所有参数,包括任何返回值的缓冲区。这是笨拙和不方便的,是另一个可能或可能不起作用的hacky解决方法。


汇总所有这些变通方法并构建在some creative work with templates and operators上,我们可以尝试安全地跨DLL边界传递对象。请注意,C ++ 11支持是必需的,对EXPORTS GetCCDLL=?GetCCDLL@@YAPAUCCDLL_v1@@XZ @1 及其变体的支持也是如此; MSVC 2013提供此支持,最新版本的GCC和clang也提供此支持。

#pragma pack

//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将自动封装到intint32_t将封装到uint,由于重载的uint32_t=运算符,这一切都发生在幕后。我已经省略了其余的基本类型特化,因为它们几乎完全相同,除了底层数据类型(()特化有一些额外的逻辑,因为它转换为a bool然后将int8_t与0进行比较以转换回int8_t,但这非常简单。)

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

bool

现在我们可以创建一个使用这些pod类型的DLL。首先我们需要一个接口,所以我们只有一种方法可以计算出错误。

#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(); ,而不是pod本身。现在我们需要在DLL端实现它:

pod

现在让我们实现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并在消息框中显示。毕竟,这只是一个POC,而不是一个完整的实用程序库。

现在我们可以构建DLL了。不要忘记特殊的.def文件来解决链接器的名称错误问题。 (注意:我实际构建和运行的CCDLL结构具有比我在这里提供的功能更多的功能。.def文件可能无法按预期工作。)

现在让EXE调用DLL:

wstring

And here are the results.我们的DLL工作正常。我们已经成功地解决了STL ABI问题,过去的C ++ ABI问题,过去的错误问题以及我们的MSVC DLL正在使用GCC EXE。


总之,如果你绝对必须跨越DLL边界传递C ++对象,那么你就是这样做的。但是,这些都不能保证与您的设置或其他任何人一起使用。任何这种情况都可能在任何时候中断,并且可能会在您的软件计划发布主要版本之前的那一天中断。这条路充满了黑客,风险和一般的愚蠢,我可能应该为此拍摄。如果你选择这条路线,请极其谨慎地进行测试。真的......根本不做这件事。

答案 1 :(得分:17)

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

对于C ++标准中定义的对象类型(包括那些改编自标准模板库的对象类型),情况要差得多。定义这些类型的标记在多个编译器中并不相同,因为C ++标准不提供完整的类型定义,只提供最低要求。此外,在这些类型定义中出现的标识符的名称查找不会解决相同的问题。 即使在存在C ++ ABI的系统上,尝试跨模块边界共享此类类型也会因违反One Definition Rule而导致大量未定义的行为。

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

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

答案 2 :(得分:14)

这里的一些答案使得传递C ++类听起来非常可怕,但我想分享另一种观点。其他一些响应中提到的纯虚拟C ++方法实际上比你想象的更清晰。我围绕这个概念构建了一个完整的插件系统,并且它已经运行了很多年。我有一个&#34; PluginManager&#34;使用LoadLib()和GetProcAddress()动态加载来自指定目录的dll的类(以及Linux等效项,以便使其跨平台的可执行文件)。

信不信由你,这种方法是宽容的,即使你做了一些古怪的事情,比如在纯虚拟界面的末尾添加一个新功能,并试图在没有新功能的情况下加载针对界面编译的dll - 他们&#39;加载就好了。当然......您必须检查版本号以确保您的可执行文件仅为实现该功能的较新dll调用新函数。但好消息是:它有效!所以在某种程度上,你有一个粗糙的方法来随着时间推移你的界面。

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

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

好消息是,使用少量代码行,您可以创建可重用的泛型类和接口来包装STL字符串,向量和其他容器类。或者,您可以向界面添加函数,如GetCount()和GetVal(n),以便让人们遍历列表。

为我们构建插件的人发现它很容易。他们不必成为ABI边界的专家或任何东西 - 他们只是继承他们感兴趣的界面,编写他们支持的功能,并为他们不支持的功能返回false。

据我所知,使所有这些工作的技术并非基于任何标准。从我收集的内容来看,微软决定以这种方式制作他们的虚拟表,以便他们可以制作COM,其他编译器编写者也决定效仿。这包括GCC,Intel,Borland和大多数其他主要的C ++编译器。如果你计划使用一个不起眼的嵌入式编译器,那么这种方法可能不会为你工作。从理论上讲,任何编译器公司都可以随时更改虚拟表并破坏内容,但考虑到多年来依赖于此技术编写的大量代码,如果任何主要参与者决定打破排名,我会感到非常惊讶。 / p>

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

答案 3 :(得分:8)

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

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

  

HowTo: Export C++ classes from a DLL

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