Interop com C: Chamando Código Nativo com P/Invoke e unsafe

Interop com C: Chamando Código Nativo com P/Invoke e unsafe

Como chamar uma biblioteca em C a partir do C#: LibraryImport, conversão de strings para UTF-8, structs com o mesmo layout de memória, ponteiros e fixed, SafeHandle para liberar o que o C alocou e os erros típicos da fronteira, como o long que muda de tamanho entre Windows e Linux.
Linguagem C#

• • 19 min de leitura

Décadas de bibliotecas escritas em C continuam resolvendo problemas melhor que qualquer reescrita, e o C# sabe chamá-las diretamente, desde que você assuma as responsabilidades que o C sempre exigiu.

"Concorrência" terminou lembrando que muito código excelente já existe em C: compressão, criptografia, processamento de imagens, as APIs do sistema operacional e o SQLite, o banco de dados que abrirá a próxima fase. Na aula de história vimos que o C# 1.0 já trazia ponteiros em blocos unsafe "quando necessário". Esta aula é sobre esses momentos. Vamos escrever uma pequena biblioteca em C, compilá-la com o mesmo gcc do curso de C deste site e chamá-la a partir do C#, entendendo o que acontece na fronteira entre o mundo gerenciado e o nativo e o que pode dar errado ali.

Por que chamar código nativo

Há três motivos legítimos para atravessar essa fronteira:

  • Reaproveitar bibliotecas existentes. O SQLite, o zlib, o OpenSSL e o FFmpeg são bibliotecas em C, maduras e testadas por bilhões de usuários. Reescrevê-las em C# seria caro e arriscado.
  • Acessar o sistema operacional. Muitas funcionalidades específicas de cada sistema só existem como funções em C.
  • Falar com hardware e com código legado da própria empresa.

Você usa interop o tempo todo sem perceber. Boa parte da biblioteca do .NET, dos arquivos à rede e à criptografia, é uma camada fina sobre chamadas ao sistema operacional. O que muda nesta aula é que você vai escrever essa camada.

Uma biblioteca em C

Crie o arquivo livraria_nativa.c:

#include <stdlib.h>

#ifdef _WIN32
#define EXPORTAR __declspec(dllexport)
#else
#define EXPORTAR __attribute__((visibility("default")))
#endif

typedef struct {
    int id;
    double preco;
    int estoque;
} ItemNativo;

/* Devolve 1 se o ISBN-13 (com ou sem hífens) for válido, 0 caso contrário. */
EXPORTAR int isbn13_valido(const char *isbn)
{
    int soma = 0, digitos = 0;
    for (const char *p = isbn; *p != '\0'; p++) {
        if (*p == '-') continue;
        if (*p < '0' || *p > '9') return 0;
        int d = *p - '0';
        if (digitos < 12) {
            soma += (digitos % 2 == 0) ? d : d * 3;
        } else if (digitos == 12) {
            if (d != (10 - soma % 10) % 10) return 0;
        } else {
            return 0;   /* mais de 13 dígitos */
        }
        digitos++;
    }
    return digitos == 13;
}

/* Soma preco * estoque de um array de itens. */
EXPORTAR double valor_total(const ItemNativo *itens, int quantidade)
{
    double total = 0.0;
    for (int i = 0; i < quantidade; i++)
        total += itens[i].preco * itens[i].estoque;
    return total;
}

/* Um objeto opaco, criado e destruído pela biblioteca. */
typedef struct { int valor; } Contador;

EXPORTAR void *contador_criar(void)          { return calloc(1, sizeof(Contador)); }
EXPORTAR void  contador_incrementar(void *c) { ((Contador *)c)->valor++; }
EXPORTAR int   contador_valor(void *c)       { return ((Contador *)c)->valor; }
EXPORTAR void  contador_liberar(void *c)     { free(c); }

O algoritmo do ISBN é o mesmo de "Classe, Struct ou Record", agora em C. A macro EXPORTAR torna as funções visíveis para quem carregar a biblioteca, com a sintaxe de cada sistema. Compile como biblioteca compartilhada:

