CodeGym /Cursos /C# SELF /Control de memoria a bajo nivel en C#

Control de memoria a bajo nivel en C#

C# SELF
Nivel 65 , Lección 1
Disponible

1. Introducción

En la lección anterior hablamos de cómo C# y el .NET Runtime se ocupan de la mayor parte de la gestión de memoria. Sin embargo, a veces surge la necesidad o el deseo de obtener un control más a bajo nivel sobre la memoria, especialmente para optimizar el rendimiento o para interoperar con código no gestionado (por ejemplo, APIs del sistema escritas en C++). C# ofrece varias funcionalidades para estos fines que, aunque potentes, deben usarse con cuidado.

Cuando hablamos de "control de memoria a bajo nivel" en C#, normalmente nos referimos a:

  • Trabajo directo con punteros: Acceso a memoria por direcciones, como en C/C++.
  • Asignación de memoria fuera del heap gestionado: Uso de memoria que el garbage collector no rastrea.
  • Gestión de recursos en la frontera gestionado/no gestionado: Interoperación eficiente con bibliotecas nativas.

En C# el código normal no tiene permitido trabajar directamente con direcciones de memoria (punteros) para garantizar seguridad y estabilidad. Sin embargo, para operaciones a bajo nivel se pueden usar punteros dentro del contexto unsafe.

¿Qué es el contexto unsafe?

Un bloque o método marcado con la palabra clave unsafe te permite usar la sintaxis de punteros y realizar operaciones que el CLR no comprueba por seguridad. El código dentro de un contexto unsafe no es más seguro que el código nativo. Para usar código unsafe el proyecto debe compilarse con la opción /unsafe.

Ejemplo: Declaración de un método y bloque unsafe


public unsafe class UnsafeExamples
{
    // Método unsafe
    public static unsafe void ManipulatePointer(int* ptr)
    {
        Console.WriteLine($"Valor apuntado: {*ptr}");
        *ptr = 200; // Modificamos el valor en la dirección
    }

    public static void DemoUnsafeBlock()
    {
        int value = 100;
        unsafe // Bloque unsafe dentro de un método normal
        {
            int* ptr = &value; // Obtenemos la dirección de la variable
            ManipulatePointer(ptr); // Pasamos el puntero a un método unsafe
            Console.WriteLine($"Nuevo valor: {value}"); // Salida: Nuevo valor: 200
        }
    }
}

Tipos de punteros

En C# puedes declarar punteros a tipos por valor (int*, bool*, MyStruct*) y a void (void* para un puntero genérico). No se pueden declarar punteros a tipos por referencia directamente, pero puedes obtener un puntero a un campo de un tipo por referencia si ese campo es un tipo por valor.

Ejemplo: Diferentes tipos de punteros


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

    int[] numbers = { 1, 2, 3 };
    fixed (int* arrPtr = numbers) // 'fixed' fija el objeto en memoria para que GC no lo mueva
    {
        Console.WriteLine($"Primer elemento del array: {arrPtr[0]}");
        Console.WriteLine($"Segundo elemento del array: {*(arrPtr + 1)}");
    }
}

Operadores de punteros

  • & (dirección): Obtiene la dirección de una variable.
  • * (desreferenciación): Obtiene el valor en la dirección.
  • -> (acceso a miembro): Acceso a miembro de una estructura/clase a través de un puntero a ella (solo para tipos por valor).
  • [] (indexación): Acceso a elementos de un array mediante un puntero (como en C/C++).

2. Bloques de memoria fijos y asignados

Cuando trabajas con punteros, es importante que el objeto al que apunta el puntero no sea movido por el garbage collector mientras trabajas. Para eso se usan las palabras clave fixed y stackalloc.

Operador fixed

El operador fixed "fija" una variable de tipo por referencia en memoria, evitando que sea movida por el garbage collector durante la ejecución del bloque fixed. Esto es crítico cuando pasas punteros a objetos gestionados a código no gestionado o trabajas con ellos directamente.

Ejemplo: Uso de fixed con arrays


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

        unsafe
        {
            // Fijamos el array en memoria, obtenemos un puntero a su primer elemento
            fixed (byte* p = buffer)
            {
                // Ahora podemos trabajar con p sabiendo que el array no será movido
                Console.WriteLine($"Valor del primer byte: {*p}");
                Console.WriteLine($"Valor del segundo byte: {*(p + 1)}");
                // Se puede pasar 'p' a una función nativa que espere un puntero
            } // Al salir del bloque 'fixed', el array puede ser movido por el GC
        }
    }
}

fixed también se puede usar con campos de estructuras o con strings.

Asignación en la pila: stackalloc

stackalloc permite asignar un bloque de memoria en la pila. Es muy rápido, pero la memoria solo está disponible hasta que finaliza el método actual. La memoria asignada no es gestionada por el garbage collector.

  • Ventajas: Allocación muy rápida, sin sobrecarga del GC, liberación determinista de la memoria.
  • Desventajas: Tamaño limitado (la pila es relativamente pequeña), riesgo de desbordamiento de pila (StackOverflowException) si se asigna demasiado.
  • Uso: Ideal para buffers pequeños y de corta duración.

