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

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

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

проблемы STL

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

имя коверкая

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

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

вам нужно будет создать свою DLL, а затем изучить произведенный .def-файл (если он создан; это будет зависеть от ваших параметров проекта) или использовать инструмент, такой как Dependency Walker, чтобы найти искаженное имя. Тогда вам нужно будет написать свой собственные .файл Def, определение unmangled псевдоним исковеркали функции. В качестве примера, давайте использовать . Теперь нам нужно реализовать это на стороне 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 и показывает его в messagebox. В конце концов, это просто 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, вот как вы это сделаете. Однако ничто из этого не гарантирует работу с вашей настройкой или чьей-либо еще. Любой из них может сломаться в любое время, и, вероятно, сломается за день до того, как ваше программное обеспечение планируется выпустить. Этот путь полон хаков, рисков и общего идиотизма, который я, вероятно, за это надо стрелять. Если вы идете по этому маршруту, пожалуйста, проверьте с особой осторожностью. И действительно... просто не делай этого вообще.


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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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