Référencer une DLL C++ avec une bibliothèque statique d’import

Dans un projet en C++, quand on souhaite organiser le code dans différentes bibliothèques, on a le choix entre utiliser des bibliothèques statiques et dynamiques. Les bibliothèques statiques sont facilement intégrables dans un projet toutefois si on souhaite la mettre à jour, il faut recompiler tout le projet qui le consomme. Les bibliothèques dynamiques sont, quant à elles, moins facilement intégrables mais pour les mettre à jour, il suffit de les remplacer sans forcément avoir à recompiler le projet qui les utilise. C’est cet avantage qui rends en partie les bibliothèques dynamiques intéressantes en terme de déploiement.

Avec la technologie .NET, quand on souhaite intégrer une assembly managée à un projet, il suffit de l’ajouter en tant que référence de projet. On peut ainsi, d’une part, utiliser des objets de cette assembly et compiler son projet. D’autre part, en rendant disponible l’assembly au CLR, on peut de nouveau l’utiliser à l’exécution.

Les DLL natives sont moins facilement flexibles et nécessitent quelques aménagements de façon à être utilisées avec la même flexibilité que les assemblies .NET ou presque.

Le but de cet article est de montrer quelques techniques d’implémentation pour rendre les DLL natives intégrables dans un projet C++.

Dans un 1er temps, on définira quelques termes. Avant de référencer directement une DLL C++, on va introduire le sujet en référençant une bibliothèque statique. On va ensuite convertir cette bibliothèque statique en bibliothèque dynamique et indiquer quelques méthodes pour la référencer.

Quelques définitions et explications en préambule

Dans cette partie, on va apporter quelques précisions sur les bibliothèques en C++ et sur les étapes de la compilation. Si vous êtes familier avec ces notions, passer directement à la partie suivante.

Bibliothèque statique vs bibliothèque dynamique

On distingue 2 types de bibliothèques dans lesquelles on peut compiler du code C++: les bibliothèques dynamiques et les bibliothèques statiques.

Bibliothèque dynamique

Les bibliothèques dynamiques (i.e. dynamic library) se rapprochent des assemblies .NET car elles ont quelques caractéristiques en commun:

  • Il s’agit de fichiers séparés qui ne seront pas inclus dans le fichier exécutable ou dans une bibliothèque l’utilisant comme dépendance.
  • L’extension de ces bibliothèques est .dll pour Dynamic Link Library.

Le gros intérêt de ces bibliothèques est d’être partageables entre plusieurs exécutables. Ainsi, à l’exécution, quand un appel est effectué vers une fonction se trouvant dans une bibliothèque dynamique, elle est chargée en mémoire. Si un autre exécutable utilise la même bibliothèque, le chargement en mémoire n’est effectué qu’une seule fois ce qui permet d’économiser de l’espace mémoire.

L’inconvénient majeur des bibliothèques dynamiques est qu’elles doivent être accessibles au moment de l’exécution. Comme ce sont des fichiers séparés, si l’un d’entre eux n’est pas accessible au moment de l’exécution, il se produira une erreur et l’exécutable va interrompre son exécution.

Bibliothèque statique

Les bibliothèques statiques (i.e. static library) sont des fichiers dont l’extension est .lib. Si un exécutable ou une autre bibliothèque a une dépendance vers une bibliothèque statique, le code utilisé sera directement incorporé dans le fichier de l’exécutable ou de la bibliothèque.

L’intérêt de ces bibliothèques est d’être certain d’inclure les bonnes dépendances dans l’exécutable final. Au moment de l’exécution, il n’y a pas de risques que le fichier de la bibliothèque ne soit pas accessible puisque le code utilisé est inclus dans l’exécutable lui-même. De même, il n’y a pas de risque d’utiliser une mauvaise version.

A l’inverse, pour mettre à jour ce type de bibliothèque, il faut recompiler l’exécutable ou la bibliothèque l’utilisant. D’autre part, si plusieurs exécutables utilisent la même bibliothèque, le code exécuté sera chargé en mémoire autant de fois qu’un exécutable l’utilise puisqu’il fait partie directement de l’exécutable.

Les étapes de la compilation

En C++, la compilation s’effectue en 3 étapes: le pré-processing, la compilation à proprement parlé et l’édition de liens (i.e. linking).

Pre-processing

Cette étape correspond à un prétraitement des fichiers source. Dans un premier temps, les instructions #include seront remplacées par le contenu des fichiers .h auxquelles elles font référence. Toutes les déclarations seront, ainsi, indiquées dans les fichiers .cpp.

Au cours de cette étape, d’autres traitements seront effectués:

  • Certaines parties de code seront utilisées suivant les instructions correspondant aux directives préprocesseurs comme #if, #ifdefn, #ifndef, etc… (cf. documentation Microsoft).
  • Le remplacement de macros se feront avec l’instruction #define.

