Meilleures pratiques pour l’interopérabilité native

.NET propose différents moyens de personnaliser du code d’interopérabilité native. Cet article détaille les instructions suivies par les équipes .NET de Microsoft pour l’interopérabilité native.

Règle générale

Les instructions de cette section s’appliquent à tous les scénarios d’interopérabilité.

  • ✔️ À FAIRE : utilisez les mêmes noms et la même mise en majuscules pour vos méthodes que pour la méthode native que vous souhaitez appeler.
  • ✔️ À ENVISAGER : utiliser les mêmes noms et la même mise en majuscules pour les valeurs constantes.
  • ✔️ À FAIRE : utilisez les types .NET les plus proches d’une correspondance avec le type natif. Par exemple, en C#, utilisez uint lorsque le type natif est unsigned int.
  • ✔️ PRÉFÉREZ exprimer des types natifs de niveau supérieur à l’aide de structs .NET plutôt que de classes.
  • ✔️ Utilisez [In] et [Out] attributs sur les paramètres de groupe.
  • ✔️ N’utilisez pas [In] et les attributs [Out] sur d’autres types que lorsque le comportement souhaité diffère du comportement par défaut.
  • ✔️ À ENVISAGER : utiliser System.Buffers.ArrayPool<T> pour regrouper les mémoires tampons du tableau natif.
  • ✔️ À ENVISAGER : encapsuler les déclarations P/Invoke dans une classe portant le même nom et utilisant la même mise en majuscules que votre bibliothèque native.
    • Vos attributs [DllImport] pourront ainsi utiliser la fonctionnalité nameof du langage C# pour transmettre le nom de la bibliothèque native et vérifier qu’il ne comporte pas d’erreur.

Paramètres des attributs DllImport

Paramètre Default Recommandation Détails
PreserveSig true conservez l’URL par défaut . S’il est défini explicitement sur false, les valeurs de retour HRESULT en échec sont converties en exceptions (et la valeur de retour de la définition devient ainsi Null).
SetLastError false Dépend de l'API Définissez-le sur true si l’API utilise GetLastError et Marshal.GetLastWin32Error pour obtenir la valeur. Si l’API définit une condition indiquant une erreur, récupérez l’erreur avant d’effectuer d’autres appels, de façon à éviter de la remplacer par inadvertance.
CharSet Défini par le compilateur (spécifié dans la documentation sur le jeu de caractères) Utilisez explicitement CharSet.Unicode ou CharSet.Ansi lorsque des chaînes ou des caractères sont présents dans la définition Cela permet de spécifier le comportement de marshaling des chaînes et le rôle de ExactSpelling lorsque false. Notez que CharSet.Ansi est en réalité UTF-8 sur Unix. La plupart du temps, Windows utilise Unicode et Unix UTF-8. Pour plus d'informations, voir la documentation sur les charsets.
ExactSpelling false true Définissez-le sur true pour augmenter légèrement les performances. En effet, le runtime ne recherche pas de noms de fonctions de remplacement avec un suffixe « A » ou « W » selon la valeur du paramètre CharSet (« A » pour CharSet.Ansi et « W » pour CharSet.Unicode).

Paramètres de chaîne

Lorsque le charset est Unicode ou que l’argument est explicitement marqué comme [MarshalAs(UnmanagedType.LPWSTR)]et que la chaîne est passée en valeur (pas ref ou out), la chaîne est épinglée et utilisée directement par le code natif (et non copiée).

❌ NE PAS utiliser les paramètres [Out] string. Les paramètres de chaînes passés en valeur avec l’attribut [Out] risquent de déstabiliser le runtime s’il s’agit de chaînes centralisées. Pour plus d’informations sur la centralisation des chaînes, voir la documentation de String.Intern.

✔️ ENVISAGEZ de définir la propriété CharSet dans [DllImport] afin que le runtime connaisse l’encodage de chaîne attendu.

✔️ CONSIDÉREZ les tables char[] ou byte[] d’un ArrayPool lorsque du code natif est censé remplir une mémoire tampon de caractères. Cela nécessite de transmettre l’argument en tant que [Out].

