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.
GO TO FULL VERSION