Les DLL natives en .NET

LEBRUN Thomas

Dans cet article, vous apprendrez comment faire appel, dans vos applications .NET, à des fonctions dont le code est implémenté dans des DLL natives.

N'hésitez pas à commenter cet article :! Commentez ♪

- Titre : Les DLL natives en .NET

- Auteur : LEBRUN Thomas

- Parution : 18 novembre 2004

- Licence : Copyright ® 2004 LEBRUN Thomas. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Utilisation

- II-A. Explications

- II-B. Exemples

- III. Conversion de type

- IV. Passage d'arguments par pointeurs

- IV-A. Les risques

- IV-B. La solution

- V. Passage de chaînes de caractères

- VI. Conclusion

- VII. Liens

I - Introduction

Dans le cadre de vos développements .NET, vous êtes amené à utiliser les méthodes que le Framework met à votre disposition. Mais, lors de réalisations plus complexes, vous pouvez être amené à utiliser des méthodes qui se trouvent dans des DLL (Dynamic Link Library) qui n'ont pas été écrites en .NET. Pour pallier ce problème, on utilise le mécanisme de : P/Invoke (Platform Invoke). Bien que créé pour utiliser des DLL standards, vous pouvez tout à fait utiliser P/Invoke pour appeler les méthodes de vos propres DLL. P/Invoke permet aussi d'écrire du code non managé afin d'empêcher la réflexion du code IL.

II - Utilisation

II-A - Explications

Toutes les classes qui vous permettent d'utiliser P/Invoke se trouvent dans l'espace de nom System.Runtime.InteropServices (vous devez donc penser à faire un using ou Import, selon le langage de programmation que vous utilisez, pour pouvoir utiliser ces classes).
Ensuite, vous devez déclarer, dans votre classe, la fonction que vous souhaitez utiliser. Cette déclaration doit suivre une certaine méthodologie :

- la déclaration de la fonction doit être précédée de l'attribut DllImport, qui indique le nom de la DLL ;

