¿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 están construidos estos complementos. Soy consciente de que no existe una ABI garantizada para los objetos STL y me preocupa causar inestabilidad en mi aplicación.
Respuestas:
La respuesta corta a esta pregunta es no . Debido a que no existe una ABI de C ++ estándar (interfaz binaria de aplicación, un estándar para convenciones de llamadas, empaquetado / alineación de datos, tamaño de letra, etc.), tendrá que pasar por muchos obstáculos para intentar hacer cumplir una forma estándar de tratar con la clase. objetos en su programa. Ni siquiera hay garantía de que funcione después de pasar por todos esos aros, ni hay garantía de que una solución que funcione en una versión del compilador funcione en la siguiente.
Simplemente cree una interfaz C simple usando
extern "C"
, ya que C ABI está bien definida y es estable.Si realmente desea pasar objetos C ++ a través de un límite de DLL, es técnicamente posible. Estos son algunos de los factores que deberá tener en cuenta:
Empaquetado / alineación de datos
Dentro de una clase dada, 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
podría estar alineado con un límite de 4 bytes.Si su DLL se compila con un compilador diferente al de su EXE, la versión de DLL de una clase determinada puede tener un empaque diferente al de la versión de 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 miembro de datos dado dentro de esa clase. La 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 basura.
Puede solucionar este problema utilizando la
#pragma pack
directiva de preprocesador, que obligará al compilador a aplicar un empaquetado específico. El compilador seguirá aplicando el empaquetado predeterminado si selecciona un valor de paquete mayor que el que habría elegido el compilador , por lo que si elige un valor de empaquetado grande, una clase puede tener un empaquetado diferente entre compiladores. La solución para esto es usar#pragma pack(1)
, lo que obligará al compilador a alinear los miembros de datos en un límite de un byte (esencialmente, no se aplicará ningún paquete). Esta no es una gran idea, ya que puede causar problemas de rendimiento o incluso fallas en ciertos sistemas. Sin embargo, será 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 compilador puede reorganizar sus miembros de datos en la memoria . No existe un estándar sobre cómo se hace esto, por lo que cualquier reordenamiento de datos puede causar incompatibilidades entre compiladores. Pasar datos de un lado a otro a una DLL requerirá clases de diseño estándar, por lo tanto.
Convención de llamadas
Hay varias convenciones de llamada que puede tener una función determinada. Estas convenciones de llamada especifican cómo se pasarán los datos a las funciones: ¿se almacenan los parámetros en registros o en la pila? ¿En qué orden se introducen los argumentos en la pila? ¿Quién limpia los argumentos que quedan en la pila después de que finaliza la función?
Es importante que mantenga una convención de llamadas estándar; si declaras una función como
_cdecl
, la predeterminada para C ++, e intentas llamarla usando algo_stdcall
malo, sucederán cosas ._cdecl
es la convención de llamada predeterminada para las funciones de C ++, sin embargo, esto es algo que no se romperá a menos que lo rompa deliberadamente especificando un_stdcall
en un lugar y un_cdecl
en otro.Tamaño del tipo de datos
Según 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 compilador impone el tamaño de un tipo de datos dado, no cualquier estándar (todas las garantías estándar son eso
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 del tipo de datos siempre que 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 compilan con diferentes compiladores.
Para mitigar esto, toda la memoria deberá asignarse a un montón compartido y desasignarse del mismo montón. Afortunadamente, Windows proporciona API para ayudar 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 use normal
malloc
/free
ya que no hay garantía de que funcionen como espera.Problemas de STL
La biblioteca estándar de C ++ tiene su propio conjunto de problemas de ABI. No hay garantía de que un tipo STL determinado se disponga de la misma manera en la memoria, ni existe garantía de que una clase STL determinada tenga el mismo tamaño de una implementación a otra (en particular, las compilaciones de depuración pueden poner información de depuración adicional en una dado el tipo de STL). Por lo tanto, cualquier contenedor STL deberá descomprimirse en tipos fundamentales antes de pasar a través del límite de DLL y volver a empaquetar en el otro lado.
Destrozar nombre
Su DLL probablemente exportará funciones a las que su EXE querrá llamar. Sin embargo, los compiladores de C ++ no tienen una forma estándar de alterar los nombres de las funciones . Esto significa que una función nombrada
GetCCDLL
podría modificarse_Z8GetCCDLLv
en GCC y?GetCCDLL@@YAPAUCCDLL_v1@@XZ
en MSVC.Ya no podrá garantizar la vinculación estática a su DLL, ya que una DLL producida con GCC no producirá un archivo .lib y vincular estáticamente una DLL en MSVC requiere uno. La vinculación dinámica parece una opción mucho más limpia, pero la alteración de nombres se interpone en su camino: si intenta
GetProcAddress
utilizar el nombre alterado incorrecto, la llamada fallará y no podrá utilizar su DLL. Esto requiere un poco de piratería 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.Deberá crear su DLL, luego examinar el archivo .def producido (si se produce uno; esto variará según las opciones de su proyecto) o usar una herramienta como Dependency Walker para encontrar el nombre destrozado. Luego, deberá escribir su propio archivo .def, definiendo un alias sin alterar para la función alterada. Como ejemplo, usemos la
GetCCDLL
función que mencioné un poco más arriba. En mi sistema, los siguientes archivos .def funcionan para GCC y MSVC, respectivamente:GCC:
MSVC:
Reconstruya su DLL, luego vuelva a examinar las funciones que exporta. Entre ellos debe figurar un nombre de función sin alterar. Tenga en cuenta que no puede usar funciones sobrecargadas de esta manera : el nombre de la función sin alterar es un alias para una sobrecarga de función específica definida por el nombre alterado. También tenga en cuenta que deberá crear un nuevo archivo .def para su DLL cada vez que cambie las declaraciones de función, ya que los nombres alterados cambiarán. Lo más importante es que al omitir la manipulación de nombres, está anulando cualquier protección que el vinculador esté tratando de ofrecerle con respecto a problemas de incompatibilidad.
Todo este proceso es más simple si crea una interfaz para que la siga 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 DLL. Sin embargo, se siguen aplicando las mismas advertencias.
Pasar objetos de clase a una función
Este es probablemente el más sutil y peligroso de los problemas que afectan al paso de datos entre compiladores. Incluso si maneja todo lo demás, no existe un estándar sobre cómo se pasan los argumentos a una función . Esto puede provocar fallos sutiles sin motivo aparente y sin una forma sencilla de depurarlos . Deberá pasar todos los argumentos a través de punteros, incluidos los búferes para los valores devueltos. Esto es torpe e inconveniente, y es otra solución hacky que puede funcionar o no.
Al reunir todas estas soluciones y aprovechar un poco de 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 la compatibilidad con C ++ 11 es obligatoria, al igual que la compatibilidad con
#pragma pack
sus variantes; MSVC 2013 ofrece este soporte, al igual que las versiones recientes de GCC y clang.La
pod
clase está especializada para cada tipo de datos básico, por lo queint
automáticamente se ajustará aint32_t
, seuint
ajustará auint32_t
, etc. Todo esto ocurre detrás de escena, gracias a los operadores=
y sobrecargados()
. He omitido el resto de las especializaciones de tipos básicos, ya que son casi completamente iguales excepto por los tipos de datos subyacentes (labool
especialización tiene un poco de lógica adicional, ya que se convierte en ayint8_t
luegoint8_t
se compara con 0 para volver a convertir abool
, pero esto es bastante trivial).También podemos envolver los tipos STL de esta manera, aunque requiere un poco de trabajo adicional:
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 problema.
Esto solo crea una interfaz básica que pueden usar tanto la DLL como cualquier persona que llama. Tenga en cuenta que estamos pasando un puntero a a
pod
, no apod
sí mismo. Ahora necesitamos implementar eso en el lado de DLL:Y ahora implementemos la
ShowMessage
función:Nada demasiado sofisticado: esto simplemente copia el paso
pod
a un normalwstring
y lo muestra en un cuadro de mensaje. Después de todo, esto es solo un POC , no una biblioteca de utilidades completa.Ahora podemos construir la DLL. No olvide los archivos .def especiales para evitar la alteración del nombre del vinculador. (Nota: la estructura CCDLL que realmente construí y ejecuté tenía más funciones que la que presento aquí. Es posible que los archivos .def no funcionen como se esperaba).
Ahora para que un EXE llame a la DLL:
Y aquí están los resultados. Nuestro DLL funciona. Hemos superado con éxito problemas de ABI de STL anteriores, problemas de ABI de C ++ anteriores, problemas de manipulación anteriores y nuestra DLL de MSVC está funcionando con un EXE de GCC.
En conclusión, si absolutamente debe pasar objetos C ++ a través de los límites de DLL, así es como lo hace. Sin embargo, nada de esto está garantizado para funcionar con su configuración o con la de cualquier otra persona. Todo esto puede fallar en cualquier momento y probablemente lo hará el día antes de que su software esté programado para tener una versión principal. Este camino está lleno de trucos, riesgos e idioteces generales por los que probablemente deberían dispararme. Si sigue esta ruta, pruebe con extrema precaución. Y realmente ... simplemente no hagas esto en absoluto.
fuente
@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 el control del usuario y se usa exactamente la misma secuencia de tokens 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 una ABI de C ++, intentar compartir estos tipos a través de los límites de los módulos da como resultado un comportamiento indefinido masivo debido a violaciones de la regla de una definición.
Esto es algo con lo que los programadores de Linux no estaban acostumbrados a tratar, porque 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 llegó con cambios obligatorios en casi todos los tipos de bibliotecas estándar.
Simplemente no comparta tipos de bibliotecas estándar entre módulos. Es un comportamiento indefinido.
fuente
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 piensas. Construí un sistema completo de complementos en torno al concepto y ha funcionado muy bien durante años. Tengo una clase "PluginManager" que carga dinámicamente las dlls desde un directorio especificado usando LoadLib () y GetProcAddress () (y los equivalentes de Linux, por lo que el ejecutable lo hace multiplataforma).
Lo crea o no, este método es indulgente incluso si hace algunas cosas extravagantes como agregar una nueva función al final de su interfaz virtual pura e intenta cargar dlls compilados en 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 las nuevas DLL que implementan la función. Pero la buena noticia es: ¡funciona! Entonces, de alguna manera, tiene un método burdo para evolucionar su interfaz con el tiempo.
Otra cosa interesante acerca de las interfaces virtuales puras: ¡puedes heredar tantas interfaces como quieras y nunca te encontrarás con el problema del diamante!
Yo diría que la mayor desventaja de este enfoque es que debes tener mucho cuidado con los tipos que pasas como parámetros. Sin clases u objetos STL sin envolverlos primero con interfaces virtuales puras. Sin estructuras (sin pasar por el pragma pack vudú). Solo tipos primitivos y punteros a otras interfaces. Además, no puede sobrecargar 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 contenedor. Alternativamente, puede agregar funciones a su interfaz como GetCount () y GetVal (n) para permitir que las personas recorran las listas.
Las personas que crean complementos para nosotros lo encuentran bastante fácil. No tienen que ser expertos en el límite de ABI ni nada; simplemente heredan las interfaces que les interesan, codifican las funciones que admiten y devuelven falso para las que no.
La tecnología que hace que todo esto funcione no se basa en ningún estándar que yo sepa. Por lo que sé, Microsoft decidió hacer sus tablas virtuales de esa manera para que pudieran hacer COM, y otros redactores de compiladores decidieron seguir su ejemplo. Esto incluye GCC, Intel, Borland y la mayoría de los demás compiladores de C ++ importantes. Si está planeando usar un compilador incrustado oscuro, entonces este enfoque probablemente no funcione para usted. Teóricamente, cualquier empresa compiladora podría cambiar sus tablas virtuales en cualquier momento y romper cosas, pero considerando 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.
Entonces, 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 a clases en DLL / SO entre compiladores. Compartir clases directamente == problemas, pero compartir interfaces virtuales puras no es tan malo.
fuente
No puede pasar objetos STL de manera segura a través de los límites de DLL, a menos que todos los módulos (.EXE y .DLL) estén construidos con la misma versión del compilador de C ++ y la misma configuración y sabores del CRT, lo cual es muy restrictivo y claramente no es su caso.
Si desea exponer una interfaz orientada a objetos desde su DLL, debe exponer interfaces puras de C ++ (que es similar a lo que hace COM). Considere leer este artículo interesante sobre CodeProject:
También puede considerar la posibilidad de exponer una interfaz C pura en el límite de la DLL y luego crear un contenedor 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 C puras con clases y objetos C ++.
fuente