Platform invoke en 5 min

P/Invoke (i.e. Platform Invoke) est une technique qui permet d’appeler du code non managé à partir de code managé. L’utilisation de P/Invoke convient lorsque:

  • du code non managé existe, qu’il est trop couteux de le migrer,
  • si on doit utiliser une API non managé et qu’il n’existe pas de version .NET,
  • si on doit faire appel à des fonctions de l’API Win32.

Cette méthode convient plus particulièrement lorsque le code non managé ne change pas beaucoup ou que l’interface entre le code managé et le code non managé est assez figé. Dans le cas contraire, il est préférable d’utiliser d’autres méthodes plus fléxible et moins sensibles à des erreurs.

A part P/Invoke, d’autres méthodes existent pour appeler du code non managé à partir de code managé:

  • COM interop: cette technique convient si on doit faire appel à des composants COM.
  • C++ interop: il s’agit d’une fonctionnalité spécifique au C++ qui est approprié pour appeler du code non managé particulièrement complexe.
  • C++/CLI: il s’agit d’un langage C++ modifié (CLI pour Common Language Infrastructure). Ce langage permet, par construction, d’appeler du code non managé à partir de code C++. Le grand avantage du C++/CLI par rapport au C++ interop est qu’il est adapté pour manipuler des objets managés ou non managés dans le même langage. De même, les appels managés sont faciles à effectuer vers le code C++/CLI puis vers le code non managé.

Le gros inconvénient de P/Invoke est qu’il faut adapter le code pour utiliser cette technique. Ensuite, les interfaces entre le code managé et le code non managé doivent être définis et implémentées avec soin de façon à éviter les erreurs mais aussi de mauvaises performances lors de la “conversion” des objets managés en objets non managés et inversement.

Lorsqu’on utilise P/Invoke pour appeler du code non managé, il faut être rigoureux dans le choix du type des objets et dans la façon dont ils sont passés en paramètre. Pour effectuer ces choix, il faut avoir en tête le fonctionnement du code suivant le type d’objet et certains mécanisme comme celui du Garbage Collector ou les conventions d’appels de fonctions.

1. Préambule

En préambule, il convient d’effectuer quelques rappels concernant les objets qui sont utilisés en .NET et la façon dont ces objets sont manipulés.

Pointeur et référence

D’une façon générale, un pointeur et une référence possèdent tous les deux, l’adresse mémoire d’un objet. Toutefois ils n’exposent pas les mêmes informations et il n’est pas possible d’effectuer le même type d’opération sur les deux.

Un pointeur est une variable contenant une adresse mémoire. Cette adresse peut pointer vers n’importe quel objet en mémoire, y compris vers des objets dont le type est différent. Le pointeur n’a pas connaissance du type de l’objet vers lequel il pointe. En C#, dans un contexte purement managé, on ne manipule jamais directement des pointeurs.

De même que les pointeurs, les références contiennent une adresse mémoire, toutefois cette adresse n’est pas directement accessible par la code. D’autre part, la référence a une connaissance du type d’objet vers lequel elle pointe. Par construction, il n’est pas possible de modifier le type d’une référence. En C#, en dehors des objets de type valeur, on manipule des références.

Objets de type valeur

Les objets de type valeur correspondent au struct et au enum. La plupart des types primitifs sont des struct et donc sont des objets de type valeur:

  • Les types intégraux: sbyte (signed byte), byte, char, short, ushort (unsigned short), int, uint (unsigned int), long et ulong (unsigned long).
  • Les types à virgule flottante: float et double
  • Le booléen bool.

Il faut noter que ces types primitifs sont des alias qui sont mappés vers des types dans le namespace System. Par exemple, int est un alias pour System.Int32.

Il faut garder en tête que System.String n’est pas un objet de type valeur.

Passage par valeur

Par défaut en C#, les objets de type valeur sont passés par valeur c’est-à-dire que lorsqu’on utilise des variables qui sont des objets de type valeur et qu’ils sont passés en paramètre de fonction, c’est la valeur de ces objets qui passée d’une fonction à l’autre. Ainsi lors du passage de l’objet en argument, c’est sa valeur qui est copiée et l’objet est dupliqué.

Concrêtement, si on considère la fonction:

private int GetAge(int birthYear, DateTime date) 
{ 
  // ... 
}

Et si on effectue l’appel:

int currentBirthYear =  1985; 
DateTime currentDate = DateTime.Now; 
 
var age = GetAge(currentBirthYear, currentDate);

Si on modifie la valeur de birthYear ou de date à l’intérieur de la fonction GetAge(), les valeurs de currentBirthYear et currentDate à l’extérieur de la fonction ne sont pas modifiées.

Passage par référence

En C#, il est possible de passer des objets de type valeur par référence. Dans ce cas, on ne copie pas la valeur de l’objet mais c’est la référence de l’objet qui est passée en argument. Ainsi, c’est le pointeur vers la valeur en mémoire qui est dupliqué lors du passage en argument. Si on modifie la valeur d’un objet de type valeur passé en référence dans le corps d’une fonction, sa valeur sera aussi modifiée à l’extérieur de la fonction.

Par passer un objet de type valeur par référence, on peut utiliser le mot clé ref ou out:

  • ref: il est obligatoire d’initialiser la variable à l’extérieur de la fonction. La fonction n’est pas obligée d’initialiser ou de modifier la valeur de l’argument précédé de ref.
  • out: il n’est pas obligatoire d’initialiser la variable avant d’appeler la fonction. En revanche, la fonction doit au moins affecter une valeur à l’argument précédé de out.

Par exemple, si on considère la méthode:

private void UpdateAge(int birthYear, ref int age, out DateTime date) 
{ 
  currentDate = DateTime.Now; 
  age = date.Year - birthYear; 
}

