Appeler les API Win32 en toute sécurité depuis C# — Guide pratique de P/Invoke (DllImport / LibraryImport / CsWin32)

· Mis à jour le: · · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, Interopérabilité native, Développement Windows, Conseil technique

Sur ce blog, nous avons déjà publié plusieurs articles sur l’interopérabilité native : le choix entre un wrapper C++/CLI et P/Invoke, comment appeler un DLL C# Native AOT depuis C/C++, un pont COM pour appeler un DLL 64 bits depuis une application 32 bits, et le fonctionnement de la résolution des noms de DLL sous Windows. Mais nous n’avions pas encore traité P/Invoke lui-même, le socle sur lequel repose tout cela, en tant que sujet à part entière.

P/Invoke a la simplicité de « déclarez la fonction du DLL en extern et vous pouvez l’appeler », mais c’est aussi une technologie où l’on finit tôt ou tard par se faire piéger — par le marshalling de chaînes, la durée de vie des handles, la récupération des codes d’erreur, ou l’agencement des structures. Cet article passe en revue les points essentiels à connaître en pratique, autour de LibraryImport, devenu la valeur par défaut depuis .NET 7.

1. L’essentiel d’abord

  • À partir de .NET 7, préférez LibraryImport à DllImport par défaut. Comme le code de marshalling est généré à la compilation, cela fonctionne avec Native AOT et le trimming, sans le coût de génération du stub IL à l’exécution, et le code généré peut être exécuté pas à pas dans le débogueur. L’analyseur SYSLIB1054 signale les endroits où réécrire un DllImport.12
  • Si vous devez écrire à la main des signatures d’API Win32, envisagez CsWin32. Il suffit de lister les noms des fonctions voulues dans NativeMethods.txt pour générer, à partir des métadonnées Win32 officielles, les signatures LibraryImport, les constantes et les structures correspondantes.3
  • Pour les chaînes, spécifiez explicitement StringMarshalling et évitez StringBuilder. Le marshalling de StringBuilder implique toujours une copie vers un tampon natif : un mécanisme inefficace et propice aux erreurs de terminaison.4
  • Conservez les handles dans une classe dérivée de SafeHandle, jamais dans un IntPtr brut. C’est la discipline de base de l’interopérabilité native en .NET, qui empêche la libération prématurée par le GC, la double libération et les « attaques par recyclage » de handles.56
  • Si vous ajoutez SetLastError = true, lisez Marshal.GetLastPInvokeError() immédiatement après l’appel. Il faut le récupérer avant que l’exécution d’un autre code managé n’écrase le code d’erreur.7
  • Adoptez LayoutKind.Sequential par défaut pour les structures, et réfléchissez délibérément à la nécessité de préciser Pack. L’agencement réel sous Pack = 0 (la valeur par défaut) n’a rien à voir avec la valeur par défaut de l’option /Zp du compilateur C++ (8 octets sur x86/ARM/ARM64, 16 octets sur x64/ARM64EC), et il peut même différer entre .NET Framework et .NET 5+. Ne présumez jamais que « la valeur par défaut est forcément correcte ».8
  • Gérez la durée de vie des callbacks (delegates) pour qu’ils ne soient pas récupérés par le GC avant que le côté natif ait fini de les utiliser. Conservez-les dans un champ static ou utilisez GC.KeepAlive, et privilégiez UnmanagedCallersOnly lorsque c’est possible.9
  • P/Invoke, un wrapper C++/CLI et l’interopérabilité COM ne sont pas en concurrence : ils se répartissent les rôles. Pour une interface C simple, utilisez P/Invoke ; lorsque des classes C++, la propriété ou des exceptions entrent en jeu, utilisez un wrapper C++/CLI ; pour franchir une frontière de processus (un pont 32/64 bits, par exemple), utilisez COM. Le tableau de décision du chapitre 10 récapitule ces critères.

2. DllImport et LibraryImport — lequel choisir

DllImport est le mécanisme historique : au moment de l’exécution, le runtime génère un stub IL pour le marshalling, le compile en JIT, puis effectue l’appel. Comme la génération a lieu à l’exécution, cela se marie mal avec des configurations comme Native AOT ou le trimming qui précompilent l’assembly en amont, et le coût de génération lui-même n’est pas nul.1

LibraryImport est un générateur de source ajouté dans .NET 7, qui génère à la compilation le code de marshalling pour les méthodes partial. Comme le code généré existe sous forme de source C#, il peut être parcouru pas à pas dans le débogueur, et les erreurs de signature sont détectées tôt, comme des erreurs de compilation.1

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial string ToLower(string str);
}

