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.
IntPtr et UIntPtr
Adressage de la mémoire
Utilisation de IntPtr et UIntPtr
Intervalle de valeur
Code MSIL
UIntPtr
nint et nuint
Opérations arithmétiques
Comparaisons
typeof() et GetType()
Utilisation de nint et nuint pour
des index de tableau
NativeIntegerAttribute
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 typenative int
(comme pourIntPtr
) pour stocker un entier sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits etnuint
permet de générer le typenative uint
(comme pourUIntPtr
) 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 utiliseIntPtr
ounint
native uint
dans le cas où on utiliseUIntPtr
ounuint
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 denint
ounuint
; oufalse
pour indiquer que le type native int utilisé provient deIntPtr
ouUIntPtr
.
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:

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 toujours01 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
pourtrue
et00
pourfalse
.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 est00 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é doncNamedArg
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éensfalse 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éensfalse false true
.
NumNamed
:00 00
car pas d’arguments nommés.NamedArg
qui ne contient rien.
- Introducing C# 9: Native-sized integers: https://anthonygiretti.com/2020/08/19/introducing-c-9-native-sized-integers/
- Native-sized integers: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/native-integers
- Native Integer Metadata: https://github.com/dotnet/roslyn/blob/main/docs/features/NativeIntegerAttribute.md
- Standards ECMA: https://www.ecma-international.org/publications-and-standards/standards/
- What does the IL “.custom instance void [attribute] = (…)” mean?: https://stackoverflow.com/questions/54253495/what-dœs-the-il-custom-instance-void-attribute-mean
- Add NativeIntegerAttribute: https://github.com/dotnet/runtime/issues/38683
- System.IntPtr and System.UIntPtr Improvements: https://github.com/dotnet/runtime/issues/21943
- Proposal: Allow native int / native uint in c#: https://github.com/dotnet/roslyn/issues/2788
- Champion “Native-Sized Number Types” (VS 16.8, .NET 5): https://github.com/dotnet/csharplang/issues/435
- Difference between memory of int type in C++ and C#: https://stackoverflow.com/questions/6926423/difference-between-memory-of-int-type-in-c-and-c-sharp
- What does the C++ standard state the size of int, long type to be?: https://stackoverflow.com/questions/589575/what-dœs-the-c-standard-state-the-size-of-int-long-type-to-be
- What is the difference between an int and a long in C++?: https://stackoverflow.com/questions/271076/what-is-the-difference-between-an-int-and-a-long-in-c/271132#271132
- C++/C# interoperability: https://mark-borg.github.io/blog/2017/interop/
- IntPtr Structure: https://docs.microsoft.com/fr-fr/dotnet/api/system.intptr?view=net-5.0
- Are nint and System.IntPtr interchangeable?: https://www.reddit.com/r/csharp/comments/pd69hg/are_nint_and_systemintptr_interchangeable/
- Just what is an IntPtr exactly?: https://stackoverflow.com/questions/1148177/just-what-is-an-intptr-exactly
- Cannot take the address of, get the size of, or declare a pointer to a managed type (‘T’): https://stackoverflow.com/questions/42154908/cannot-take-the-address-of-get-the-size-of-or-declare-a-pointer-to-a-managed-t
- How Many Memory Addresses Can the RAM in My Computer Hold?: https://www.howtogeek.com/163755/how-many-memory-addresses-can-the-ram-in-my-computer-hold/
- how much memory can be accessed by a 32 bit machine?: https://stackoverflow.com/questions/8869563/how-much-memory-can-be-accessed-by-a-32-bit-machine
- Difference between word addressable and byte addressable: https://stackoverflow.com/questions/2724449/difference-between-word-addressable-and-byte-addressable
- Just what is an IntPtr exactly?: https://stackoverflow.com/questions/1148177/just-what-is-an-intptr-exactly
- IntPtr vs UIntPtr: https://stackoverflow.com/questions/1320296/intptr-vs-uintptr
- How does PAE (Physical Address Extension) enable an address space larger than 4GB?: https://stackoverflow.com/questions/8373944/how-dœs-pae-physical-address-extension-enable-an-address-space-larger-than-4g