✔️ ENVISAGEZ d’éviter les paramètres StringBuilder. Le marshaling StringBuilder crée toujours une copie de la mémoire tampon native. Il peut donc se révéler extrêmement inefficace. Prenons le scénario classique d’appel d’une API Windows qui prend une chaîne :

  1. Créez un StringBuilder de la capacité souhaitée (alloue de la capacité gérée) {1}.
  2. Appelez :
    1. Alloue une mémoire tampon native {2}.
    2. Copie le contenu si [In](valeur par défaut d’un paramètre StringBuilder).
    3. Copie la mémoire tampon native dans un tableau managé nouvellement alloué si [Out]{3} (également la valeur par défaut pour StringBuilder).
  3. ToString() alloue encore un autre tableau managé {4}.

Il s’agit d’allocations {4} pour obtenir une chaîne du code natif. Le mieux que l’on puisse faire pour limiter ce nombre est de réutiliser StringBuilder dans un autre appel, mais cela ne permet d’enregistrer qu’une seule allocation. Il est préférable d’utiliser et de mettre en cache une mémoire tampon de caractères à partir de ArrayPool. Vous pouvez ensuite revenir à l’allocation de la ToString() lors des appels suivants.

L’autre problème de StringBuilder est qu’il copie toujours la sauvegarde de la mémoire tampon de retour sur la première valeur Null. Si la chaîne de retour transmise n’est pas terminée ou se termine par un double Null, P/Invoke est au mieux incorrect.

Si vous utilisezStringBuilder, le dernier piège est que la capacité n’inclut pas de valeur Null masquée, qui est toujours prise en compte dans l’interopérabilité. Il est courant de se tromper de ce point de vue, car la plupart des API demandent la taille de la mémoire tampon valeur Null incluse. Il en résulte parfois des allocations gaspillées/inutiles. En outre, cet écueil empêche le runtime d’optimiser le marshaling StringBuilder afin de réduire les copies.

Pour plus d’informations sur le marshaling des chaînes, voir Marshaling par défaut pour les chaînes et Personnaliser le marshaling des chaînes.

Spécifique à Windows Pour les chaînes [Out], le CLR utilisera CoTaskMemFree par défaut pour libérer les chaînes ou SysStringFree pour les chaînes marquées UnmanagedType.BSTR. Pour la plupart des API avec une mémoire tampon de chaîne de sortie : le nombre de caractères transmis doit inclure la valeur Nul. Si la valeur de retour est inférieure au nombre de caractères transmis, c’est le signe que l’appel a réussi et que la valeur correspond au nombre de caractères sans la valeur Null de fin. Sinon, le nombre représente la taille requise de la mémoire tampon caractère Null inclus.

  • Nombre transmis = 5, valeur de retour = 4 : la chaîne comporte 4 caractères avec une valeur Nul de fin.
  • Nombre transmis = 5, valeur de retour = 6 : la chaîne comporte 5 caractères et a besoin d’une mémoire tampon de 6 caractères pour conserver la valeur Nul. Types de données Windows pour les chaînes

Paramètres et champs booléens

Il est facile de se perdre avec les booléens. Par défaut, un bool .NET est marshalé en un BOOL Windows, où il s’agit d’une valeur de 4 octets. À l’inverse, les types _Bool et bool en C et C++ correspondent à un seul octet. Il peut alors être difficile de traquer les bogues, car la valeur de retour est à moitié ignorée, ce qui ne modifie que potentiellement le résultat. Pour plus d’informations sur le marshaling des valeurs bool .NET en types bool C ou C++, voir la documentation Personnaliser le marshaling des champs booléens.

GUID

Les GUID sont directement utilisables dans les signatures. De nombreuses API Windows acceptent des alias de type GUID& comme REFIID, Lorsque la signature de méthode contient un paramètre de référence, placez un mot clé ref ou un attribut [MarshalAs(UnmanagedType.LPStruct)] sur la déclaration de paramètre GUID.