Cette valeur de retour string cache une hypothèse facile à manquer. Le marshaller essaie toujours de libérer la mémoire pointée par le pointeur retourné, une fois sa chaîne copiée. Sous Windows, c’est CoTaskMemFree qui est utilisé ; si le côté natif a alloué ce pointeur autrement qu’avec CoTaskMemAlloc (un tampon statique, malloc, new[], etc. — des implémentations courantes dans une API C), le marshaller libère la mémoire avec le mauvais allocateur, ce qui peut provoquer une corruption du tas ou un crash.10 À moins que l’en-tête ou la documentation du côté natif ne précise explicitement une allocation compatible avec CoTaskMemAlloc, faites en sorte que la valeur de retour soit reçue en IntPtr plutôt qu’en string, et appelez vous-même la fonction de libération correspondante (ou la procédure de libération exigée par le côté natif). Faire allouer le tampon par l’appelant et le transmettre (un tableau de caractères à la place de StringBuilder, comme mentionné plus haut, ou le motif de tampon [Out] décrit plus loin) évite d’emblée ce genre d’ambiguïté de propriété.

Voici les principales différences par rapport à DllImport.11

  • CharSet a disparu, remplacé par StringMarshalling (Utf16 / Utf8 / personnalisé). L’ANSI a été supprimé, et l’UTF-8 devient une option de premier ordre.
  • CallingConvention a été remplacé par UnmanagedCallConvAttribute.
  • Il n’existe pas d’équivalent à ExactSpelling ni à PreserveSig. Le nom du point d’entrée doit toujours être spécifié avec l’orthographe exacte, et la conversion de la valeur de retour se fait toujours de manière directe.
  • La classe et la méthode appelée doivent toutes deux être partial, et le projet doit activer AllowUnsafeBlocks.

DllImport reste nécessaire lorsque vous dépendez de réglages que LibraryImport ne prend pas encore en charge (par exemple certaines spécifications MarshalAs). Comme l’analyseur signale par une erreur toute tentative d’utiliser un réglage non pris en charge, une approche pragmatique consiste à écrire d’abord LibraryImport, puis à revenir à DllImport seulement s’il est rejeté.11

3. CsWin32 — l’option de ne pas écrire les signatures à la main

Déclarer les API Win32 une par une à la main via DllImport/LibraryImport accumule le risque de se tromper sur le type d’un paramètre, la valeur d’une constante ou l’ordre des champs d’une structure. CsWin32 (Microsoft.Windows.CsWin32) est un générateur de source qui produit automatiquement, à partir des métadonnées officielles des API Win32, les signatures, constantes et structures associées aux fonctions que vous voulez appeler.3

L’utilisation est simple : ajoutez le paquet NuGet au projet, puis listez les noms des fonctions voulues dans un fichier texte nommé NativeMethods.txt.

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

À la compilation, les signatures P/Invoke de ces fonctions (valeur de retour, paramètres, spécification SetLastError incluse) sont générées. Notez que par défaut, la génération repose sur l’ancien mécanisme DllImport. Pour viser Native AOT ou le trimming, vous pouvez basculer vers du code source généré à partir de LibraryImport en spécifiant allowMarshaling: false dans NativeMethods.json.3 Les HANDLE sont produits sous forme de types dérivés de SafeHandle appropriés, et les chaînes sont produites avec le bon CharSet/StringMarshalling, ce qui élimine d’emblée les erreurs de CharSet ou d’ordre des champs de structure fréquentes lors d’une écriture manuelle.

Comme nous l’écrivions dans « où un wrapper C++/CLI paie », insérer un wrapper léger est efficace pour les DLL complexes impliquant des classes C++, de la propriété et des exceptions. Mais si le côté natif est une API Win32 simple (ou un DLL à interface C équivalente), générer automatiquement les signatures avec CsWin32 est le chemin le plus court et le moins accidentogène. CsWin32 ne peut pas être utilisé pour vos propres DLL internes, mais même dans ce cas, le style du code généré peut servir de modèle.

4. Les pièges du marshalling de chaînes

Les compilateurs C#, VB et F# attribuent par défaut CharSet.None à une déclaration P/Invoke qui ne précise pas CharSet. Le comportement réel de CharSet.None est identique à CharSet.Ansi : sous Windows, il est marshallé en non-Unicode (une page de code localisée). Si l’API Win32 appelée suppose la version Unicode (suffixe W), l’appeler avec cette valeur par défaut provoque des caractères corrompus ou la perte de caractères multi-octets.12

Avec LibraryImport, la forme de base consiste à préciser explicitement StringMarshalling.Utf16. Comme l’option ANSI elle-même a été supprimée, l’accident classique de l’époque DllImport — se retrouver en ANSI sans le vouloir en s’en remettant à la valeur par défaut — devient structurellement beaucoup moins probable.11