A la suite de cette étape, le code ne contiendra plus de références vers les fichiers .h et les directives préprocesseurs seront remplacées par leur signification.

Compilation

La compilation consiste à générer des fichiers objet avec l’extension .obj à partir des fichiers de code source .cpp. Les fichiers .obj contiennent des instructions en langage machine correspondant au code compilé toutefois ces fichiers ne peuvent pas être exécutés directement. Chaque instruction dans les fichiers .cpp est compilée en langage machine et en symboles. Ces symboles peuvent, par exemple, désignés des objets. Les symboles sont référencés avec leur nom.

Les fichiers .obj peuvent contenir des références vers des symboles déclarés (grâce aux références vers les fichiers .h) mais qui ne possèdent pas de définition. Le code compilé peut, ainsi, faire référence à ces symboles sans que le compilateur ne remarque l’absence de définition car les références utilisent le nom des symboles. Ces noms peuvent ne correspondre à aucun code machine exécutable.

Edition de liens

Cette étape va permettre de rassembler tous les fichiers .obj pour produire un exécutable (fichier .exe), une bibliothèque statique (fichier .lib) ou une bibliothèques dynamiques (fichier .dll). En fonction des bibliothèques statiques indiquées en paramètre du linker, il va être capable de trouver une définition aux noms de symboles utilisés par le compilateur. Ainsi les noms des symboles sont remplacés par des adresses exactes.

Il est donc important de préciser en paramètre d’entrée du linker quelles sont les bibliothèques pouvant contenir les définitions des symboles utilisés.

Exposer les symboles d’une bibliothèque

Le fichier d’une bibliothèque statique est une archive des fichiers .obj obtenus après la compilation. Lors de l’édition de liens, le contenu des bibliothèques statiques est parcouru pour chercher la définition des symboles. Quand cette définition est trouvée, le code correspondant est récupéré et placé dans le fichier résultant.

Les bibliothèques dynamiques sur les plateformes Windows n’exposent pas de symboles. Le linker n’est pas capable d’identifier le code se trouvant directement dans une DLL à partir du nom des symboles. Pour être capable d’identifier le code se trouvant dans une DLL, il faut que celle-ci expose les symboles correspondant.

Pour exposer les symboles à partir d’une DLL Windows, il faut utiliser dans le code l’instruction __declspec(dllexport) suivi du code à exposer. Cette instruction va permettre de créer une bibliothèque statique d’import qui va contenir le code permettant de charger la DLL dynamique contenant le code exposé et de l’exécuter. Il faut référencer cette bibliothèque statique d’import dans les paramètres d’entrée du linker.

Lors de l’étape d’édition de liens, pour un symbole donné, le linker va récupérer le code se trouvant dans la bibliothèque statique d’import et l’inclure dans le fichier résultant. A l’exécution, le code se trouvant dans la DLL sera, ainsi, appelé sans que le linker ne connaisse directement l’adresse du code dans la DLL.

Dans la 2e partie de cette article, on indiquera comment utiliser l’instruction __declspec(dllexport) pour générer une bibliothèque statique d’import.

Création d’une dépendance vers une bibliothèque statique

Avant de rentrer directement dans la génération d’une bibliothèque statique d’import, on va simplement créer 2 projets:

  • Un projet appelé NativeCallee permettant de générer une bibliothèque statique contenant du code à appeler.
  • Un projet appelé NativeCaller permettant de générer un exécutable. Le code de ce projet permet d’appeler du code se trouvant dans la bibliothèque statique générée par le premier projet NativeCallee.

Pour résumer, les appels se font de cette façon: NativeCallerNativeCallee.

Le code de cet article se trouve dans le repository GitHub github.com/msoft/cpp_dll_reference.

Installation Visual Studio

Avant de commencer, il faut s’assurer que le support C++ est bien installé dans Visual Studio. En exécutant l’installateur de Visual Studio 2017, il faut que “Développement Desktop en C++” soit coché:

Création de la bibliothèque statique NativeCallee

On va créer un projet C++ vide en cliquant sur les éléments suivant dans Visual Studio 2017:

  1. Créer un projet C++ vide en cliquant sur “Nouveau” ⇒ “Projet” ⇒ Dans “Visual C++”, cliquer sur “Projet vide” (“Empty project“).
    Il faut nommer le projet NativeCallee.
  2. Ajouter un classe en effectuant un clique droit sur le projet puis cliquer sur “Ajouter…” et enfin cliquer sur “Classe”.
    Il faut nommer la classe Callee.
  3. Implémentation de Callee.h: insérer ce code dans le fichier Callee.h:
    #ifndef CALLEE_H 
    #define CALLEE_H 
    
    #pragma once 
    
    class Callee 
    { 
    private: 
        wchar_t *textToDisplay; 
    
    public: 
        Callee(wchar_t *textToDisplay); 
        ~Callee(); 
    
        void DisplayText(); 
    }; 
    
    #endif /* CALLEE_H */
    
  4. Implémentaiton de Callee.cpp: insérer ce code dans le fichier Callee.cpp:
    #include "Callee.h" 
    
    #include <wchar.h> 
    
    Callee::Callee(wchar_t *textToDisplay) 
    {
        this->textToDisplay = textToDisplay; 
    } 
    
    Callee::~Callee() 
    { 
    } 
    
    void Callee::DisplayText() 
    { 
        wprintf(this->textToDisplay, "%s\n"); 
    }
    

    Ce code permet simplement de créer une classe avec un texte à afficher dans la variable membre textToDisplay. La fonction DisplayText() permet d’afficher le texte.

  5. Configurer les propriétés du projet NativeCallee:
    Effectuer un clique droit sur le projet NativeCallee puis cliquer sur Propriétés:

    Dans la partie Général, sélectionner les éléments suivants:

    • Type de configuration” (“Configuration type“): “Bibliothèque statique (.lib)” (“Static Library“).
    • Jeu de caractères” (“Character set“): “Utiliser le jeu de caractères Unicode” (“Use Unicode Character set“).
    • Prise en charge du Common Language Runtime” (“Common Language Runtime Support“): “Pas de prise en charge du Common Language” (“No Common Language Runtime Support“).

    Ne pas oublier de valider en cliquant sur “Appliquer”.

Les paramètres d’un projet C++ sont dépendants de la configuration et de la plateforme sélectionnées

Dans la fenêtre de présentation des paramètres du projet, si on change les valeurs des paramètres “Configuration” et/ou “Plateforme” (“Platform“), on peut voir que les paramètres du projet reviennent à leur valeur par défaut:

Ainsi, toutes les valeurs sélectionnées sont spécifiques à une configuration et à une plateforme donnée. Ce qui signifie que si on change la configuration et/ou la plateforme cible, il faut ressaisir les paramètres du projet.

Si on lance la compilation du projet NativeCallee en cliquant sur “Générer…” (“Build…“), on obtient les fichiers suivants:

  • Dans le répertoire NativeCallee\NativeCallee\Debug, le fichier Callee.obj correspondant au fichier objet de la classe Callee.
  • Dans le répertoire Debug, le fichier NativeCallee.lib correspondant à la bibliothèque statique générée.

Création de l’exécutable NativeCaller

On va, maintenant, créer un projet C++ permettant de générer une application:

  1. Créer une application Console en C++ en effectuant un clique droit sur la solution ⇒ cliquer sur “Ajouter” ⇒ “Nouveau projet” ⇒ Dans la partie “Visual C++”, sélectionner “Application Console Windows” (“Windows Console Application“).
    Il faut nommer le projet NativeCaller.
  2. Ajouter la référence vers la bibliothèque statique NativeCallee: accéder aux propriétés du projet NativeCaller en effectuant un clique droit sur le projet NativeCaller puis en cliquant sur “Propriétés”.

    Indiquer les paramètres suivant:

    • Dans la partie C/C++ ⇒ Général:

      Indiquer:
      Autres répertoires Include” (“Additional Include Directories“): $(MSBuildProjectDirectory)\..\NativeCallee
      Ce paramètre permet d’accéder au fichier NativeCallee.h.

    • Dans la partie “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“):

      Indiquer:
      Dépendances supplémentaires” (“Additional dependencies“): $(MSBuildProjectDirectory)\..\Debug\NativeCallee.lib
      Ce paramètre permet d’indiquer la bibliothèque statique NativeCallee.lib.

    Ne pas oublier de changer les paramètres pour toutes les configurations.

  3. Dans le fichier NativeCaller.cpp (contenant le main de l’application), il faut indiquer l’implémentation suivante:
    #include "pch.h" 
    #include "Redirect.h" 
    #include <iostream> 
    
    int main() 
    { 
        std::wstring textToDisplay(L"Text to display"); 
         
        Callee *callee = new Callee(const_cast<wchar_t*>(textToDisplay.c_str())); 
        callee->DisplayText(); 
      
        delete callee; 
      
        std::cin.get(); 
      
        return 0; 
    } 
    

    Ce code permet d’instancier la classe Callee avec une chaine de caractères et d’appeler une méthode permettant d’afficher cette chaine.

Lancer l’exécution

Avec de lancer l’exécution, il faut définir le projet de démarrage:

  1. Effectuer un clique droit sur le projet NativeCaller (correspondant à l’exécutable)
  2. Cliquer sur “Définir comme projet de démarrage” (“Set as Startup project“).

Lancer l’exécution en appuyant sur [F5].

Le résultat devrait être de la forme:

Calling other lib: 
Text to display 

Ce code permet d’appeler du code se trouvant dans une bibliothèque statique. Le code s’exécute normallement.

Convertir la dépendance statique en dépendance dynamique

Dans cette partie, on va convertir la bibliothèque statique en bibliothèque dynamique de façon à mettre en évidence l’erreur lors de l’édition de liens.

Référencer une DLL

Tout d’abord, on convertit le projet NativeCallee en bibliothèque dynamique:

  1. Dans les propriétés du projet NativeCallee: effectuer un clique droit sur le projet NativeCallee puis cliquer sur “Propriétés”.
  2. Dans la partie “Général”, changer le paramètre suivant:
    Type de configuration” (“Configuration Type“): “Bibliothèque dynamique (.dll)” (“Dynamic Library“)
  3. Valider en cliquant sur OK.

Si on essaie de compiler, on obtient une erreur provenant du linker indiquant qu’il n’arrive pas à ouvrir le fichier NativeCallee.lib.

Si on supprime la référence vers ce fichier dans les propriétés du projet NativeCaller dans “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“) ⇒ “Dépendances supplémentaires” (“Additional dependencies“), on obtiendra une erreur indiquant que la résolution de symboles n’a pas pu aboutir.

Exposer des objets avec __declspec(dllexport)

Comme indiqué plus haut, il faut exposer les symboles de la bibliothèque dynamique NativeCallee.dll de façon à générer une bibliothèque statique d’import. Cette bibliothèque statique d’import pourra être utilisée par NativeCaller.

Pour générer la bibliothèque statique d’import, il faut modifier l’implémentation de Callee.h en rajoutant les directives __declspec(dllexport) devant les éléments à exposer:

#ifndef CALLEE_H 
#define CALLEE_H 
  
#pragma once  

class Callee 
{
private: 
    wchar_t *textToDisplay; 
  
public: 
    __declspec(dllexport) Callee(wchar_t *textToDisplay); 
    __declspec(dllexport) ~Callee(); 
 
    __declspec(dllexport) void DisplayText(); 
}; 
  
#endif /* CALLEE_H */ 

Si on compile le projet NativeCallee, on remarque dans le répertoire Debug, les fichiers:

  • NativeCallee.dll correspondant à la bibliothèque dynamique NativeCallee.
  • NativeCallee.lib correspondant à la bibliothèque statique d’import de la DLL NativeCallee.dll.

Si on compile toute la solution et qu’on lance l’exécution, on constate que l’exécution est la même que précédemment.

Voir les fonctions exposées

Quelques méthodes permettent de voir les fonctions exposées par le DLL NativeCallee.dll:

  • Avec DependencyWalker: “DependancyWalker” est un utilitaire qui permet de voir toutes les dépendances d’une assembly, on peut ainsi vérifier que les dépendances existent et sont de la bonne version ou architecture.

    “DependancyWalker” peut être téléchargé sur www.dependencywalker.com/.

    Si on ouvre la DLL NativeCallee.dll avec “DependencyWalker”:

    On remarque les fonctions exportées correspondant aux objets qui sont précédés par __declspec(dllexport) dans la classe Callee:

    ??0Callee@@QAE@PA_W@Z 
    ??1Callee@@QAE@XZ 
    ?DisplayText@Callee@@QAEXXZ 
    
    Les noms de fonctions sont déformés

    Les fonctions exposées correspondent à du code C++. Pour les exposer dans la DLL sont prendre en compte l’encapsulation dans une classe, leur nom est déformé (i.e. mangled).

  • Avec dumpbin: dumpbin est un utilitaire livré avec Visual Studio C++, le chemin de l’exécutable est du type:
    C:\Program Files\Microsoft Visual Studio [version VS]\VC\bin 
    

    ou

    C:\Program Files (x86)\Microsoft Visual Studio [version VS]\VC\bin 
    

    On peut y accéder directement à partir de la ligne de commandes Visual Studio (ou “Developer Command Prompt for VS2017“).

    On peut utiliser dumpbin pour afficher les symboles se trouvant dans un fichier .obj. Ainsi pour afficher les symboles se trouvant dans le fichier objet de la classe Callee, il faut exécuter la commande suivante:

    dumpbin /symbols NativeCallee\Debug\Callee.obj
    

Amélioration de l’implémentation

Le code précédant fonctionne et permet d’exposer des objets dans une DLL toutefois il peut générer des déclarations d’export inutiles dans le cas où le code est appelé par une autre DLL.

Pourquoi éviter les déclarations d’export inutiles ?

Pour illustrer, on va créer une DLL intermédiaire entre NativeCaller et NativeCallee. Cette DLL sera appelée par NativeCaller et elle appellera NativeCallee. On appellera la DLL intermédiaire CallerRedirection.

Pour résumer, les appels se feront de cette façon: NativeCallerCallerRedirectionNativeCallee.

Pour créer la DLL CallerRedirection.dll, on crée un nouveau projet:

  1. Créer un projet C++ vide en cliquant sur “Nouveau” ⇒ “Projet” ⇒ Dans “Visual C++”, cliquer sur “Projet vide” (“Empty project“).
    Il faut nommer le projet CallerRedirection.
  2. Ajouter un classe en effectuant un clique droit sur le projet puis cliquer sur “Ajouter…” et enfin cliquer sur “Classe”.
    Il faut nommer la classe Redirect.
  3. Implémentation de Redirect.h: insérer ce code dans le fichier Redirect.h:
    #ifndef REDIRECT_H 
    #define REDIRECT_H 
    
    #pragma once 
    
    #include "Callee.h" 
    
    class Redirect 
    { 
    private: 
        Callee* callee;  
    
    public: 
        __declspec(dllexport) Redirect(wchar_t *textToDisplay); 
        __declspec(dllexport) ~Redirect(); 
    
        __declspec(dllexport) void RedirectCall(); 
    };
    
  4. Implémentation de Redirect.cpp: insérer le code suivant dans le fichier Redirect.cpp:
    #include "Redirect.h" 
      
    Redirect::Redirect(wchar_t *textToDisplay) 
    { 
        this->callee = new Callee(textToDisplay); 
    } 
    
    Redirect::~Redirect() 
    { 
        delete this->callee; 
    } 
    
    void Redirect::RedirectCall() 
    { 
        this->callee->DisplayText(); 
    } 
    

    Ce code permet d’instancier Callee avec une chaine de caractères et il permet d’appeler Callee::DisplayText() pour afficher la chaine de caractères.

  5. Ajouter la référence vers la bibliothèque statique NativeCallee: accéder aux propriétés du projet CallerRedirection en effectuant un clique droit sur le projet CallerRedirection puis en cliquant sur “Propriétés”.

    Indiquer les paramètres suivant:

    • Dans la partie “C/C++”” ⇒ “Général”, indiquer:
      Autres répertoires Include“: $(MSBuildProjectDirectory)\..\NativeCallee
      Ce paramètre permet d’accéder au fichier NativeCallee.h.
    • Dans la partie “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“), indiquer:
      Dépendances supplémentaires” (“Additional dependencies“): $(MSBuildProjectDirectory)\..\Debug\NativeCallee.lib
      Ce paramètre permet d’indiquer la bibliothèque statique NativeCallee.lib.
  6. On modifie les propriétés du projet NativeCaller pour qu’il appelle CallerRedirection au lieu d’appeler NativeCallee.

    Dans les propriétés du projet NativeCaller (effectuer un clique droit sur le projet NativeCaller puis en cliquant sur “Propriétés”):

    Indiquer les paramètres suivant:

    • Dans la partie C/C++ ⇒ Général:

      Indiquer:
      Autres répertoires Include” (“Include directories“): $(MSBuildProjectDirectory)\..\NativeCallee;$(MSBuildProjectDirectory)\..\CallerRedirection
      Ce paramètre permet d’accéder aux fichiers NativeCallee.h et Redirect.h.

    • Dans la partie “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“):

      Indiquer:
      Dépendances supplémentaires” (“Additional dependencies“): $(MSBuildProjectDirectory)\..\Debug\CallerRedirection.lib
      Ce paramètre permet d’indiquer la bibliothèque statique d’import de CallerRedirection.lib.

  7. On modifie l’implémentation de NativeCaller dans le fichier NativeCaller.cpp:
    #include "pch.h" 
    #include "Redirect.h" 
    #include <iostream> 
    
    int main() 
    { 
        std::wstring textToDisplay(L"Text to display"); 
    
        Redirect *callRedirection = new Redirect(const_cast<wchar_t*>(textToDisplay.c_str()));
      
        std::cout << "Calling other lib:\n"; 
        callRedirection->RedirectCall();
      
        delete callRedirection; 
      
        std::cin.get(); 
      
        return 0; 
    } 
    

    Ce code permet d’appeler CallerRedirection au lien d’appeler directement NativeCallee.

  8. Pour que la compilation puisse réussir, il faut préciser l’ordre de compilation des projets. Ainsi on indique les dépendances pour que la compilation des projets se fasse dans le bon ordre:
    • On effectue un clique droit sur la solution puis on clique sur “Dépendances du projet” (“Project dependencies“).
    • En sélectionnant NativeCaller, il faut cocher CallerRedirection et NativeCaller.
    • En sélectionnant CallerRedirection, il faut cocher NativeCallee.

    L’ordre de compilation doit être le suivant:

Si on compile la solution et si on exécute, le résultat devrait être semblable à ce qu’on a obtenu précédemment.

Si on affiche les symboles exportés de CallerRedirection en exécutant la commande suivante:

dumpbin /symbols CallerRedirection/Debug/Redirect.obj

On peut voir parmi les objets exportées:

03C 00000000 UNDEF  notype ()    External     | ??0Callee@@QAE@PA_W@Z (public: __thiscall Callee::Callee(wchar_t *)) 
03D 00000000 UNDEF  notype ()    External     | ??1Callee@@QAE@XZ (public: __thiscall Callee::~Callee(void)) 
03E 00000000 UNDEF  notype ()    External     | ?DisplayText@Callee@@QAEXXZ (public: void __thiscall Callee::DisplayText(void)) 
03F 00000000 SECTF  notype ()    External     | ?__autoclassinit2@Callee@@QAEXI@Z (public: void __thiscall Callee::__autoclassinit2(unsigned int)) 
040 00000000 SECTB  notype ()    External     | ??_GCallee@@QAEPAXI@Z (public: void * __thiscall Callee::`scalar deleting destructor'(unsigned int)) 
041 00000000 SECT5  notype ()    External     | ??0Redirect@@QAE@PA_W@Z (public: __thiscall Redirect::Redirect(wchar_t *)) 
042 00000000 SECT8  notype ()    External     | ??1Redirect@@QAE@XZ (public: __thiscall Redirect::~Redirect(void)) 
043 00000000 SECTD  notype ()    External     | ?RedirectCall@Redirect@@QAEXXZ (public: void __thiscall Redirect::RedirectCall(void)) 

On peut voir des fonctions exposées provenant de NativeCallee.

Pourquoi CallerRedirection contient les objets exposés par NativeCallee ?

CallerRedirection contient les objets exposés par NativeCallee à cause de l’instruction:

#include "Callee.h"

Cette instruction se trouve dans Redirect.h.

Pour rappel et comme on l’a expliqué plus haut dans les étapes de compilation, lors de l’étape de pré-processing, le code se trouvant dans les fichiers .h est directement inclus dans le code dans les fichiers .cpp. Ainsi le contenu du fichier Callee.h est placé dans le fichier Redirect.h qui lui-même est placé dans le fichier Redirect.cpp.

Ainsi le fichier objet Redirect.obj contient aussi les directives d’export de NativeCallee:

__declspec(dllexport) Callee(wchar_t *textToDisplay); 
__declspec(dllexport) ~Callee(); 

__declspec(dllexport) void DisplayText(); 

Ces objets sont exposés par CallerRedirection pourtant CallerRedirection ne les expose pas directement.

Nouvelle implémentation pour éviter les déclarations d’export inutiles

On améliore l’implémentation en utilisant des macros et des directives préprocesseurs. Cette implémentation permet d’éviter des déclarations d’export soient faites inutilement dans une DLL alors qu’elle ne définit pas directement les objets exportés.

Pour éviter ça:

  1. On modifie l’implémentation dans Callee.h et on rajoute des directives pré-processeur:
    #ifndef CALLEE_H 
    #define CALLEE_H 
      
    #pragma once 
      
    #ifdef NATIVECALLEE_EXPORTS 
    #define NATIVECALLEE_API __declspec(dllexport) 
    #else 
    #define NATIVECALLEE_API __declspec(dllimport) 
    #endif
    
    class Callee 
    { 
    private: 
        wchar_t *textToDisplay; 
      
    public: 
        NATIVECALLEE_API Callee(wchar_t *textToDisplay); 
        NATIVECALLEE_API ~Callee(); 
      
        NATIVECALLEE_API void DisplayText(); 
    }; 
      
    #endif /* CALLEE_H */ 
    
  2. Dans les propriétés du projet NativeCallee
    Dans la partie “C/C++”” ⇒ “Préprocesseur”, on indique la valeur suivante pour le paramètre “Définitions de préprocesseur” (“Preprocessor definitions“):

    NATIVECALLEE_EXPORTS 
    

La présence de cette valeur lors de l’étape de pré-processing permet d’affecter une valeur différente à la macro NATIVECALLEE_API. Ainsi:

  • Dans le projet NativeCallee, quand l’étape de pré-processing s’exécute, la valeur NATIVECALLEE_EXPORTS est définie et c’est la ligne suivante qui va s’exécuter:
    #define NATIVECALLEE_API __declspec(dllexport) 
    

    Par suite, les objets dans la classe Callee seront déclarés de la façon suivante:

    __declspec(dllexport) Callee(wchar_t *textToDisplay); 
    __declspec(dllexport) ~Callee(); 
      
    __declspec(dllexport) void DisplayText(); 
    

    Ces objets seront donc bien exposés par la DLL NativeCallee.dll comme précédemment.

  • Dans le projet CallerRedirection, quand l’étape de pré-processing s’exécute, la valeur NATIVECALLEE_EXPORTS n’est pas définie et c’est la ligne suivant qui va s’exécuter:
    #define NATIVECALLEE_API __declspec(dllimport) 
    

    Par suite, les objets dans la classe Callee seront déclarés de la façon suivante:

    __declspec(dllimport) Callee(wchar_t *textToDisplay); 
    __declspec(dllimport) ~Callee(); 
      
    __declspec(dllimport) void DisplayText(); 
    

    Ces objets seront, ainsi importés et non pas exposés par la DLL CallerRedirection.dll.

Si on recompile le code, ainsi obtenu, et si on observe les symboles se trouvant dans le fichier objet CallerRedirection/Debug/Redirect.obj, on obtient:

03C 00000000 UNDEF  notype       External     | __imp_??0Callee@@QAE@PA_W@Z (__declspec(dllimport) public: __thiscall Callee::Callee(wchar_t *)) 
03D 00000000 UNDEF  notype       External     | __imp_??1Callee@@QAE@XZ (__declspec(dllimport) public: __thiscall Callee::~Callee(void)) 
03E 00000000 UNDEF  notype       External     | __imp_?DisplayText@Callee@@QAEXXZ (__declspec(dllimport) public: void __thiscall Callee::DisplayText(void)) 
03F 00000000 SECTF  notype ()    External     | ?__autoclassinit2@Callee@@QAEXI@Z (public: void __thiscall Callee::__autoclassinit2(unsigned int)) 
040 00000000 SECTB  notype ()    External     | ??_GCallee@@QAEPAXI@Z (public: void * __thiscall Callee::`scalar deleting destructor'(unsigned int)) 
041 00000000 SECT5  notype ()    External     | ??0Redirect@@QAE@PA_W@Z (public: __thiscall Redirect::Redirect(wchar_t *)) 
042 00000000 SECT8  notype ()    External     | ??1Redirect@@QAE@XZ (public: __thiscall Redirect::~Redirect(void)) 
043 00000000 SECTD  notype ()    External     | ?RedirectCall@Redirect@@QAEXXZ (public: void __thiscall Redirect::RedirectCall(void)) 

On remarque que les fonctions de NativeCallee sont importées et non plus exportées.

Utilisation d’un fichier DEF pour exposer des objets

Au lieu d’utiliser l’instruction __declspec(dllexport) dans le code, il est possible d’utiliser un fichier dans lequel on indique les objets à exposer. La difficulté à utiliser ce fichier est qu’il faut indiquer le nom de la fonction tel qu’il apparaît dans les symboles du fichier .obj.
Par exemple si on modifie le projet CallerRedirection:

  1. Dans l’implémentation de Redirect.h, on supprime toutes les directives __declspec(dllexport):
    #ifndef REDIRECT_H 
    #define REDIRECT_H 
      
    #pragma once 
      
    #include "Callee.h" 
      
    class Redirect 
    { 
    private: 
        Callee* callee; 
      
    public: 
        Redirect(wchar_t *textToDisplay); 
        ~Redirect(); 
      
        void RedirectCall(); 
    }; 
    
    #endif /* REDIRECT_H */
    
  2. On recompile seulement le projet CallerRedirection en effectuant un clique droit sur le projet puis en cliquant sur “Regénérer” (“Rebuild“).
  3. On génère les symboles en exécutant la commande suivante avec la ligne de commandes de Visual Studio:
    dumpbin /symbols CallerRedirection\Debug\Redirect.obj
    

    Pour faciliter la récupération des noms de fonctions on peut rediriger la sortie dans un fichier texte:

    dumpbin /symbols CallerRedirection\Debug\Redirect.obj > symbols.txt 
    

    Les noms des fonctions à exposer apparaissent de cette façon:

    041 00000000 SECT5  notype ()    External     | ??0Redirect@@QAE@PA_W@Z (public: __thiscall Redirect::Redirect(wchar_t *)) 
    042 00000000 SECT8  notype ()    External     | ??1Redirect@@QAE@XZ (public: __thiscall Redirect::~Redirect(void)) 
    043 00000000 SECTD  notype ()    External     | ?RedirectCall@Redirect@@QAEXXZ (public: void __thiscall Redirect::RedirectCall(void)) 
    
  4. On crée un fichier nommé CallerRedirection.def dans le répertoire CallerRedirection avec le contenu suivant:
    LIBRARY CALLERREDIRECTION 
    EXPORTS 
        ??0Redirect@@QAE@PA_W@Z @1 
        ??1Redirect@@QAE@XZ @2 
      
        ?RedirectCall@Redirect@@QAEXXZ @3 
    

    On indique chaque fonction à exposer suivie d’un espace et d’une numérotation du type @<index de la fonction>

  5. Dans les propriétés du projet CallerRedirection:

    Dans la partie “Editeurs de liens” (“Linker“) ⇒ “entrée” (“Input“) ⇒ Pour le paramètre “Fichier de définition de module” (“Module Definition File“), on indique le nom:

    CallerRedirection.def 
    

On relance la compilation, toute la solution devrait compiler normalement comme précédemment. Les fonctions sont donc bien exposées.

Exporter des fonctions sous forme d’un linkage C

Les méthodes précédentes permettent d’exposer des objets C++. Ces exports sont compatibles à condition d’être appelés par du code C++. Il est possible d’effectuer des déclarations d’export en utilisant des exports compatibles C en utilisant la directive extern "C".

Pour l’utiliser, il suffit de préfixer la déclaration de la fonction à exposer avec extern "C".

Par exemple si on modifie le projet NativeCallee:

  1. On modifie le fichier Callee.h avant la déclaration de la classe Callee:
    #ifndef CALLEE_H 
    #define CALLEE_H 
      
    #pragma once 
      
    #ifdef NATIVECALLEE_EXPORTS 
    #define NATIVECALLEE_API __declspec(dllexport) 
    #else 
    #define NATIVECALLEE_API __declspec(dllimport) 
    #endif 
      
    extern "C" NATIVECALLEE_API void DisplayTextWithCallee(const wchar_t *textToDisplay);
      
    class Callee 
    { 
    private: 
        wchar_t *textToDisplay; 
      
    public: 
        NATIVECALLEE_API Callee(wchar_t *textToDisplay); 
        NATIVECALLEE_API ~Callee(); 
      
        NATIVECALLEE_API void DisplayText(); 
    }; 
      
    #endif /* CALLEE_H */ 
    

    On déclare une nouvelle méthode à exposer DisplayTextWithCallee().

  2. On implémente la fonction dans Callee.cpp:
    void DisplayTextWithCallee(const wchar_t *textToDisplay) 
    { 
        Callee *callee = new Callee(const_cast<wchar_t*>(textToDisplay)); 
        callee->DisplayText(); 
        delete callee; 
    } 
    

    Cette méthode permet d’instancier une classe Callee avec une chaine de caractères comme pour les exemples précédents. Ensuite, on affiche le contenu de la chaine de caractères.

  3. On modifie l’implémentation dans le projet CallerRedirection pour appeler la méthode DisplayTextWithCallee().

    D’abord on modifie l’implémentation de Redirect.h:

    #ifndef REDIRECT_H 
    #define REDIRECT_H 
      
    #pragma once 
      
    #include "Callee.h" 
      
    class Redirect 
    { 
    private: 
        wchar_t *textToDisplay;
      
    public: 
        __declspec(dllexport) Redirect(wchar_t *textToDisplay); 
        __declspec(dllexport) ~Redirect(); 
      
        __declspec(dllexport) void RedirectCall(); 
    }; 
      
    #endif /* REDIRECT_H */ 
    
  4. On modifie ensuite l’implémentation de Redirect.cpp:
    #include "Redirect.h" 
      
    Redirect::Redirect(wchar_t *textToDisplay) 
    { 
        this->textToDisplay = textToDisplay; 
    } 
      
    Redirect::~Redirect() 
    { 
    } 
      
    void Redirect::RedirectCall() 
    { 
        DisplayTextWithCallee(this->textToDisplay);
    } 
    

Si on observe la façon dont la fonction est exportée avec “DependencyWalker” dans la DLL CallerRedirection.dll, on constate que le nom n’est pas déformé comme c’est le cas pour les fonctions provenant d’objets C++:

Si on exécute la commande suivante avec la ligne de commandes Visual Studio:

dumpbin /symbols NativeCallee\Debug\Callee.obj

On obtient les symboles suivants:

058 00000000 UNDEF  notype ()    External     | ??2@YAPAXI@Z (void * __cdecl operator new(unsigned int)) 
059 00000000 UNDEF  notype ()    External     | ??3@YAXPAXI@Z (void __cdecl operator delete(void *,unsigned int)) 
05A 00000000 SECT10 notype ()    External     | _DisplayTextWithCallee
05B 00000000 SECT5  notype ()    External     | ??0Callee@@QAE@PA_W@Z (public: __thiscall Callee::Callee(wchar_t *))
05C 00000000 SECT7  notype ()    External     | ??1Callee@@QAE@XZ (public: __thiscall Callee::~Callee(void)) 
05D 00000000 SECTC  notype ()    External     | ?DisplayText@Callee@@QAEXXZ (public: void __thiscall Callee::DisplayText(void)) 

On constate que le nom de fonction exportée sous forme d’un linkage C n’est pas déformé.

Le code de cet article se trouve dans le repository GitHub github.com/msoft/cpp_dll_reference.

Conclusion

Le but de cet article était d’apporter quelques indications pour exposer des fonctions se trouvant dans une DLL de façon à pouvoir les consommer à partir d’un autre projet C++ lors de la compilation et par une autre bibliothèque au runtime. J’espère qu’il aura pu rendre toutes ces problématiques plus claires.

Cet article sert d’introduction pour illustrer la consommation de bibliothèque native à partir de code managé. Sujet qui sera traité dans des articles ultérieurs.

Leave a Reply