GUID GUID en référence
KNOWNFOLDERID REFKNOWNFOLDERID

❌NE PAS utiliser [MarshalAs(UnmanagedType.LPStruct)] pour autre chose que des paramètres GUID ref.

Types blittables

Les types blittables sont des types qui ont la même représentation au niveau du bit dans le code managé et dans le code natif. Il n’est donc pas nécessaire de les convertir dans un autre format pour les marshaler vers et à partir du code natif. Ils sont à privilégier en raison de l’amélioration des performances qui en résulte. Certains types ne sont pas blittables, mais sont connus pour contenir du contenu blittable. Ces types ont des optimisations similaires à celles des types blittables, lorsqu’ils ne sont pas contenus dans un autre type, mais ne sont pas considérés comme blittables dans des champs de structs ou pour les besoins de UnmanagedCallersOnlyAttribute.

Types blittables lorsque le marshaling du runtime est activé

Types blittables :

  • byte, sbyte, short, ushort, int, uint, long, ulong, single, double
  • structs à disposition fixe qui n’ont que des types valeurs blittables, par exemple les champs
    • La disposition fixe exige [StructLayout(LayoutKind.Sequential)] ou [StructLayout(LayoutKind.Explicit)].
    • par défaut, les structs sont LayoutKind.Sequential

Types avec contenu blittable :

  • tableaux unidimensionnels non imbriqués de types simples blittables (par exemple, int[])
  • classes à disposition fixe qui n’ont que des types valeur blittables, par exemple les champs
    • La disposition fixe exige [StructLayout(LayoutKind.Sequential)] ou [StructLayout(LayoutKind.Explicit)].
    • par défaut, les classes sont LayoutKind.Auto

Types NON blittables :

  • bool

Types PARFOIS blittables :

  • char

Types avec contenu blittable SOMETIMES :

  • string

Lorsque les types blittables sont transmis par référence avec in, ref ou out ou lorsque les types avec du contenu blittable sont transmis par valeur, ils sont simplement épinglés par le marshaleur plutôt que d’être copiés dans une mémoire tampon intermédiaire.

Un char est blittable dans un tableau unidimensionnel ou s’il fait partie d’un type explicitement marqué [StructLayout] avec CharSet = CharSet.Unicode.

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct UnicodeCharStruct
{
    public char c;
}

string contient du contenu blittable si elle n’est pas contenue dans un autre type et transmise en argument marqué [MarshalAs(UnmanagedType.LPWStr)] ou que [DllImport] est défini sur CharSet = CharSet.Unicode.

Pour savoir si un type est blittable ou contient du contenu blittable, essayez de créer un GCHandle épinglé. Si le type n’est pas une chaîne ou n’est pas considéré comme blittable, GCHandle.Alloc lèvera une ArgumentException.

Types blittables lorsque le marshaling du runtime est désactivé

Lorsque le marshaling du runtime est désactivé, les règles pour lesquelles les types sont blittables sont considérablement plus simples. Tous les types de type C# unmanaged et qui n’ont pas de champs marqués avec [StructLayout(LayoutKind.Auto)] sont blittables. Tous les types qui ne sont pas de type C# unmanaged ne sont pas blittables. Le concept de types avec du contenu blittable, tels que des tableaux ou des chaînes, ne s’applique pas lorsque le marshaling du runtime est désactivé. Tout type qui n’est pas considéré comme blittable par la règle ci-dessus n’est pas pris en charge lorsque le marshaling du runtime est désactivé.

Ces règles diffèrent du système intégré principalement dans les situations où bool et char sont utilisés. Lorsque le marshaling est désactivé, bool est transmis en tant que valeur de 1 octet et non normalisé et char est toujours transmis en tant que valeur de 2 octets. Lorsque le marshaling du runtime est activé, bool peut être mappé à une valeur de 1, 2 ou 4 octets et est toujours normalisé et char mappé à une valeur de 1 ou 2 octets en fonction de CharSet.

✔️ À FAIRE : rendez vos structures blittables dans la mesure du possible.

Pour plus d'informations, consultez les pages suivantes :

Maintenir actifs les objets gérés

GC.KeepAlive() garantit qu'un objet reste accessible jusqu'à ce que la méthode KeepAlive soit atteinte.

HandleRef permet au marshaleur de maintenir un objet actif pendant la durée de P/Invoke. Il peut être utilisé à la place de IntPtr dans les signatures de méthode. SafeHandle remplace cette classe et doit être utilisé à la place.

GCHandle permet d’épingler un objet géré et d’y associer le pointeur natif. En voici le modèle de base :

GCHandle handle = GCHandle.Alloc(obj, GCHandleType.Pinned);
IntPtr ptr = handle.AddrOfPinnedObject();
handle.Free();

L’épinglage n’est pas le comportement par défaut de GCHandle. L’autre grand modèle sert à faire passer une référence à un objet géré à travers le code natif, puis à nouveau au code managé, en général avec un rappel. En voici le modèle :

GCHandle handle = GCHandle.Alloc(obj);
SomeNativeEnumerator(callbackDelegate, GCHandle.ToIntPtr(handle));

// In the callback
GCHandle handle = GCHandle.FromIntPtr(param);
object managedObject = handle.Target;

// After the last callback
handle.Free();

N’oubliez pas que GCHandle doit être explicitement libéré pour éviter les fuites de mémoire.

Types de données Windows courants

Voici la liste des types de données courants dans les API Windows et des types C# à utiliser pour les appels dans le code Windows.

Malgré leur nom, les types suivants ont la même taille sur Windows 32 bits et 64 bits.

Largeur Windows C# Alternative
32 BOOL int bool
8 BOOLEAN byte [MarshalAs(UnmanagedType.U1)] bool
8 BYTE byte
8 UCHAR byte
8 UINT8 byte
8 CCHAR byte
8 CHAR sbyte
8 CHAR sbyte
8 INT8 sbyte
16 CSHORT short
16 INT16 short
16 SHORT short
16 ATOM ushort
16 UINT16 ushort
16 USHORT ushort
16 WORD ushort
32 INT int
32 INT32 int
32 LONG int Consultez CLong et CULong.
32 LONG32 int
32 CLONG uint Consultez CLong et CULong.
32 DWORD uint Consultez CLong et CULong.
32 DWORD32 uint
32 UINT uint
32 UINT32 uint
32 ULONG uint Consultez CLong et CULong.
32 ULONG32 uint
64 INT64 long
64 LARGE_INTEGER long
64 LONG64 long
64 LONGLONG long
64 QWORD long
64 DWORD64 ulong
64 UINT64 ulong
64 ULONG64 ulong
64 ULONGLONG ulong
64 ULARGE_INTEGER ulong
32 HRESULT int
32 NTSTATUS int

Comme il s’agit de pointeurs, les types suivants suivent la largeur de la plateforme. Utilisez pour eux IntPtr/UIntPtr.

Types de pointeurs signés (utilisez IntPtr) Types de pointeurs non signés (utilisez UIntPtr)
HANDLE WPARAM
HWND UINT_PTR
HINSTANCE ULONG_PTR
LPARAM SIZE_T
LRESULT
LONG_PTR
INT_PTR

Un PVOID Windows, qui correspond à un void* C, peut être marshalé comme IntPtr ou UIntPtr ; préférez void* dans la mesure du possible.

Types de données Windows

Plages de types de données

Anciennement des types pris en charge intégrés

Il existe de rares cas où le support intégré d’un type est supprimé.

Le support marshal intégré UnmanagedType.HString et UnmanagedType.IInspectable a été supprimé dans la version .NET 5. Vous devez recompiler les fichiers binaires qui utilisent ce type de marshaling et qui ciblent une infrastructure précédente. Il est toujours possible de marshaler ce type, mais vous devez le marshaler manuellement, comme le montre l’exemple de code suivant. Ce code fonctionnera à l’avenir et est également compatible avec les infrastructures précédentes.

