¿Cómo puedo pasar objetos de forma segura, especialmente objetos STL, hacia y desde una DLL?


¿Cómo paso objetos de clase, especialmente objetos STL, hacia y desde una DLL de C++?

Mi aplicación tiene que interactuar con complementos de terceros en forma de archivos DLL, y no puedo controlar con qué compilador se construyen estos complementos. Soy consciente de que no hay ABI garantizado para objetos STL, y me preocupa causar inestabilidad en mi aplicación.

Author: Deduplicator, 2014-04-02

4 answers

La respuesta corta a esta pregunta es no. Porque no hay un estándar C++ ABI (interfaz binaria de aplicación, un estándar para llamar a convenciones, empaquetamiento/alineación de datos, tamaño de tipo, etc.), usted tendrá que saltar a través de un montón de aros para tratar de hacer cumplir una forma estándar de tratar con los objetos de clase en su programa. Ni siquiera hay una garantía de que funcionará después de saltar a través de todos esos aros, ni hay una garantía de que una solución que funciona en un compilador la liberación funcionará en la próxima.

Simplemente cree una interfaz C simple usando extern "C", ya que el ABI de C es bien definido y estable.


Si realmente, realmente desea pasar objetos de C++ a través de un límite DLL, es técnicamente posible. Estos son algunos de los factores que tendrás que tener en cuenta:

Embalaje/alineación de datos

Dentro de una clase dada, los miembros de datos individuales generalmente se colocarán especialmente en la memoria las direcciones corresponden a un múltiplo del tamaño del tipo. Por ejemplo, un int podría estar alineado a un límite de 4 bytes.

Si su DLL está compilado con un compilador diferente a su EXE, la versión del DLL de una clase dada podría tener un embalaje diferente a la versión del EXE, por lo que cuando el EXE pasa el objeto de clase a la DLL, la DLL podría ser incapaz de acceder correctamente a un miembro de datos dado dentro de esa clase. El DLL intentaría leer desde la dirección especificada por su propia definición de la clase, no la definición del EXE, y dado que el miembro de datos deseado no está realmente almacenado allí, resultarían valores de basura.

Puede solucionar esto usando el #pragma pack directiva preprocesador, que obligará al compilador a aplicar empaquetamiento específico. El compilador todavía aplicará el embalaje predeterminado si selecciona un valor de paquete mayor que el que el compilador habría elegido, por lo que si elige un valor de embalaje grande, una clase todavía puede tener un embalaje diferente entre compiladores. La solución para esto es usar #pragma pack(1), lo que forzará al compilador a alinear los miembros de datos en un límite de un byte (esencialmente, no se aplicará empaquetamiento). Esto no es una gran idea, ya que puede causar problemas de rendimiento o incluso bloqueos en ciertos sistemas. Sin embargo, garantizará la consistencia en la forma en que los miembros de datos de su clase se alinean en la memoria.

Reordenamiento de miembros

Si su clase no es standard-layout , el compilador puede reorganizar sus miembros de datos en la memoria . No hay un estándar para cómo se hace esto, por lo que cualquier reordenamiento de datos puede causar incompatibilidades entre compiladores. Por lo tanto, pasar datos de ida y vuelta a una DLL requerirá clases de diseño estándar.

Calling convention

Hay múltiples llamadas a convenciones que una función dada puede tener. Estas convenciones de llamada especifican cómo se van a pasar los datos a funciones: ¿se almacenan los parámetros en los registros o en la pila? ¿En qué orden se colocan los argumentos en la pila? ¿Quién limpia los argumentos que quedan en la pila después de que finalice la función?

Es importante que mantenga una convención de llamada estándar; si declara una función como _cdecl, la predeterminada para C++, e intenta llamarla usando _stdcall cosas malas sucederán . _cdecl es la convención de llamada predeterminada para funciones de C++, sin embargo, por lo que esta es una cosa que no se romperá a menos que se rompe deliberadamente especificando un _stdcall en un lugar y un _cdecl en otro.

Tamaño del tipo de datos

De acuerdo con esta documentación, en Windows, la mayoría de los tipos de datos fundamentales tienen los mismos tamaños independientemente de si su aplicación es de 32 bits o 64 bits. Sin embargo, dado que el tamaño de un tipo de datos dado es impuesto por el compilador, no por ningún estándar (todas las garantías estándar son 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)), es una buena idea usar tipos de datos de tamaño fijo para asegure la compatibilidad del tamaño del tipo de datos cuando sea posible.

Problemas de montón

Si su DLL enlaza a una versión diferente del tiempo de ejecución de C que su EXE, los dos módulos usarán montones diferentes. Este es un problema especialmente probable dado que los módulos se están compilando con diferentes compiladores.

Para mitigar esto, toda la memoria tendrá que ser asignada a un montón compartido, y desasignada desde el mismo montón. Afortunadamente, Windows proporciona API para ayuda con esto: GetProcessHeap le permitirá acceder al montón de EXE del host, y HeapAlloc/HeapFree le permitirá asignar y liberar memoria dentro de este montón. Es importante que no utilice normal malloc/free como no hay garantía de que funcionarán de la manera que usted espera.

STL issues

La biblioteca estándar de C++ tiene su propio conjunto de problemas ABI. No hay ninguna garantía de que un tipo STL dado sea el mismo en memoria, ni hay garantía de que una clase STL dada tenga el mismo tamaño de una implementación a otra (en particular, las compilaciones de depuración pueden poner información extra de depuración en un tipo STL dado). Por lo tanto, cualquier contenedor STL tendrá que descomprimirse en tipos fundamentales antes de pasar a través del límite DLL y volver a empaquetarse en el otro lado.

Name mangling

Su DLL presumiblemente exportará las funciones que su EXE querrá llamar. Sin embargo, C++ los compiladores no tienen una forma estándar de modificar los nombres de las funciones. Esto significa que una función llamada GetCCDLL podría ser mutilada a _Z8GetCCDLLv en GCC y ?GetCCDLL@@YAPAUCCDLL_v1@@XZ en MSVC.

Ya no podrá garantizar la vinculación estática a su DLL, ya que un DLL producido con GCC no producirá un .el archivo lib y la vinculación estática de una DLL en MSVC requiere uno. La vinculación dinámica parece una opción mucho más limpia, pero el mangling de nombre se interpone en su camino: si intenta GetProcAddress el mal destrozado nombre, la llamada fallará y no podrá usar su DLL. Esto requiere un poco de hacker para moverse, y es una razón bastante importante por la que pasar clases de C++ a través de un límite DLL es una mala idea.

Necesitarás construir tu DLL, luego examinar el producido .def file (si se produce uno; esto variará en función de las opciones de su proyecto) o use una herramienta como Dependency Walker para encontrar el nombre mutilado. Entonces, necesitarás escribir tu propio.def archivo, definiendo un unmangled alias a la función mangled. Como ejemplo, usemos la función GetCCDLL que mencioné un poco más arriba. En mi sistema, lo siguiente .los archivos def funcionan para GCC y MSVC, respectivamente:

CCG:

EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:

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

Reconstruya su DLL, luego vuelva a examinar las funciones que exporta. Un nombre de función no entrelazado debe estar entre ellos. Tenga en cuenta que no puede usar funciones sobrecargadas de esta manera: el nombre de la función no entrelazada es un alias para una función específica overload como se define por el nombre mutilado. También tenga en cuenta que tendrá que crear un nuevo .def file para su DLL cada vez que cambie las declaraciones de función, ya que los nombres mutilados cambiarán. Lo más importante, al pasar por alto el nombre mangling, está anulando cualquier protección que el enlazador está tratando de ofrecerle con respecto a los problemas de incompatibilidad.

Todo este proceso es más simple si crea una interfaz para que su DLL siga, ya que solo tendrá una función para definir un alias en lugar de tener que crear un alias para cada función en la DLL. Sin embargo, siguen aplicándose las mismas advertencias.

Pasar objetos de clase a una función

Este es probablemente el más sutil y más peligroso de los problemas que plagan la transmisión de datos entre compiladores. Incluso si maneja todo lo demás, no hay un estándar para cómo se pasan los argumentos a una función. Esto puede causar choques sutiles sin razón aparente y no hay una manera fácil de depurarlos . Tendrá que pasar todos los argumentos a través de punteros, incluidos los búferes para cualquier valor devuelto. Esto es torpe e inconveniente, y es otra solución pirateada que puede o no funcionar.


Juntando todas estas soluciones y construyendo sobre algún trabajo creativo con plantillas y operadores, podemos intentar pasar objetos de forma segura a través de un límite DLL. Tenga en cuenta que el soporte para C++11 es obligatorio, al igual que el soporte para #pragma pack y sus variantes; MSVC 2013 ofrece este soporte, al igual que las versiones recientes de GCC y 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)

La clase pod está especializada para cada tipo de datos básico, de modo que int se ajustará automáticamente a int32_t, uint se envolverá en uint32_t, etc. Todo esto ocurre detrás de escena, gracias a los operadores sobrecargados = y (). He omitido el resto de las especializaciones de tipos básicos, ya que son casi totalmente iguales, excepto por los tipos de datos subyacentes (el bool la especialización tiene un poco de lógica extra, ya que se convierte a int8_t y luego el int8_t se compara con 0 para convertir de nuevo a bool, pero esto es bastante trivial).

También podemos envolver los tipos STL de esta manera, aunque requiere un poco de trabajo adicional:

#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)

Ahora podemos crear una DLL que haga uso de estos tipos de pod. Primero necesitamos una interfaz, así que solo tendremos un método para resolver el problema.

//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();

Esto simplemente crea una interfaz básica DLL y cualquier persona que llame puede usar. Tenga en cuenta que estamos pasando un puntero a un pod, no a un pod en sí. Ahora necesitamos implementar eso en el lado 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;
}

Y ahora vamos a implementar la función 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);
}

Nada demasiado sofisticado: esto simplemente copia el pod pasado en un wstring normal y lo muestra en un messagebox. Después de todo, esto es solo un POC , no una biblioteca de utilidades completa.

Ahora podemos construir el DLL. No olvides el especial .archivos def para solucionar el nombre del enlazador destrozando. (Nota: la estructura CCDLL que realmente construí y corrí tenía más funciones que la que presento aquí. El .es posible que los archivos def no funcionen como se esperaba.)

Ahora para que un EXE llame al 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;
}

Y aquí están los resultados. Nuestro DLL funciona. Hemos llegado con éxito a problemas pasados de STL ABI, problemas pasados de C++ ABI, problemas pasados de mangling, y nuestra DLL MSVC está trabajando con un GCC EXE.


En conclusión, si usted absolutamente debe pasar Objetos C++ a través de los límites de DLL, así es como se hace. Sin embargo, nada de esto está garantizado para funcionar con su configuración o de cualquier otra persona. Cualquiera de esto puede romper en cualquier momento, y probablemente se romperá el día antes de que su software está programado para tener una versión principal. Este camino está lleno de hacks, riesgos e idioteces generales por las que probablemente deberían dispararme. Si usted va esta ruta, por favor pruebe con extrema precaución. Y realmente... no hagas esto en absoluto.

 130
Author: computerfreaker,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:26:08

@computerfreaker ha escrito una gran explicación de por qué la falta de ABI impide pasar objetos C++ a través de los límites de DLL en el caso general, incluso cuando las definiciones de tipo están bajo control del usuario y la misma secuencia de tokens exacta se utiliza en ambos programas. (Hay dos casos que funcionan: clases de diseño estándar e interfaces puras)

Para los tipos de objetos definidos en el Estándar C++ (incluidos los adaptados de la Biblioteca de Plantillas Estándar), la situación es mucho, mucho peor. Los tokens que definen estos tipos NO son los mismos en varios compiladores, ya que el estándar C++ no proporciona una definición de tipo completa, solo requisitos mínimos. Además, la búsqueda de nombres de los identificadores que aparecen en estas definiciones de tipo no resuelve lo mismo. Incluso en sistemas donde hay un ABI de C++, intentar compartir tales tipos a través de los límites de los módulos resulta en un comportamiento indefinido masivo debido a violaciones de Una Regla de Definición.

Esto es algo que Los programadores de Linux no estaban acostumbrados a tratar con, porque el libstdc++de g++ era un estándar de facto y prácticamente todos los programas lo usaban, satisfaciendo así el ODR. libc++ de clang rompió esa suposición, y luego C++11 vino junto con cambios obligatorios a casi todos los tipos de bibliotecas estándar.

Simplemente no comparta tipos de bibliotecas estándar entre módulos. Es un comportamiento indefinido.

 14
Author: Ben Voigt,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-11-18 15:24:02

Algunas de las respuestas aquí hacen que pasar clases de C++ suene realmente aterrador, pero me gustaría compartir un punto de vista alternativo. El método C++ virtual puro mencionado en algunas de las otras respuestas en realidad resulta ser más limpio de lo que podría pensar. He construido todo un sistema de complementos alrededor del concepto y ha estado funcionando muy bien durante años. Tengo una clase "PluginManager" que carga dinámicamente las DLL de un directorio especificado usando Loadlib () y GetProcAddress () (y Linux equivalentes por lo que el ejecutable para que sea multiplataforma).

Lo creas o no, este método es indulgente incluso si haces algunas cosas locas como agregar una nueva función al final de tu interfaz virtual pura y tratar de cargar archivos DLL compilados contra la interfaz sin esa nueva función - se cargarán bien. Por supuesto... tendrá que verificar un número de versión para asegurarse de que su ejecutable solo llame a la nueva función para DLL más nuevos que implementen la función. Pero la buena noticia es: funciona! Así que, en cierto modo, tienes un método crudo para evolucionar tu interfaz con el tiempo.

Otra cosa interesante sobre las interfaces virtuales puras: ¡puede heredar tantas interfaces como desee y nunca se encontrará con el problema del diamante!

Yo diría que el mayor inconveniente de este enfoque es que tienes que tener mucho cuidado con los tipos que pasas como parámetros. No hay clases u objetos STL sin envolverlos con interfaces virtuales puras primero. Sin estructuras (sin pasar por el pragma pack voodoo). Solo tipos primativos y punteros a otras interfaces. Además, no puede sobrecargar las funciones, lo cual es un inconveniente, pero no un obstáculo.

La buena noticia es que con un puñado de líneas de código puede crear clases e interfaces genéricas reutilizables para envolver cadenas STL, vectores y otras clases de contenedores. Alternativamente, puede agregar funciones a su interfaz como getCount () y getVal(n) para permitir que las personas recorran las listas.

Edificio de personas plugins para nosotros es bastante fácil. No tienen que ser expertos en el límite ABI ni nada, solo heredan las interfaces que les interesan, codifican las funciones que admiten y devuelven false para las que no lo hacen.

La tecnología que hace que todo esto funcione no se basa en ningún estándar que yo sepa. Por lo que deduzco, Microsoft decidió hacer sus tablas virtuales de esa manera para que pudieran hacer COM, y otros escritores de compiladores decidieron hacer lo mismo. Esto incluye GCC, Intel, Borland, y la mayoría de los otros compiladores principales de C++. Si está planeando usar un compilador incrustado oscuro, entonces este enfoque probablemente no funcionará para usted. Teóricamente, cualquier compañía de compiladores podría cambiar sus tablas virtuales en cualquier momento y romper cosas, pero teniendo en cuenta la enorme cantidad de código escrito a lo largo de los años que depende de esta tecnología, me sorprendería mucho si alguno de los principales jugadores decidiera romper el rango.

Así es la moraleja de la historia... Salvo de algunas circunstancias extremas, necesita una persona a cargo de las interfaces que pueda asegurarse de que el límite ABI se mantenga limpio con tipos primitivos y evite la sobrecarga. Si está de acuerdo con esa estipulación, entonces no tendría miedo de compartir interfaces con clases en DLL/SOs entre compiladores. Compartir clases directamente = = problemas, pero compartir interfaces virtuales puras no es tan malo.

 11
Author: Ph0t0n,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-09-27 19:18:43

No puede pasar objetos STL de forma segura a través de los límites DLL, a menos que todos los módulos (.EXE y .DLLs) se construyen con la misma versión del compilador de C++ y la misma configuración y sabores de la CRT, que es muy restrictiva, y claramente no es su caso.

Si desea exponer una interfaz orientada a objetos desde su DLL, debe exponer las interfaces puras de C++ (que es similar a lo que hace COM). Considere la posibilidad de leer este interesante artículo sobre CodeProject:

HowTo: Exportar clases de C++ desde una DLL

También puede considerar exponer una interfaz C pura en el límite de DLL, y luego construir un contenedor de C++ en el sitio de la persona que llama.
Esto es similar a lo que sucede en Win32: el código de implementación de Win32 es casi C++, pero muchas API de Win32 exponen una interfaz C pura (también hay API que exponen interfaces COM). Luego ATL / WTL y MFC envuelven estas interfaces de C puro con clases y objetos de C++.

 7
Author: Mr.C64,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-04-01 21:50:18