1. Introduction
Dans la leçon précédente on a parlé de la façon dont C# et le .NET Runtime prennent en charge la plupart des soucis de gestion de la mémoire. Cependant, parfois il est nécessaire ou souhaitable d'obtenir un contrôle plus bas niveau sur la mémoire, notamment pour optimiser les performances ou interagir avec du code non managé (par exemple des API système écrites en C++). C# fournit plusieurs fonctionnalités pour ça, qui, bien que puissantes, exigent une utilisation prudente.
Quand on parle de "gestion bas niveau de la mémoire" en C#, on entend généralement :
- Travail direct avec des pointeurs : accès à la mémoire par adresse, comme en C/C++.
- Allocation hors du tas GC : utilisation de mémoire que le garbage collector ne suit pas.
- Gestion des ressources à la frontière managé/non-managé : interaction efficace avec des bibliothèques natives.
En C#, le code habituel n'est pas autorisé à manipuler directement des adresses mémoire (pointeurs) pour des raisons de sécurité et de stabilité. Toutefois, pour des opérations bas niveau on peut utiliser des pointeurs à l'intérieur d'un contexte unsafe.
Qu'est-ce qu'un contexte unsafe ?
Un bloc ou une méthode marquée par le mot-clé unsafe vous permet d'utiliser la syntaxe des pointeurs et d'exécuter des opérations qui ne sont pas vérifiées par le CLR pour la sécurité. Le code dans un contexte unsafe n'est pas plus sûr que du code natif. Pour utiliser du code unsafe, le projet doit être compilé avec l'option /unsafe.
Exemple : déclaration d'une méthode et d'un bloc unsafe
public unsafe class UnsafeExamples
{
// Méthode unsafe
public static unsafe void ManipulatePointer(int* ptr)
{
Console.WriteLine($"Valeur pointée : {*ptr}");
*ptr = 200; // On modifie la valeur à l'adresse
}
public static void DemoUnsafeBlock()
{
int value = 100;
unsafe // Bloc unsafe à l'intérieur d'une méthode normale
{
int* ptr = &value; // On obtient l'adresse de la variable
ManipulatePointer(ptr); // On passe le pointeur à la méthode unsafe
Console.WriteLine($"Nouvelle valeur : {value}"); // Sortie : Nouvelle valeur : 200
}
}
}
Types de pointeurs
En C# on peut déclarer des pointeurs vers des types valeur (int*, bool*, MyStruct*) et vers void (void* pour un pointeur générique). On ne peut pas déclarer directement des pointeurs sur des types référence, mais on peut obtenir un pointeur vers un champ d'un type référence si ce champ est un type valeur.
Exemple : différents types de pointeurs
unsafe
{
double d = 123.45;
double* dPtr = &d; // Pointeur vers double
int[] numbers = { 1, 2, 3 };
fixed (int* arrPtr = numbers) // 'fixed' épingle l'objet en mémoire pour que le GC ne le déplace pas
{
Console.WriteLine($"Premier élément du tableau : {arrPtr[0]}");
Console.WriteLine($"Deuxième élément du tableau : {*(arrPtr + 1)}");
}
}
Opérateurs de pointeurs
- & (adresse) : obtient l'adresse d'une variable.
- * (déréférencement) : obtient la valeur à l'adresse.
- -> (accès aux membres) : accès aux membres d'une structure via un pointeur sur celle-ci (seulement pour les types valeur).
- [] (indexation) : accès aux éléments d'un tableau via un pointeur (comme en C/C++).
2. Blocs mémoire épinglés et alloués
Quand vous travaillez avec des pointeurs, il est important que l'objet pointé ne soit pas déplacé par le garbage collector pendant que vous l'utilisez. Pour ça on utilise les mots-clés fixed et stackalloc.
L'opérateur fixed
L'opérateur fixed "épinglé" une variable de type référence en mémoire, empêchant son déplacement par le garbage collector pendant l'exécution du bloc fixed. C'est critique quand vous passez des pointeurs vers des objets managés à du code non-managé ou que vous travaillez avec eux directement.
Exemple : utilisation de fixed avec des tableaux
public unsafe class FixedExample
{
public static void ProcessFixedArray()
{
byte[] buffer = new byte[10];
// On remplit le buffer
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)(i + 1);
}
unsafe
{
// On épingle le tableau en mémoire, on obtient un pointeur vers son premier élément
fixed (byte* p = buffer)
{
// Maintenant on peut travailler avec p en sachant que le tableau ne sera pas déplacé
Console.WriteLine($"Valeur du premier octet : {*p}");
Console.WriteLine($"Valeur du deuxième octet : {*(p + 1)}");
// On peut passer 'p' à une fonction native qui attend un pointeur
} // À la sortie du bloc 'fixed', le tableau peut être déplacé par le GC
}
}
}
fixed peut aussi être utilisé avec les champs de structures ou les chaînes (string).
Allocation sur la pile : stackalloc
stackalloc permet d'allouer un bloc de mémoire sur la pile. C'est très rapide, mais la mémoire n'est disponible que jusqu'à la fin de la méthode courante. La mémoire allouée n'est pas gérée par le garbage collector.
- Avantages : allocation très rapide, pas de coût GC, libération déterministe de la mémoire.
- Inconvénients : taille limitée (la pile est relativement petite), risque de dépassement de pile (StackOverflowException) si on alloue trop.
- Usage : idéal pour des buffers petits et de courte durée de vie.
Exemple : utilisation de stackalloc
public unsafe class StackAllocExample
{
public static void ProcessStackAlloc()
{
unsafe
{
// On alloue 10 int sur la pile
int* numbers = stackalloc int[10];
for (int i = 0; i < 10; i++)
{
numbers[i] = i * 10;
}
Console.WriteLine($"Valeur du premier élément : {numbers[0]}");
Console.WriteLine($"Valeur du cinquième élément : {numbers[4]}");
} // La mémoire est automatiquement libérée à la sortie de cette méthode.
}
}
L'opérateur stackalloc peut être utilisé avec Span<T>, ce qui rend le travail avec cette mémoire plus sûr, car Span<T> est un type managé (structure) qui fournit un accès sûr à des blocs contigus de mémoire. Plus de détails sur Span dans la leçon suivante.
Exemple : stackalloc avec Span<T>
using System;
public class StackAllocWithSpan
{
public static void DemoSpanStackAlloc()
{
// Allocation sur la pile, mais on travaille via le Span
sécurisé Span
buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Premier élément du Span : {buffer[0]}"); Console.WriteLine($"Dernier élément du Span : {buffer[9]}"); // On peut passer Span
aux méthodes qui l'acceptent PrintSpan(buffer); } public static void PrintSpan(Span
s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } }
C'est une approche hybride : allocation sur la pile (bas niveau), mais accès sûr via Span<T> (haut niveau).
3. Interaction avec du code non-managé (P/Invoke)
Platform Invoke (P/Invoke) est le mécanisme qui permet au code C# d'appeler des fonctions depuis des bibliothèques non managées (par exemple des DLL Windows ou des fichiers .so sur Linux). C'est un aspect fondamental de la gestion bas niveau de la mémoire, car on passe souvent des pointeurs entre le monde managé et le monde non-managé.
Déclaration de fonctions externes
On utilise l'attribut DllImport pour déclarer des méthodes extern statiques qui correspondent aux fonctions natives.
Exemple 3.1 : Appel d'une fonction Windows API
using System.Runtime.InteropServices;
public class PInvokeExample
{
// On importe la fonction native MessageBox depuis user32.dll
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);
public static void ShowMessageBox()
{
// On appelle la fonction native
MessageBox(IntPtr.Zero, "Hello from C#!", "Window title", 0);
}
}
Marshaling des données
Quand on appelle des fonctions natives, le .NET Runtime effectue du marshaling — conversion des types de données entre les formats managés et non-managés. Par exemple, une string en C# peut être marshaled en char* ou wchar_t* en C++.
Pour des scénarios de marshaling plus complexes on peut utiliser l'attribut MarshalAs et la classe Marshal.
Exemple : marshaling de structures
using System.Runtime.InteropServices;
// Cette structure sera marshaled en une structure native
[StructLayout(LayoutKind.Sequential)] // Indique que les champs doivent être organisés séquentiellement
public struct NativePoint
{
public int X;
public int Y;
}
public class StructMarshalExample
{
[DllImport("your_native_lib.dll")] // Exemple : fonction dans une bibliothèque native
public static extern void ProcessPoint(NativePoint point);
[DllImport("your_native_lib.dll")]
public static extern void FillPoint(out NativePoint point); // Reçoit un pointeur vers la structure
public static void DemoStructMarshal()
{
NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
ProcessPoint(myPoint); // La structure sera marshaled par valeur (copie)
NativePoint resultPoint;
FillPoint(out resultPoint); // La structure sera remplie par la fonction native
Console.WriteLine($"Point depuis le natif : ({resultPoint.X}, {resultPoint.Y})");
}
}
4. GCHandle et épinglage d'objets
GCHandle est une structure qui permet d'obtenir un "handle" vers un objet dans le tas managé et, si nécessaire, de l'épingler temporairement pour empêcher son déplacement ou sa collecte par le GC. C'est utile quand vous devez fournir un pointeur stable sur un objet managé à du code non-managé.
Exemple : épingler un objet avec GCHandle
using System.Runtime.InteropServices;
public class GCHandleExample
{
public static void PinObject()
{
byte[] data = new byte[100];
GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // On épingle le tableau en mémoire
try
{
IntPtr pointer = handle.AddrOfPinnedObject(); // On obtient le pointeur vers l'objet épinglé
Console.WriteLine($"Adresse du tableau épinglé : {pointer:X}");
// Maintenant 'pointer' peut être passé en toute sécurité à une fonction native.
// La fonction native peut manipuler cette mémoire directement.
Marshal.WriteByte(pointer, 0, 255); // On modifie le premier octet via le pointeur
Console.WriteLine($"Premier octet du tableau : {data[0]}"); // Sortie : 255
}
finally
{
if (handle.IsAllocated)
{
handle.Free(); // On libère le handle, on permet au GC de reprendre la gestion de l'objet
}
}
}
}
Un examen détaillé de P/Invoke et de GCHandle dépasse le cadre de ce cours, mais ils peuvent être très utiles si vous voulez appeler des fonctions de bibliothèques Windows directement. Au minimum, vous savez maintenant où creuser.
GO TO FULL VERSION