Un autre piège est le paramètre StringBuilder. Il est souvent utilisé dans les API du type « le côté natif écrit dans un tampon de chaîne et le renvoie », mais le marshalling de StringBuilder implique toujours une copie vers un tampon natif, et ToString() déclenche encore une allocation supplémentaire. Si le tampon est [Out] (le cas par défaut), c’est un mécanisme inefficace où plusieurs allocations s’accumulent à chaque appel. De plus, il a tendance à mal se comporter lorsque le tampon retourné n’est pas terminé par NUL, ou qu’il s’agit d’une chaîne à double terminaison NUL. Pour des appels fréquents, utiliser un tableau de caractères issu d’ArrayPool<char> est plus stable.4

Le paramètre [Out] string est également une spécification à éviter. Si la chaîne se trouve être internée, cela peut déstabiliser le runtime.4

5. Gestion de la durée de vie des handles — pourquoi utiliser SafeHandle

Conserver des ressources natives comme des handles de fichier, des clés de Registre ou des handles de périphérique sous forme d’IntPtr brut est une conception à éviter dans l’interopérabilité native en .NET, pour trois raisons.5

  • Libération prématurée du handle par le GC. Si une classe implémentant un finaliseur conserve un handle dans un champ IntPtr, une situation de concurrence peut survenir où le GC récupère cet objet et ferme le handle en pleine exécution d’un appel P/Invoke.
  • Attaque par recyclage de handle. Windows réutilise activement les valeurs de handle. Continuer à utiliser un ancien IntPtr alors que la valeur de handle censée être fermée a été réattribuée à une autre ressource entraîne un incident grave : on finit par manipuler une ressource sans rapport.
  • Fuite due à une exception asynchrone. Une interruption asynchrone, comme un abandon de thread, survenant entre l’acquisition du handle et son stockage dans un champ, peut provoquer une fuite de handle.

SafeHandle est une classe abstraite conçue pour résoudre ces problèmes. Elle hérite de CriticalFinalizerObject, ce qui garantit que le traitement de libération s’exécute de façon fiable même en cas de terminaison anormale de l’AppDomain. Les appels P/Invoke incrémentent et décrémentent automatiquement le compteur de références du handle, de sorte qu’il ne peut pas être recyclé pendant qu’un appel est en cours.5

Pour vos propres handles, héritez par exemple de SafeHandleZeroOrMinusOneIsInvalid dans l’espace de noms Microsoft.Win32.SafeHandles et redéfinissez ReleaseHandle(). Comme ReleaseHandle() s’exécute dans une région d’exécution contrainte où l’échec n’est pas censé se produire, la règle de l’art consiste à s’en tenir à un simple appel d’API de libération, sans logique complexe. Vous n’avez pas besoin d’écrire votre propre finaliseur (et vous devriez même l’éviter).6

6. Gestion des erreurs — SetLastError et GetLastPInvokeError

La plupart des API Win32 définissent, en cas d’échec, un code d’erreur local au thread via SetLastError, que l’appelant lit ensuite avec GetLastError. Pour gérer cela depuis P/Invoke, mettez DllImportAttribute.SetLastError (une propriété du même nom existe aussi sur LibraryImport) à true.13

[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);

Deux points méritent l’attention ici.

  • Lisez le code d’erreur immédiatement après l’appel. En .NET (à l’exception de .NET Framework), chaque appel P/Invoke avec SetLastError = true efface d’abord les informations d’erreur et ne conserve que le résultat de cet unique appel. Insérer une écriture de log ou un autre appel d’API entre-temps écrase la valeur et la perd ; récupérez-la donc dès que vous détectez l’échec.13
  • Utilisez Marshal.GetLastPInvokeError() plutôt que Marshal.GetLastWin32Error(). À partir de .NET 6, les deux sont fonctionnellement identiques, mais le premier est le nom récent, recommandé, qui reflète une intention multiplateforme.7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. Marshalling de structures — types blittable et StructLayout

Les types dont la représentation en bits est identique entre .NET et le code natif sont dits « blittable » : ils peuvent être transmis tels quels sans conversion, ce qui les rend rapides. C’est le cas des types de base comme byte, int ou long, ainsi que des structures à agencement fixe composées uniquement de types valeur blittable. Pour une structure blittable, utiliser sizeof() en C# est plus rapide que Marshal.SizeOf<T>(). À l’inverse, bool n’est pas blittable (le BOOL natif fait 4 octets, alors que le bool en C/C++ fait 1 octet), et l’utiliser sans y prêter attention crée des bugs où la moitié de la valeur de retour est jetée.14

L’agencement d’une structure se contrôle avec StructLayoutAttribute. Utilisez LayoutKind.Sequential (disposition dans l’ordre de déclaration) par défaut, et réservez LayoutKind.Explicit aux cas où vous voulez préciser explicitement la position des champs, comme pour une union.8

Le champ Pack est facile à négliger. D’après la documentation officielle, l’alignement global du type correspond à la plus petite valeur entre « la taille du plus grand champ » et « la valeur Pack spécifiée », et chaque champ est positionné selon la plus petite valeur entre « sa propre taille » et « l’alignement du type ».8 Autrement dit, fixer Pack explicitement à une petite valeur (2 ou 4, par exemple) le fait agir comme un plafond d’alignement, à la manière du #pragma pack(N) de C++. En revanche, la valeur par défaut 0 signifie « l’alignement global du type est égal à la taille du plus grand champ (sans plafond spécial supplémentaire) » — une règle différente de la valeur par défaut de l’option /Zp du compilateur C++ (alignement des membres de structure : par défaut une frontière de 8 octets sur x86/ARM/ARM64, de 16 octets sur x64/ARM64EC) —, et les deux ne doivent pas être assimilées naïvement l’une à l’autre.15 De plus, cet agencement par défaut peut différer entre .NET Framework et .NET 5+. Par exemple, la documentation officielle donne l’exemple d’une structure contenant un decimal dont la taille, sous le packing par défaut, est de 28 octets sur .NET Framework contre 32 octets sur .NET 5+, en raison de différences dans la composition interne des champs.8 Autrement dit, ne présumez jamais, par architecture, que « c’est forcément correct puisque c’est la valeur par défaut ». Si vous avez affaire à un DLL dont l’en-tête natif modifie explicitement la taille de packing via #pragma pack, ou qui contient un champ exigeant un alignement supérieur à 8 octets, spécifiez explicitement Pack côté C#, ou vérifiez les décalages de champs réels avec Marshal.OffsetOf avant de vous en servir. Négliger cela fait dériver les décalages de champs et entraîne une corruption silencieuse des données. À l’inverse, pour une API simple qui utilise telles quelles les en-têtes du Windows SDK, avec des champs tous constitués de types de base de 8 octets ou moins, laisser l’alignement par défaut sans toucher à Pack ne pose pratiquement jamais de problème en pratique.

// Exemple où l'en-tête natif spécifie explicitement pack(4)
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

8. Gestion de la durée de vie des callbacks (delegates)

Il n’est pas rare de transmettre à une API native un callback du type « appelle cette fonction une fois terminé ». En code managé, c’est le rôle du delegate, mais il y a ici un piège propre au GC. Même après avoir obtenu un pointeur de fonction à partir d’un delegate via Marshal.GetFunctionPointerForDelegate, le GC ne suit pas le lien entre ce pointeur de fonction et le delegate. Si le delegate est récupéré alors que le côté natif utilise encore ce pointeur de fonction, cela provoque un crash.9

Un autre point facile à manquer est la convention d’appel. Lorsque vous transmettez un delegate à du code natif comme pointeur de fonction via P/Invoke, la « convention d’appel par défaut de la plateforme » est utilisée par défaut ; si vous voulez la faire correspondre explicitement, ajoutez UnmanagedFunctionPointerAttribute au type du delegate.16 Sur x64/ARM/ARM64, il n’existe en pratique qu’une seule convention d’appel, donc cela cause rarement un dégât réel même sans y penser, mais sous Windows x86 (32 bits), Stdcall (la valeur par défaut des API Win32) et Cdecl (fréquente dans les bibliothèques C d’origine Unix) diffèrent. Si l’en-tête du côté natif utilise Cdecl, laisser la valeur par défaut peut provoquer une corruption de pile.16

// Spécifie explicitement la convention d'appel. Indispensable sur les builds x86
// si le côté natif utilise Cdecl
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);

private static readonly MyCallback s_callback = OnNativeEvent;  // conservé dans un champ static pour fixer sa durée de vie

// [UnmanagedFunctionPointer] régit la convention utilisée quand le callback
// lui-même est « appelé » — ce qui est distinct de la convention de cet
// appel-ci (RegisterCallback, un P/Invoke) proprement dit.
// La valeur par défaut de LibraryImport est celle de la plateforme
// (équivalente à stdcall sous Windows) ; si le DLL C en face utilise Cdecl,
// il faut donc le préciser explicitement ici aussi
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);

private static void OnNativeEvent(int code)
{
    // ...
}

// Côté appelant
RegisterCallback(s_callback);
GC.KeepAlive(s_callback);  // maintient explicitement en vie une variable qui pourrait autrement sortir de portée juste après

En le conservant dans un champ static, il ne sera pas récupéré par le GC pendant toute la durée de vie de l’application. Seulement lorsque vous êtes certain que le côté natif n’utilise le callback que le temps d’un seul appel (et qu’il abandonne le pointeur de fonction une fois le callback revenu), une écriture plus légère — une variable locale associée à GC.KeepAlive pour prolonger sa durée de vie — est également possible.

Les bonnes pratiques officielles recommandent, chaque fois que c’est possible, de préférer au type Delegate une méthode statique marquée UnmanagedCallersOnlyAttribute combinée à un pointeur de fonction (delegate*<...>). Cette approche a un coût moindre que le marshalling de delegate et s’accorde mieux avec Native AOT.9

9. Différences 32 bits / 64 bits

Écrire une seule signature P/Invoke suffit : au moment de l’exécution, le même chemin de code est utilisé, que ce soit depuis un processus 32 bits ou 64 bits. Ce qui pose souvent problème ici, c’est que la largeur des types côté natif suit le nombre de bits du processus.

  • Les types de la famille pointeur comme HANDLE, HWND ou LPARAM font 4 octets dans un processus 32 bits et 8 octets dans un processus 64 bits. Côté .NET, il est correct de les recevoir en IntPtr/UIntPtr (ou nint/nuint) ; les recevoir en int/long de taille fixe produit un code qui ne fonctionne que sur l’une des deux largeurs.4
  • Si une structure contient l’un de ces champs de type pointeur, sa taille globale varie elle aussi selon le nombre de bits. En combinant cela avec le fait que la valeur par défaut de Pack diffère selon l’architecture (chapitre 7), testez en partant du principe que la même définition de structure peut avoir un agencement binaire différent entre un build 32 bits et un build 64 bits.
  • L’exigence « vouloir utiliser, depuis une application 32 bits existante, une fonctionnalité d’un DLL qui ne fonctionne qu’en 64 bits » ne peut pas être résolue par P/Invoke lui-même (des DLL de largeurs de bits différentes ne peuvent pas coexister dans le même processus). Dans ce cas, il faut séparer les processus et établir un pont via COM ou des tubes nommés. Pour un exemple concret, voir « Étude de cas d’un pont COM : appeler un DLL 64 bits depuis une application 32 bits ».
  • Le fait qu’un DLL soit introuvable, ou qu’une version non voulue soit chargée, n’est pas une question de P/Invoke mais du chargeur de Windows. « Comment fonctionne la résolution des noms de DLL sous Windows » détaille l’ordre de recherche et le comportement SxS ; consultez-le en complément lors d’une investigation sur un DllNotFoundException.

10. Tableau de décision — P/Invoke vs. wrapper C++/CLI vs. interopérabilité COM

P/Invoke n’est pas le seul moyen d’appeler du code natif depuis C#. Si le côté natif est un DLL complexe avec des classes C++, de la propriété et des exceptions, un wrapper C++/CLI fonctionne bien ; s’il faut franchir une frontière de processus (un pont 32/64 bits, ou une utilisation depuis un autre langage comme VBA), COM devient une option.

Aspect P/Invoke (LibraryImport) Wrapper C++/CLI Interopérabilité COM
Adapté à Une interface C simple (structures et types primitifs) Un DLL impliquant des classes C++, la propriété, des exceptions, des types std:: Un correspondant au-delà d’une frontière de processus, ou un autre langage comme VBA
Coût d’implémentation Faible à moyen (définir la signature suffit) Moyen (écrire une couche de wrapper supplémentaire) Élevé (conception d’interface, enregistrement dans le Registre)
Sécurité de type Moyenne (une erreur de signature écrite à la main peut ne se révéler qu’à l’exécution ; CsWin32 améliore cela) Élevée (les types C++ sont utilisables directement) Moyenne (garantie par l’IDL/la bibliothèque de types)
Compatibilité AOT/trimming ◎ (avec LibraryImport) △ (C++/CLI n’est pas compatible avec Native AOT)
Gestion des exceptions ✕ (jugement manuel via la valeur de retour ou HRESULT) ◎ (les exceptions C++ peuvent être converties en exceptions .NET) ○ (HRESULT est converti en exception COM)
Franchissement de frontière de processus ✕ (usage intra-processus uniquement) ✕ (usage intra-processus uniquement) ◎ (des serveurs hors processus sont possibles)
Facilité de débogage ○ (le code généré par LibraryImport peut être exécuté pas à pas) ○ (le code natif et managé se débogue tous deux dans VS) △ (les problèmes liés au comptage de références ou à l’enregistrement sont difficiles à suivre)
Coût d’apprentissage Faible Moyen à élevé (syntaxe C++/CLI) Élevé (l’ensemble des conventions COM)