# Linux
gcc -shared -fPIC -O2 -o liblivraria.so livraria_nativa.c
# macOS
gcc -shared -fPIC -O2 -o liblivraria.dylib livraria_nativa.c
# Windows (MinGW)
gcc -shared -O2 -o livraria.dll livraria_nativa.c

Para que o programa encontre a biblioteca, copie-a para a pasta de saída acrescentando ao .csproj do projeto C#. O Link é o que põe o arquivo na raiz da pasta de saída: sem ele, o MSBuild preserva a subpasta (bin/Debug/net10.0/nativo/), o .NET não procura ali e a primeira chamada termina em DllNotFoundException:

<ItemGroup>
  <None Include="nativo/liblivraria.so" Link="liblivraria.so" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Declarando as funções: LibraryImport

A ponte entre C# e C se chama P/Invoke (Platform Invoke). Você declara, em C#, a assinatura de cada função nativa, e o .NET cuida de carregar a biblioteca, localizar a função e converter os argumentos. A forma moderna, desde o .NET 7, usa o atributo [LibraryImport] com métodos parciais. É um source generator, como os de "Source Generators": o código de conversão é gerado na compilação, e não em execução.

using System.Runtime.InteropServices;

internal static partial class Nativo
{
    private const string Biblioteca = "livraria";

