CodeGym /Corsi /C# SELF /Gestione della memoria a basso livello in C#

Gestione della memoria a basso livello in C#

C# SELF
Livello 65 , Lezione 1
Disponibile

1. Introduzione

Nella lezione precedente abbiamo parlato di come C# e il .NET Runtime si occupino della maggior parte delle preoccupazioni relative alla gestione della memoria. Tuttavia, a volte è necessario o desiderabile ottenere un controllo più a basso livello sulla memoria, specialmente per ottimizzare le performance o per interoperare con codice non gestito (per esempio API di sistema scritte in C++). C# offre una serie di funzionalità per questi scopi che, pur essendo potenti, richiedono un uso attento.

Quando parliamo di "gestione della memoria a basso livello" in C#, di solito intendiamo:

  • Lavorare direttamente con i puntatori: accesso alla memoria tramite indirizzi, come in C/C++.
  • Allocazione della memoria fuori dall'heap gestito dal GC: usare memoria che il garbage collector non monitora.
  • Gestione delle risorse al confine tra codice gestito/non gestito: interoperare efficientemente con librerie native.

In C# il codice managed normale non è autorizzato a lavorare direttamente con indirizzi di memoria (puntatori) per motivi di sicurezza e stabilità. Tuttavia, per operazioni a basso livello è possibile usare i puntatori all'interno di un contesto unsafe.

Che cos'è un contesto unsafe?

Un blocco o un metodo marcato con la keyword unsafe ti permette di usare la sintassi dei puntatori ed eseguire operazioni che la CLR non verifica per la sicurezza. Il codice in un contesto unsafe non è meno sicuro del codice nativo. Per usare codice unsafe il progetto deve essere compilato con l'opzione /unsafe.

Esempio: dichiarazione di un metodo e di un blocco unsafe


public unsafe class UnsafeExamples
{
    // Metodo Unsafe
    public static unsafe void ManipulatePointer(int* ptr)
    {
        Console.WriteLine($"Znacheniye po ukazatelyu: {*ptr}");
        *ptr = 200; // Modifichiamo il valore all'indirizzo
    }

    public static void DemoUnsafeBlock()
    {
        int value = 100;
        unsafe // Blocco Unsafe dentro un metodo normale
        {
            int* ptr = &value; // Otteniamo l'indirizzo della variabile
            ManipulatePointer(ptr); // Passiamo il puntatore al metodo unsafe
            Console.WriteLine($"Novoye znacheniye: {value}"); // Output: Novoye znacheniye: 200
        }
    }
}

Tipi di puntatori

In C# è possibile dichiarare puntatori a tipi value (int*, bool*, MyStruct*) e a void (void* per un puntatore generico). Non è possibile dichiarare puntatori direttamente a tipi reference, ma si può ottenere un puntatore a un campo di un tipo reference se quel campo è un type value.

Esempio: diversi tipi di puntatori


unsafe
{
    double d = 123.45;
    double* dPtr = &d; // Puntatore a double

    int[] numbers = { 1, 2, 3 };
    fixed (int* arrPtr = numbers) // 'fixed' fissa l'oggetto in memoria in modo che il GC non lo sposti
    {
        Console.WriteLine($"Pervyy element massiva: {arrPtr[0]}");
        Console.WriteLine($"Vtoroy element massiva: {*(arrPtr + 1)}");
    }
}

Operatori dei puntatori

  • & (address): ottiene l'indirizzo di una variabile.
  • * (dereference): ottiene il valore all'indirizzo.
  • -> (accesso al membro): accede a un membro di una struct/di una classe tramite puntatore a essa (solo per tipi value).
  • [] (indicizzazione): accesso agli elementi di un array tramite puntatore (come in C/C++).

2. Blocchi di memoria fissi e allocati

Quando lavori con i puntatori è importante che l'oggetto a cui punta il puntatore non venga spostato dal garbage collector durante l'uso. Per questo si usano le keyword fixed e stackalloc.

Operatore fixed

L'operatore fixed "fissa" una variabile di tipo reference in memoria, prevenendone lo spostamento da parte del GC per la durata del blocco fixed. Questo è critico quando passi puntatori a oggetti gestiti in codice non gestito o ci lavori direttamente.

Esempio: uso di fixed con array


public unsafe class FixedExample
{
    public static void ProcessFixedArray()
    {
        byte[] buffer = new byte[10];
        // Riempiamo il buffer
        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = (byte)(i + 1);
        }

        unsafe
        {
            // Fissiamo l'array in memoria, otteniamo il puntatore al suo primo elemento
            fixed (byte* p = buffer)
            {
                // Ora possiamo lavorare con p in modo sicuro, sapendo che l'array non verrà spostato
                Console.WriteLine($"Znacheniye po pervomu baytu: {*p}");
                Console.WriteLine($"Znacheniye po vtoromu baytu: {*(p + 1)}");
                // Possiamo passare 'p' a una funzione nativa che si aspetta un puntatore
            } // Dopo l'uscita dal blocco 'fixed', l'array può essere spostato dal GC
        }
    }
}

fixed si può usare anche con campi di struct o con stringhe.

Allocazione sullo stack: stackalloc