Si on effectue l’appel:

int currentBirthYear = 1985; 
int age = 69; 
 
DateTime currentDate; 
UpdateAge(currentBirthYear, ref age, out currentDate);

Les valeurs de currentBirthYear et currentDate seront modifiées par UpdateAge().

Objets de type référence

Les objets de type référence sont les classes, les interfaces et les delegates. Ces objets dérivent de System.Object. Par suite, object et string sont des objets de type référence.
Lorsqu’on manipule des objets de type référence, en C# on manipule des références d’objet. 2 variables peuvent contenir la même référence et une modification sur une variable aura pour effet de modifier directement l’objet vers lequel pointe la référence.

Passage par valeur

Par défaut en C#, les objets de type référence sont passés par valeur c’est-à-dire que c’est la valeur du pointeur vers le type qui est passée en paramètre de la fonction. Si on modifie la référence dans le corps de la fonction, il n’y aura pas d’incidence sur la référence à l’extérieur de la fonction. En revanche, on peut modifier directement l’objet pointé par la référence.

Ainsi, si on considère la classe:

class Person  
{ 
  private string Name { get; set; } 
  private int Age { get; set; } 
} 

Si on considère la méthode:

private void ModifyPerson(Person person) 
{ 
  person.Age = 45; 
}

L’argument person est passé par valeur, la valeur du pointeur vers le type est dupliqué mais la référence pointe vers le même objet en mémoire. Donc la modification de person sera visible à l’extérieur de la fonction.

Si on exécute le code suivant:

private void ModifyNewPerson(Person person) 
{ 
  person = new Person { Age = 45, Name = "John" }; 
  person.Age = 54; 
}

La variable person contient une référence mais elle est réinitialisée par une nouvelle affectation et donc une nouvelle référence. Cette nouvelle référence possède un pointeur vers une nouvel objet en mémoire donc la modification sur ce nouvel objet ne modifie l’objet définit à l’extérieur de la fonction. La modification est donc locale à la fonction.

Passage par référence

On peut passer des objets de type référence par référence. Dans ce cas, on passe un pointeur vers un pointeur qui pointe vers un type. Si on modifie la référence à l’intérieur de la fonction, le premier pointeur ne sera pas modifié, en revanche, le 2e pointeur pointera vers un objet différent. Ainsi sachant que le premier pointeur n’est pas modifié, une nouvelle affectation dans le corps de la fonction sera visible à l’extérieur de la fonction.

Le passage par référence d’un objet de type référence se fait en utilisant les mot-clés ref ou out avec les mêmes conditions que pour les objets de type valeur.

Ainsi, si on considère la méthode:

private void ModifyNewPerson(ref Person person) 
{ 
  person = new Person { Age = 45, Name = "John" }; 
}

Et si on exécute le code:

var person = new Person { Age = 15, Name = "Albert" }; 
ModifyNewPerson(ref person);

La valeur de person.age sera 45 et person.Name sera “Albert”.

“System.String” est un objet immutable

Toute modification d’une string entraîne la création d’une nouvelle string. Pour que la modification d’une string soit visible à l’extérieur du corps d’une fonction, il faut que l’argument soit précédé de ref ou out.

Garbage collector

La gestion des objets en mémoire est très différente entre le C++ et le C#. En C++, on manipule des pointeurs qu’il faut penser à libérer ou des smart pointers qui passent par un référencement du nombre d’utilisations de l’objet. Si le nombre d’utilisation atteint zéro, le smart pointer est libéré.

Marquage des objets

En C#, les objets de type référence sont alloués sur un ou plusieurs tas (i.e. heap). Lorsqu’il est nécessaire d’allouer un espace mémoire pour effectuer un traitement, le garbage collector s’exécute pour vérifier si tous les objets dans le tas sont toujours utilisés. Le garbage collector va donc étudier les dépendances entre les objets pour fournir un graphe d’objets à partir duquel il pourra déterminer quels sont les objets qui ne sont plus atteignables. Ces objets peuvent être supprimés du tas car ils ne sont plus utilisés.

Si ces objets ne possèdent pas de finalizers, ils sont supprimés. Dans le cas contraire, pour éviter que le garbage collector ne prenne trop de ressources, ces objets sont, dans un premier temps, marqués comme étant à supprimer. Ils seront supprimés, lors d’une seconde exécution du garbage collector, après avoir exécuté les finalizers

Compactage

Quand les objets sont supprimés de la mémoire, ils laissent des espaces vides qui rendent la mémoire fragmentée. Une mémoire trop fragmentée réduit les performances quand de nouvelles affectations sont effectuées. Une étape du garbage collector consiste à défragmenter la mémoire et à compacter les objets restants dans une zone de la mémoire de façon à favoriser les nouvelles allocations.

Les déplacements d’objets en mémoire peuvent être couteux en ressource, plus les objets sont lourds et plus ce déplacement prendra du temps. D’autant qu’il n’est pas tout le temps nécessaire de déplacer des objets de grande taille si ceux-ci prennent une allocation continue en mémoire et si leurs dépendances ne changent pas durant l’exécution du processus. La gestion des objets de grande taille est donc différente de celle des objets de petite taille ou ceux dont les dépendances changent beaucoup durant l’exécution.

“Large object heap” et générations

Ainsi, d’une part, les objets dont la taille est supérieur à 85 MO sont rangés dans un tas pour les objets de grande taille (i.e. large object heap). D’autre part, les objets sont marqués par une génération de 0 à 2. Les nouveaux objets font partie de la génération 0 et suivant les différentes itérations du garbage collector et si les dépendances des objets ne changent pas, ils sont promus vers la génération 1. Si ces objets de génération 1 sont assez statiques, ils peuvent être promus à la génération 2.

Tous ces mécanismes nécessitent de déplacer des objets en mémoire et donc de modifier leur adresse dans les références. Ce type de modification des adresses mémoire est en opposition aux mécanismes utilisés dans du code natif. En effet, les objets en code natif ne sont jamais déplacés. Il faut donc avoir en tête la différence de traitement entre le code managé et le code non managé puisque certains objets sont partagés.

Convention d’appels

Il s’agit d’une convention sur la façon dont les fonctions sont appelées. Ces conventions permettent de préciser comment les arguments sont passés aux fonctions, comment les résultats sont retournés ou comment les fonctions sont nommées après compilation.

Par défaut, les programmes C et C++ utilisent la convention __cdecl. L’option de compilation est /Gd. Avec cette convention, il est possible d’appeler des fonctions avec un nombre d’arguments différent mais les fichiers compilés sont plus volumineux.

Il existe une convention __stdcall qui est la convention par défaut de l’API Win32. On utilise l’option de compilation /Gz pour cette convention. Les fonctions avec un nombre variable d’arguments ne sont pas permis, toutefois les fichies compilés sont moins volumineux par rapport à la convention précédente.

Du code C et C++ peut aussi utiliser la convention __fastcall avec l’option de compilation /Gr. Cette convention favorise l’utilisation de registres plutôt que celle du tas lors des appels de fonctions car l’accès aux registres est plus rapide que pour le tas.

Unicode/ANSI

A l’origine, les caractères étaient codés en utilisant l’ensemble de caractères ASCII. Cet ensemble comporte 128 caractères qui correspondent aux caractères nécessaires pour un texte en anglais plus des caractères de ponctuation. Ces caractères sont numérotés de 0 à 127. Les caractères spécifiques aux autres langues sont numérotés de 128 à 255. Sachant que les 127 caractères suivants ne suffisent pas pour toutes les langues, chaque langue avait sa variante de ASCII et les caractères 128 à 255 n’était pas standardisés. Les différents mappings s’appellent “code page” (code page russe, code page allemand etc…).

Pour unifier tous ces mappings a émergé un autre standard: Unicode. Unicode est une version plus “grande” que l’ASCII qui affecte à chaque caractère un identifiant unique. Unicode est codé sur plusieurs octets pour permettre d’identifier plus de caractères. La correspondance entre l’identifiant et le caractère est appelé “code point”. Les 65535 code points identifiés avec les 2 premiers octets correspondent à des caractères communs dans la plupart des langues occidentales. Les autres caractères utilisant un identifiant sur plus de 2 octets sont des caractères pour les autres langues.

Au standard Unicode correspond plusieurs encodage qui convertissent le code point Unicode en octets. Les encodages les plus connus sont UTF-8 et UTF-16. UTF-16 permet de coder les caractères sur 4 octets. Le gros inconvénient de cet encodage est que la plupart de temps, on utilise des caractères codés sur 2 octets, ce qui implique que les 2 autres octets sont nuls d’où une perte de 2 octets. Pour palier ce problème, on privilégie d’utiliser UTF-8 qui encode les caractères Unicode sur un seul octet quand c’est possible. D’autre part, UTF-8 est compatible avec l’ASCII historique.

En C#, les chaines de caractères sont encodés en utilisant Unicode UTF-16. Par défaut, elles sont converties et passées au code non managé suivant le standard ANSI c’est-à-dire ASCII.

2. Platform invoke avec “DllImport”

Les appels au code non managé sont possibles grâce à l’attribut DllImport. C’est cet attribut qui va permettre d’importer les fonctions C ou C++ dans le code managé.

Dans un premier temps, l’exemple suivant permet d’illustrer simplement la façon dont on fait un appel P/Invoke. Ensuite, on va expliciter les mécanismes qui interviennent lorsqu’on fait ce type d’appel. Enfin dans la dernière partie, on rentrera davantages dans les détails sur la façon de traiter des cas plus atypiques.

Pour illustrer simplement un appel P/Invoke, on se propose de déclarer la fonction C++ suivante:
Dans le fichier “.h”:

extern "C" { 
    __declspec(dllexport) int __cdecl AddIntegers(int a, int b); 
}

Dans le fichier “.cpp”:

extern int __cdecl AddIntegers(int a, int b)  
{ 
    return a + b; 
}

Si le code C++ est compilé dans le fichier DLL TestPInvoke.dll, l’utilisation de DllImport en C# peut se faire de cette façon:

using System.Runtime.InteropServices; 
 
public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll", CallingConvention = CallingConvention.Cdecl)] 
    public static extern int AddIntegers(int a, int b); 
}

L’appel C# se fait en exécutant:

public class CallCppCode 
{ 
    public int AddIntegersInCPP(int a, int b) 
    { 
        return UnsafeNativeMethods.AddIntegers(a, b); 
    } 
}

Précisions sur le code C++

Dans le code C++:

  • __declspec(dllexport): permet d’indiquer que le linker doit exporter les symboles vers une DLL. Cette déclaration permet d’appeler la fonction à l’extérieur de la DLL.
  • __cdecl: permet de définir la convention d’appel de la fonction. Cette déclaration n’est pas indispensable puisque par défaut le code C ou C++ est compilé en utilisant cette convention.

On peut préciser une autre convention d’appels dans les paramètres du projet C++:
C/C++ ⇒ Advanced puis modifier “Calling Convention” en sélectionnant la valeur __stdcall. Ce paramètre ajoute l’option de compilation /Gz.

On peut aussi changer la déclaration de la fonction:

extern "C" { 
    __declspec(dllexport) int __stdcall AddIntegers(int a, int b); 
}
Convention d’appels par défaut de DllImport

Utiliser la convention d’appels __stdcall permet d’éviter de préciser la convention lors de l’appel C# puisque la convention par défaut de DllImport est __stdcall.
Si la convention d’appels n’est pas la même entre l’appel par DllImport et la DLL native, une erreur du type "PInvokeStackImbalance was detected" survient lors de l’appel.

DependencyWalker

il est possible de voir la méthode définie en utilisant DependencyWalker. La méthode doit donc apparaître sans “décoration” C++ pour être appelable avec P/Invoke.

Précisions sur le code C#

Concernant le code C#, DllImport permet de préciser la nom du fichier DLL où se trouve la fonction à importer “TestPInvoke.dll”.
Dans cet exemple, le nom de la fonction AddIntegers est le même que celui définit en C ou C++. Toutefois, il est possible de définir un nom de méthode spécifique au code C# en utilisant le paramètre Entrypoint:

[DllImport("TestPInvoke.dll",  
   CallingConvention = CallingConvention.Cdecl, 
   EntryPoint="AddIntegers")] 
public static extern int AddIntegersFromCppDll(int a, int b);

L’attribut DllImport s’utilise avec les modificateurs static et extern:

  • extern: permet de déclarer une méthode qui est implémentée extérieurement au code C#.
  • static: les déclarations utilisant l’attribut DllImport sont définies de façon statique.

Le paramètre CallingConvention permet de préciser la convention d’appels et de choisir la même convention que le fichier DLL.

Comme indiqué précédemment, si on choisit la convention d’appels __stdcall pour la compilation du fichier DLL, il n’est pas nécessaire d’utiliser le paramètre CallingConvention de DllImport puisque la convention d’appels par défaut de DllImport est __stdcall.

Plus de détails concernant l’attribut DllImport sur MSDN.

Précisions sur les projets

La DLL compilé par le projet C++ doit se trouver dans le même répertoire que l’assembly .NET qui fera l’appel P/Invoke. Si la DLL native se trouve pas dans le même répertoire, une exception DllNotFoundException sera lancée lors de l’exécution.

Dans les paramètres du projet C++, on peut préciser les paramètres suivants:

  • Configuration Type: Dynamic Library (.dll)
  • Use of MFC: Use Standard Windows Library
  • Use of ATL: Not Using ATL
  • Common Language Runtime Support: No Common Language Runtime Support
  • Whole Program Optimization: Use Link Time Code Generation

3. Marshalling

Dans cette partie, on expliquera plus en détails les mécanismes qui entrent en jeu lorsqu’on fait un appel P/Invoke.

Ainsi dans le cas d’un appel P/Invoke provenant de code managé vers du code non managé, il existe des différences existent entre les deux codes dans les mécanismes de gestion de la mémoire ou la façon d’effectuer les appels de fonctions. Il peut exister, aussi, des différences quant à la représentation des objets en mémoire. Cette différence peut nécessiter d’effectuer une conversion des objets entre le code managé et le code non managé.

Cette conversion est effectuée par le CLR (pour Common Language Runtime) lors des appels au code non managé. Il est effectué pour faire passer des arguments aux fonctions ou pour récupérer le résultat. Ce mécanisme de conversion est le marshalling.

Le plus souvent, le marshalling est effectué implicitement pour le CLR pendant l’appel interop et ne nécessite pas de précisions supplémentaire. Toutefois lorsque la différence est trop importante, il peut être nécessaire de préciser certains attributs pour indiquer comment effectuer le marshalling.

Quand on parle de marshalling dans Platform invoke, on désigne la conversion des objets managés vers des objets non managés dont le type est compatible avec le langage C. Il est possible d’appeler des DLL natives codées en C++ toutefois les conversions d’objets se font sur des types compatibles avec le C.

Règle principale du “marshalling”

Le marshalling paraît être un processus complexe pour lequel il faut suivre des règles précises. D’une façon générale, le marshaller effectue toutes les conversions sans qu’il soit nécessaire d’utiliser d’attributs ou de faire des indications particulières. La plupart du temps, le comportement par défaut est le comportement le plus approprié.

La principale règle qu’il faut suivre dans tous les cas pour que le marshalling soit sûr, est qu’il faut que le type managé occupe la même quantité de mémoire que le type non managé. Si cette règle est observée, le marshalling sera toujours possible même si un marshalling vers un type trop différent peut mener à des comportements indésirables. Par exemple, on est pas forcé d’utiliser strictement un entier signé en managé avec un type signé en non managé, on peut utiliser un entier signé en non managé avec un entier non signé en non managé.

Types “blittables” et “non-blittables”

Les types d’objets qui ont une représentation similaire en mémoire entre le code managé et le code non managé sont des types “blittables”. Ils ne nécessitent pas de précisions particulières et le marshalling de ces types est immédiat. A l’opposé, le marshalling des types non “blittables” n’est pas forcément immédiat car leur représentation en mémoire entre le code managé et le code non managé est différente.

La plupart des types simples c’est-à-dire les types primitifs sont “blittables”. A l’opposé, tous les types complexes sont “non-blittables”.

Types simples

Les types simples sont les types primitifs. La plupart de ces types est “blittable” et la conversion entre le code managé et non managé est directe:

Type C# Type C/C++ Taille en octets Intervalle
bool(1) bool 1, 2 ou 4 “true” ou “false”
char wchar_t (ou char) 2 (ou 1) Unicode BMP(2)
byte unsigned char 1 0 à 255
sbyte char 1 -128 à 127
short short 2 -32768 à 32767
ushort unsigned short 2 0 à 65535
int int 4 -2147483648 à 2147483647
uint unsigned int 4 0 à 4294967295
long __int64 8 -9,2 x 10^18 à 9,2 x 10^18
ulong unsigned __int64 8 0 à 18,4 x 10^18
float float 4 7 chiffres décimaux -3,4 x 10^38 à 3,4 x 10^38
double double 8 15 chiffres décimaux