Ejemplo: Uso de stackalloc


public unsafe class StackAllocExample
{
    public static void ProcessStackAlloc()
    {
        unsafe
        {
            // Asignamos 10 int en la pila
            int* numbers = stackalloc int[10];

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

            Console.WriteLine($"Valor del primer elemento: {numbers[0]}");
            Console.WriteLine($"Valor del quinto elemento: {numbers[4]}");
        } // La memoria se libera automáticamente al salir de este método.
    }
}

El operador stackalloc se puede usar con Span<T>, lo que hace que trabajar con esa memoria sea más seguro, ya que Span<T> es un tipo gestionado (struct) que proporciona acceso seguro a bloques contiguos de memoria. Más sobre Span en la siguiente lección.

Ejemplo: stackalloc con Span<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // Asignación en la pila, pero trabajamos a través de un Span
  
    seguro Span
   
     buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Primer elemento del Span: {buffer[0]}"); Console.WriteLine($"Último elemento del Span: {buffer[9]}"); // Se puede pasar Span
    
      a métodos que lo acepten PrintSpan(buffer); } public static void PrintSpan(Span
     
       s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } } 
     
    
   
  

Este es un enfoque híbrido: asignación en la pila (a bajo nivel), pero acceso seguro mediante Span<T> (a alto nivel).

3. Interoperación con código no gestionado (P/Invoke)

Platform Invoke (P/Invoke) es el mecanismo que permite al código C# llamar funciones de librerías no gestionadas (por ejemplo, DLLs de Windows API o archivos .so en Linux). Esto es un aspecto fundamental del control de memoria a bajo nivel, porque a menudo pasarás punteros a datos entre el mundo gestionado y el no gestionado.

Declaración de funciones externas

Usas el atributo DllImport para declarar métodos estáticos extern que representan funciones nativas.

Ejemplo 3.1: Llamada a una función de la API de Windows


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // Importamos la función nativa MessageBox desde 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()
    {
        // Llamamos a la función nativa
        MessageBox(IntPtr.Zero, "Hello from C#!", "Window title", 0);
    }
}

Marshalling de datos

Al llamar funciones nativas, el .NET Runtime realiza el marshalling: la conversión de tipos de datos entre los formatos gestionados y no gestionados. Por ejemplo, un string en C# puede marshalarse como char* o wchar_t* en C++.

Para marshalling más complejo puedes usar el atributo MarshalAs y la clase Marshal.

Ejemplo: Marshalling de estructuras


using System.Runtime.InteropServices;

// Esta estructura será marshalada a una estructura nativa
[StructLayout(LayoutKind.Sequential)] // Indica que los campos deben estar en orden secuencial
public struct NativePoint
{
    public int X;
    public int Y;
}

public class StructMarshalExample
{
    [DllImport("your_native_lib.dll")] // Ejemplo: función en una librería nativa
    public static extern void ProcessPoint(NativePoint point);

    [DllImport("your_native_lib.dll")]
    public static extern void FillPoint(out NativePoint point); // Recibe un puntero a la estructura

    public static void DemoStructMarshal()
    {
        NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
        ProcessPoint(myPoint); // La estructura será marshalada por valor (copia)

        NativePoint resultPoint;
        FillPoint(out resultPoint); // La estructura será rellenada por la función nativa
        Console.WriteLine($"Point from native: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle y fijado de objetos

GCHandle es una struct que permite obtener un "handle" a un objeto en el heap gestionado y, si es necesario, fijarlo temporalmente para evitar que sea movido o recolectado por el GC. Esto es útil cuando necesitas pasar un puntero estable a un objeto gestionado a código no gestionado.

Ejemplo: Fijar un objeto 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); // Fijamos el array en memoria
        try
        {
            IntPtr pointer = handle.AddrOfPinnedObject(); // Obtenemos el puntero al objeto fijado
            Console.WriteLine($"Dirección del array fijado: {pointer:X}");

            // Ahora 'pointer' se puede pasar de forma segura a una función nativa.
            // La función nativa puede operar directamente sobre esa memoria.

            Marshal.WriteByte(pointer, 0, 255); // Modificamos el primer byte vía puntero
            Console.WriteLine($"Primer byte del array: {data[0]}"); // Salida: 255
        }
        finally
        {
            if (handle.IsAllocated)
            {
                handle.Free(); // Liberamos el handle, permitiendo que el GC gestione el objeto de nuevo
            }
        }
    }
}

Un análisis detallado de P/Invoke y GCHandle está fuera del alcance de este curso, pero pueden ser muy útiles si quieres llamar funciones de librerías de Windows directamente. Al menos ya sabes por dónde empezar a investigar.

2
Tarea
C# SELF, nivel 65, lección 1
Bloqueada
Asignación de memoria en la pila con stackalloc
Asignación de memoria en la pila con stackalloc
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION