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

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

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

4 ответа

Решение

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

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


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

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

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

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

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

Изменение порядка членов

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

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

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

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

Размер типа данных

Согласно этой документации, в 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

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

Имя искажения

Ваша DLL, вероятно, будет экспортировать функции, которые ваш EXE захочет вызвать. Однако компиляторы C++ не имеют стандартного способа искажения имен функций. Это означает функцию с именем GetCCDLL может быть искалечен _Z8GetCCDLLv в GCC и ?GetCCDLL@@YAPAUCCDLL_v1@@XZ в MSVC.

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

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

GCC:

EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:

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

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

Весь этот процесс будет проще, если вы создадите интерфейс для своей библиотеки 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_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, которая использует эти типы модулей. Во-первых, нам нужен интерфейс, поэтому у нас будет только один метод, для которого нужно разобраться.

//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, прошлых проблем искажения, и наша DLL-библиотека MSVC работает с GCC EXE.


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Как: Экспортировать классы C++ из DLL

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

Другие вопросы по тегам