web-dev-qa-db-fra.com

Comment passer en toute sécurité des objets, notamment des objets STL, à une DLL?

Comment passer des objets de classe, en particulier des objets STL, vers et depuis une DLL C++?

Mon application doit interagir avec les plugins tiers sous la forme de fichiers DLL, et je ne peux pas contrôler le compilateur avec lequel ces plugins sont générés. Les objets STL et je crains de causer une instabilité dans mon application.

99
computerfreaker

La réponse courte à cette question est non . Comme il n’existe pas de C++ standard ABI (interface binaire d’application, norme pour les conventions d’appel, l’archivage/alignement des données, la taille du type, etc.), vous devrez Parcourez de nombreux obstacles pour essayer d'appliquer une méthode standard de gestion des objets de classe dans votre programme. Rien ne garantit même que cela fonctionnera après avoir franchi toutes ces étapes, ni une solution qui fonctionne dans une version du compilateur fonctionnera dans la suivante.

Créez simplement une interface C simple en utilisant extern "C", Car le C ABI est bien défini et stable.


Si vous voulez vraiment, vraiment, passer des objets C++ à travers une frontière DLL, c'est techniquement possible. Voici certains des facteurs que vous devrez prendre en compte:

Compression/alignement des données

Dans une classe donnée, les membres de données individuels sont généralement spécialement placés en mémoire afin que leurs adresses correspondent à un multiple de la taille du type. Par exemple, un int peut être aligné sur une limite de 4 octets.

Si votre DLL est compilé avec un compilateur différent de votre EXE, la version de la DLL d'une classe donnée peut avoir une compression différente de celle de l'EXE. Ainsi, lorsque l'EXE transmet l'objet de classe à la DLL, le DLL pourrait ne pas être en mesure d'accéder correctement à un membre de données donné dans cette classe. DLL essaierait de lire à partir de l'adresse spécifiée par sa propre définition de la classe, et non de celle de l'EXE. Etant donné que le membre de données souhaité n'est pas réellement stocké à cet endroit, des valeurs parasites seraient générées.

Vous pouvez contourner ce problème en utilisant la directive du préprocesseur #pragma pack , ce qui forcera le compilateur à appliquer un empaquetage spécifique. Le ​​compilateur appliquera toujours la compression par défaut si vous sélectionnez une valeur de paquet supérieure à celle que le compilateur aurait choisie . Ainsi, si vous choisissez une valeur de compression élevée, une classe peut toujours présenter une compression différente entre les compilateurs. La solution à cela est d'utiliser #pragma pack(1), ce qui obligera le compilateur à aligner les données sur une limite d'un octet (aucune compression ne sera appliquée). Ce n'est pas une bonne idée car cela peut causer des problèmes de performances ou même des plantages sur certains systèmes. Cependant, il sera = assurer la cohérence dans la façon dont les données des membres de votre classe sont alignées en mémoire.

Réorganisation de membres

Si votre classe n'est pas standard-layout , le compilateur peut réorganiser ses données membres en mémoire . Il n'y a pas de standard pour cela, donc toute réorganisation des données peut causer des incompatibilités entre les compilateurs. Par conséquent, le transfert de données vers une DLL nécessitera des classes de présentation standard.

Convention d'appel

Il existe plusieurs conventions d'appel qu'une fonction donnée peut avoir. Ces conventions d'appel spécifient comment les données doivent être transmises aux fonctions: les paramètres sont-ils stockés dans des registres ou sur la pile? Dans quel ordre les arguments sont-ils placés dans la pile? Qui nettoie tous les arguments laissés sur la pile après la fin de la fonction?

Il est important que vous mainteniez une convention d'appel standard. si vous déclarez une fonction en tant que _cdecl, valeur par défaut pour C++, et essayez de l'appeler avec _stdcallil se passera de mauvaises choses . _cdecl Est la convention d'appel par défaut pour les fonctions C++. Cependant, il s'agit d'une chose qui ne cassera pas à moins que vous ne le fassiez délibérément en spécifiant un _stdcall À un endroit et un _cdecl en autre.

Taille du type de données

Selon cette documentation , sous Windows, la plupart des types de données fondamentaux ont la même taille, que votre application soit en 32 bits ou en 64 bits. Cependant, étant donné que la taille d'un type de données donné est imposée par le compilateur, et non par aucun standard (toutes les garanties standard sont que 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)), il est judicieux d'utiliser types de données de taille fixe pour assurer la compatibilité de la taille des types de données dans la mesure du possible.

Problèmes liés au tas

Si votre DLL relie à une version différente de l'exécution C de votre EXE, les deux modules utiliseront des tas différents . C'est un problème particulièrement probable étant donné que les modules sont compilés avec différents compilateurs.

Pour atténuer ce problème, toute la mémoire devra être allouée dans un segment de mémoire partagé et désallouée à partir du même segment de mémoire. Heureusement, Windows fournit des API pour aider avec ceci: GetProcessHeap vous laissera accéder au tas de l'hôte EXE, et HeapAlloc / HeapFree vous laissera allouer et mémoire libre dans ce tas. Il est important de ne pas utiliser les valeurs normales malloc/free, car rien ne garantit qu'elles fonctionneront comme vous le souhaitez.

Problèmes STL

La bibliothèque standard C++ a son propre ensemble de problèmes ABI. Il n'y a pas de garantie qu'un type de STL donné est agencé de la même manière en mémoire, pas plus qu'il n'est garanti qu'une classe de STL donnée a la même taille d'une implémentation à l'autre (en particulier, les versions de débogage peut mettre des informations de débogage supplémentaires dans un type STL donné). Par conséquent, tout conteneur STL devra être décompressé en types fondamentaux avant d'être passé à travers la frontière DLL et remballé de l'autre côté.

Nom mutilé

Votre DLL exportera probablement les fonctions que votre EXE voudra appeler. Cependant, les compilateurs C++ n’ont pas de méthode standard pour modifier les noms de fonctions . Cela signifie qu'une fonction nommée GetCCDLL pourrait être modifiée en _Z8GetCCDLLv Dans GCC et ?GetCCDLL@@YAPAUCCDLL_v1@@XZ En MSVC.

Vous ne serez déjà plus en mesure de garantir une liaison statique avec votre DLL, puisqu’une DLL produite avec GCC ne produira pas de fichier .lib et que lier statiquement une DLL dans MSVC en nécessite un. . La liaison dynamique semble être une option beaucoup plus propre, mais le changement de nom vous gêne: si vous essayez de GetProcAddress le mauvais nom mutilé, l'appel échouera et vous ne pourrez plus utiliser votre DLL. Cela nécessite un peu de hackery pour se déplacer et constitue une raison assez importante pour laquelle passer des classes C++ à travers une frontière DLL est une mauvaise idée.

Vous devrez créer votre DLL, puis examiner le fichier .def généré (le cas échéant, cela varie en fonction des options de votre projet) ou utiliser un outil tel que Dependency Walker pour rechercher le nom mutilé. Ensuite, vous devrez écrire votre fichier own .def, en définissant un alias non démêlé pour la fonction mutilée. Par exemple, utilisons la fonction GetCCDLL que j'ai mentionnée un peu plus haut. Sur mon système, les fichiers .def suivants fonctionnent pour GCC et MSVC, respectivement:

GCC:

EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:

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

Reconstruisez votre DLL, puis réexaminez les fonctions qu'il exporte. Un nom de fonction non démêlé devrait être parmi eux. Notez que vous ne pouvez pas utiliser les fonctions surchargées de cette façon: le nom de la fonction non démêlé est un alias pour une surcharge de fonction spécifique telle que définie par le nom mutilé. Notez également que vous devrez créer un nouveau fichier .def pour votre DLL chaque fois que vous modifierez les déclarations de fonction, car les noms mutilés seront modifiés. Plus important encore, en contournant le nom en changeant de nom, vous annulez les protections que l'éditeur de liens tente de vous offrir en ce qui concerne les problèmes d'incompatibilité.

Tout ce processus est plus simple si vous créez une interface pour votre DLL à suivre, car vous aurez juste une fonction pour définir un alias au lieu de devoir créer un alias pour chaque fonction dans votre DLL. Cependant, les mêmes mises en garde s'appliquent.

Passage d'objets de classe à une fonction

C’est probablement le problème le plus subtil et le plus dangereux qui affecte les transmissions de données croisées. Même si vous gérez tout le reste, il n'y a pas de standard pour la façon dont les arguments sont passés à une fonction . Cela peut provoquer de légers crashs sans raison apparente et sans moyen facile de les déboguer . Vous devrez passer tous arguments via des pointeurs, y compris les tampons pour toutes les valeurs renvoyées. C'est maladroit et peu pratique, et c’est encore une autre solution de contournement qui peut ou peut ne pas fonctionner.


En rassemblant toutes ces solutions de contournement et en s'appuyant sur certains travaux de création avec des modèles et des opérateurs , nous pouvons essayer de faire passer en toute sécurité des objets au-delà d'une limite DLL. Notez que la prise en charge de C++ 11 est obligatoire, de même que la prise en charge de #pragma pack Et de ses variantes; MSVC 2013 offre cette prise en charge, à l'instar des versions récentes de GCC et de 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 classe pod est spécialisée pour chaque type de données de base, de sorte que int sera automatiquement encapsulé dans int32_t, uint sera encapsulé dans uint32_t, etc. Tout cela se passe en coulisse, grâce aux opérateurs surchargés = et (). J'ai omis le reste des spécialisations de types de base car elles sont presque entièrement identiques, à l'exception des types de données sous-jacents (la spécialisation bool comporte un peu de logique supplémentaire, car elle est convertie en int8_t puis le int8_t est comparé à 0 pour reconvertir en bool, mais c'est assez trivial).

Nous pouvons également envelopper les types STL de cette manière, même si cela nécessite un peu de travail supplémentaire:

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

Nous pouvons maintenant créer une DLL utilisant ces types de pod. Nous avons d’abord besoin d’une interface, nous n’aurons donc qu’une méthode à utiliser.

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

Cela crée simplement une interface de base que le DLL et tous les appelants peuvent utiliser. Notez que nous passons un pointeur sur un pod, pas sur un pod lui-même. Maintenant, nous devons implémenter cela du côté 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;
}

Et maintenant, implémentons la fonction 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);
}

Rien d'extraordinaire: cela copie simplement le pod passé dans un wstring normal et l'affiche dans une boîte à messages. Après tout, il ne s’agit que d’un POC , et non d’une bibliothèque d’utilitaires complète.

Nous pouvons maintenant construire la DLL. N'oubliez pas les fichiers spéciaux .def pour contourner le nom du linker. (Remarque: la structure CCDLL que j'ai construite et exécutée présentait davantage de fonctions que celle que je présente ici. Les fichiers .def risquent de ne pas fonctionner comme prévu.)

Maintenant, pour un fichier EXE appeler la 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;
}

Et voici les résultats. Notre DLL fonctionne. Nous sommes parvenus à résoudre les problèmes passés de STL ABI, de C++ ABI, de problèmes récurrents et notre MSVC DLL fonctionne avec un fichier EXE GCC.


En conclusion, si must passez absolument des objets C++ au-delà de DLL frontières, procédez comme suit. Cependant, rien de tout cela n'est garanti pour fonctionner avec votre configuration ou celle de quelqu'un d'autre. N'importe lequel de ces événements peut être interrompu à tout moment et probablement la veille de la publication d'une version majeure de votre logiciel. Ce chemin est plein de piratages, de risques et d’idioties pour lesquelles je devrais probablement être pris pour cible. Si vous suivez cette voie, veuillez tester avec une extrême prudence. Et vraiment ... juste ne fais pas ça du tout.

143
computerfreaker

@computerfreaker a bien expliqué pourquoi l'absence d'ABI empêche les objets C++ de passer à travers les frontières DLL dans le cas général, même lorsque les définitions de type sont sous le contrôle de l'utilisateur et que la séquence de jetons est identique) utilisé dans les deux programmes (deux cas fonctionnent: les classes de mise en page standard et les interfaces pures)

Pour les types d'objet définis dans la norme C++ (y compris ceux adaptés de la bibliothèque de modèles standard), la situation est bien pire. Les jetons définissant ces types ne sont PAS identiques entre plusieurs compilateurs, car la norme C++ ne fournit pas de définition de type complète, mais uniquement des exigences minimales. De plus, la recherche de nom des identificateurs apparaissant dans ces définitions de types ne résout pas le même problème. Même sur les systèmes dotés d'une ABI C++, toute tentative de partage de ces types entre les limites de modules entraîne un comportement indéfini massif en raison de violations de la règle de définition unique.

C’est quelque chose que les programmeurs Linux n’étaient pas habitués à traiter, car libstdc ++ de g ++ était un standard de facto et pratiquement tous les programmes l’utilisaient, satisfaisant ainsi l’ODR. La libc ++ de clang a brisé cette hypothèse, puis C++ 11 a entraîné des modifications obligatoires de presque tous les types de bibliothèques Standard.

Il suffit de ne pas partager les types de bibliothèque Standard entre les modules. C'est un comportement indéfini.

17
Ben Voigt

