Подтвердить что ты не робот

Как безопасно передавать объекты, особенно объекты STL, в и из DLL?

Как передать объекты класса, особенно объекты STL, в/из библиотеки C++?

Мое приложение должно взаимодействовать с сторонними плагинами в виде файлов DLL, и я не могу контролировать, с каким компилятором созданы эти плагины. Я знаю, что нет гарантированного ABI для объектов STL, и я обеспокоен созданием нестабильности в моем приложении.

4b9b3361

Ответ 1

Короткий ответ на этот вопрос не. Поскольку нет стандартного С++ ABI (двоичный интерфейс приложения, стандарт для вызовов, упаковка/выравнивание данных, размер шрифта и т.д.), Вам придется прыгать через множество обручей, чтобы попытаться обеспечить стандартный способ работы с объектами класса в вашей программе. Там даже не гарантируется, что он будет работать после того, как вы перепрыгнете через все эти обручи, и не будет гарантии, что решение, которое работает в одном выпуске компилятора, будет работать в следующем.

Просто создайте простой интерфейс C с помощью extern "C", так как C ABI четко определен и устойчив.


Если вы действительно хотите передать объекты С++ через границу DLL, это технически возможно. Вот некоторые из факторов, которые вам придется учитывать:

Упаковка/выравнивание данных

В рамках данного класса отдельные элементы данных обычно будут помещаться в память, поэтому их адреса соответствуют кратному размеру типа. Например, int может быть выровнена с 4-байтовой границей.

Если ваша DLL скомпилирована с другим компилятором, чем ваш EXE, DLL-версия данного класса может иметь разную упаковку, чем версия EXE, поэтому, когда EXE передает объект класса DLL, DLL может быть неспособна правильно получить доступ к данному элементу данных в этом классе. DLL попытается прочитать по адресу, указанному в его собственном определении класса, а не в определении EXE, и поскольку нужный элемент данных на самом деле не хранится там, будут отображаться значения мусора.

Вы можете обойти это с помощью директивы #pragma pack препроцессора, которая заставит компилятор применить определенную упаковку. Компилятор по-прежнему будет применять упаковку по умолчанию, если вы выберете значение пакета, большее, чем тот, который был выбран компилятором, поэтому, если вы выберете большое значение упаковки, класс все еще может имеют различную упаковку между компиляторами. Решением для этого является использование #pragma pack(1), что заставит компилятор выровнять элементы данных на однобайтовой границе (по существу, упаковка не будет применяться). Это не отличная идея, так как это может привести к проблемам с производительностью или даже к сбоям в определенных системах. Однако это обеспечит согласованность в том, как элементы данных вашего класса выровнены в памяти.

Переопределение членов

Если ваш класс не standard-layout, компилятор может переупорядочить свои элементы данных в памяти. Нет никакого стандарта для того, как это делается, поэтому любое перераспределение данных может вызывать несовместимость между компиляторами. Поэтому передача данных взад и вперед в DLL потребует классов стандартного макета.

Соглашение о вызовах

Существует несколько соглашений о вызовах, которые может иметь данная функция. Эти соглашения вызова определяют, как данные должны передаваться функциям: являются ли параметры, хранящиеся в регистрах или в стеке? Какой порядок аргументов вставляется в стек? Кто очищает любые аргументы, оставшиеся в стеке после завершения функции?

Важно, чтобы вы поддерживали стандартное соглашение о вызове; если вы объявите функцию как _cdecl, по умолчанию для С++, и попытайтесь вызвать ее с помощью _stdcall будут происходить плохие вещи. _cdecl является стандартным вызовом для С++-функций, однако это одно дело, которое не сломается, если вы не намеренно сломаете его, указав _stdcall в одном месте и _cdecl в другом.

Размер 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 позволит вам выделять и освобождать память внутри этой кучи. Важно, чтобы вы не использовали обычный malloc/free, так как нет гарантии, что они будут работать так, как вы ожидаете.

Проблемы с STL

Стандартная библиотека С++ имеет свой собственный набор проблем ABI. Существует нет гарантии, что данный тип STL выложен таким же образом в памяти, и нет гарантии, что данный класс STL имеет одинаковый размер от одной реализации до другой (в частности, отладочные сборки могут помещать дополнительную отладочную информацию в заданный тип STL). Поэтому любой STL-контейнер должен быть распакован в основные типы, прежде чем проходить через границу DLL и переупаковать с другой стороны.

Название mangling

Ваша DLL, по-видимому, будет экспортировать функции, которые ваш EXE захочет вызывать. Тем не менее, компиляторы С++ не имеют стандартного способа отображения имен функций. Это означает, что функция с именем GetCCDLL может быть искажена до _Z8GetCCDLLv в GCC и [email protected]@[email protected]@XZ в MSVC.

Вы уже не сможете гарантировать статическое связывание с вашей DLL, поскольку DLL, созданная с помощью GCC, не будет создавать .lib файл и статически связывать DLL в MSVC. Динамическое связывание похоже на гораздо более чистый вариант, но наложение имени на вас мешает: если вы попытаетесь GetProcAddress неправильное искаженное имя, вызов не удастся, и вы не сможете использовать свою DLL. Для этого требуется немного хакерства, и это довольно серьезная причина, по которой передача классов С++ через границу DLL - плохая идея.

Вам нужно будет создать свою DLL, а затем изучить полученный файл .def(если он будет создан, это будет зависеть от ваших параметров проекта) или использовать инструмент, например Dependency Walker, чтобы найти искаженное имя. Затем вам нужно будет написать свой собственный .def файл, определяя неподписанный псевдоним для искаженной функции. В качестве примера, позвольте использовать функцию GetCCDLL, о которой я упоминал немного дальше. В моей системе для GCC и MSVC работают следующие .def файлы:

GCC:

EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:

EXPORTS
    [email protected]@[email protected]@XZ @1

Восстановите свою DLL, затем переустановите функции, которые она экспортирует. Среди них должно быть неперечисленное имя функции. Обратите внимание, что вы не можете использовать перегруженные функции таким образом: имя unmangled функции является псевдонимом для одной конкретной перегрузки функции, как определено измененным именем. Также обратите внимание, что вам нужно будет создать новый .def файл для вашей DLL каждый раз, когда вы измените объявления функций, так как измененные имена будут изменены. Самое главное, что в обход имени, вы нарушаете любые меры защиты, которые линкер пытается предложить вам в отношении проблем несовместимости.

Весь этот процесс проще, если вы создаете интерфейс для своей DLL, потому что у вас будет только одна функция для определения псевдонима вместо необходимости создайте псевдоним для каждой функции в вашей DLL. Тем не менее, те же предостережения по-прежнему применяются.

Передача объектов класса в функцию

Это, вероятно, самая тонкая и самая опасная проблема, связанная с передачей данных кросс-компилятора. Даже если вы справитесь со всем остальным, нет стандарта для того, как аргументы передаются функции. Это может вызвать тонкие сбои без видимых причин и простой способ их отладки. Вам необходимо передать все аргументы с помощью указателей, включая буферы для любых возвращаемых значений. Это неудобно и неудобно, и это еще одно хакерское обходное решение, которое может работать или не работать.


Объединяя все эти обходные пути и опираясь на некоторую творческую работу с шаблонами и операторами, мы можем попытаться безопасно передать объекты через границу DLL. Обратите внимание, что поддержка С++ 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_t, uint будет завернут на 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, использующую эти типы контейнеров. Сначала нам нужен интерфейс, поэтому у нас будет только один метод, чтобы выяснить, что нужно для mangling.

//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 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, прошлые проблемы с С++ ABI, проблемы с прошлым изменением, а наша MSVC DLL работает с GCC EXE.


В заключение, если вы абсолютно должны передавать объекты С++ через границы DLL, так вы это делаете. Однако ничто из этого не гарантируется для работы с вашей установкой или кем-то еще. Любое из этого может быть разорвано в любое время и, вероятно, сломается за день до того, как ваше программное обеспечение будет иметь большой выпуск. Этот путь полон хаков, рисков и общего идиотизма, за которые я, вероятно, должен быть застрелен. Если вы идете по этому маршруту, проконсультируйтесь с ним с особой осторожностью. И действительно... просто не делай этого вообще.

Ответ 2

@computerfreaker написал большое объяснение того, почему отсутствие ABI предотвращает передачу объектов С++ через границы DLL в общем случае, даже если определения типов находятся под пользовательским контролем, и в обеих программах используется одна и та же последовательность токенов. (Есть два случая, которые работают: классы стандартного макета и чистые интерфейсы)

Для типов объектов, определенных в стандарте С++ (в том числе адаптированных из стандартной библиотеки шаблонов), ситуация намного хуже. Токены, определяющие эти типы, не являются одинаковыми для нескольких компиляторов, так как стандарт С++ не дает полного определения типа, а только минимальные требования. Кроме того, поиск имен идентификаторов, которые появляются в этих определениях типов, не позволяет их устранить. Даже в системах, где есть С++ ABI, попытка разделить такие типы по границам модулей приводит к массивному поведению undefined из-за нарушений правил определения.

Это то, к чему не привыкли программисты Linux, потому что g++ libstdС++ был стандартом де-факто и практически всеми используемыми ими программами, таким образом удовлетворяя ODR. clang libС++ нарушил это предположение, а затем С++ 11 пришел с обязательными изменениями почти ко всем типам стандартных библиотек.

Просто не используйте стандартные типы библиотек между модулями. Это поведение undefined.

Ответ 3

Некоторые из ответов здесь делают прохождение С++-классов действительно ужасным, но я хотел бы поделиться альтернативной точкой зрения. Чистый виртуальный метод С++, упомянутый в некоторых других ответах, на самом деле оказывается более чистым, чем вы думаете. Я построил целую плагинную систему вокруг концепции, и она работает очень хорошо годами. У меня есть класс "PluginManager", который динамически загружает DLL из указанного каталога с помощью LoadLib() и GetProcAddress() (и эквивалентов Linux, чтобы исполняемый файл мог быть перекрестной платформой).

Верьте или нет, этот метод прощает, даже если вы делаете некоторые глупые вещи, например, добавляете новую функцию в конец своего чистого виртуального интерфейса и пытаетесь загрузить DLL, скомпилированные по интерфейсу без этой новой функции - они будут загружать просто хорошо. Конечно... вам нужно будет проверить номер версии, чтобы убедиться, что ваш исполняемый файл вызывает только новую функцию для новых DLL, реализующих эту функцию. Но хорошая новость: это работает! Таким образом, у вас есть грубый метод для развития вашего интерфейса с течением времени.

Еще одна интересная вещь о чистых виртуальных интерфейсах - вы можете наследовать столько интерфейсов, сколько хотите, и вы никогда не столкнетесь с проблемой алмазов!

Я бы сказал, что самым большим недостатком этого подхода является то, что вы должны быть очень осторожны в том, какие типы вы передаете в качестве параметров. Нет классов или объектов STL, не обертывая их с помощью виртуальных интерфейсов. Нет структур (без прохождения прагма-пакета voodoo). Просто примитивные типы и указатели на другие интерфейсы. Кроме того, вы не можете перегружать функции, что является неудобством, но не шоу-стоппер.

Хорошей новостью является то, что с помощью нескольких строк кода вы можете сделать многоразовые классы и интерфейсы для использования STL-строк, векторов и других классов контейнеров. Кроме того, вы можете добавлять функции в свой интерфейс, такие как GetCount() и GetVal (n), чтобы люди могли перебирать списки.

Люди, создающие плагины для нас, находят это довольно легко. Им не обязательно быть экспертами на границе ABI или что-то еще - они просто наследуют интерфейсы, которые им интересны, кодируют функции, которые они поддерживают, и возвращают false для тех, которые у них нет.

Технология, которая делает всю эту работу, не зависит от каких-либо стандартов, насколько я знаю. Из того, что я собираю, Microsoft решила сделать свои виртуальные таблицы таким образом, чтобы они могли сделать COM, а другие авторы компиляторов решили последовать этому примеру. Это включает GCC, Intel, Borland и большинство других основных компиляторов С++. Если вы планируете использовать скрытый встроенный компилятор, то этот подход, вероятно, не сработает для вас. Теоретически любая компания-компилятор может в любой момент изменить свои виртуальные таблицы и сломать вещи, но учитывая огромное количество кода, написанного на протяжении многих лет, которое зависит от этой технологии, я был бы очень удивлен, если кто-либо из основных игроков решил разорвать ранг.

Итак, мораль этой истории... За исключением нескольких экстремальных обстоятельств, вам нужен один человек, отвечающий за интерфейсы, которые могут убедиться, что граница ABI остается чистой с примитивными типами и позволяет избежать перегрузки. Если вы согласны с этим условием, я бы не стал бояться делиться интерфейсами с классами в DLL/SO между компиляторами. Совместное использование классов напрямую == проблема, но совместное использование чистых виртуальных интерфейсов не так уж плохо.

Ответ 4

Вы не можете безопасно передавать объекты STL через границы DLL, если только все модули (.EXE и .DLL) не построены с той же версией компилятора С++ и теми же настройками и настройками CRT, которые сильно сдерживаются и, ваш случай.

Если вы хотите открыть объектно-ориентированный интерфейс из своей DLL, вы должны открыть чистые интерфейсы С++ (что похоже на то, что делает COM). Подумайте об этой интересной статье о CodeProject:

HowTo: экспортировать классы С++ из DLL

Вы также можете рассмотреть возможность разоблачения чистого интерфейса C на границе DLL, а затем создание оболочки С++ на сайте вызывающего абонента.
Это похоже на то, что происходит в Win32: код реализации Win32 почти С++, но множество API-интерфейсов Win32 выставляют чистый C-интерфейс (есть также API, которые выставляют COM-интерфейсы). Затем ATL/WTL и MFC переносят эти чистые C-интерфейсы с С++-классами и объектами.