    [LibraryImport(Biblioteca, EntryPoint = "isbn13_valido", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int Isbn13Valido(string isbn);
}

O nome "livraria" é resolvido pelo .NET com os prefixos e extensões de cada sistema: liblivraria.so no Linux, liblivraria.dylib no macOS e livraria.dll no Windows. EntryPoint diz o nome exato da função em C, o que permite usar nomes em PascalCase do lado C#. StringMarshalling.Utf8 diz como converter a string do C#, que internamente é UTF-16, para o const char * que o C espera: o gerador cria um buffer temporário em UTF-8, terminado em '\0', passa o ponteiro e libera o buffer depois da chamada.

Como o código gerado usa ponteiros, o projeto precisa permitir código inseguro:

<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

A chamada fica igual à de qualquer método:

Console.WriteLine(Nativo.Isbn13Valido("978-0-306-40615-7"));   // 1
Console.WriteLine(Nativo.Isbn13Valido("978-0-306-40615-8"));   // 0

Repare que devolvemos int, e não bool. Isso é deliberado. Em C, "verdadeiro" costuma ser um int de 4 bytes. O bool do C99 ocupa 1 byte, e o BOOL da API do Windows, 4. O bool do .NET não tem um tamanho nativo único, e declarar bool sem dizer como converter é uma fonte clássica de bugs de interop. Devolver int e comparar com zero deixa a conversão explícita. Uma alternativa é o atributo [return: MarshalAs(UnmanagedType.Bool)], que declara um booleano de 4 bytes.

Você também vai encontrar o atributo antigo, [DllImport], em muito código existente. Ele funciona, mas faz as conversões em tempo de execução, com custo maior e incompatível com Native AOT em vários casos. Em código novo, prefira [LibraryImport].

Structs na fronteira: layout de memória

Para passar um array de ItemNativo ao C, o C# precisa de uma struct com exatamente o mesmo layout na memória:

[StructLayout(LayoutKind.Sequential)]
public struct ItemNativo
{
    public int Id;
    public double Preco;
    public int Estoque;
}

LayoutKind.Sequential garante que os campos fiquem na ordem declarada, com o mesmo alinhamento que o compilador C usaria. E aqui está uma lição que o curso de C ensina com cuidado: o alinhamento introduz espaços vazios. Um double precisa começar num endereço múltiplo de 8, então, depois do int id (4 bytes), há 4 bytes de preenchimento antes do preco. Depois do int estoque, mais 4 bytes completam a struct até um múltiplo de 8. O total é 24 bytes, e não 16. Se a ordem dos campos no C# for diferente da ordem no C, cada campo será lido na posição errada, e o resultado será lixo, sem nenhum erro de compilação e sem nenhuma exceção.

Uma struct como essa, que só contém tipos primitivos, é chamada de blittable: sua representação na memória é idêntica nos dois mundos, e ela pode ser passada ao C sem nenhuma conversão, só com um ponteiro para os dados.

unsafe, ponteiros e fixed

Para passar o endereço de um array, precisamos de ponteiros, e o C# os permite em contextos marcados como unsafe:

internal static partial class Nativo
{
    [LibraryImport(Biblioteca, EntryPoint = "valor_total")]
    internal static unsafe partial double ValorTotal(ItemNativo* itens, int quantidade);
}

static unsafe double CalcularValorTotal(ReadOnlySpan<ItemNativo> itens)
{
    fixed (ItemNativo* ponteiro = itens)
    {
        return Nativo.ValorTotal(ponteiro, itens.Length);
    }
}

ItemNativo* é um ponteiro, com a mesma semântica do C: um endereço, que pode ser desreferenciado com *, indexado com [] e, perigosamente, avançado com aritmética. Dentro de unsafe, o C# abre mão das garantias que o acompanharam no curso inteiro: não há verificação de limites, e um ponteiro errado lê ou escreve em qualquer lugar.

E por que o fixed? Lembre-se de "Memória no .NET": o coletor de lixo compacta o heap, movendo objetos para novos endereços. Se ele movesse o array no meio da chamada ao C, a função nativa continuaria lendo o endereço antigo, agora ocupado por outra coisa. O fixed fixa (pin) o objeto: durante o bloco, o GC está proibido de movê-lo. Esse é o preço de dar um endereço ao mundo nativo, e ele explica por que o C# comum não trabalha com endereços: eles não são estáveis.

Mantenha os blocos fixed curtos. Objetos fixados atrapalham a compactação e fragmentam o heap. Para arrays simples, o gerador do LibraryImport pode fazer a fixação automaticamente durante a chamada. Escrevemos à mão aqui para que o mecanismo fique visível.

Quem libera a memória: SafeHandle

A última dupla de funções da biblioteca cria um objeto no heap nativo (com calloc) e o devolve como um void * opaco. Quem é responsável por liberá-lo? O C# não pode chamar free sobre ele: só a biblioteca que alocou sabe liberar, e por isso ela oferece contador_liberar. O GC do .NET não sabe que esse bloco existe. Se o programa esquecer de liberar, a memória vaza, exatamente como em C.

Em "Memória no .NET" prometemos o SafeHandle para esse caso. É uma classe do .NET feita para embrulhar recursos nativos e garantir a liberação:

internal sealed class ContadorHandle : SafeHandle
{
    public ContadorHandle() : base(IntPtr.Zero, ownsHandle: true) { }

    public override bool IsInvalid => handle == IntPtr.Zero;

    protected override bool ReleaseHandle()
    {
        Nativo.ContadorLiberar(handle);
        return true;
    }
}

internal static partial class Nativo
{
    [LibraryImport(Biblioteca, EntryPoint = "contador_criar")]
    internal static partial ContadorHandle ContadorCriar();

    [LibraryImport(Biblioteca, EntryPoint = "contador_incrementar")]
    internal static partial void ContadorIncrementar(ContadorHandle contador);

    [LibraryImport(Biblioteca, EntryPoint = "contador_valor")]
    internal static partial int ContadorValor(ContadorHandle contador);

    [LibraryImport(Biblioteca, EntryPoint = "contador_liberar")]
    internal static partial void ContadorLiberar(IntPtr contador);
}

O gerador sabe converter SafeHandle: ao receber o ponteiro de contador_criar, ele o guarda num ContadorHandle novo. Ao passar um ContadorHandle para as outras funções, ele extrai o ponteiro, e garante que o handle não seja liberado durante a chamada. O SafeHandle implementa IDisposable e tem um finalizador. Se você usar using, a liberação é determinística. Se esquecer, o GC ainda chamará ReleaseHandle em algum momento. E ele protege contra uma corrida sutil: uma thread liberando o handle enquanto outra ainda o usa numa chamada nativa.

using ContadorHandle contador = Nativo.ContadorCriar();
Nativo.ContadorIncrementar(contador);
Nativo.ContadorIncrementar(contador);
Console.WriteLine(Nativo.ContadorValor(contador));   // 2
// ao sair do escopo: ReleaseHandle → contador_liberar → free

O que pode dar errado na fronteira

Interop é o lugar do .NET em que os erros são mais difíceis de diagnosticar, porque as proteções da plataforma param na fronteira. Os problemas típicos:

  • Biblioteca ou função não encontrada. Surgem como DllNotFoundException e EntryPointNotFoundException. Verifique o nome, a pasta de saída e a arquitetura: uma biblioteca de 32 bits não carrega num processo de 64 bits.
  • Tipos com tamanhos diferentes por plataforma. O long do C tem 4 bytes no Windows e 8 no Linux de 64 bits. Declarar long do lado C# (sempre 8 bytes) funciona num sistema e corrompe dados no outro. Prefira, no C, tipos de tamanho fixo como int32_t e int64_t, ou use o tipo CLong do .NET, que acompanha o tamanho nativo.
  • Posse da memória. Quem aloca libera, com a função correspondente. Um texto devolvido pelo C com malloc precisa voltar ao C para o free, e nunca deve ser liberado com as funções do .NET.
  • Falhas nativas derrubam o processo. Um acesso inválido de memória dentro do C não vira uma exceção que o try/catch captura: o processo inteiro termina. Todo o tratamento de erros construído em "Quando Algo Dá Errado" não alcança o outro lado da fronteira.

Por isso, a boa prática é concentrar o código nativo numa camada fina, de preferência internal, e expor ao resto do sistema métodos C# comuns, validados e seguros, que escondem completamente os ponteiros.

Juntando tudo: uma fachada segura para a Livraria

O resto da Livraria não deve saber que existe C por trás. Uma classe estática oferece métodos com os tipos do domínio e valida tudo antes de atravessar a fronteira:

public static class ServicosNativos
{
    public static bool IsbnValido(string isbn)
    {
        ArgumentNullException.ThrowIfNull(isbn);
        return Nativo.Isbn13Valido(isbn) != 0;
    }

    public static decimal ValorEmEstoque(IEnumerable<LivroImpresso> livros)
    {
        ItemNativo[] itens = livros
            .Select(l => new ItemNativo { Id = l.Id, Preco = (double)l.Preco, Estoque = l.Estoque })
            .ToArray();

        return (decimal)CalcularValorTotal(itens);
    }

    private static unsafe double CalcularValorTotal(ReadOnlySpan<ItemNativo> itens)
    {
        if (itens.IsEmpty) return 0;
        fixed (ItemNativo* p = itens)
            return Nativo.ValorTotal(p, itens.Length);
    }
}
var livros = new List<LivroImpresso>
{
    new("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10),
    new("Vidas Secas", "Graciliano Ramos", 38.00m, pesoGramas: 250, estoque: 4),
};

Console.WriteLine(ServicosNativos.IsbnValido("978-0-306-40615-7"));   // True
Console.WriteLine(ServicosNativos.ValorEmEstoque(livros));             // 751

(10 × 59,90 + 4 × 38,00 = 599 + 152 = 751.) Uma observação honesta sobre o exemplo: a conversão de decimal para double na ida e na volta reintroduz a imprecisão binária que "Cada Valor Tem um Tipo" nos ensinou a evitar em valores monetários, porque o C não tem um equivalente ao decimal. Para uma estimativa de patrimônio, é aceitável. Para um cálculo que fecha o caixa, não seria. E as versões C# do ISBN e da soma, que já escrevemos, são mais simples e igualmente rápidas. Esta aula usou funções triviais para que o foco ficasse na fronteira. Interop compensa quando a biblioteca nativa já existe e resolve um problema difícil, e não para reescrever em C o que o C# faz bem.

Com a interop, você conhece todos os níveis em que o C# opera: do domínio de alto nível às threads, da memória gerenciada aos ponteiros. Um sistema com tantas peças, porém, só se mantém de pé se cada alteração puder ser feita sem medo de quebrar o que já funcionava. Até aqui verificamos o comportamento do código lendo a saída no console, e em alguns exercícios com um if improvisado. Um projeto profissional precisa de algo muito mais sistemático: centenas de verificações automáticas, executadas em segundos a cada mudança, que avisam na hora exata em que algo deixou de funcionar.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Acrescente à biblioteca C uma função int soma_estoque(const ItemNativo *itens, int quantidade) que devolva o total de exemplares, declare-a em C# com LibraryImport e exponha-a pela fachada ServicosNativos.

Ver resposta

✓ Resposta: No C:

EXPORTAR int soma_estoque(const ItemNativo *itens, int quantidade)
{
    int total = 0;
    for (int i = 0; i < quantidade; i++)
        total += itens[i].estoque;
    return total;
}

No C#:

internal static partial class Nativo
{
    [LibraryImport(Biblioteca, EntryPoint = "soma_estoque")]
    internal static unsafe partial int SomaEstoque(ItemNativo* itens, int quantidade);
}

public static class ServicosNativos
{
    public static int TotalDeExemplares(IEnumerable<LivroImpresso> livros)
    {
        ItemNativo[] itens = livros.Select(l => new ItemNativo { Id = l.Id, Estoque = l.Estoque }).ToArray();
        return SomarEstoque(itens);
    }

    private static unsafe int SomarEstoque(ReadOnlySpan<ItemNativo> itens)
    {
        if (itens.IsEmpty) return 0;
        fixed (ItemNativo* p = itens)
            return Nativo.SomaEstoque(p, itens.Length);
    }
}

Depois de recompilar a biblioteca C, o padrão se repete: declaração com LibraryImport apontando o EntryPoint, um método privado unsafe que fixa a memória e um método público seguro que recebe tipos do domínio. O IsEmpty evita passar um ponteiro nulo para o C: com um span vazio, o fixed produz um ponteiro nulo, e, embora a função C trate quantidade == 0 corretamente, não depender disso é uma defesa barata.

Exercício 2

Troque a ordem dos campos da struct C# para Preco, Id, Estoque, sem alterar o C, e chame ValorEmEstoque. O que acontece? Depois calcule o tamanho da struct nos dois lados e explique o valor.

Ver resposta

✓ Resposta: O programa não dá erro de compilação nem lança exceção, e devolve um valor errado: com os dois livros do exemplo, 0 em vez de 751. O C continua lendo id nos primeiros 4 bytes e preco no deslocamento 8, mas o C# agora grava Preco no deslocamento 0 e Id no 8. Os bytes do double são interpretados como int e parte dos bytes do int como double, e o resultado é lixo. Para o tamanho, use sizeof(ItemNativo) num contexto unsafe no C# e printf("%zu\n", sizeof(ItemNativo)); no C. A struct original tem 24 bytes nos dois lados: id (4) + preenchimento (4) + preco (8) + estoque (4) + preenchimento final (4). A trocada tem só 16 no C#: com o double na frente, os dois int cabem juntos nos 8 bytes seguintes, sem preenchimento. Como o C anda de 24 em 24 bytes, a partir do segundo elemento ele lê além do fim do array. O preenchimento existe porque o double exige alinhamento de 8 bytes, e o tamanho total precisa ser múltiplo de 8 para que os elementos de um array fiquem alinhados. A lição é que, na fronteira, o compilador não protege o layout: ele precisa ser idêntico por disciplina, e é por isso que structs de interop devem ser declaradas com cuidado, idealmente geradas a partir do cabeçalho C por ferramentas.

Exercício 3

Explique o que poderia acontecer se o fixed fosse removido e o endereço do array fosse obtido de alguma outra forma para a chamada nativa. Por que esse bug seria difícil de reproduzir?

Ver resposta

✓ Resposta: Sem fixação, o coletor de lixo fica livre para mover o array durante a compactação do heap. Se uma coleta acontecer enquanto a função C percorre o array, disparada por alocações de outra thread por exemplo, o array vai para outro endereço, e a função continua lendo o endereço antigo, agora ocupado por outros objetos ou por lixo. O resultado seria um valor errado, na melhor hipótese, ou uma escrita que corrompe objetos do .NET e derruba o processo muito depois, longe da causa. O bug seria quase impossível de reproduzir porque só se manifesta quando uma coleta de lixo coincide com a janela exata da chamada nativa, o que depende da carga, da memória disponível e da atividade das outras threads. Em desenvolvimento, com pouca memória em uso, talvez nunca aconteça. Em produção, aconteceria "às vezes". É a mesma natureza intermitente das condições de corrida de "Concorrência", e por isso a regra é absoluta: todo endereço de objeto gerenciado entregue ao código nativo precisa estar fixado durante todo o tempo em que o nativo pode usá-lo.

Exercício 4

Uma função C devolve um texto alocado com malloc: char *descrever_item(const ItemNativo *item). Descreva o desenho correto da API nos dois lados para que a memória seja liberada corretamente.

Ver resposta

✓ Resposta: A regra é que quem aloca libera, com o mesmo alocador. O C# não pode chamar free diretamente, porque a biblioteca pode ter sido compilada com um runtime de C diferente daquele que o .NET enxergaria. E liberar memória do malloc com Marshal.FreeHGlobal seria usar o alocador errado. A biblioteca deve exportar a função de liberação correspondente:

EXPORTAR char *descrever_item(const ItemNativo *item);   /* devolve texto alocado com malloc */
EXPORTAR void  liberar_texto(char *texto);               /* chama free */

Do lado C#, a chamada devolve um ponteiro, que é convertido em string (uma cópia gerenciada) e imediatamente devolvido à biblioteca:

[LibraryImport(Biblioteca, EntryPoint = "descrever_item")]
internal static unsafe partial byte* DescreverItem(ItemNativo* item);

[LibraryImport(Biblioteca, EntryPoint = "liberar_texto")]
internal static unsafe partial void LiberarTexto(byte* texto);

static unsafe string Descrever(ItemNativo item)
{
    byte* texto = Nativo.DescreverItem(&item);
    try
    {
        return Marshal.PtrToStringUTF8((IntPtr)texto) ?? "";
    }
    finally
    {
        Nativo.LiberarTexto(texto);
    }
}

O finally garante a liberação mesmo se a conversão falhar. &item obtém o endereço de uma variável local, que já está na pilha e não se move, então dispensa fixed. Um desenho alternativo, frequentemente melhor, inverte a posse: o C# fornece o buffer e o C apenas escreve nele, e nenhuma memória nativa precisa ser liberada.

Exercício 5

Uma função C foi declarada como long contar_vendas(void); e, do lado C#, como internal static partial long ContarVendas();. O programa funciona no Linux e devolve valores errados no Windows. Explique e corrija.

Ver resposta

✓ Resposta: Em C, o tamanho de long depende da plataforma. No Linux e no macOS de 64 bits, long tem 8 bytes. No Windows, mesmo em 64 bits, long tem 4 bytes. O long do C# tem sempre 8 bytes. No Linux, os tamanhos coincidem e tudo funciona. No Windows, a função devolve 4 bytes, mas o C# lê 8, e metade do valor é lixo do registrador. Há duas correções. A melhor é mudar o C para um tipo de tamanho fixo, int64_t contar_vendas(void); (de <stdint.h>), e manter long no C#, com o mesmo tamanho em todo lugar. Se o C não puder ser alterado, declare o retorno em C# com o tipo CLong do .NET, que acompanha o tamanho do long nativo em cada plataforma:

[LibraryImport(Biblioteca, EntryPoint = "contar_vendas")]
internal static partial CLong ContarVendas();

long total = Nativo.ContarVendas().Value;

É o tipo de bug que passa em todos os testes de quem desenvolve no Linux e explode na máquina do cliente com Windows. O curso de C deste site insiste nos tipos de tamanho fixo de <stdint.h> justamente por causa de fronteiras como esta.

Comentários

Mais em Linguagem C#

Deploy: contêineres, nginx, Linux e a orquestração com Aspire
Deploy: contêineres, nginx, Linux e a orquestração com Aspire

A Livraria sai do computador de quem desenvolve: imagens de contêiner geradas…

Configuração, logging e testes de integração: a API pronta para ser operada
Configuração, logging e testes de integração: a API pronta para ser operada

A API da Livraria pronta para operar: configuração em camadas com o padrão…

Enums e Pattern Matching: Nomes para Estados e Perguntas sobre Formas
Enums e Pattern Matching: Nomes para Estados e Perguntas sobre Formas

Enums e pattern matching em C#: valores com nome, números fixos para o banco…