Certaines des réponses proposées ici rendent les classes C++ passives très inquiétantes, mais j'aimerais partager un point de vue différent. La méthode C++ virtuelle pure mentionnée dans certaines des réponses s'avère en réalité plus claire que vous ne le pensez. J'ai construit tout un système de plug-in autour du concept et cela fonctionne très bien depuis des années. J'ai une classe "PluginManager" qui charge dynamiquement les dll à partir d'un répertoire spécifié à l'aide de LoadLib () et de GetProcAddress () (et des équivalents Linux afin que l'exécutable le transforme entre plates-formes).

Croyez-le ou non, cette méthode pardonne même si vous faites des choses loufoques, comme ajouter une nouvelle fonction à la fin de votre interface virtuelle pure et essayer de charger des dll compilées contre l'interface sans cette nouvelle fonction - elles se chargent très bien. Bien sûr ... vous devrez vérifier un numéro de version pour vous assurer que votre exécutable appelle uniquement la nouvelle fonction pour les nouvelles DLL qui implémentent la fonction. Mais la bonne nouvelle est que cela fonctionne! D'une certaine manière, vous disposez d'une méthode grossière pour faire évoluer votre interface au fil du temps.

Une autre bonne chose à propos des interfaces virtuelles pures: vous pouvez hériter de toutes les interfaces que vous voulez et vous ne rencontrerez jamais le problème des diamants!

Je dirais que le principal inconvénient de cette approche est que vous devez faire très attention aux types de paramètres que vous transmettez. Pas de classes ni d'objets STL sans les avoir préalablement enveloppés avec des interfaces virtuelles pures. Pas de structs (sans passer par le pragma pack vaudou). Juste des types primatifs et des pointeurs vers d'autres interfaces. En outre, vous ne pouvez pas surcharger les fonctions, ce qui est un inconvénient, mais pas un obstacle.

La bonne nouvelle est qu’avec quelques lignes de code, vous pouvez créer des classes et des interfaces génériques réutilisables pour envelopper des chaînes STL, des vecteurs et d’autres classes de conteneur. Vous pouvez également ajouter des fonctions à votre interface, telles que GetCount () et GetVal (n) pour permettre aux utilisateurs de parcourir des listes.

Les gens qui construisent des plugins pour nous le trouvent assez facile. Ils ne doivent pas nécessairement être des experts de la frontière ABI, ils héritent simplement des interfaces qui les intéressent, codent les fonctions qu’ils supportent et renvoient false pour celles qu’ils ne considèrent pas.

La technologie qui fait tout ce travail ne repose sur aucune norme à ma connaissance. D'après ce que j'ai compris, Microsoft a décidé de faire ses tables virtuelles de cette manière pour pouvoir créer COM, et d'autres auteurs de compilateurs ont décidé de faire de même. Cela inclut GCC, Intel, Borland et la plupart des autres grands compilateurs C++. Si vous envisagez d'utiliser un obscur compilateur intégré, cette approche ne fonctionnera probablement pas pour vous. Théoriquement, toute entreprise de compilateur pourrait modifier ses tables virtuelles à tout moment et casser des choses, mais compte tenu de la quantité énorme de code écrit au cours des années qui dépendent de cette technologie, je serais très surpris que l'un des principaux acteurs décide de rompre son rang.

La morale de l'histoire est donc ... À l'exception de quelques circonstances extrêmes, vous avez besoin d'un responsable des interfaces qui peut s'assurer que la limite ABI reste propre avec des types primitifs et évite les surcharges. Si vous êtes d'accord avec cette stipulation, alors je n'aurais pas peur de partager des interfaces de classes dans des DLL/SO entre des compilateurs. Partager des classes directement == ennui, mais partager des interfaces virtuelles pures n'est pas si mal.

15
Ph0t0n

Vous ne pouvez pas transmettre en toute sécurité des objets STL à travers DLL), à moins que tous les modules (.EXE et .DLLs) soient générés avec la même version du compilateur C++ et les mêmes paramètres et variantes du CRT, à savoir très contraignant, et clairement pas votre cas.

Si vous souhaitez exposer une interface orientée objet à partir de votre DLL, vous devez exposer les interfaces pures C++ (ce qui est similaire à ce que COM fait). Pensez à lire cet article intéressant sur CodeProject:

HowTo: Exporter des classes C++ à partir d'une DLL

Vous pouvez également envisager d'exposer une interface C pure à la limite DLL), puis de créer un wrapper C++ sur le site de l'appelant.
Cela ressemble à ce qui se passe dans Win32: le code d’implémentation Win32 est presque C++, mais de nombreuses API Win32 exposent une interface en C pur (il existe également des API qui exposent des interfaces COM). Ensuite, ATL/WTL et MFC encapsulent ces interfaces en C pur avec des classes et des objets C++.

8
Mr.C64