Appeler des dépendances C++ à partir d’un exécutable .NET AnyCPU

Il existe différentes méthodes pour appeler des dépendances natives à partir de .NET (cf. Appeler des DLL natives à partir de .NET). Ces méthodes ont en commun de devoir charger la bibliothèque native de façon à exécuter le code qui s’y trouve. Le chargement de DLL par l’application appelante implique que l’architecture de cette dernière soit compatible avec l’architecture d’exécution des DLL.

Le but de cet article est d’indiquer quelles sont les compatibilités des architectures d’exécution entre .NET et les DLL natives et d’indiquer une méthode pour exécuter de code dans des dépendances natives à partir d’un exécutable .NET AnyCPU.

Plateforme cible

Sur une machine desktop suivant le système, un programme peut généralement s’exécuter en 32 bits ou en 64 bits en utilisant l’architecture d’exécution, respectivement x86 ou x64. L’architecture d’exécution d’une application dépend d’abord de l’architecture du système d’exploitation et ensuite de la plateforme cible sélectionnée au moment de la compilation de l’exécutable.

En .NET, sur une machine desktop, on considère généralement 3 types de plateformes cible:

  • AnyCPU: l’exécutable pourra être exécuté par le CLR 32 bits sur un système 32 bits. Sur un système 64 bits, il pourra être exécuté par le CLR 32 ou 64 bits suivant le choix indiqué du paramètre “architecture de préférence” (i.e. “AnyCPU 32-bit prefered“) au moment de la compilation (pour plus de précisions voir Plateforme cible en 5 min).
  • x86: l’exécutable sera exécuté par le CLR 32 bits sur un système 32 ou 64 bits.
  • x64: l’exécution se fera par le CLR 64 bits seulement sur un système 64 bits.

En C++, seules les plateformes cible Win32 (pour une exécution en 32 bits) et x64 sont possibles. Il n’existe pas de plateforme cible AnyCPU comme en .NET.

On peut résumer les différents cas de figure dans le tableau suivant:

Architecture du système Technologie Plateforme cible Architecture possible du processus
32-bit .NET AnyCPU 32-bit
x86 32-bit
x64 Impossible (1)
C++ win32 32-bit
x64 Impossible (1)
64-bit .NET AnyCPU 32-bit si l’architecture de préférence est 32-bit sinon 64-bit.
x86 32-bit
x64 64-bit
C++ win32 32-bit
x64 64-bit

(1) Le système d’exploitation ne peut pas exécuter un processus avec cette architecture d’exécution.

Architecture des dépendances

Dans le cas managée et natif, les dépendances sous forme de fichiers DLL peuvent être compilées séparément. Chaque dépendance peut donc être compilée suivant une plateforme cible spécifique. En plus de la plateforme cible des dépendances, l’exécutable peut lui aussi être compilé suivant une plateforme cible particulière. Ces différentes plateforme cible peuvent introduire des incompatiblités.

Ainsi:

  • Un processus 32 bits ne peut charger que des dépendances x86 et AnyCPU dans le cas d’une dépendance managée et seulement Win32 dans le cas d’une dépendance native.
  • Un processus 64 bits peut charger des dépendances x64 et AnyCPU dans le cas d’une dépdendance managée et x64 pour une dépendance native.

On peut résumer les différents cas de figure dans le tableau suivant:

Architecture du processus Technologie de l’exécutable Plateforme cible de la dépendance
32-bit .NET .NET (managée) AnyCPU OK
x86 OK
x64 Erreur (2)
C++ (native) win32 OK
x64 Erreur (2)
C++ C++ (native) win32 OK
x64 Erreur (2)
64-bit .NET .NET (managée) AnyCPU OK
x86 Erreur (2)
x64 OK
C++ (native) win32 Erreur (2)
x64 OK
C++ C++ (native) win32 Erreur (2)
x64 OK

(2) Dans le cas d’un exécutable .NET, la DLL native sera chargée au moment où on fait appel à une fonction se trouvant dans la DLL. Si l’architecture de la DLL n’est pas compatible avec celle de l’exécutable, une exception du type BadImageFormatException ou FileLoadException sera lancée.

Comme on peut le voir, il existe certains cas où le chargement d’une dépendance compilée avec la mauvaise plateforme cible peut mener à une erreur.

Le cas le plus compliqué à gérer, dans le cas d’un déploiement, est le cas d’un exécutable compilé avec pour plateforme cible AnyCPU car suivant le système il pourra être exécuté aussi bien en 32 bits qu’en 64 bits:

  • Dans le cas de dépendances managées uniquement: une solution est de compiler les dépendances avec la plateforme cible AnyCPU. Ainsi quel que soit l’architecture d’exécution du processus, le chargement des dépendances ne mènera pas à une erreur.
  • Dans le cas de dépendances natives: il n’y a pas de solutions triviales puisque la plateforme cible AnyCPU n’existe pas dans ce cas.

Quelle architecture d’exécution choisir ?

2 solutions sont possibles pour déployer une application et garantir la compatibilité des dépendances natives avec l’exécutable:

  1. Déployer 2 versions distinctes de l’exécutable:
    • Une version x86 compilée en Win32 pour les DLL natives et en x86 pour l’exécutable .NET. Cette version est exécutable sur un système 32 bits et 64 bits.
    • Une version x64 exécutable seulement sur un système 64 bits.

    Cette solution permet de facilement adresser tous les cas de figure toutefois elle nécessite de déployer 2 versions différentes et choisir la bonne version suivant le système sur lequel on veut lancer l’exécution.

  2. Déployer un exécutable AnyCPU compatible avec tous les systèmes d’exploitation. On compile ensuite 2 groupes de dépendances natives:
    • Un groupe Win32 utilisable avec un exécutable AnyCPU lancé par le CLR 32 bits (possible sur un système 32 bits et 64 bits).
    • Un groupe x64 utilisable avec un exécutable AnyCPU lancé par le CLR 64 bits (possible sur un système 64 bits).

    L’intérêt de cette méthode est que c’est l’exécutable, en fonction de son architecture d’exécution, qui va choisir quel est le groupe de dépendances natives qui devra être chargé. Ainsi, on déploie les mêmes assemblies et DLL sur tous les systèmes et l’exécution est possible quel que soit le système.

Au moyen d’un exemple, on va présenter comment appliquer la 2e solution.

Exemple d’appel d’une DLL native

On va illustrer un appel d’une DLL native par un exécutable .NET avec un exemple simple d’une application Console qui appelle une fonction dans une DLL native pour afficher le contenu d’une chaîne de caractères.

Le code d’origine de l’exemple se trouve dans la branche master du repository GitHub github.com/msoft/cpp_execution_architecture. La solution comporte 3 projets :

  • ArchitectureExample: application Console C# qui appelle la méthode NativeCaller::CallNativeCode() se trouvant dans le projet MixedAssembly.
  • MixedAssembly: il s’agit d’un projet C++ permettant de générer une assembly mixte. Cette assembly ne contient que la classe NativeCaller qui va appeler la fonction DisplayTextWithCallee() exposée dans le projet NativeCallee.
  • NativeCallee: c’est un projet C++ pour générer une bibliothèque dynamique. Cette bibliothèque expose la méthode DisplayTextWithCallee() pour afficher le contenu d’une chaine de caractères.

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

ArchitectureExample (exécutable .NET) MixedAssembly (Assembly mixte) NativeCallee (DLL native C++)
Main() NativeCaller::CallNativeCode() DisplayWithCallee()

Si on exécute l’application, le résultat est du type:

Displaying from managed code: text to display 
Displaying from unmanaged code: text to display 

Ainsi:

  • "Displaying from managed code: text to display" est affiché par ArchitectureExample.exe et
  • "Displaying from unmanaged code: text to display" est affiché par NativeCallee.dll.

Pour une explication plus complète du code de cet exemple, on peut se référer à l’article Référencer une DLL C++ avec une bibliothèque statique d’import.

Après avoir cloné le repository GitHub, il faut l’ouvrir avec Visual Studio et le compiler. Dans le répertoire ArchitectureExample\bin\Debug\, après compilation, il devrait résulter les fichiers suivants parmi les fichiers générés:

  • ArchitectureExample.exe,
  • MixedAssembly.dll et
  • NativeCallee.dll

Choix de l’architecture d’exécution

Si on regarde plus en détails l’architecture d’exécution de la solution en cliquant sur “Générer” (“Build”) ⇒ “Gestionnaire de configurations” (i.e. “Configuration Manager”), on remarque la configuration suivante:

  • En x86:
  • En x64:

L’application est générée en AnyCPU toutefois dans les paramètres du projet ArchitectureExample accessible en faisant un clique droit sur le projet puis en cliquant sur “Propriétés”), dans l’onglet “Build”, le paramètre “Préférer 32 bits” (i.e. “AnyCPU 32-bit prefered”) est coché. Quel que soit le système, l’exécutable va démarrer en 32 bits et les DLL générées avec l’architecture Win32 seront chargées correctement.

Si on effectue les étapes suivantes, on aboutira à une erreur de chargement des dépendances natives (dans le cas où on exécute l’application dans un système 64 bits):

  1. Dans les propriétés du projet ArchitectureExample, dans l’onglet “Build”, on décoche “Préférer 32 bits”.
  2. On se place dans la configuration suivante:
    • Configuration de la solution active: Debug
    • Plateforme de la solution active: x86.
  3. On compile et on exécute la solution. Une erreur de chargement devrait se produire car l’exécutable démarre en 64 bits alors que les dépendances natives sont compilées en x86.

On va apporter une solution pour que le chargement s’effectue correctement quelques soit l’architecture d’exécution de l’exécution .NET.

Solution pour utiliser un exécutable AnyCPU avec des dépendances natives

La solution consiste à charger les dépendances en fonction de l’architecture d’exécution. Par exemple si l’architecture d’exécution de l’exécutable est:

  • x86 alors on charge les dépendances natives compilées en Win32.
  • x64 alors on charge les dépendances natives compilées en x64.

En .NET, les dépendances sont chargées au moment de leur exécution. Il est donc possible d’effectuer une copie des DLL natives pour qu’elles soient présentes dans le répertoire de l’exécutable juste avant d’effectuer les appels aux fonctions se trouvant dans ces dépendances natives. Ainsi, les bonnes dépendances seront chargées et exécutées lors de l’appel du code se trouvant dans les DLL.

Ainsi:

  • Dans un 1er temps, on va configurer les projets des dépendances natives pour qu’ils génèrent des DLL dans les 2 architectures d’exécution (Win32 et x64).
  • Dans un 2e temps, on va créer un projet qui fera office de proxy pour lancer l’exécution du code dans les dépendances natives, c’est ce projet qui effectuera les copies en fonction de l’architecture d’exécution.
  • Enfin, on modifiera le code dans le projet de l’exécutable ArchitectureExample pour appeler le code du Proxy et non celui des dépendances natives.

Générer les dépendances natives en win32 et x64

On configure les projets des dépendances natives MixedAssembly et NativeCallee pour qu’ils soient générés en Win32 et en x64.

  1. On va dans le gestionnaire de configuration en effectuant un clique droit sur la solution ⇒ “Gestionnaire de configuration”.
  2. Il faut supprimer les configurations de la solution x86 et x64 pour ne garder que AnyCPU. La configuration devrait se présenter de cette façon:

    Les configurations Win32 et x64 doivent rester disponibles pour les projets correspondant aux dépendances natives MixedAssembly et NativeCallee.

  3. On modifie les répertoires des sorties des projets MixedAssembly et NativeCallee:
    • On effectue un clique droit sur le projet NativeCallee ⇒ “Propriétés”
    • Dans “Général” ⇒ “Répertoire de sortie”, on indique le chemin suivant: $(SolutionDir)$(Platform)\$(Configuration)\
      On effectue la même modification pour toutes les configurations Debug et Release ainsi que pour toutes les plateformes Win32 et x64.
    • On accède aux propriétés du projet MixedAssembly et on modifie le répertoire de sortie de la même façon.
    • Dans la partie “Editeur de liens” du projet MixedAssembly, on modifie les dépendances du linker. Dans “Editeur de liens” ⇒ “entrée”, il faut modifier le paramètre “Dépendances supplémentaires” pour que la configuration soit:
      • En Debug et pour la plateforme Win32: ..\win32\Debug\NativeCallee.lib;%(AdditionalDependencies)
      • En Release et pour la plateforme Win32: ..\win32\Release\NativeCallee.lib;%(AdditionalDependencies)
      • En Debug et pour la plateforme x64: ..\x64\Debug\NativeCallee.lib;%(AdditionalDependencies)
      • En Release et pour la plateforme x64: ..\x64\Release\NativeCallee.lib;%(AdditionalDependencies)
  4. On modifie le fichier projet de MixedAssembly pour générer toutes les architectures à chaque compilation. On ajoute une commande “AfterBuild” en éditant le fichier cpp_execution_architecture\MixedAssembly\MixedAssembly.vcxproj en ajoutant le code suivant:
    <Project> 
      <!-- ... -->   
      <Target Name="AfterBuild" Condition=" '$(Platform)' == 'x64' "> 
        <Message Text="Building platform Win32" Importance="High" /> 
        <MsBuild Projects="$(MSBuildProjectFullPath)" Properties="Platform=Win32" /> 
      </Target> 
    </Project> 
    
  5. Après avoir lancé la compilation du projet MixedAssembly en Debug, les fichiers suivants devraient être générés:
    • Win32\Debug\MixedAssembly.dll
    • Win32\Debug\NativeCallee.dll
    • x64\Debug\MixedAssembly.dll
    • x64\Debug\NativeCallee.dll

Créer un projet “Proxy” pour charger les dépendances natives

On crée un projet proxy qui effectuera la copie des dépendances natives dans la bonne architecture d’exécution auprès de l’exécutable:

  1. On ajoute un projet en C# de type “Bibliothèque de classes (Framework .NET)” en effectuant un clique droit sur la solution ⇒ “Ajouter” ⇒ “Nouveau Projet”. Sélectionner “Visual C#” ⇒ “Bibliothèque de classes (.NET Framework)”, on nomme ce nouveau projet DependencyLoader.
  2. On ajoute une référence vers le projet MixedAssembly en effectuant un clique droit sur la partie “Références” du projet DependencyLoader ⇒ “Ajouter une référence” ⇒ Dans “Projets”, cocher MixedAssembly.
  3. Déplier la partie “Références” et effectuer un clique droit sur MixedAssembly ⇒ “Propriétés”, pour le paramètre “Copie locale”, affecter la valeur false. Ce paramètre permet d’éviter que la référence soit copiée dans le répertoire de sortie du projet. Ainsi on évite d’avoir une DLL avec la mauvaise architecture de compilation dans le répertoire de sortie.
  4. Dans ce nouveau projet, on ajoute une classe nommée NativeCallerProxy avec le code suivant:
    namespace DependencyLoader 
    { 
        public class NativeCallerProxy 
        { 
            private const string dependencySubFolder = "Dependencies"; 
      
            public NativeCallerProxy() 
            { 
                CopyDependencies(); 
            } 
    
            public void CallNativeCaller(string textToDisplay) 
            { 
                NativeCaller nativeCaller = new NativeCaller(textToDisplay); 
                nativeCaller.CallNativeCode(); 
            }
    
            private static void CopyDependencies() 
            { 
                string executableFolder = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); 
                string dependencyArchitecture = Environment.Is64BitProcess ? "x64" : "Win32"; 
                string dependencyFolder = Path.Combine(executableFolder, dependencySubFolder, dependencyArchitecture); 
      
                foreach (var sourceFile in Directory.GetFiles(dependencyFolder, "*", SearchOption.TopDirectoryOnly)) 
                { 
                    string fileName = Path.GetFileName(sourceFile); 
                    string destinationFilePath = Path.Combine(executableFolder, fileName); 
                    File.Copy(sourceFile, destinationFilePath, true); 
                } 
            } 
        }
    }
    

    Ce code permet d’effectuer une copie des dépendances natives dans le répertoire de l’exécutable suivant l’architecture d’exécution:

    • L’architecture d’exécution peut être récupérée avec la propriété statique Environment.Is64BitProcess.
    • On récupère le chemin de l’exécutable avec la propriété Assembly.GetEntryAssembly().Location.
    • On effectue une copie des DLL se trouvant dans les répertoires:
      • Dependencies\x64 si l’exécutable est lancé en x64
      • Dependencies\Win32 si l’exécutable est lancé en x86

      La copie est effectuée dans le répertoire de l’exécutable.

    • Cette classe permet de lancer le code se trouvant dans les dépendances natives avec la méthode CallNativeCaller():
      public void CallNativeCaller(string textToDisplay) 
      { 
          NativeCaller nativeCaller = new NativeCaller(textToDisplay); 
          nativeCaller.CallNativeCode(); 
      }
      
  5. On modifie les dépendances du projet lors de la génération en effectuant un clique droit sur la projet DependencyLoader ⇒ “Dépendances de build” ⇒ “Dépendances du projet” puis on coche MixedAssembly:
  6. Dans le gestionnaire de configurations accessible en effectuant un clique droit sur la solution ⇒ “Gestionnaire de configurations”. Le projet DependencyLoader ne doit comporter que la configuration AnyCPU en Debug et en Release (comme pour le projet ArchitectureExample):
  7. Modification du projet ArchitectureExample pour appeler la classe “Proxy”

    On modifie le projet ArchitectureExample correspondant à l’exécutable pour qu’il appelle le classe Proxy NativeCallerProxy dans le projet DependancyLoader. Ainsi:

    1. On supprime la référence vers le projet MixedAssembly en accédant à la partie “Références” du projet ArchitectureExample et on supprime la référence MixedAssembly.
    2. On ajoute une référence de projet vers DependencyLoader en effectuant un clique droit sur la partie “References” du projet ArchitectureExample ⇒ “Ajouter une référence”. Dans la partie “Projets”, il faut cocher DependencyLoader.
    3. On modifie le main dans le fichier Program.cs pour appeler la classe NativeCallerProxy dans le projet DependancyLoader:
      static void Main(string[] args) 
      { 
          string textToDisplay = "text to display"; 
        
          Console.WriteLine($"Displaying from managed code: {textToDisplay}"); 
        
          var proxy = new NativeCallerProxy(); 
          proxy.CallNativeCaller(textToDisplay);
        
          Console.ReadLine(); 
      } 
      
    4. On ajoute une commande post-build pour effectuer les copies des DLL natives dans les bons répertoires:

      On effectue un clique droit sur le projet ArchitectureExample ⇒ “Propriétés”. Dans l’onglet “Evènements de build”, il faut ajouter les commandes suivantes dans la partie “Ligne de commande de l’évènement post-build”:

      mkdir $(TargetDir)Dependencies\Win32 
      xcopy $(SolutionDir)Win32\$(ConfigurationName)\NativeCallee.dll $(TargetDir)Dependencies\Win32 /Y 
      xcopy $(SolutionDir)Win32\$(ConfigurationName)\MixedAssembly.dll $(TargetDir)Dependencies\Win32 /Y 
      mkdir $(TargetDir)Dependencies\x64 
      xcopy $(SolutionDir)x64\$(ConfigurationName)\NativeCallee.dll $(TargetDir)Dependencies\x64 /Y 
      xcopy $(SolutionDir)x64\$(ConfigurationName)\MixedAssembly.dll $(TargetDir)Dependencies\x64 /Y 
      

      On valide en enregistrant le projet.

      En mode Debug, ces commandes permettent de copier les DLL MixedAssembly.dll et NativeCallee.dll dans les répertoires:

      • ArchitectureExample\bin\Debug\Dependencies\Win32 et
      • ArchitectureExample\bin\Debug\Dependencies\x64.
    5. On modifie les dépendances du projet ArchitectureExample lors de la génération en effectuant un clique droit sur la projet DependencyLoader ⇒ “Dépendances de build” ⇒ “Dépendances du projet” puis on coche MixedAssembly et NativeCallee:

    Si on compile en mode Debug, les fichiers suivants seront générés dans le répertoire de sortie du projet de l’exécutable ArchitectureExample (ArchitectureExample\bin\Debug):

    • Les fichiers de l’exécutable:
      • ArchitectureExample.exe
      • ArchitectureExample.exe.config
      • DependencyLoader.dll
    • Les fichiers des dépendances natives de toutes les architectures:
      • Dependencies\Win32\MixedAssembly.dll
      • Dependencies\Win32\NativeCallee.dll
      • Dependencies\x64\MixedAssembly.dll
      • Dependencies\x64\NativeCallee.dll

    A l’exécution, les DLL natives sont copiées dans le même répertoire que l’exécutable et le résultat de l’exécution est le même:

    Displaying from managed code: text to display 
    Displaying from unmanaged code: text to display
    

    Le résultat de l’exécution est le même quel que soit le CLR qui exécute l’exécutable (32 ou 64 bits).

    Le code final de cet exemple se trouve dans la branche final du repository GitHub github.com/msoft/cpp_execution_architecture.

    Pour résumer

    Les assemblies .NET peuvent être compilées avec une plateforme cible supplémentaire par rapport aux DLL natives. Cette plateforme cible est AnyCPU qui permet de démarrer un exécutable en utilisant le CLR 32 ou 64 bits. Lorsqu’une dépendance native existe, il faut qu’elle soit compilée avec une plateforme cible compatible avec celle de l’exécutable .NET.

    Dans le cas d’un exécutable .NET compilé en AnyCPU, de façon à éviter les erreurs de chargement, il faut être vigilant sur la plateforme choisie pour les dépendances natives car l’exécutable peut démarrer en 32 bits ou en 64 bits. Pour éviter les problèmes de chargement, une solution consiste à copier programmatiquemet les dépendances natives dans le répertoire de l’exécutable juste avant d’appeler le code de la dépendance. Ainsi connaissant l’architecture d’exécution de l’exécutable, on peut savoir quelle plateforme cible des DLL natives est compatible.

    L’intérêt de cette méthode est de déployer les mêmes DLL et assemblies quel que soit le système sur lequel l’exécutable est exécuté.

Leave a Reply