(1): les booléens sont des cas particuliers. Entre du code C++ et du code C#, les booléens sont des types identiques codés sur 1 octet. La conversion se fait en System.Boolean ou bool. En revanche l’API Win32 considère 2 types de booléen: BOOL et BOOLEAN:

  • BOOL: il s’agit d’un alias pour INT codé sur 4 octets.
  • BOOLEAN: alias pour le type BYTE codé sur 1 octets.

BOOL et BOOLEAN peuvent être convertis en System.Boolean, toutefois BOOL peut être converti en System.Int32. De même BOOLEAN peut aussi être converti en System.Byte.
Pour éviter un mauvais marshalling dans le cas de l’API Win32, il est préférable d’utiliser MarshalAsAttribute avec UnmanagedType.Bool pour cadrer davantage la conversion.

(2): correspond à l’ensemble des caractères Unicode BMP (Basic Multilingual Plane).

Types complexes

Les types complexes sont des types d’objets contenant des membres dont le type est simple ou complexe. Il y a 2 types complexes compatibles avec le langage C avec lesquels il est possible d’effectuer des appels P/Invoke: les structures et les unions. Ces types peuvent être convertis en structures ou en classes en C#.

Les membres d’une structure sont rangés en mémoire à une adresse particulière et ils occupent un ou plusieurs blocs mémoire en fonction de leur type. Ainsi chaque membre aura une adresse mémoire relative à l’adresse de départ de la structure.

Pour que l’équivalence soit possible sans précision particulière entre l’objet managé et l’objet non-managé, il faut:

  • que les membres soient les mêmes,
  • que l’ordre de ces membres soit le même et
  • que le type de ces objets occupe le même nombre de blocs mémoire.

Dans le cas où l’une de ces conditions n’est pas remplie, on peut utiliser des attributs qui vont apporter de indications au marshaller pour effectuer la conversion.

LayoutKind

Cet attribut permet d’indiquer au marshaller la façon dont les membres de l’objet complexe sont placés en mémoire pour qu’il soit utilisable dans le code managé et dans le code non-managé lors d’un appel P/Invoke. 3 valeurs sont possibles:

  • LayoutKind.Auto: il s’agit de la valeur par défaut. Elle empêche au CLR de marshaller un objet complexe décoré de cet attribut vers un objet non-managé. Si on utilise un objet avec cet attribut dans un appel P/Invoke, une exception sera lancée.
  • LayoutKind.Sequential: lors du marshalling, les valeurs sont affectées aux membres de la structure complexe dans l’ordre dans lequel ils sont déclarés dans la structure. Cette valeur convient bien lorsque les membres sont des types simples.
  • LayoutKind.Explicit: cette valeur permet d’indiquer explicitement la position relative du membre par rapport à la première adresse de la structure. Pour indiquer l’adresse relative, on utilise pour tous les membres, l’attribut FieldOffset avec le nombre d’octet d’écart. On peut utiliser le tableau plus haut pour connaître l’espace mémoire occupé par un membre en fonction de son type.

“Marshalling” d’un objet complexe

Pour marshaller un objet complexe, il faut:

  • Créer la struct en C et son équivalent en C#. L’équivalent peut être une structure C# ou une classe.
  • Expliciter les membres de l’objet complexe dans le même ordre.
  • Ajouter l’attribut LayoutKind qui convient en fonction de la façon dont les membres sont déclarés.

Comportement du “marshaller” pour les objets passés en paramètre

Lorsque les appels sont faits à du code non managé, il est possible ou non d’utiliser une représentation commune de l’objet en mémoire pour le code managé et pour le code non managé. Dans cette partie, on se propose d’expliciter les mécanismes du marshaller pour le passage d’objet vers le code non-managé en fonction des attributs utilisés dans la déclaration de la méthode appelée par P/Invoke.

Pour la suite, on parle de comportement par défaut lorsqu’il s’agit du comportement si on n’apporte aucune indication avec les attributs.

In et Out

Dans la suite, lorsqu’on parle de In et Out, on parle des attributs utilisés pour le marshalling: System.Runtime.InteropServices.InAttribute et System.Runtime.InteropServices.OutAttribute. Il ne faut pas confondre ces attributs avec le mot-clé C# out:

  • out: pour les appels P/Invoke out est utilisé pour que des objets de type valeur soient passés en paramétre par référence. out peut être remplacé par ref.
  • System.Runtime.InteropServices.InAttribute: attribut permettant d’indiquer au que le paramètre sera marshallé de la fonction appelante vers la fonction appelée.
  • System.Runtime.InteropServices.OutAttribute: attribut permettant d’indiquer au marshaller que le paramètre sera marshallé de la fonction appelée vers la fonction appelante.

On peut résumer les différents cas de figures dans les tableaux suivants:

Pour les objets de type valeur

Passage des arguments Blittable ? Attributs utilisés lors de l’appel Comportement par défaut Comportement du marshaller
Passage par valeur Blittable IN Oui L’objet est copié et l’argument passé dans le tas de l’appelé est directement la valeur de l’objet.
Non-blittable Même comportement que précédemment. Toutefois lors de la copie, l’objet est marshallé.
Passage par référence Blittable IN et OUT Non (il faut préciser explicitement les attributs) L’objet est copié (l’objet se trouve dans le tas managé et sa copie se trouve dans le tas non managé). L’argument passé est un pointeur vers la copie dans le tas non managé.
Non-blittable Même comportement que précédemment. Toutefois lors de la copie, l’objet est marshallé.

Pour les objets de type référence

Passage des arguments Blittable ? Attributs utilisés lors de l’appel Comportement par défaut Comportement du marshaller
Passage par valeur Non-blittable IN Oui L’objet est copié et marshallé. L’argument passé est un pointeur vers l’objet copié dans le tas non managé.
OUT Non (il faut préciser explicitement l’attribut) L’objet est copié et marshallé en retour de la fonction appelée. L’argument passé est un pointeur vers l’objet dans le tas managé.
IN et OUT Non (il faut préciser explicitement les attributs) 2 copies sont effectuées, lors de l’appel et lors du retour des arguments.
Blittable IN Oui L’objet est fixé en mémoire (i.e. pinned).
IN et OUT Non (il faut préciser explicitement les attributs) Même comportement mais plus sûr si l’appelé doit modifié l’objet passé en argument.
Passage par référence N/A IN Non (il faut préciser explicitement l’ attribut) L’objet est copié et marshallé (l’objet se trouve dans le tas managé et sa copie, dans le tas non managé). L’argument passé est un pointeur vers un pointeur qui pointe vers l’objet dans le tas non managé.
IN et OUT Oui Même comportement que précédemment. En retour l’appelant reçoit un pointeur vers pointeur vers l’objet dans le tas managé.

Lorsque l’objet est commun et qu’il est fixé en mémoire (i.e. pinned), les modifications faites par la fonction appelée sur l’objet sont visibles de la fonction appelante. Ce mécanisme est plus performant qu’une copie puisqu’une seule version de l’objet subsite et est utilisée dans le tas managé. Cette copie est utilisée à la fois par le code managé et par le code non managé. Pour éviter, un déplacement de l’objet par le garbage collector, l’objet est marqué pour ne pas être manipulé lors de l’appel au code managé.

Il est possible de forcer un objet à être fixé pour qu’il soit accesible à partir de code non managé en utilisant GCHandle:

using System; 
using System.Runtime.InteropServices; 
using System.Security.Permissions; 
 
// ... 
public class GcHandleTest 
{ 
    [DllImport("user32.dll")] 
    public static extern bool EnumWindows(CallBack cb, IntPtr param); 
     
    // ... 
    [SecurityPermission(SecurityAction.Demand, UnmanagedCode=true)] 
    public void PinObject(object objectToPin) 
    { 
        GCHandle gcHandle = GCHandle.Alloc(tw); 
        try 
        { 
            // ... 
            // Utilisation de l'objet par le code non managé 
        } 
        finally 
        { 
            gcHandle.Free(); 
        } 
    } 
}
Résumé sur le comportement par défaut

Pour résumer le comportement par défaut lors des appels P/Invoke:

  • Si un objet de type valeur est passé en argument, par défaut, il est passé par valeur. Le marshalling se fait si l’objet n’est pas “blittable”.
  • Si un objet de type valeur est passé en argument par référence, le code managé et le code non managé utilisent des objets différents stockés respectivement dans le tas managé et dans le tas non managé.
  • Si un objet de type référence est passé en argument, par défaut, il est passé par référence en utilisant les attributs In et Out. Le code managé et le code non managé utilisent des pointeurs différents et des copies différentes des objets. Le marshalling se fait si l’objet n’est pas “blittable”.
  • Si un objet de type référence est passé en argument par valeur, par défaut, il est passé en utilisant l’attribut In. Si l’objet est “blittable”, il est fixé en mémoire (i.e. pinned) et le code managé et non managé utilisent le même objet qui se trouve dans le tas managé. Si l’objet est non “blittable”, le code managé et le code non managé utilisent des copies différentes des objets, respectivement dans le tas managé et dans le tas non managé.

Cas particulier des “strings”

Le type string en C# est un type référence, toutefois il n’est pas marshallé de la même façon que les autres objet de type référence. Il existe 2 façons en C# de stocker une chaine de caractères: System.String et System.Text.StringBuilder. La différence essentiel entre ces 2 objets est que System.String est immutable alors que System.Text.StringBuilder autorise les modifications.

Quand des objets de type System.String ou System.Text.StringBuilder sont passés en paramètre d’une fonction avec Platform Invoke, le marshaller se comportent différemment:

Type d’objet Passage des arguments Comportement par défaut Comportement du marshaller
System.String Par référence Non Le marshaller copie les données vers un buffer et passe la référence du buffer à la fonction appelée. En retour de l’appel, le contenu du buffer est copié dans un 2e buffer. Cette étape permet de ne pas altérer la chaine managée.
Par valeur Oui Lorsque la chaine est une chaine Unicode, le marshaller optimise en passant directement à la fonction appelée un pointeur vers la chaine managée au lieu d’effectuer un copie vers un buffer.
System.Text.StringBuilder Par valeur Oui Le marshaller copie les données vers le buffer interne du StringBuilder et passe une référence vers ce buffer interne directement à la fonction appelée. La longueur de la StringBuilder doit être choisie à l’affectation de la chaine dans le code managé.
Par référence Non Le marshaller passe un pointeur vers l’objet StringBuilder en mémoire et non un pointeur vers le buffer interne.

Il est préférable d’utiliser System.String pour des paramètres en entrée d’une fonction appelée qui ne seront pas retournées à la fonction appelante. Dans le cas où on est intéressé par l’utliisation des chaines en sortie de la fonction appelée, System.Text.StringBuilder convient mieux car il permettra de changer la valeur interne de la chaine de caractères.
En revanche, il faut que la longueur de la chaine dans la StringBuilder soit définie par la fonction appelante et qu’elle soit assez grande pour contenir la chaine en retour.
Le point négatif de la StringBuilder est qu’il n’est pas possible d’utiliser ce type dans des types complexes.

L’utilisation d’un objet System.String en sortie d’une fonction suppose que la mémoire coté code natif soit allouée en utilisant CoTaskMemAlloc. Après avoir appelé la fonction et récupéré le résultat, le CLR va libérer la mémoire native en utilisant CoTaskMemFree. Un crash peut se produire si l’allocation n’a pas été effectuée avec CoTaskMemAlloc.

StringBuilder est un type référence, pourtant par défaut, lorsqu’elle est passée par valeur, elle est passée avec les attributs In et Out (contraiement aux autres objets de type référence qui sont passés par défaut seulement avec l’attribut In).

Enfin, il faut avoir en tête que StringBuilder n’est pas utilisable dans un type complexe. Il faut obligatoirement utiliser une objet de type string pour stocker une chaine de caractères.

Détails des appels à l’API Win32

Les appels P/Invoke permettent d’appeler beaucoup de fonctions de l’API Win32. Ces fonctions ne sont pas très différentes des fonctions qu’on déclare dans une DLL native, toutefois il existe certaines spécificités propre à l’API Win32.

Ainsi les types utilisés pour les appels à l’API sont très souvent des alias vers des types C. On peut résumer les types les plus courants dans le tableau suivant, la liste exhaustive de ces types se trouve sur la page Windows Data Types:

Type C# Type Windows Taille en octets
char CHAR 2
byte BYTE 1
sbyte CHAR 1
short SHORT 2
ushort WORD ou USHORT 2
int INT, INT32, LONG et LONG32 4
uint DWORD, DWORD32, UINT et UINT32 4
long INT64, LONGLONG et LONG64 8
ulong DWORDLONG, DWORD64, ULONGLONG et UINT64 8
double FLOAT 8

Une autre règle est que l’API Win32 ne permet pas de passage d’arguments par référence.

La grande majorité des déclarations permettant d’effectuer des appels à l’API Win32 sont explicitées sur pinvoke.net.

4. Précisions sur les appels P/Invoke

Déclaration des méthodes P/Invoke dans des “wrappers”

D’une façon générale, il est conseillé d’encapsuler les déclarations des méthodes P/Invoke dans des classes statiques dont le nom peut être NativeMethods, SafeNativeMethods ou UnsafeNativeMethods.

Encodage des chaines de caractères

Comme précisé plus haut, en C# les chaines de caractères sont encodées en utilisant Unicode UTF-16. Par défaut, elles sont converties et passées au code non managé suivant le standard ANSI. Si on veut convertir dans un ensemble de caractères différents, il faut le préciser dans les attributs de DllImport, par exemple:

[DllImport("TestPInvoke.dll", CharSet = CharSet.Unicode)] 
public static void UseStringParameter([MarshalAs(UnmanagedType::LPStr)]string stringValue);

Les différentes valeurs possibles sont:

  • CharSet.Auto: la valeur change suivant le système d’exploitation. Unicode pour Windows NT, XP et suivant sinon Ansi pour les versions précédentes. Le codage par défaut du langage est prioritaire sur le choix suivant l’OS. Par défaut, pour le C#, c’est un codage en Ansi qui est utilisé.
  • CharSet.Ansi: valeur par défaut en C#.
  • CharSet.Unicode
  • CharSet.None: même comportement que Ansi.

Enfin, pour une chaine de caractères encodée en ANSI, chaque caractère utilise un seul octet en mémoire. La même chaine de caractères encodée en Unicode (UTF-16) nécessite deux fois plus d’espace mémoire puisque chaque caractère occupe 2 octets. Il faut avoir cette caractéristique en tête lorsqu’on réserve un espace pour une chaine de caractères dans un type complexe.

Performances

Avant de rentrer dans le détail d’appels P/Invoke, quelques adaptations peuvent être nécessaires dans le code:

  • Pour éviter un impact trop fort dans les performances dans l’étape de marshalling, il est préférable de privilégier des interfaces fournies et minimiser les appels plutôt que d’utiliser des interfaces qui nécessitent un grand nombre d’appels.
  • Eviter d’éviter des conversions entre ANSI et Unicode dans les chaines de caractères car cette opération est couteuse en performance. Utiliser un pointeur IntPtr plutôt qu’utiliser une chaine explicitement permet d’éviter au marshaller d’effectuer des conversions lui-même.
  • D’une façon générale, utiliser IntPtr et effectuer des étapes de marshalling manuellement en utilisant la classe Marshal est plus rapide que de laisser le marshaller effectuer toutes les conversions, car on peut choisir finement les éléments à marshaller.
  • Les attributs InAttribute et OutAttribute permettent de minimiser les conversions par rapport au comportement par défaut qui peut être amener à effectuer trop d’étapes de marshalling inutiles.

Exemple d’utilisation des attributs de “marshalling”

La déclaration suivante permet d’appeler la fonction ReadConsole() de l’API Win32:

[DllImport("Kernel32.dll", CharSet = CharSet.Unicode)] 
[return: MarshalAs(UnmanagedType.Bool)] 
static extern bool ReadConsole( 
    IntPtr hConsoleInput, 
 
    [param: MarshalAs(UnmanagedType.LPTStr), Out()] 
    StringBuilder lpBuffer, 
 
    [param: MarshalAs(UnmanagedType.U4)] 
    uint nNumberOfCharsToRead, 
 
    [param: MarshalAs(UnmanagedType.U4), Out()] 
    out uint lpNumberOfCharsRead, 
 
    [param: MarshalAs(UnmanagedType.AsAny)] 
    uint lpReserved);

Cette déclaration utilise les attributs:

  • CharSet = CharSet.Unicode: permet d’indiquer que les chaines de caractères ne seront pas converties en utilisant l’ensemble de caractères Ansi, elles seront laissées en Unicode.
  • [param: MarshalAs(UnmanagedType.LPTStr), Out()]: permet d’indiquer que le paramètre est marshallé de la fonction appelée vers la fonction appelante. De plus le marshalling de la chaine de caractères sera fait à partir d’une chaine codée en Unicode.
  • [param: MarshalAs(UnmanagedType.U4), Out()]: l’entier sera codé de la fonction appelée vers la fonction appelante et la conversion se fera à partir d’un entier non signé sur 4 octets. Pour ce paramètre, on utilise out en plus de l’attribut System.Runtime.InteropServices.OutAttribute pour indiquer que le paramètre entier non signé qui est un objet de type valeur soit passé par référence.
  • [param: MarshalAs(UnmanagedType.AsAny)]: permet d’indiquer que le type de l’objet marshallé sera déterminé à l’exécution.

Pour plus de renseignements sur les types de marshalling, se reporter à MSDN.

Exemples de passage d’objets en paramètre

Passage d’objet de type valeur par valeur

C’est le comportement par défaut, il n’y rien à préciser. Par défaut, l’attribut appliqué est IN:

En C++:

typedef struct _PERSON 
{ 
   char* first;  
   char* last;  
} PERSON, *LP_PERSON; 
 
int FunctionUsingPerson(PERSON person);

En C#:

[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)] 
public struct Person 
{ 
    public string first; 
    public string last; 
} 
 
public static class UnsafeNativeMethods  
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern int FunctionUsingPerson(Person person); 
}

L’appel se fait de cette façon:

Person person = new Person(); 
person.first = "Mat"; 
person.last = "Robert"; 
int result = UnsafeNativeMethods.FunctionUsingPerson(person);

Passage d’objet de type valeur par référence

Il faut ajouter le mot clé ref en C#. Par défaut les attributs utilisés sont IN et OUT.
Le code C# devient:

public static class UnsafeNativeMethods  
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern int FunctionUsingPerson(ref Person person); 
}

En C++:

int FunctionUsingPerson(PERSON* person);

Passage d’objet de type référence par référence

C’est le comportement par défaut, il n’y a rien à préciser. L’attribut utilisé par défaut est IN:
En C++:

typedef struct _CUSTOMTIME {  
    WORD wHour;  
    WORD wMinute;  
    WORD wSecond;  
} CUSTOMTIME, *PCUSTOMTIME; 
 
VOID FunctionUsingRefType(CUSTOMTIME* time);

En C#:

[StructLayout(LayoutKind.Sequential)] 
public class CustomTime 
{ 
    public ushort hour; 
    public ushort minute; 
    public ushort second; 
} 
 
public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern void FunctionUsingRefType(CustomTime time); 
}

L’appel se fait simplement:

CustomTime time = new CustomTime{ hour = 12, minute = 45, second = 32 }; 
UnsafeNativeMethods.FunctionUsingRefType(time);

Passage d’objet de type référence par valeur

Il faut préciser les attributs IN et OUT pour que le passage se fasse par valeur.

La déclaration de la méthode en C++:

VOID FunctionUsingRefType(CUSTOMTIME time);

En C#:

public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern void FunctionUsingRefTypeByVal([In,Out] CustomTime time); 
}

Passage de “System.String”

Si le string est en paramètre seulement en entrée:

En C++:

void UseStringParameter(char* stringValue);

En C#:

public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll", CharSet = CharSet.Ansi)] 
    static public void UseStringParameter([MarshalAs(UnmanagedType::LPStr)]string stringValue); 
}

Retour d’une chaine de caractères

Si on doit récupérer un résultat sous forme de chaine de caractères, il vaut mieux utiliser un StringBuilder.

En C++:

void ReturnsAString([out] char* stringValue);

En C#:

public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll", CharSet = CharSet.Unicode)] 
    static extern void ReturnsAString( 
        [param: MarshalAs(UnmanagedType.LPTStr), Out()] 
        StringBuilder lpBuffer); 
}

Dans l’appel, il faut fixer la taille de la StringBuilder:

StringBuilder builder = new StringBuilder(256); 
UnsafeNativeMethods.ReturnsAString(builder); 
string result = builder.ToString();

Dans le cas de StringBuilder, on précise l’attribut Out car par défaut, le StringBuilder est passé avec les attributs In et Out.

Structure contenant un pointeur vers une autre structure

En C++:

typedef struct _POINTCOORDINATES 
{ 
    int latitude;  
    int longitude;  
} POINTCOORDINATES, *LP_POINTCOORDINATES; 
 
 
typedef struct _NAMEDPOINT 
{ 
   POINTCOORDINATES* point; 
   char* name;  
} NAMEDPOINT, *LP_NAMEDPOINT; 
 
 
int UsingNamedPoint(NAMEDPOINT* namedPoint);

En C#:

[StructLayout(LayoutKind.Sequential)] 
public struct PointCoordinates 
{ 
    public int latitude; 
    public int longitude; 
} 
 
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)] 
public struct NamedPoint 
{ 
    public IntPtr point; 
    public char* name; 
} 
 
public class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern int UsingNamedPoint(ref NamedPoint namedPoint); 
}

L’appel se fait en exécutant:

PointCoordinates pointCoordinates; 
pointCoordinates.latitude = 34; 
pointCoordinates.longitude = 84; 
 
NamedPoint namedPoint; 
namedPoint.name = "point1"; 
 
IntPtr buffer = Marshal.AllocCoTaskMem(Marshal.SizeOf(pointCoordinates)); 
Marshal.StructureToPtr(pointCoordinates, buffer, false); 
 
personAll.point = buffer; 
 
int res = UnsafeNativeMethods.UsingNamedPoint(ref personAll);
Références

Explications générales:

PInvoke tutorial:

Explications Marshalling:

Précisions techniques:

Leave a Reply