CodeGym /Cursos /C# SELF /Controle de memória de baixo nível em C#

Controle de memória de baixo nível em C#

C# SELF
Nível 65 , Lição 1
Disponível

1. Introdução

Na palestra anterior a gente falou sobre como C# e o .NET Runtime cuidam da maior parte da gestão de memória. Porém, às vezes é necessário ou desejável ter controle mais de baixo nível sobre a memória, especialmente para otimizar performance ou interagir com código unmanaged (por exemplo, APIs do sistema escritas em C++). C# fornece um conjunto de recursos para isso que, embora poderosos, exigem cuidado ao usar.

Quando a gente fala de "controle de memória de baixo nível" em C#, normalmente queremos dizer:

  • Trabalhar diretamente com ponteiros: Acesso à memória por endereço, como em C/C++.
  • Alocar memória fora do heap do GC: Usar memória que o garbage collector não rastreia.
  • Gerenciar recursos na fronteira managed/unmanaged: Interação eficiente com bibliotecas nativas.

Em C# código normal não tem permissão para trabalhar diretamente com endereços de memória (ponteiros) por segurança e estabilidade. Contudo, para operações de baixo nível é possível usar ponteiros dentro de um contexto unsafe.

O que é um contexto unsafe?

Um bloco ou método marcado com a palavra-chave unsafe permite usar sintaxe de ponteiros e executar operações que o CLR não verifica quanto à segurança. Código em contexto unsafe não é menos seguro do que código nativo. Para usar código unsafe, o projeto precisa ser compilado com a opção /unsafe.

Exemplo: Declaração de método e bloco unsafe


public unsafe class UnsafeExamples
{
    // Método Unsafe
    public static unsafe void ManipulatePointer(int* ptr)
    {
        Console.WriteLine($"Valor pelo ponteiro: {*ptr}");
        *ptr = 200; // Mudamos o valor no endereço
    }

    public static void DemoUnsafeBlock()
    {
        int value = 100;
        unsafe // Bloco unsafe dentro de método normal
        {
            int* ptr = &value; // Pegamos o endereço da variável
            ManipulatePointer(ptr); // Passamos o ponteiro pro método unsafe
            Console.WriteLine($"Novo valor: {value}"); // Saída: Novo valor: 200
        }
    }
}

Tipos de ponteiros

Em C# dá pra declarar ponteiros para tipos value (int*, bool*, MyStruct*) e para void (void* como ponteiro genérico). Não dá pra declarar ponteiros diretamente para tipos reference, mas dá pra obter ponteiro para campo de um reference type se ele for um value.

Exemplo: Diferentes tipos de ponteiros


unsafe
{
    double d = 123.45;
    double* dPtr = &d; // Ponteiro para double

    int[] numbers = { 1, 2, 3 };
    fixed (int* arrPtr = numbers) // 'fixed' fixa o objeto na memória para o GC não movê-lo
    {
        Console.WriteLine($"Primeiro elemento do array: {arrPtr[0]}");
        Console.WriteLine($"Segundo elemento do array: {*(arrPtr + 1)}");
    }
}

Operadores de ponteiro

  • & (endereço): Obtém o endereço da variável.
  • * (desreferência): Obtém o valor no endereço.
  • -> (acesso a membro): Acessa membro de struct/classe via ponteiro pra ela (só para value types).
  • [] (indexação): Acessa elementos de array via ponteiro (como em C/C++).

2. Blocos de memória fixos e alocados

Quando você trabalha com ponteiros, é importante que o objeto apontado não seja movido pelo garbage collector enquanto você estiver usando. Para isso existem as palavras-chave fixed e stackalloc.

Operador fixed

O operador fixed "fixa" uma variável de tipo reference na memória, impedindo que o GC a mova durante a execução do bloco fixed. Isso é crítico quando você passa ponteiros para objetos gerenciados para código unmanaged ou trabalha com eles diretamente.

Exemplo: Usando fixed com arrays


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

        unsafe
        {
            // Fixamos o array na memória, pegamos o ponteiro pro primeiro elemento
            fixed (byte* p = buffer)
            {
                // Agora podemos trabalhar com p sabendo que o array não será movido
                Console.WriteLine($"Valor no primeiro byte: {*p}");
                Console.WriteLine($"Valor no segundo byte: {*(p + 1)}");
                // Dá pra passar 'p' pra função nativa que espera um ponteiro
            } // Ao sair do bloco 'fixed', o array pode ser movido pelo GC
        }
    }
}

fixed também pode ser usado com campos de struct ou strings.

Alocação de memória na pilha: stackalloc

stackalloc permite alocar um bloco de memória na pilha. Isso é bem rápido, mas a memória só existe até o fim do método atual. A memória alocada não é gerenciada pelo garbage collector.

  • Vantagens: Alocação muito rápida, sem overhead do GC, liberação determinística.
  • Desvantagens: Tamanho limitado (a pilha é relativamente pequena), risco de overflow de pilha (StackOverflowException) se alocar demais.
  • Uso: Ideal para buffers pequenos e de vida curta.

