Native ints (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Cette fonctionnalité consiste à permettre d’utiliser les types “native int” et “native unsigned int” dans du code C#. Avant cette fonctionnalité, ces types n’existaient que dans le code MSIL, ils étaient générés quand on utilisait les types System.IntPtr et System.UIntPtr.
L’inconvénient est que les types IntPtr et UIntPtr ne sont pas trop très flexibles et ne permettent pas d’effectuer des opérations arithmétiques.

En préambule, on va expliquer l’intérêt des types IntPtr et UIntPtr; ensuite on indiquera l’intérêt des nouveaux types nint et nuint et la différence avec IntPtr et UIntPtr.

IntPtr et UIntPtr

Historiquement avant C# 9, IntPtr était le type privilégié pour contenir une adresse mémoire sans être dans un contexte unsafe. Dans cette partie, on va détailler l’utilisation de ce type.

Adressage de la mémoire

La mémoire est composée de blocs d’octets (i.e. bytes). Chaque octet est lui-même composé de 8 bits. Ensuite chaque octet est identifié par une adresse unique qui correspond au décalage par rapport au début de la mémoire. Le CPU identifie le bloc avec une adresse dont la taille est fixe, cette taille est appelée mot (i.e. word). Actuellement le plus souvent, la taille des mots est:

  • 4 octets pour les processeurs ou systèmes 32 bits.
  • 8 octets pour les processeurs ou systèmes 64 bits.

Pour un système 32 bits, en théorie il est possible d’adresser au maximum 232-1 = 4294967295 blocs (car la 1ère adresse commence à 0). Pour un système 64 bits, on peut adresser 264-1 blocs. 232-1 correspond à un peu près à 4 x 230 octets = 4 GiB (i.e. gibibyte) = 4 x 1073741824 octets.

Dans la pratique, dans un système Windows 32 bits, un processus ne peut pas adresser plus de 3 GiB. Dans un système Windows 64 bits, un processus 32 bits ne peut pas adresser plus de 4GiB.

Dans un système d’exploitation, il faut distinguer les adresses physiques de la mémoire utilisées par le CPU et les adresses virtuelles utilisées par les processus. Le lien entre les adresses physiques et les adresses virtuelles est effectué par le système d’exploitation.

Utilisation de IntPtr et UIntPtr

Comme on l’a vu précédemment, l’adresse d’un bloc mémoire est identifiée par un mot de 4 ou 8 octets suivant l’architecture du système sur lequel est exécuté un processus. Dans le cadre de Windows, il est possible d’exécuter des processus dont l’architecture d’exécution ne correspond pas forcément à l’architecture du système:

Architecture du système Architecture d’exécution du processus
32 bits 64 bits
32 bits OK Impossible
64 bits Possible avec WoW64(*) OK

*: Windows 32-bit on Windows 64-bit.

Ainsi au delà de l’architecture du système, suivant l’architecture d’exécution d’un processus l’adressage de la mémoire pourrait se faire avec des mots dont la taille est différente. Le type System.IntPtr permet de proposer une solution dans le code pour stocker une adresse mémoire en permettant de s’adapter à l’architecture d’exécution du processus:

  • Dans un processus 32 bits sizeof(IntPtr) est 4
  • Dans un processus 64 bits sizeof(IntPtr) est 8

Dans la pratique, System.IntPtr est un entier dont la taille est la même qu’un pointeur pour une architecture d’exécution donnée. L’intérêt de ce type est de pouvoir stocker des adresses mémoire comme des pointeurs sans forcément être dans un contexte unsafe et sans se préoccuper de la taille du pointeur qui peut varier suivant l’architecture d’exécution.

Le nom du type IntPtr peut prêter à confusion à cause du terme Ptr pour “pointer”. Il laisse penser qu’une variable de type IntPtr est un pointeur. Ce n’est pas le cas, un IntPtr correspond seulement à un entier dans lequel peut être stocké une adresse. Il n’y a pas de garantie ni de vérification que l’adresse correspond effectivement à un objet en mémoire ou à un emplacement utilisable par le processus. Ce type d’objet est similaire au type void* en C++. L’intérêt d’utiliser IntPtr est d’avoir un entier dont la taille varie suivant l’architecture d’exécution du processus.

Le plus souvent IntPtr sert dans le cadre d’appels à du code natif pour stocker des pointeurs non typés.

Par exemple si on considère une fonction native dont la signature est:

void* NativeFunctionExample(void* ptr);

Cette fonction utilise et retourne des pointeurs non typés. On peut effectuer un appel à cette fonction à partir de code C# en utilisant Platform/Invoke avec IntPtr:

[DllImport(...)]
public extern static IntPtr NativeFunctionExample(IntPtr ptr);

Ainsi les pointeurs pourront être stockés dans des variables de type IntPtr, par exemple:

int valueExample = 7;
int* valuePtr = &valueExample;
IntPtr valueIntPtr = new IntPtr(valuePtr);

Console.WriteLine(valueIntPtr.ToString("X")); // Afficher l'adresse du pointeur

IntPtr result = NativeFunctionExample(valueIntPtr);

Autre exemple:

int[] arrayValues = { 1, 2, 3, 4 };
unsafe
{
  // fixed Permet d'extraire le pointeur et empêche au 
  // Garbage Collector de déplacer l'objet dans un scope donné
  fixed (int* arrayPtr = arrayValues)
  {
    IntPtr arrayIntPtr = new IntPtr(arrayPtr);
    Console.WriteLine(arrayIntPtr.ToString("X")); // Affichage du pointeur
  }
}

Intervalle de valeur

Sachant que la taille de IntPtr s’adapte en fonction de l’architecture d’exécution du processus, sur un processus 32 bits, IntPtr est un entier sur 4 octets Int32. Ainsi l’intervalle de valeur de IntPtr est celui de System.Int32 c’est-à-dire:

  • Int32.MinValue = -2147483648
  • Int32.MaxValue = 2147483647

Ainsi une exception System.OverflowException survient si on initialise un objet IntPtr avec une valeur supérieure à Int32.MaxValue dans un processus 32 bits:

IntPtr value1 = new IntPtr((long)Int32.MaxValue); // OK 
IntPtr value2 = new IntPtr((long)Int32.MaxValue + 1L); // ⚠ ERREUR ⚠

On peut se poser la question de l’intérêt d’utiliser des valeurs négatives pour représenter des adresses mémoires. De ce point de vue System.UIntPtr serait plus adapté.

Code MSIL

Les objets IntPtr et UIntPtr sont convertis respectivement en native int et native uint dans le code MSIL. Les types MSIL native int et native uint ne sont pas directement accessibles dans le code C#. L’arithmétique mathématique et les conversions applicables aux objets Int32 sont aussi applicables aux native ints.

Avant C# 9, seuls IntPtr et UIntPtr permettent de générer des objets native int et native uint. Bien que les opérations d’arithmétiques classiques peuvent s’appliquer aux types MSIL native int et native uint, il n’est bas possible d’effectuer ces opérations avec du code C#. IntPtr n’autorise que les additions ou soustraction avec:

Par exemple quelques opérations qui sont possibles si on manipule IntPtr:

IntPtr intPtrValue1 = new IntPtr(5);
// Utilisation de IntPtr.Add()
IntPtr result = IntPtr.Add(4); // OK

// Addition impossible
IntPtr offset = new IntPtr(4);
IntPtr addResult = intPtrValue1 + offset; // ⚠ ERREUR ⚠

// Cast possible
int castValue = (int)intPtrValue1; // OK

// Boxing possible
object boxedValue = intPtrValue1; // OK

D’un point du code MSIL, on peut voir que la type MSIL utilisé est native int Si on compile le code suivant:

IntPtr initialValue = new IntPtr(5);
IntPtr withOffset = IntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL (compile en mode Release) est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
  IL_0006:  ldc.i4.4
  // Le type MSIL utilisé est native int
  IL_0007:  call       native int [System.Runtime]System.IntPtr::Add(native int,int32)
  // Le boxing est effectué implicitement pour utiliser object.ToString()
  IL_000c:  box        [System.Runtime]System.IntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

UIntPtr

System.UIntPtr est l’équivalent non signé de System.IntPtr. De la même façon il s’agit d’un type d’entier dont l’intervalle de valeurs varie suivant l’architecture d’exécution du processus:

UIntPtr IntPtr
Processus 32 bits Minimum 0 -232/2 = -2147483648
Maximum 232-1 = 4294967296 232/2-1 = 2147483647
Processus 64 bits Minimum 0 -264/2 = -9,22 x 1018
Maximum 264-1 = 18,45 x 1018 264/2-1 = 9,22 x 1018

Etant donné que les adresses mémoires sont forcément positives, on pourrait se demander pourquoi ne pas utiliser UIntPtr plutôt que IntPtr pour stocker des adresses mémoire. En effet dans un processus 64 bits, l’intervalle de valeurs de IntPtr dépasse largement le nombre d’adresses possibles pour un processus. En revanche pour un processus 32 bits et si on ne considère que des valeurs supérieures à 0, l’intervalle de valeur de IntPtr (de 0 à 232/2-1) ne permet pas de traiter toutes les adresses mémoire possibles. En effet, un processus 32 bits peut adresser au maximum:

  • 2 GB sans ajouter IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly (voir Plateforme cible en .NET).
  • 3 GB sur un système 32 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.
  • 4 GB sur un système 64 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.

Par exemple si on exécute le code suivant dans un processus 32 bits, il se produit une exception System.OverflowException:

// 0x7FFFFFFF est la valeur hexadecimale de Int32.MaxValue
IntPtr val1 = new IntPtr(0x7FFFFFFF); // OK
// 0x80000000 est la valeur hexadecimale de Int32.MaxValue + 1
IntPtr val2 = new IntPtr(0x80000000); // Exception dans un processus 32 bits.

Dans un processus 32 bits, l’intervalle de valeur de UIntPtr comprend Int32.MaxValue + 1 donc le code précédent ne produit pas d’erreur:

UIntPtr val3 = new UIntPtr(0x80000000); // OK dans un processus 32 bits.

L’autre différence significative entre IntPtr et UIntPtr est que UIntPtr n’est pas conforme au CLS (i.e. Common Language Specification) (voir CLS-compliant). Cette caractéristique de non-conformité est partagée par tous les entiers non signés à part byte.

D’un point du vue du code MSIL, si on reprend le code précédent dans le cas de UIntPtr:

UIntPtr initialValue = new UIntPtr(5);
UIntPtr withOffset = UIntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.UIntPtr::.ctor(uint32)
  IL_0006:  ldc.i4.4
  // Le type MSIL correspondant à UIntrPtr est native uint
  IL_0007:  call       native uint [System.Runtime]System.UIntPtr::Add(native uint,
                                                                       int32)
  IL_000c:  box        [System.Runtime]System.UIntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

nint et nuint

Comme on l’a évoqué précédemment IntPtr et UIntPtr sont limités pour effectuer des opérations arithmétiques ou des comparaisons. Les nouveaux types nint et nuint disponibles à partir de C# 9.0 proposent les mêmes fonctionnalités que IntPtr et UIntPtr mais ils permettent d’effectuer des opérations arithmétiques et des comparaisons. Après compilation, les types MSIL utilisés sont les mêmes que pour IntPtr et UIntPtr:

  • nint permet de générer le type native int (comme pour IntPtr) pour stocker un entier sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits et
  • nuint permet de générer le type native uint (comme pour UIntPtr) pour stocker un entier non signé sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits.

Dans le code C#, les types équivalents sont interchangeables, par exemple si on écrit le code suivant:

IntPtr initialValue = new IntPtr(5);
nint nintValue = initialValue;
IntPtr otherValue = nintValue;

Console.WriteLine(otherValue); 

Le code MSIL généré n’effectue pas de conversion entre IntPtr et nint, c’est le même type MSIL qui est utilisé. Pour cet exemple, le code MSIL généré en mode release n’effectue pas d’affectations mis à part l’initialisation car elles sont inutiles:

.maxstack  8
IL_0000:  ldc.i4.5
IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
IL_0006:  box        [System.Runtime]System.IntPtr
IL_000b:  call       void [System.Console]System.Console::WriteLine(object)
IL_0010:  ret

Opérations arithmétiques

nint et nuint permettent d’effectuer des opérations arithmétiques en plus de l’addition et de la soustraction:

nint val1 = 4;
nint val2 = 5;

nint addResult = val1 + val2;
nint subResult = val1 - val2;
nint divResult = val1 / val2;
nint mulResult = val1 * val2; 

Des conversions implicites peuvent être effectuées:

nint result = val1 + 5;
float floatResult = val1 + 5f;
long doubleResult = val1 + 5L;

Comparaisons

nint et nuint autorisent les comparaisons:

Console.WriteLine(val1 > val2);   // False
Console.WriteLine(val1 >= val2);  // False
Console.WriteLine(val1 == val2);  // False
Console.WriteLine(val1 == val3);  // True
Console.WriteLine(val1.Equals(val3));   // True

typeof() et GetType()

typeof() et GetType() retournent IntPtr au lieu de nint:

Console.WriteLine(typeof(result)); // System.IntPtr
Console.WriteLine(val1.GetType());  // System.IntPtr

De même pour UIntPtr et nuint, typeof() et GetType() retournent UIntPtr au lieu de nuint:

nuint nuintValue = 5;
Console.WriteLine(typeof(nuintValue)); // System.UIntPtr
Console.WriteLine(nuintValue.GetType());  // System.UIntPtr

Utilisation de nint et nuint pour des index de tableau

Les types nint et nuint peuvent être utilisés pour les index d’un tableau:

int[] array = new int[] { 2, 3, 4, 5 };
nint index = 2;
nuint unsignedIndex = 3;
Console.WriteLine(table[index]);   // OK le résultat est 4
Console.WriteLine(table[unsignedIndex]);  // OK le résultat est 5

Il n’est pas possible d’utiliser nint ou nuint en tant qu’index dans le cadre de List<>:

List<int> list = new List<int>{ 2, 3, 4, 5 };
Console.WriteLine(list[index]);   // ⚠ ERREUR ⚠
Console.WriteLine(list[unsignedIndex]);  // ⚠ ERREUR ⚠

NativeIntegerAttribute

Quelque soit le type d’objet utilisé dans le code C#, le même type d’objet est utilisé dans le code MSIL:

  • native int dans le cas où on utilise IntPtr ou nint
  • native uint dans le cas où on utilise UIntPtr ou nuint

La différence est que le compilateur ajoute l’attribut System.Runtime.CompilerServices.NativeIntegerAttribute sur les objets utilisant nint ou nuint. Si ces objets utilisent IntPtr ou UIntPtr, cet attribut n’est pas ajouté. NativeIntegerAttribute est une classe utilisée seulement par le compilateur, elle ne peut être utilisée dans du code C#.

Par exemple si considère l’objet suivant:

public class NintExample
{
  public IntPtr A;
  public UIntPtr B;
  public nint C;
  public nuint D; 
}

Le code MSIL correspondant aux membres est:

.field public native int A

.field public native uint B

.field public native int C
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

.field public native uint D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

L’initialisation de l’attribut NativeIntegerAttribute se fait avec un tableau de booléens. Chaque booléen du tableau permet d’indiquer quelle partie du type référence utilise nint ou nuint

  • true indique que le type native int utilisé provient de nint ou nuint; ou
  • false pour indiquer que le type native int utilisé provient de IntPtr ou UIntPtr.

Dans le code MSIL, l’attribut est initialisé suivant les spécifications d’initialisation des custom attributes (i.e. attributs personnalisés) (cf. ECMA):
La syntaxe de cette initialisation est définie par le diagramme général suivant:

source: ECMA-335

Ainsi les éléments sont indiqués sous forme d’octet en hexadecimal en little-endian (i.e. octet de poids faible en premier):

  • Prolog est un entier sur 2 octets permetant d’indiquer qu’il s’agit d’un custom attribute, sa valeur est toujours 01 00.
  • FixedArg indique les paramètres fixes du constructeur sous forme de tableau. Cet élément respecte une syntaxe particulière, dans le cas d’un tableau de booléen, un entier sur 4 octets indique la taille du tableau. Chaque octet suivant contient un booléen: 01 pour true et 00 pour false.
  • NumNamed est un entier sur 2 octets indiquant le nombre de propriétés nommés. Dans notre cas, il n’y en a pas de propriété nommé donc la valeur est 00 00.
  • NamedArg indique les propriétés nommées suivant une syntaxe particulière. Dans notre cas, il n’y a pas de propriété nommé donc NamedArg ne contient pas de valeurs.

Par exemple, si considère l’objet suivant:

private (IntPtr, nint) D;

Le code MSIL correspond est:

.field private valuetype [System.Runtime]System.ValueTuple`2<native int,native int> D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 02 00 00 00 00 01 00 00 ) 

La valeur d’initialisation est sur 10 octets:

01 00 02 00 00 00 00 01 00 00

Le tuple contient 2 objets native ints de type IntPtr et nint. 2 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute, ainsi:

  • Le Prolog: 01 00
  • FixedArg: 02 00 00 00 00 01 avec:
    • 02 00 00 00 permettant d’indiquer le nombre d’arguments qui est 2.
    • 00 01 qui sont 2 booléens false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.

De la même façon, si on considère le tuple:

private (IntPtr, IntPtr, nint) E;

Le code MSIL correspondant est:

.field private valuetype [System.Runtime]System.ValueTuple`3<native int,native int,native int> E
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 03 00 00 00 00 00 01 00 00 ) 

3 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute

  • Le Prolog: 01 00
  • FixedArg: 03 00 00 00 00 00 01 avec:
    • 03 00 00 00 permettant d’indiquer le nombre d’arguments qui est 3.
    • 00 00 01 qui sont 3 booléens false false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.
Références

Leave a Reply