- les mots-clés static et extern (pour indiquer que la méthode à utiliser provient d'une source extérieure) doivent être utilisés ;

- le nom de la fonction doit être le même que celui spécifié dans la DLL ;

- chaque argument doit avoir un nom ;

- vous devez connaître le prototype de la méthode que vous désirez implémenter.

Étant donné que cela peut paraître abstrait, la section suivante vous montrera un exemple.

II-B - Exemples

Le programme suivant vous montrera un exemple d'utilisation de l'attribut DllImport, pour utiliser une méthode contenue dans le fichier user32.dll.
La méthode que nous allons appeler est la méthode FlashWindow, dont le but est de faire clignoter une fenêtre (de la même façon qu'une fenêtre MSN Messenger par exemple).

Utilisation de DllImport

// Déclaration des using
using System;
using System.Runtime.InteropServices;

class FenetreClignotante
{
// Déclaration de la méthode à utiliser
[ DllImport("user32") ]
static extern int FlashWindow
(
int hwnd,
int bInvert
);

private int hWND;

// Point d'entrée de l'application
static void Main(string[] args)
{
hWND = (int)this.Handle;
// Utilisation de la méthode
FlashWindow(this.hWND, 1);
}

}

Il est parfois possible que vous ne puissiez pas spécifier le nom de la méthode définie, car l'une de vos fonctions porte déjà ce même nom. Dans ce cas, vous avez deux possibilités :

- changer le nom de votre méthode, et remplacer tous les appels de votre méthode avec le nouveau nom (ce qui peut parfois s'avérer long…) ;

- changer le nom de la fonction implémentée dans la DLL native ;

- mettre votre classe dans un de vos namespace.

Dans le deuxième cas, vous devrez utiliser la propriété optionnelle EntryPoint (Point d'entrée) pour indiquer le nom de la méthode à charger.
Un exemple étant toujours plus parlant que des mots, voici de quoi je veux parler

Utilisation de l'attribut EntryPoint

// Déclaration des using
using System;
using System.Runtime.InteropServices;

// Déclaration de la méthode à utiliser
[ DllImport("user32.dll", EntryPoint="FlashWindow") ]
static extern int MonClignotement
(
int hwnd,
int bInvert
);

private int hWND;

// Point d'entrée de l'application
static void Main(string[] args)
{
hWND = (int)this.Handle;
// Utilisation de la méthode
MonClignotement(this.hWND, 1);
}

Une autre propriété qu'il est intéressant d'utiliser est SetLastError. En effet, lorsque vous programmez, il est important de penser aux différentes erreurs qui peuvent survenir (la plupart des DLL étant écrites en C, elles ne génèrent pas d'exceptions, mais renvoient des codes d'erreurs). Pour pouvoir intercepter ces erreurs lorsque vous travaillez avec P/Invoke, vous devez mettre à true (vrai) cette propriété, afin de pouvoir récupérer l'éventuelle exception qui pourrait survenir, au moyen de la méthode GetLastWin32Error (GetLastWin32Error est défini dans System.Runtime.InteropServices.Marshal).
Voici un exemple pour illustrer cette propriété :

Utilisation de SetLastError et GetLastWin32Error

// Nous utiliserons ici la méthode Beep de la DLL kernel32.dll
// Déclaration des using
using System;
using System.Runtime.InteropServices;

class FenetreClignotante
{
// Déclaration de la méthode à utiliser
[ DllImport("kernel32", SetLastError=true) ]
static extern bool Beep
(
uint iFreq,
uint iDuration
);

// Point d'entrée de l'application
static void Main(string[] args)
{
if ( !Beep(100, 100) )
{
Int32 err = Marshal.GetLastWin32Error();
throw new Win32Exception(err);
}
}

}

GetLastWin32Error() nous permet donc de récupérer le code d'erreur de l'exception. Vous pouvez voir à quoi correspond chaque code d'erreur à cette adresse : Liste des codes d'erreur.
Néanmoins, il existe deux techniques pour pouvoir récupérer directement la description correspondant au code d'erreur :

- vous pouvez utiliser l'API : FormatMessage, ce qui est assez fastidieux (vous devrez utiliser une API pour comprendre l'erreur générée par une autre API…) ;

- vous pouvez également utiliser une méthode que j'ai trouvée : ici.

La deuxième méthode est simple: dans votre code, remplacez :

Int32 err = Marshal.GetLastWin32Error();
throw new Win32Exception(err);

par ceci :

string errorMessage = new Win32Exception(Marshal.GetLastWin32Error()).Message;

errorMessage contient alors le message d'erreur, plutôt que le code d'erreur.

Si vous tenez tout de même à utiliser l'API FormatMessage voici une classe fournie par : abelman et qui vous permet de récupérer le message d'une DLL à partir du code d'erreur : Classe permettant la récupération des messages d'erreur d'une DLL depuis le code d'erreur.

III - Conversion de type

Dans nos exemples, le prototype de FlashWindow est relativement simple, car les types utilisés sont les mêmes en .NET et en Win32. Celui de Beep par contre est un peu plus complexe, car les types des arguments de la méthode n'existent pas en .NET. Ce sont des types Win32. Si vous essayez de passer en paramètre un entier, alors que la méthode attend un LPSTR ou un Short, vous aurez une erreur à la compilation. Pour éviter cela, il existe un tableau de conversion des types Win32 vers les types .NET :

Type Win32

Type .NET

Équivalence en C#/VB.NET

HWND, HANDLE, HINSTANCE, HMODULE

System.IntPtr

System.IntPtr

LPSTR, LPCSTR, LPWSTR, LPCWSTR

System.String, System.StringBuilder

string

BYTE

System.Byte

Byte

SHORT

System.Int16

Short

WORD

System.UInt16

ushort

DWORD, UINT, ULONG

System.Int32

uint

INT, LONG

System.UInt32

uint

BOOL

System.Bool

bool

CHAR

System.Char

char

FLOAT

System.Single

float

DOUBLE

System.Double

double

Ce tableau peut être retrouvé dans la MSDN de Microsoft, à cette adresse : http://msdn.microsoft.com/en-us/magazine/cc164123.aspx#S6

IV - Passage d'arguments par pointeurs

IV-A - Les risques

Utiliser le passage d'arguments par pointeurs peut représenter un gros risque si vous n'avez pas bien saisi le fonctionnement du Garbage Collector (ramasse-miette).
En effet, ce ramasse-miette est « l'outil » du framework .NET qui est chargé de libérer les objets qui ne sont plus nécessaires dans votre application : il est donc chargé de déplacer des objets en mémoire. Étant donné que son passage est aléatoire, vous prenez le risque, si vous passez l'adresse d'un objet par l'intermédiaire d'un pointeur, que le Garbage Collector déplace cet objet en mémoire. Votre pointeur sera alors invalide, car il ne pointera plus vers l'objet. Ce résultat entraînera alors des bugs ou bien des plantages de votre application.

IV-B - La solution

Pour éviter que cela ne se produise, vous devez fixer les objets en mémoire. Cela peut être fait au moyen du mot clef fixed. Le Garbage Collector saura alors qu'il ne doit pas bouger, en mémoire, les instances de ces objets. Un exemple de code vous permettra de mieux comprendre de quoi il retourne: nous utiliserons la méthode ReadFile, qui doit être utilisée après un appel à la méthode CreateFile. Pour information, ces méthodes peuvent être utilisées pour envoyer et lire des données sur les ports COM.

// Fonction ReadFile
[ DllImport("kernel32") ]
public static extern bool ReadFile
(
IntPtr hFile,
void *lpBuffer,
uint NbOctevtALire,
uint *lpNbOctetsLus,
IntPtr lpOverlapped
);

...

int handle = ... // Appel à la fonction CreateFile
uint NbOctectsLus = 0;
byte [] Buffer = new Byte[1024];

fixed( byte* pBuffer = Buffer )
{
bool b = ReadFile(handle, pBuffer, 1024, &NbOctectsLus, 0);
}

.....

Notez que la fonction ReadFile renvoie un booléen, qui indique si la méthode s'est bien déroulée ou non.
Une petite astuce pour savoir si un argument doit être passé par valeur ou par référence: si, dans son prototype, le type d'un paramètre commence par la lettre P ou par les lettres LP, alors vous devez passer ce paramètre par référence.

V - Passage de chaînes de caractères

Travailler avec des données textuelles est chose commune. Dans ce cas, la première idée à laquelle on pourrait penser serait d'utiliser une instance de la classe String pour travailler sur les paramètres de la méthode. Néanmoins, les choses ne sont pas si simples: en effet, les instances de la classe String étant immuables, vous devez d'abord déterminer si le paramètre est en entrée ou en sortie. Si le paramètre est en sortie (ou entrée et sortie), vous devez utiliser la classe StringBuilder. Afin de déterminer le « sens » du paramètre texte, vous avez deux possibilités :

- premièrement, vous pouvez essayer de comprendre le but du paramètre. En effet, si vous avez un paramètre ayant par exemple le nom de SetTexte, vous pouvez supposer que ce paramètre en en entrée uniquement. À l'inverse, un paramètre du nom de GetTexte fait plutôt penser à un paramètre de sortie. Bien que fonctionnant, cette technique n'est pas sure à 100 % (que faire par exemple si le paramètre porte le nom de Toto ?), et n'est pas très « professionnelle » ;

- l'autre méthode consiste à regarder le nom du type de paramètre. Si celui-ci contient un C (par exemple LTCTSTR), le paramètre sera en entrée. Dans le cas contraire, le paramètre sera en sortie.

Vous avez également la possibilité d'utiliser l'énumération System.Runtime.InteropServices.CharSet, afin d'indiquer le type d'encodage utilisé par la chaîne de caractères. Il existe deux types d'encodage :

- Unicode ;

- ANSI.

La plupart des paramètres étant de l'un ou l'autre de ces deux types, le framework .NET vous offre la possibilité de déterminer automatiquement l'encodage utilisé, si vous utilisez CharSet.Auto.
Voici un exemple de code, afin de vous faire comprendre un peu mieux ces principes :

Passage de chaîne de caractères : Exemple avec un paramètre de sortie

// Déclaration des using
using System;
using System.Runtime.InteropServices;

class FenetreClignotante
{
// Déclaration de la méthode à utiliser
[ DllImport("kernel32", CharSet=CharSet.Auto) ]
public static extern uint GetCurrentDirectory
(
uint Taille,
StringBuilder sTmp
);

// Point d'entrée de l'application
static void Main(string[] args)
{
uint Taille = 255;
StringBuilder sTmp = new StringBuilder((int)Taille);

uint i = GetCurrentDirectory(Taille, sTmp);

Console.WriteLine(sTmp);
}

}

Passage de chaîne de caractères : Exemple avec un paramètre d'entrée et un paramètre de sortie

// ** Documentation for Win32 GetShortPathName() API Function
// DWORD GetShortPathName(
//   LPCTSTR lpszLongPath,      // file for which to get short path
//   LPTSTR lpszShortPath,      // short path name (output)
//   DWORD cchBuffer            // size of output buffer
// );

[ DllImport("Kernel32", CharSet = CharSet.Auto) ]
static extern Int32 GetShortPathName
(
String path,                // input string
StringBuilder shortPath,    // output string
Int32 shortPathLength);     // StringBuilder.Capacity
)

VI - Conclusion

Cet article vous a donc fourni une bonne approche des Dll natives et de leurs utilisations dans vos applications .NET. Il ne vous reste plus qu'à tenter vous-même l'expérience.
Et j'ajouterai juste un dernier mot, pour dire aux développeurs d'applications mobiles, qu'utiliser P/Invoke sur les périphériques embarqués (Pocket PC, SmartPhone, etc.) est tout à fait possible, comme le démontre cette source : Contrôler la LED de votre Pocket PC.

VII - Liens

Calling Win32 DLLs in C# with P/Invoke
Consommation de fonctions DLL non managées