public sealed class HStringMarshaler : ICustomMarshaler
{
    public static readonly HStringMarshaler Instance = new HStringMarshaler();

    public static ICustomMarshaler GetInstance(string _) => Instance;

    public void CleanUpManagedData(object ManagedObj) { }

    public void CleanUpNativeData(IntPtr pNativeData)
    {
        if (pNativeData != IntPtr.Zero)
        {
            Marshal.ThrowExceptionForHR(WindowsDeleteString(pNativeData));
        }
    }

    public int GetNativeDataSize() => -1;

    public IntPtr MarshalManagedToNative(object ManagedObj)
    {
        if (ManagedObj is null)
            return IntPtr.Zero;

        var str = (string)ManagedObj;
        Marshal.ThrowExceptionForHR(WindowsCreateString(str, str.Length, out var ptr));
        return ptr;
    }

    public object MarshalNativeToManaged(IntPtr pNativeData)
    {
        if (pNativeData == IntPtr.Zero)
            return null;

        var ptr = WindowsGetStringRawBuffer(pNativeData, out var length);
        if (ptr == IntPtr.Zero)
            return null;

        if (length == 0)
            return string.Empty;

        return Marshal.PtrToStringUni(ptr, length);
    }

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern int WindowsCreateString([MarshalAs(UnmanagedType.LPWStr)] string sourceString, int length, out IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern int WindowsDeleteString(IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern IntPtr WindowsGetStringRawBuffer(IntPtr hstring, out int length);
}

// Example usage:
[DllImport("api-ms-win-core-winrt-l1-1-0.dll", PreserveSig = true)]
internal static extern int RoGetActivationFactory(
    /*[MarshalAs(UnmanagedType.HString)]*/[MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef = typeof(HStringMarshaler))] string activatableClassId,
    [In] ref Guid iid,
    [Out, MarshalAs(UnmanagedType.IUnknown)] out object factory);

Considérations relatives aux types de données multiplateformes

Il existe des types en langage C/C++ qui ont une latitude dans leur mode de définition. Lors de l’écriture de l’interopérabilité multiplateforme, des cas peuvent survenir où les plateformes diffèrent et peuvent entraîner des problèmes si elles ne sont pas prises en compte.

C/C++ long

long de C/C++ et long de C# n’ont pas nécessairement la même taille.

Le type long en C/C++ est défini pour avoir « au moins 32 » bits. Cela signifie qu’il existe un nombre minimal de bits requis, mais que les plateformes peuvent choisir d’utiliser davantage de bits si vous le souhaitez. Le tableau suivant illustre les différences de bits fournis pour le type de données C/C++ long entre les plateformes.

Plateforme 32 bits 64 bits
Windows 32 32
macOS/*nix 32 64

En revanche, long de C# est toujours sur 64 bits. Pour cette raison, il est préférable d’éviter d’utiliser long de C# pour l’interopérabilité avec C/C++ long.

(Ce problème avec long de C/C++ n’existe pas pour char, short, int et long long de C/C++, car leur taille est respectivement de 8, 16, 32 et 64 bits sur toutes ces plateformes.)

Dans .NET 6 et versions ultérieures, utilisez les types et pour l’interopérabilité CLong et CULong pour l’interopérabilité avec les types de données C/C++ long et unsigned long. L’exemple suivant concerne CLong, mais vous pouvez utiliser CULong pour effectuer une abstraction unsigned long de la même manière.

// Cross platform C function
// long Function(long a);
[DllImport("NativeLib")]
extern static CLong Function(CLong a);

// Usage
nint result = Function(new CLong(10)).Value;

Lorsque vous ciblez .NET 5 et les versions antérieures, vous devez déclarer des signatures Windows et non-Windows distinctes pour gérer le problème.

static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

// Cross platform C function
// long Function(long a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static int FunctionWindows(int a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static nint FunctionUnix(nint a);

// Usage
nint result;
if (IsWindows)
{
    result = FunctionWindows(10);
}
else
{
    result = FunctionUnix(10);
}

Structures

Les structs managés sont créés sur la pile et ne sont pas supprimés tant que la méthode n’a pas retourné de valeur. Ils sont donc par définition « épinglés » (ils ne sont pas déplacés par le récupérateur de mémoire). Vous pouvez aussi prendre simplement l’adresse dans des blocs de code unsafe si le code natif n’utilise pas le pointeur après la fin de la méthode actuelle.

Les structs blittables sont beaucoup plus performants, car ils sont directement utilisables par la couche de marshaling. Essayez de rendre les structs blittables (par exemple, évitez bool). Pour plus d'informations, voir la section Types blittables.

Si le struct est blittable, utilisez sizeof() au lieu de Marshal.SizeOf<MyStruct>() pour obtenir de meilleures performances. Comme nous l’avons mentionné plus haut, vous pouvez valider que le type est blittable en essayant de créer un GCHandle épinglé. Si le type n’est pas une chaîne ou n’est pas considéré comme blittable, GCHandle.Alloc lèvera une ArgumentException.

Les pointeurs vers des structs dans les définitions doivent être passés en ref ou utiliser unsafe et *.

✔️ À FAIRE : faites correspondre le struct managé aussi fidèlement que possible à la forme et aux noms utilisés dans l’en-tête ou dans la documentation officielle de la plateforme.

✔️ À FAIRE : utilisez le sizeof() C# au lieu de Marshal.SizeOf<MyStruct>() pour les structures blittables afin d’améliorer les performances.

❌ ÉVITEZ d’utiliser des classes pour exprimer des types natifs complexes via l’héritage.

❌ ÉVITEZ d’utiliser des champs System.Delegate ou System.MulticastDelegate pour représenter des champs de pointeur de fonctions dans des structures.

Étant donné que System.Delegate et System.MulticastDelegate n’ont pas de signature requise, ils ne garantissent pas que le délégué transmis correspondra à la signature attendue par le code natif. En outre, dans .NET Framework et .NET Core, le marshaling d’une struct contenant System.Delegate ou System.MulticastDelegate de sa représentation native vers un objet managé peut déstabiliser le runtime si la valeur du champ dans la représentation native n’est pas un pointeur de fonctions qui inclut dans un wrapper un délégué managé. Dans .NET 5 et versions ultérieures, le marshaling d’un champ System.Delegate ou System.MulticastDelegate d’une représentation native vers un objet managé n’est pas pris en charge. Utilisez un type de délégué spécifique au lieu de System.Delegate ou System.MulticastDelegate.

Mémoires tampons fixes

Un tableau comme INT_PTR Reserved1[2] doit être marshalé en deux champs IntPtr, Reserved1a et Reserved1b. Lorsque le tableau natif est un type primitif, il est possible d’utiliser le mot clé fixed pour que le code soit un peu plus propre. Par exemple, SYSTEM_PROCESS_INFORMATION se présente ainsi dans l’en-tête natif :

typedef struct _SYSTEM_PROCESS_INFORMATION {
    ULONG NextEntryOffset;
    ULONG NumberOfThreads;
    BYTE Reserved1[48];
    UNICODE_STRING ImageName;
...
} SYSTEM_PROCESS_INFORMATION

On peut l’écrire ainsi en C# :

internal unsafe struct SYSTEM_PROCESS_INFORMATION
{
    internal uint NextEntryOffset;
    internal uint NumberOfThreads;
    private fixed byte Reserved1[48];
    internal Interop.UNICODE_STRING ImageName;
    ...
}

Toutefois, les mémoires tampons fixes présentent quelques pièges. Les mémoires tampons fixes de types non blittables ne sont pas marshalées correctement ; par conséquent, le tableau sur place doit être étendu à plusieurs champs différents. En outre, dans .NET Framework et .NET Core avant la version 3.0, si un struct contenant un champ de mémoire tampon fixe est imbriqué dans un struct non blittable, le champ de mémoire tampon fixe n’est pas marshalé correctement dans le code natif.