stackalloc permette di allocare un blocco di memoria sullo stack. È molto veloce, ma la memoria è disponibile solo fino al termine del metodo corrente. La memoria allocata non è gestita dal garbage collector.

  • Vantaggi: allocazione molto veloce, nessun overhead del GC, rilascio deterministico della memoria.
  • Svantaggi: dimensione limitata (lo stack è relativamente piccolo), rischio di overflow dello stack (StackOverflowException) se si allocano blocchi troppo grandi.
  • Uso: ideale per buffer piccoli e di breve durata.

Esempio: uso di stackalloc


public unsafe class StackAllocExample
{
    public static void ProcessStackAlloc()
    {
        unsafe
        {
            // Allochiamo 10 int sullo stack
            int* numbers = stackalloc int[10];

            for (int i = 0; i < 10; i++)
            {
                numbers[i] = i * 10;
            }

            Console.WriteLine($"Znacheniye pervogo elementa: {numbers[0]}");
            Console.WriteLine($"Znacheniye pyatogo elementa: {numbers[4]}");
        } // La memoria viene liberata automaticamente all'uscita da questo metodo.
    }
}

L'operatore stackalloc si può usare con Span<T>, il che rende il lavoro con quella memoria più sicuro, perché Span<T> è un tipo gestito (struct) che fornisce accesso sicuro a blocchi contigui di memoria. Maggiori dettagli su Span nella lezione successiva.

Esempio: stackalloc con Span<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // Allocazione sullo stack, ma lavoriamo tramite lo Span
  
    sicuro Span
   
     buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Pervyy element Span: {buffer[0]}"); Console.WriteLine($"Posledniy element Span: {buffer[9]}"); // Possiamo passare Span
    
      a metodi che lo accettano PrintSpan(buffer); } public static void PrintSpan(Span
     
       s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } } 
     
    
   
  

Questo è un approccio ibrido: allocazione sullo stack (basso livello), ma accesso sicuro tramite Span<T> (alto livello).

3. Interoperabilità con codice non gestito (P/Invoke)

Platform Invoke (P/Invoke) è il meccanismo che permette al codice C# di chiamare funzioni da librerie non gestite (per esempio DLL di Windows API o file .so su Linux). Questo è un aspetto fondamentale della gestione della memoria a basso livello, perché spesso passerai puntatori ai dati tra il mondo gestito e quello non gestito.

Dichiarare funzioni esterne

Si usa l'attributo DllImport per dichiarare metodi statici extern che corrispondono a funzioni native.

Esempio 3.1: chiamare una funzione dell'API Windows


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // Importiamo la funzione nativa MessageBox da 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()
    {
        // Chiamiamo la funzione nativa
        MessageBox(IntPtr.Zero, "Privet iz C#!", "Zagolovok okna", 0);
    }
}

Marshaling dei dati

Quando chiami funzioni native il .NET Runtime esegue il marshaling — la conversione dei tipi di dato tra formati gestiti e non gestiti. Per esempio, una string in C# può essere marshalata come char* o wchar_t* in C++.

Per marshaling più complesso puoi usare l'attributo MarshalAs e la classe Marshal.

Esempio: marshaling di struct


using System.Runtime.InteropServices;

// Questa struct verrà marshalata in una struct nativa
[StructLayout(LayoutKind.Sequential)] // Indica che i campi devono essere disposti in sequenza
public struct NativePoint
{
    public int X;
    public int Y;
}

public class StructMarshalExample
{
    [DllImport("your_native_lib.dll")] // Esempio: funzione nella libreria nativa
    public static extern void ProcessPoint(NativePoint point);

    [DllImport("your_native_lib.dll")]
    public static extern void FillPoint(out NativePoint point); // Riceve un puntatore alla struct

    public static void DemoStructMarshal()
    {
        NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
        ProcessPoint(myPoint); // La struct verrà marshalata per valore (copia)

        NativePoint resultPoint;
        FillPoint(out resultPoint); // La struct verrà riempita dalla funzione nativa
        Console.WriteLine($"Point from native: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle e pinning degli oggetti

GCHandle è una struct che permette di ottenere un "handle" a un oggetto nell'heap gestito e, se necessario, di pinnarlo temporaneamente in modo da evitare che venga spostato o raccolto dal GC. Questo è utile quando devi passare un puntatore stabile a un oggetto gestito al codice non gestito.

Esempio: pinning di un oggetto con GCHandle


using System.Runtime.InteropServices;

public class GCHandleExample
{
    public static void PinObject()
    {
        byte[] data = new byte[100];
        GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // Pinnamo l'array in memoria
        try
        {
            IntPtr pointer = handle.AddrOfPinnedObject(); // Otteniamo il puntatore all'oggetto pinnato
            Console.WriteLine($"Adres zakreplennogo massiva: {pointer:X}");

            // Ora 'pointer' può essere passato in modo sicuro a una funzione nativa.
            // La funzione nativa può lavorare direttamente con questa memoria.

            Marshal.WriteByte(pointer, 0, 255); // Modifichiamo il primo byte tramite il puntatore
            Console.WriteLine($"Pervyy bayt massiva: {data[0]}"); // Output: 255
        }
        finally
        {
            if (handle.IsAllocated)
            {
                handle.Free(); // Rilasciamo l'handle, permettendo al GC di gestire di nuovo l'oggetto
            }
        }
    }
}

Un'analisi dettagliata di P/Invoke e GCHandle va oltre il campo di questo corso, però possono essere molto utili se vuoi chiamare direttamente funzioni delle librerie di sistema. Almeno ora sai dove approfondire.

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION