¿Cómo paso con seguridad objetos, 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é comstackdor se crean estos complementos. Soy consciente de que no hay un ABI garantizado para objetos STL, y me preocupa causar inestabilidad en mi aplicación.

La respuesta corta a esta pregunta es “no” . Debido a que no hay C ++ ABI estándar (interfaz binaria de aplicación, estándar para convenciones de llamadas, empaque / alineación de datos, tamaño de letra, etc.), tendrá que pasar por muchos aros para intentar y aplicar una forma estándar de tratar con la clase objetos en tu progtwig. Ni siquiera hay una garantía de que funcione después de saltar todos esos detalles, ni hay garantía de que una solución que funcione en una versión del comstackdor funcione en la próxima.

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


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

Embalaje / alineación de datos

Dentro de una clase determinada, los miembros de datos individuales generalmente se colocarán especialmente en la memoria para que sus direcciones correspondan a un múltiplo del tamaño del tipo. Por ejemplo, un int puede estar alineado con un límite de 4 bytes.

Si su DLL está comstackda con un comstackdor diferente de su EXE, la versión de DLL de una clase determinada puede tener un empaque 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 no poder acceder correctamente a una dado miembro de datos dentro de esa clase. La DLL intentará 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án valores basura.

Puede #pragma pack esto utilizando la directiva de preprocesador de #pragma pack , que forzará al comstackdor a aplicar un empaque específico. El comstackdor seguirá aplicando el empaque predeterminado si selecciona un valor de paquete más grande que el que el comstackdor hubiera elegido , por lo que si elige un valor de empaque grande, una clase todavía puede tener un empaque diferente entre los comstackdores. La solución para esto es usar #pragma pack(1) , que forzará al comstackdor a alinear los miembros de datos en un límite de un byte (esencialmente, no se aplicará empaque). Esta no es una gran idea, ya que puede causar problemas de rendimiento o incluso lockings en ciertos sistemas. Sin embargo, garantizará la coherencia en la forma en que los miembros de datos de su clase están alineados en la memoria.

Reordenación de miembros

Si su clase no es de diseño estándar , el comstackdor puede reorganizar sus miembros de datos en la memoria . No existe un estándar para la forma en que se hace esto, por lo que cualquier reorganización de datos puede causar incompatibilidades entre los comstackdores. Por lo tanto, pasar datos de ida y vuelta a un archivo DLL requerirá clases de diseño estándar.

Convención de llamadas

Existen múltiples convenciones de llamadas que una función determinada puede tener. Estas convenciones de llamadas especifican cómo se pasarán los datos a las funciones: ¿se almacenan los parámetros en los registros o en la stack? ¿Qué orden tienen los argumentos en la stack? ¿Quién limpia los argumentos que quedan en la stack después de que termina la función?

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

Tamaño de 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 de 64 bits. Sin embargo, dado que el comstackdor hace cumplir el tamaño de un tipo de datos dado, no por ningún estándar (todas las garantías estándar son que 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 garantizar la compatibilidad del tamaño de tipo de datos cuando sea posible.

Problemas de montón

Si su DLL se vincula 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 comstackndo con diferentes comstackdores.

Para mitigar esto, toda la memoria tendrá que asignarse a un montón compartido y desasignarse del mismo montón. Afortunadamente, Windows proporciona las API para ayudar con esto: GetProcessHeap le permitirá acceder al montón del EXE del host, y HeapAlloc / HeapFree le permitirá asignar y liberar memoria dentro de este montón. Es importante que no utilice malloc / free normal, ya que no hay garantía de que funcionará de la manera esperada.

Problemas de STL

La biblioteca estándar de C ++ tiene su propio conjunto de problemas ABI. No hay garantía de que un tipo de STL determinado se establezca de la misma manera en la memoria, ni hay garantía de que una clase de STL determinada tenga el mismo tamaño de una implementación a otra (en particular, las comstackciones de depuración pueden poner información de depuración adicional en dado el tipo de STL). Por lo tanto, cualquier contenedor STL tendrá que desempaquetarse en tipos fundamentales antes de pasarse a través del límite de la DLL y volver a empaquetarse en el otro lado.

Nombre mangling

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

Ya no podrá garantizar enlaces estáticos a su DLL, ya que una DLL producida con GCC no producirá un archivo .lib y la vinculación estática de una DLL en MSVC requiere una. La vinculación dinámica parece una opción mucho más limpia, pero la manipulación de nombres se interpone en tu camino: si tratas de GetProcAddress un nombre equivocado en GetProcAddress , la llamada fallará y no podrás utilizar tu DLL. Esto requiere un poco de hackeo para moverse, y es una razón bastante importante por la que pasar clases de C ++ a través de un límite de DLL es una mala idea.

Necesitarás construir tu DLL, luego examinar el archivo .def producido (si se produce uno, esto variará según las opciones de tu proyecto) o usar una herramienta como Dependency Walker para encontrar el nombre destrozado. Luego, tendrá que escribir su propio archivo .def, definiendo un alias no modificado para la función destruida. Como ejemplo, usemos la función GetCCDLL que mencioné un poco más arriba. En mi sistema, los siguientes archivos .def funcionan para GCC y MSVC, respectivamente:

GCC:

 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 sin mantenimiento debe estar entre ellos. Tenga en cuenta que no puede usar las funciones sobrecargadas de esta manera : el nombre de la función no desactivada es un alias para una sobrecarga de función específica tal como se define por el nombre destruido. También tenga en cuenta que deberá crear un nuevo archivo .def para su DLL cada vez que cambie las declaraciones de funciones, ya que los nombres destruidos cambiarán. Lo que es más importante, al omitir el cambio de nombre, está anulando las protecciones que el vinculador intenta ofrecerle con respecto a los problemas de incompatibilidad.

Todo este proceso es más simple si crea una interfaz para su DLL, ya que solo tendrá una función para definir un alias en lugar de tener que crear un alias para cada función en su archivo DLL. Sin embargo, las mismas advertencias aún se aplican.

Pasar objetos de clase a una función

Este es probablemente el más sutil y el más peligroso de los problemas que afectan el paso de datos del comstackdor cruzado. 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 lockings sutiles sin razón aparente y sin una forma fácil de depurarlos . Tendrá que pasar todos los argumentos a través de punteros, incluidos los almacenamientos intermedios para cualquier valor de retorno. Esto es torpe e inconveniente, y es otra solución hacky que puede o no funcionar.


Al unir todas estas soluciones alternativas y desarrollar algún trabajo creativo con plantillas y operadores , podemos intentar pasar objetos de forma segura a través de un límite de DLL. Tenga en cuenta que el soporte de 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 class pod { protected: pod(); pod(const T& value); pod(const pod& copy); ~pod(); pod& operator=(pod value); operator T() const; T get() const; void swap(pod& first, pod& second); }; #pragma pack(pop) //POD_basic_types.h: holds pod specializations for basic datatypes. #pragma pack(push, 1) template<> class pod { //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& copyVal) { original_type copyData = copyVal.get(); set_from(copyData); } ~pod() { release(); } pod& operator=(pod 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(*data); return result; } void set_from(const original_type& value) { data = reinterpret_cast(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& first, pod& second) { using std::swap; swap(first.data, second.data); } }; #pragma pack(pop) 

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

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

 #pragma pack(push, 1) template class pod> //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 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& copyVal) { original_type copyData = copyVal.get(); set_from(copyData); } ~pod() { release(); } pod& operator=(pod 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(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& first, pod& 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, por lo que solo tendremos un método para resolver el cambio.

 //CCDLL.h: defines a DLL interface for a pod-based DLL struct CCDLL_v1 { virtual void ShowMessage(const pod* message) = 0; }; CCDLL_v1* GetCCDLL(); 

Esto solo crea una interfaz básica que pueden usar la DLL y las personas que llaman. Tenga en cuenta que estamos pasando un puntero a un pod , no a un pod sí mismo. Ahora tenemos que implementar eso en el lado de la DLL:

 struct CCDLL_v1_implementation: CCDLL_v1 { virtual void ShowMessage(const pod* message) override; }; CCDLL_v1* GetCCDLL() { static CCDLL_v1_implementation* CCDLL = nullptr; if (!CCDLL) { CCDLL = new CCDLL_v1_implementation; } return CCDLL; } 

Y ahora implementemos la función ShowMessage :

 #include "CCDLL_implementation.h" void CCDLL_v1_implementation::ShowMessage(const pod* 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 wstring . Después de todo, esto es solo un POC , no una biblioteca de utilidad completa.

Ahora podemos construir la DLL. No olvides los archivos especiales .def para evitar el cambio de nombre del enlazador. (Nota: la estructura CCDLL que en realidad construí y ejecuté tenía más funciones que la que aquí presento. 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 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 superado con éxito los problemas de ABI de STL, los problemas de ABI de C ++, los problemas de manipulación anteriores y nuestra DLL de MSVC está trabajando con un EXE de GCC.


En conclusión, si absolutamente debe pasar objetos C ++ a través de límites de DLL, así es como lo hace. Sin embargo, nada de esto está garantizado para funcionar con su configuración o la de cualquier otra persona. Todo esto puede romperse en cualquier momento, y probablemente se romperá el día antes de que su software tenga una versión principal. Este camino está lleno de piratas informáticos, riesgos e idiotez general por los que probablemente debería dispararme. Si sigue esta ruta, pruébela con extrema precaución. Y realmente ... simplemente no hagas esto en absoluto.

@computerfreaker ha escrito una gran explicación de por qué la falta de ABI evita pasar objetos de C ++ a través de límites de DLL en el caso general, incluso cuando las definiciones de tipo están bajo el control del usuario y la misma secuencia de token se usa en ambos progtwigs. (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 estándar de plantillas), la situación es mucho, mucho peor. Los tokens que definen estos tipos NO son los mismos en múltiples comstackdores, ya que el estándar de C ++ no proporciona una definición de tipo completa, solo requisitos mínimos. Además, la búsqueda de nombre de los identificadores que aparecen en estas definiciones de tipo no resuelve el mismo. Incluso en sistemas donde hay un ABI de C ++, intentar compartir dichos tipos a través de los límites del módulo da como resultado un comportamiento indefinido masivo debido a las infracciones de la regla de una sola definición.

Esto es algo a lo que los progtwigdores de Linux no estaban acostumbrados, porque la libstdc ++ de g ++ era un estándar de facto y virtualmente todos los progtwigs lo usaban, satisfaciendo así la ODR. la librería libc ++ de clang rompió esa suposición, y luego apareció C ++ 11 con cambios obligatorios para casi todos los tipos de bibliotecas estándar.

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

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 virtual puro de C ++ mencionado en algunas de las otras respuestas en realidad resulta ser más limpio de lo que piensas. Creé un sistema completo de complementos alrededor del concepto y ha funcionado muy bien durante años. Tengo una clase “PluginManager” que carga dinámicamente las dlls desde un directorio específico usando LoadLib () y GetProcAddress () (y los equivalentes de Linux para que el ejecutable lo haga en plataforma cruzada).

Créalo o no, este método es tolerante, incluso si usted hace algunas cosas extravagantes, como agregar una nueva función al final de su interfaz virtual pura e intentar cargar dlls comstackdos 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 dlls más nuevos que implementen la función. Pero la buena noticia es: ¡funciona! Entonces, de alguna manera, tienes un método crudo para desarrollar tu interfaz a lo largo del tiempo.

Otra cosa interesante acerca de las interfaces virtuales puras: ¡puedes heredar tantas interfaces como quieras y nunca te encontrarás con el problema de los diamantes!

Yo diría que la mayor desventaja de este enfoque es que debe tener mucho cuidado con los tipos que pasa como parámetros. Sin clases ni objetos STL sin antes envolverlos con interfaces virtuales puras. Sin estructuras (sin pasar por el vudú del paquete pragma). Solo tipos primitivos y punteros a otras interfaces. Además, no se pueden sobrecargar las funciones, lo que es un inconveniente, pero no un stop-stopper.

La buena noticia es que con un puñado de líneas de código puede crear clases e interfaces genéricas reutilizables para ajustar cadenas AWL, vectores y otras clases de contenedor. Alternativamente, puede agregar funciones a su interfaz como GetCount () y GetVal (n) para que las personas puedan recorrer las listas.

Las personas que crean complementos para nosotros lo encuentran bastante fácil. No tienen que ser expertos en el límite ABI ni nada, simplemente heredan las interfaces que les interesan, codifican las funciones que respaldan y devuelven falso para las que no.

La tecnología que hace que todo este trabajo no se base en ningún estándar, hasta donde yo sé. Por lo que veo, Microsoft decidió hacer sus tablas virtuales de esa manera para que pudieran hacer COM, y otros escritores de comstackdores decidieron hacer lo mismo. Esto incluye a GCC, Intel, Borland y la mayoría de los otros comstackdores principales de C ++. Si planea utilizar un comstackdor incrustado oscuro, entonces este enfoque probablemente no funcione para usted. Teóricamente, cualquier compañía comstackdora podría cambiar sus tablas virtuales en cualquier momento y romper las cosas, pero teniendo en cuenta la gran 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í que la moraleja de la historia es … Con la excepción 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 los 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 / SO entre comstackdores. Compartir clases directamente == problema, pero compartir interfaces virtuales puras no es tan malo.

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 .DLL) estén comstackdos con la misma versión del comstackdor C ++ y las mismas configuraciones y sabores del CRT, lo cual es muy limitado 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 leer este interesante artículo sobre CodeProject:

HowTo: Exportar clases de C ++ desde una DLL

También puede considerar exponer una interfaz pura de C en el límite de la DLL y luego crear 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 Win32 es casi C ++, pero muchas API Win32 exponen una interfaz C pura (también hay API que exponen las interfaces COM). Luego, ATL / WTL y MFC envuelven estas interfaces C puras con clases y objetos C ++.