Si « le côté natif est une API Win32 basée sur des fonctions C, ou votre propre DLL C simple », optez pour P/Invoke (avec CsWin32 si possible) ; si « le côté natif est une classe C++ et que vous voulez faire circuler naturellement la propriété et les exceptions », optez pour un wrapper C++/CLI (voir « Appeler un DLL natif depuis C# : wrapper C++/CLI vs. P/Invoke » pour les détails) ; et si « il faut avant tout franchir une frontière de processus, ou permettre l’utilisation depuis VBA », optez pour COM — suivre cet ordre évite les hésitations. Dans le sens inverse (appeler du traitement C# depuis C/C++), ce n’est pas P/Invoke qu’il faut utiliser, mais une configuration basée sur UnmanagedCallersOnly de Native AOT. Voir « Comment appeler un DLL C# Native AOT depuis C/C++ ».

11. Exemple d’implémentation — gestion de handle et gestion des erreurs avec LibraryImport

Voici un exemple d’implémentation combinant tout ce qui précède. Nous encapsulons OpenDevice / CloseDevice / ReadDeviceData, exposées par un SDK fictif de capteur device.dll, avec la gestion de handle via SafeHandle, le marshalling à la compilation via LibraryImport, et la gestion des erreurs via SetLastError + GetLastPInvokeError.

Commençons par la classe dérivée de SafeHandle qui conserve le handle natif.

using Microsoft.Win32.SafeHandles;

// Encapsule un handle de device.dll. Indépendamment de la durée de vie
// gérée par le GC, cela empêche la double libération, les attaques par
// recyclage et la libération prématurée du handle
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // Un constructeur sans paramètre est requis car ceci est utilisé comme
    // type de retour de OpenDevice
    public DeviceSafeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
        // ReleaseHandle s'exécute dans une région d'exécution contrainte où
        // l'échec n'est pas censé se produire. On se limite à un seul appel
        // natif de libération, simple
        => DeviceNativeMethods.CloseDevice(handle);
}

Voici ensuite les déclarations P/Invoke. Les chaînes précisent explicitement StringMarshalling.Utf16, et tous les appels susceptibles d’échouer portent SetLastError = true.

using System.Runtime.InteropServices;

internal static partial class DeviceNativeMethods
{
    private const string DeviceDll = "device.dll";

    // En faisant du handle la valeur de retour, SafeHandle commence à
    // suivre sa durée de vie dès que l'appel réussit. En cas d'échec, un
    // handle avec IsInvalid à true est retourné
    [LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial DeviceSafeHandle OpenDevice(string devicePath);

    // API interne destinée à être appelée directement depuis le
    // ReleaseHandle de SafeHandle. handle est réservé à la libération,
    // donc reçu comme IntPtr brut
    [LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool CloseDevice(IntPtr handle);

    // buffer est un tableau déjà alloué par l'appelant. byte[] étant
    // blittable, il est épinglé, et l'écriture côté natif se fait sur
    // cette même mémoire. Préciser [Out] n'est pas strictement obligatoire,
    // mais on le fait pour documenter l'intention
    [LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool ReadDeviceData(
        DeviceSafeHandle handle,
        [Out] byte[] buffer,
        int bufferLength,
        out int bytesRead);
}

Enfin, voici un wrapper léger côté consommateur. Le code d’erreur est récupéré dès la détection de l’échec, puis enveloppé dans un Win32Exception avant d’être propagé à l’appelant.

using System.ComponentModel;
using System.Runtime.InteropServices;

public sealed class DeviceConnection : IDisposable
{
    private readonly DeviceSafeHandle _handle;

    private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;

    public static DeviceConnection Open(string devicePath)
    {
        DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
        if (handle.IsInvalid)
        {
            // Récupéré immédiatement après l'échec, avant qu'un autre appel d'API ne l'écrase
            int error = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException(
                $"Impossible d'ouvrir l'appareil : {devicePath} (Win32 error {error})",
                new Win32Exception(error));
        }
        return new DeviceConnection(handle);
    }

    public byte[] Read(int maxBytes)
    {
        var buffer = new byte[maxBytes];
        if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
        {
            int error = Marshal.GetLastPInvokeError();
            throw new IOException($"Échec de la lecture depuis l'appareil (Win32 error {error})",
                new Win32Exception(error));
        }
        return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
    }

    // Il suffit d'appeler SafeHandle.Dispose ; n'écrivez pas de finaliseur
    public void Dispose() => _handle.Dispose();
}

Le code consommant DeviceConnection n’a besoin que d’un bloc using, sans se soucier d’un éventuel oubli de libération de handle. Le principe de savoir où détecter quoi et comment le traduire, dans cette architecture en couches, correspond exactement à la répartition des responsabilités par couche décrite dans « Où placer catch et la journalisation dans la gestion des exceptions ? ». Le point clé consiste à traduire les codes d’erreur de la couche native en exceptions à la frontière P/Invoke, et à traiter tout ce qui se trouve au-dessus de cette frontière comme des exceptions .NET ordinaires.

12. Résumé

Derrière la simplicité de « déclarez la fonction du DLL et vous pouvez l’appeler », P/Invoke est une technologie où l’on finit tôt ou tard par se faire piéger — par le marshalling de chaînes, la durée de vie des handles, le moment de récupération des codes d’erreur, ou l’agencement des structures. À partir de .NET 7, faites de LibraryImport votre défaut, et laissez si possible CsWin32 générer les signatures elles-mêmes. Pour les chaînes, précisez explicitement StringMarshalling et évitez StringBuilder. Conservez les handles avec SafeHandle. Si vous utilisez SetLastError, récupérez le code d’erreur immédiatement après l’appel. Pour les structures, gardez à l’esprit que la valeur par défaut de Pack diffère selon l’architecture. Gérez explicitement la durée de vie des callbacks — chaque point soulevé dans cet article appartient à cette catégorie de choses qui « se règlent en quelques lignes si vous les connaissez, mais deviennent un bug qui ne se reproduit qu’en production si vous ne les connaissez pas ».

Quant à la décision de s’en tenir à P/Invoke ou de basculer vers un wrapper C++/CLI ou COM, elle dépend de la mesure dans laquelle le DLL en face est « de nature C », et de la nécessité ou non de franchir une frontière de processus. Les demandes portant sur l’appel d’actifs natifs existants depuis C#, ou inversement l’appel d’actifs C# depuis du code natif, ne révèlent souvent leur configuration optimale qu’en examinant les fichiers d’en-tête réels ou la structure du DLL — n’hésitez donc pas à nous consulter en cas de doute.

Articles connexes

Domaines de conseil associés

KomuraSoft LLC (合同会社小村ソフト) prend en charge le conseil technique sur la conception de la frontière entre C# et les DLL natifs/API Win32, le développement et l’investigation de composants COM, ainsi que les projets de migration reliant des actifs natifs existants à .NET.

Références

  1. Microsoft Learn, Source generation for platform invokes. Sur la génération de code de marshalling à la compilation via LibraryImportAttribute, sa différence avec la génération de stub IL à l’exécution de DllImport, et sa compatibilité avec Native AOT/le trimming.  2 3

  2. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. Sur la liste des identifiants de diagnostic, dont l’analyseur SYSLIB1054 qui incite à réécrire DllImport en LibraryImport. 

  3. Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop. Sur la manière d’introduire le C#/Win32 P/Invoke Source Generator (Microsoft.Windows.CsWin32), et la procédure de génération des signatures en listant les noms de fonctions dans NativeMethods.txt.  2 3

  4. Microsoft Learn, Native interoperability best practices. Sur le fait que le marshalling de StringBuilder implique toujours une copie vers un tampon natif et reste inefficace, sur le fait d’éviter les arguments [Out] string, et sur l’utilisation de SafeHandle en évitant les finaliseurs.  2 3 4

  5. Microsoft Learn, SafeHandle Class. Sur la manière dont SafeHandle empêche la libération prématurée des handles et les attaques par recyclage, et sur la garantie de libération assurée par CriticalFinalizerObject.  2 3

  6. Microsoft Learn, Native interoperability best practices - General guidance. Sur la recommandation d’utiliser SafeHandle pour gérer la durée de vie des ressources non managées, et d’éviter l’utilisation de finaliseurs.  2

  7. Microsoft Learn, Marshal.GetLastPInvokeError Method. Sur la manière de récupérer le code d’erreur immédiatement après un appel P/Invoke avec SetLastError=true, et sur sa recommandation par rapport à GetLastWin32Error à partir de .NET 6.  2

  8. Microsoft Learn, StructLayoutAttribute.Pack Field. Sur la signification de la valeur par défaut 0 de Pack, « la taille de packing par défaut de la plateforme actuelle », et sur les règles de calcul de l’alignement des champs.  2 3 4

  9. Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. Sur le fait que le GC ne suit pas le lien entre un pointeur de fonction obtenu via GetFunctionPointerForDelegate et son delegate, sur le prolongement de la durée de vie avec GC.KeepAlive, et sur la recommandation d’utiliser UnmanagedCallersOnly.  2 3

  10. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. Sur le fait que le marshaller essaie toujours de libérer la mémoire allouée par du code non managé, que CoTaskMemFree est utilisé sous Windows, et que la mémoire allouée autrement qu’avec CoTaskMemAlloc doit être reçue en IntPtr et libérée manuellement. 

  11. Microsoft Learn, Source generation for platform invokes - Differences from DllImport. Sur le remplacement de CharSet par StringMarshalling, l’utilisation de UnmanagedCallConvAttribute à la place de CallingConvention, et l’absence d’équivalent à ExactSpelling/PreserveSig.  2 3

  12. Microsoft Learn, Charsets and marshalling. Sur le fait que les compilateurs C#, Visual Basic et F# attribuent par défaut CharSet.None lorsque CharSet n’est pas spécifié, et que CharSet.None se comporte comme CharSet.Ansi (marshalling en non-Unicode). 

  13. Microsoft Learn, DllImportAttribute.SetLastError Field. Sur le comportement en .NET lorsque SetLastError est mis à true, notamment le fait que les informations d’erreur sont effacées à chaque appel.  2

  14. Microsoft Learn, Native interoperability best practices - Blittable types. Sur la définition des types blittable, le piège causé par le fait que bool n’est pas blittable, et l’avantage d’utiliser sizeof() avec des structures blittable. 

  15. Microsoft Learn, /Zp (Struct Member Alignment). Sur le fait que l’alignement par défaut des membres de structure du compilateur C++ correspond à une frontière de 8 octets sur x86/ARM/ARM64 et de 16 octets sur x64/ARM64EC. 

  16. Microsoft Learn, Unmanaged calling conventions. Sur le fait que Stdcall et Cdecl sont des conventions d’appel par défaut différentes sous Windows x86, qu’il n’existe en pratique qu’une seule convention d’appel sur x64/ARM/ARM64, et sur la possibilité de préciser explicitement la convention d’appel via UnmanagedFunctionPointerAttribute.  2

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Faut-il utiliser DllImport ou LibraryImport ?
À partir de .NET 7, faites de LibraryImport votre choix par défaut. Alors que DllImport génère au moment de l'exécution un stub IL pour le marshalling, LibraryImport s'appuie sur un générateur de source qui produit le code de marshalling à la compilation : cela permet la compatibilité avec Native AOT et le trimming, et le code généré peut être parcouru pas à pas dans le débogueur. L'analyseur SYSLIB1054 signale les endroits où réécrire un DllImport. Ne revenez à DllImport que pour les configurations que LibraryImport ne prend pas encore en charge (certaines spécifications MarshalAs, par exemple), en écrivant d'abord LibraryImport et en ne repliant que si l'analyseur le rejette.
Pourquoi est-il dangereux de conserver un handle sous forme d'IntPtr avec P/Invoke ?
Pour trois raisons. D'abord, une libération prématurée peut se produire : le GC peut récupérer l'objet et fermer le handle en pleine exécution d'un appel P/Invoke. Ensuite, Windows réutilise activement les valeurs de handle, ce qui peut mener à une « attaque par recyclage » où une valeur de handle censée être fermée finit par manipuler une ressource sans rapport. Enfin, une exception asynchrone peut provoquer une fuite de handle. Une classe dérivée de SafeHandle évite ces trois problèmes grâce à la gestion automatique du comptage de références et à une garantie de libération fiable.
Comment obtenir le code d'erreur Win32 après l'échec d'un appel P/Invoke ?
Mettez SetLastError = true sur la déclaration et lisez Marshal.GetLastPInvokeError() immédiatement après l'échec de l'appel. En .NET (à l'exception de .NET Framework), chaque P/Invoke avec SetLastError = true efface d'abord les informations d'erreur et ne conserve que le résultat de cet unique appel, donc insérer de la journalisation ou un autre appel d'API entre-temps écrase la valeur. GetLastPInvokeError est le nom recommandé à partir de .NET 6 ; il est fonctionnellement identique à GetLastWin32Error mais reflète une intention multiplateforme.
Qu'est-ce que CsWin32 et quand l'utiliser ?
CsWin32 (Microsoft.Windows.CsWin32) est un générateur de source qui produit des signatures P/Invoke, des constantes et des structures à partir des métadonnées officielles des API Win32 : il suffit de lister les noms de fonctions voulus dans un fichier NativeMethods.txt. Les handles sortent sous forme de types dérivés de SafeHandle appropriés et les chaînes reçoivent le marshalling correct, ce qui empêche les erreurs de CharSet ou d'ordre des champs de structure fréquentes dans du code écrit à la main. Notez qu'il génère du code basé sur DllImport par défaut ; spécifiez allowMarshaling: false dans NativeMethods.json pour obtenir une sortie basée sur LibraryImport, utile pour Native AOT ou le trimming. Il ne peut pas être utilisé pour vos propres DLL internes, mais son code généré reste utilisable comme modèle.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog