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
.
Quelle architecture d’exécution choisir ?
Exemple d’appel d’une DLL native
Choix de l’architecture d’exécution
Solution pour utiliser un exécutable AnyCPU avec des dépendances natives
Générer les dépendances natives en win32 et x64
Créer un projet “Proxy” pour charger les dépendances natives
Modification du projet ArchitectureExample pour appeler la classe “Proxy”
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 seulementWin32
dans le cas d’une dépendance native. - Un processus 64 bits peut charger des dépendances
x64
etAnyCPU
dans le cas d’une dépdendance managée etx64
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:
- Déployer 2 versions distinctes de l’exécutable:
- Une version
x86
compilée enWin32
pour les DLL natives et enx86
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.
- Une version
- 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écutableAnyCPU
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.
- Un groupe
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éthodeNativeCaller::CallNativeCode()
se trouvant dans le projetMixedAssembly
.MixedAssembly
: il s’agit d’un projet C++ permettant de générer une assembly mixte. Cette assembly ne contient que la classeNativeCaller
qui va appeler la fonctionDisplayTextWithCallee()
exposée dans le projetNativeCallee
.NativeCallee
: c’est un projet C++ pour générer une bibliothèque dynamique. Cette bibliothèque expose la méthodeDisplayTextWithCallee()
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é parArchitectureExample.exe
et"Displaying from unmanaged code: text to display"
est affiché parNativeCallee.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
etNativeCallee.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):
- Dans les propriétés du projet
ArchitectureExample
, dans l’onglet “Build”, on décoche “Préférer 32 bits”. - On se place dans la configuration suivante:
- Configuration de la solution active:
Debug
- Plateforme de la solution active:
x86
.
- Configuration de la solution active:
- 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 enWin32
.x64
alors on charge les dépendances natives compilées enx64
.
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
etx64
). - 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
.
- On va dans le gestionnaire de configuration en effectuant un clique droit sur la solution ⇒ “Gestionnaire de configuration”.
- Il faut supprimer les configurations de la solution
x86
etx64
pour ne garder queAnyCPU
. La configuration devrait se présenter de cette façon:
Les configurations
Win32
etx64
doivent rester disponibles pour les projets correspondant aux dépendances nativesMixedAssembly
etNativeCallee
. - On modifie les répertoires des sorties des projets
MixedAssembly
etNativeCallee
:- 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 configurationsDebug
etRelease
ainsi que pour toutes les plateformesWin32
etx64
. - 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 plateformeWin32
:..\win32\Debug\NativeCallee.lib;%(AdditionalDependencies)
- En
Release
et pour la plateformeWin32
:..\win32\Release\NativeCallee.lib;%(AdditionalDependencies)
- En
Debug
et pour la plateformex64
:..\x64\Debug\NativeCallee.lib;%(AdditionalDependencies)
- En
Release
et pour la plateformex64
:..\x64\Release\NativeCallee.lib;%(AdditionalDependencies)
- En
- On effectue un clique droit sur le projet
- On modifie le fichier projet de
MixedAssembly
pour générer toutes les architectures à chaque compilation. On ajoute une commande “AfterBuild” en éditant le fichiercpp_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>
- Après avoir lancé la compilation du projet
MixedAssembly
enDebug
, 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:
- 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
. - On ajoute une référence vers le projet
MixedAssembly
en effectuant un clique droit sur la partie “Références” du projetDependencyLoader
⇒ “Ajouter une référence” ⇒ Dans “Projets”, cocherMixedAssembly
. - 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 valeurfalse
. 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. - 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é enx64
Dependencies\Win32
si l’exécutable est lancé enx86
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(); }
- L’architecture d’exécution peut être récupérée avec la propriété statique
- 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 cocheMixedAssembly
:
- 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 configurationAnyCPU
enDebug
et enRelease
(comme pour le projetArchitectureExample
):
- On supprime la référence vers le projet
MixedAssembly
en accédant à la partie “Références” du projetArchitectureExample
et on supprime la référenceMixedAssembly
. - On ajoute une référence de projet vers
DependencyLoader
en effectuant un clique droit sur la partie “References” du projetArchitectureExample
⇒ “Ajouter une référence”. Dans la partie “Projets”, il faut cocherDependencyLoader
. - On modifie le
main
dans le fichierProgram.cs
pour appeler la classeNativeCallerProxy
dans le projetDependancyLoader
: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(); }
- 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 DLLMixedAssembly.dll
etNativeCallee.dll
dans les répertoires:ArchitectureExample\bin\Debug\Dependencies\Win32
etArchitectureExample\bin\Debug\Dependencies\x64
.
- On modifie les dépendances du projet
ArchitectureExample
lors de la génération en effectuant un clique droit sur la projetDependencyLoader
⇒ “Dépendances de build” ⇒ “Dépendances du projet” puis on cocheMixedAssembly
etNativeCallee
:
- 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
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:
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
):
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é.