Exemplo: Usando stackalloc


public unsafe class StackAllocExample
{
    public static void ProcessStackAlloc()
    {
        unsafe
        {
            // Alocamos 10 ints na pilha
            int* numbers = stackalloc int[10];

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

            Console.WriteLine($"Valor do primeiro elemento: {numbers[0]}");
            Console.WriteLine($"Valor do quinto elemento: {numbers[4]}");
        } // A memória é liberada automaticamente ao sair desse método.
    }
}

O operador stackalloc pode ser usado com Span<T>, o que torna o trabalho com essa memória mais seguro, já que Span<T> é um tipo gerenciado (struct) que fornece acesso seguro a blocos contíguos de memória. Mais sobre Span na próxima palestra.

Exemplo: stackalloc com Span<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // Alocação na pilha, mas trabalhamos via Span
  
    seguro Span
   
     buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Primeiro elemento do Span: {buffer[0]}"); Console.WriteLine($"Último elemento do Span: {buffer[9]}"); // Dá pra passar Span
    
      pra métodos que o aceitam PrintSpan(buffer); } public static void PrintSpan(Span
     
       s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } } 
     
    
   
  

Esse é um approach híbrido: alocação na pilha (baixo nível), mas acesso seguro via Span<T> (alto nível).

3. Interação com código não gerenciado (P/Invoke)

Platform Invoke (P/Invoke) é o mecanismo que permite ao código C# chamar funções de bibliotecas unmanaged (por exemplo, DLLs do Windows ou arquivos .so no Linux). Isso é um aspecto fundamental do controle de memória de baixo nível, porque você frequentemente vai passar ponteiros entre o mundo managed e o unmanaged.

Declaração de funções externas

Você usa o atributo DllImport para declarar métodos estáticos extern que correspondem a funções nativas.

Exemplo 3.1: Chamada de função da API do Windows


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // Importamos a função nativa MessageBox de 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()
    {
        // Chamamos a função nativa
        MessageBox(IntPtr.Zero, "Olá do C#!", "Título da janela", 0);
    }
}

Marshaling de dados

Ao chamar funções nativas, o .NET Runtime faz o marshaling — conversão de tipos entre formatos managed e unmanaged. Por exemplo, string em C# pode ser marshaled como char* ou wchar_t* em C++.

Para marshaling mais complexo dá pra usar o atributo MarshalAs e a classe Marshal.

Exemplo: Marshaling de structs


using System.Runtime.InteropServices;

// Essa struct será marshaled para uma struct nativa
[StructLayout(LayoutKind.Sequential)] // Indica que os campos devem ficar em sequência
public struct NativePoint
{
    public int X;
    public int Y;
}

public class StructMarshalExample
{
    [DllImport("your_native_lib.dll")] // Exemplo: função em biblioteca nativa
    public static extern void ProcessPoint(NativePoint point);

    [DllImport("your_native_lib.dll")]
    public static extern void FillPoint(out NativePoint point); // Recebe ponteiro pra struct

    public static void DemoStructMarshal()
    {
        NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
        ProcessPoint(myPoint); // A struct será marshaled por valor (cópia)

        NativePoint resultPoint;
        FillPoint(out resultPoint); // A struct será preenchida pela função nativa
        Console.WriteLine($"Point from native: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle e fixação de objetos

GCHandle é uma struct que permite obter um "handle" para um objeto no heap gerenciado e, quando necessário, fixá-lo temporariamente, evitando sua movimentação ou coleta pelo GC. Isso é útil quando você precisa passar um ponteiro estável para um objeto gerenciado para código unmanaged.

Exemplo: Fixando objeto com GCHandle


using System.Runtime.InteropServices;

public class GCHandleExample
{
    public static void PinObject()
    {
        byte[] data = new byte[100];
        GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // Fixamos o array na memória
        try
        {
            IntPtr pointer = handle.AddrOfPinnedObject(); // Pegamos o ponteiro pro objeto fixado
            Console.WriteLine($"Endereço do array fixado: {pointer:X}");

            // Agora 'pointer' pode ser passado com segurança pra função nativa.
            // A função nativa pode trabalhar diretamente com essa memória.

            Marshal.WriteByte(pointer, 0, 255); // Mudamos o primeiro byte via ponteiro
            Console.WriteLine($"Primeiro byte do array: {data[0]}"); // Saída: 255
        }
        finally
        {
            if (handle.IsAllocated)
            {
                handle.Free(); // Liberamos o handle, permitindo que o GC volte a gerenciar o objeto
            }
        }
    }
}

Um estudo detalhado de P/Invoke e GCHandle fica fora do escopo deste curso, mas eles podem ser muito úteis se você quiser chamar funções de bibliotecas do Windows diretamente. Pelo menos agora você sabe por onde começar a investigar.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION