P/Invoke, LibraryImport e Interop Nativo
Objective
Cedo ou tarde o código gerenciado precisa chamar C: uma API do sistema, um SDK
de fornecedor. Platform invoke (P/Invoke) é a ponte, e no .NET 10 / C# 14 ela
tem dois sabores: DllImport, cujo stub de marshalling o runtime gera, e o
source generator LibraryImport, que escreve esse stub no seu projeto em tempo
de compilação. Este conceito chama a libc e uma .dylib minúscula compilada com
clang e mostra o que quebra em cada fronteira, de nomes e encodings a callbacks
e handles. Tudo rodou no macOS arm64; o comportamento no Windows e no Linux é
citado da documentação.
Use Cases
- Chamar uma função do sistema que a BCL não encapsula, como uma API da libc ou Win32.
- Encapsular um SDK nativo (codec, driver, biblioteca de hardware) numa API segura.
- Tornar código
DllImportcompatível com trimming e Native AOT viaLibraryImport. - Publicar um pacote que carrega
foo.dll,libfoo.sooulibfoo.dylib.
Deep Dive
DllImport versus LibraryImport
Um método DllImport é static extern e o runtime monta um stub IL na primeira
chamada. Um método LibraryImport é static partial e o gerador escreve o
corpo, visível em obj/ com EmitCompilerGeneratedFiles:
csharp[LibraryImport("libc", SetLastError = true, StringMarshalling = StringMarshalling.Utf8)]
public static partial int open(string path, int flags);
// generated (trimmed):
__path_native__marshaller.FromManaged(path, stackalloc byte[...BufferSize]); // UTF-8 copy
__path_native = __path_native__marshaller.ToUnmanaged();
Marshal.SetLastSystemError(0);
__retVal = __PInvoke(__path_native, flags);
__lastError = Marshal.GetLastSystemError(); // finally: marshaller.Free(); SetLastPInvokeError
[DllImport("libc", EntryPoint = "open", ExactSpelling = true)] static extern unsafe int __PInvoke(byte* p, int f);O DllImport interno só vê byte* e int, que dispensam stub em runtime;
segundo a documentação, um stub IL gerado em runtime "isn't available for
ahead-of-time (AOT) compiler or IL trimming scenarios". Sem partial: CS0501
mais SYSLIB1050: Method 'getpid' should be 'static', 'partial', and non-generic when marked with 'LibraryImportAttribute'. Sem <AllowUnsafeBlocks>:
SYSLIB1062. Um bool sem MarshalAs, uma string sem StringMarshalling ou
um Func<int,int> dão SYSLIB1051. A dica SYSLIB1054 ("use LibraryImport") é
só info: o dotnet build não a imprime.
Chamando a libc: nomes, entry points e strings
csharp[LibraryImport("libc")] static partial int getpid();
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)] static partial nuint strlen(string s);
[LibraryImport("libc")] static partial int gettimeofday(out TimeVal tv, nint tz);
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)] static partial nint getenv(string name);
[StructLayout(LayoutKind.Sequential)] struct TimeVal { public long tv_sec; public int tv_usec; }
getpid() == Environment.ProcessId; // True
strlen("héllo"); // 6: é is 2 bytes in UTF-8
gettimeofday(out var tv, 0); // tv.tv_sec == ToUnixTimeSeconds()
Marshal.PtrToStringUTF8(getenv("HOME")); // /Users/... (unset variable: 0)
// the same strlen wrapper in libfoo, imported with StringMarshalling.Utf16:
byte_len16("héllo"); // 1: 'h' is 68 00, and the 00 ends the C stringgetenv devolve memória que pertence à libc. Declarado retornando string, o
stub converte o ponteiro e depois chama Utf8StringMarshaller.Free nele (uma
string retornada é tratada como alocada pelo chamado): o processo abortou com
exit code 134 e nenhuma exceção gerenciada. libc, c e libSystem
resolveram, embora nenhum desses arquivos exista em /usr/lib. Um nome errado
falha na primeira chamada, listando cada caminho tentado:
textDllNotFoundException: Unable to load shared library 'libnope' or one of its dependencies. ... dlopen(<dotnet>/shared/Microsoft.NETCore.App/10.0.0/libnope.dylib ...), dlopen(<app>/libnope.dylib ...), dlopen(libnope.dylib ...), then the same three for liblibnope.dylib, libnope, liblibnope EntryPointNotFoundException: Unable to find an entry point named 'no_such_function' in shared library 'libc'.
EntryPoint assume o nome do método. O nm mostra símbolos C com underscore
inicial (_add), que o loader acrescenta sozinho. C++ faz name mangling, salvo
em funções extern "C": int twice(int) saiu como __Z5twicei, então importar
twice lançou EntryPointNotFoundException, e EntryPoint = "_Z5twicei"
devolveu 42 para 21. Prefira exports extern "C". Para erros, a documentação
pede ler o código antes que outra chamada o sobrescreva; SetLastError = true
faz o stub capturar o errno logo após a chamada:
csharpint fd = open("/definitely/not/here", 0);
int err = Marshal.GetLastPInvokeError();
Console.WriteLine($"{fd} {err} {Marshal.GetPInvokeErrorMessage(err)}");
// -1 2 No such file or directory (ENOENT); without SetLastError = true: -1 0Tipos blittable, bool e layout de structs
Tipos blittable (int, long, double, ponteiros, structs só com eles) têm os
mesmos bits dos dois lados e passam direto. bool não: o marshalling do runtime
o transforma no BOOL de 4 bytes do Windows, enquanto o bool de C tem 1 byte.
Com a struct C { bool a; bool b; } e uma função que devolve f->b:
csharp[StructLayout(LayoutKind.Sequential)] struct FlagsWin { public bool a; public bool b; }
[DllImport("libfoo")] static extern int flags_b(ref FlagsWin f);
// C sizeof = 2, Marshal.SizeOf<FlagsWin>() = 8, C# sizeof(FlagsWin) = 2
var f = new FlagsWin { a = false, b = true };
flags_b(ref f); // 0: native read byte 1 of a's 4-byte BOOL (byte fields: 1)O LibraryImport obriga a escolher: [return: MarshalAs(UnmanagedType.U1)]
vira um retorno byte comparado com != 0, e FlagsWin é rejeitada
(SYSLIB1051) sem [DisableRuntimeMarshalling]. A documentação sugere um
GCHandle pinned como teste de blittability, mas a struct com bool foi fixada
sem erro; só uma com string lançou ArgumentException: Object contains references.
LayoutKind.Sequential (padrão do C# para structs) mantém ordem e alinhamento
de C; Pack precisa bater com o #pragma pack nativo. { int a; long b; }
mediu 16 bytes em Marshal.SizeOf e no sizeof de C, 12 com Pack = 1. Em
{ byte tag; int value; }, value fica no offset 4, ou 1 com Pack = 1; um
t->value em C na versão compactada devolveu 0, não 1234 (leu além do fim).
Compare Marshal.SizeOf<T>()/OffsetOf<T>() com sizeof/offsetof de C.
Arrays, spans, parâmetros ref e pinning
T[], Span<T>, ReadOnlySpan<T> e ref/out (pela documentação, também
in) de T blittable são fixados e passados como ponteiro num fixed gerado:
csharp[LibraryImport("libfoo")] static partial int sum_ints(ReadOnlySpan<int> xs, int n);
[LibraryImport("libfoo")] static partial void fill_squares(Span<int> xs, int n);
sum_ints(new[] { 1, 2, 3, 4 }.AsSpan(1, 2), 2); // 5
Span<int> sq = stackalloc int[5]; fill_squares(sq, 5); // 0,1,4,9,16[Out] Span<int> dá SYSLIB1051: The '[Out]' attribute is only supported on array parameters. O pin termina com a chamada: depois de um GC.Collect com
compactação, um endereço obtido antes com fixed tinha mudado (700003e3b8 -> 7000015d28), e um mantido por um handle GCHandleType.Pinned não.
Callbacks: ponteiros de função, delegates e keep-alive
O callback amigável a AOT é um método estático [UnmanagedCallersOnly] passado
como delegate* unmanaged, aqui como comparador do qsort da libc:
csharp[UnmanagedCallersOnly] static int Cmp(void* a, void* b) => (*(int*)a).CompareTo(*(int*)b);
[LibraryImport("libc")] static partial void qsort(void* b, nuint n, nuint size, delegate* unmanaged<void*, void*, int> cmp);
int[] data = [5, 3, 9, 1, 7]; fixed (int* p = data) qsort(p, 5, sizeof(int), &Cmp); // 1,3,5,7,9Um delegate como parâmetro de DllImport também funcionou (uma lambda ordenou
9,7,5,3,1). O perigo é o nativo guardar o ponteiro, como o store_callback:
csharpstatic void StoreTemp() // invoke_stored(5) == 500 inside
{ int mul = 100; IntFn temp = x => x * mul; store_callback(Marshal.GetFunctionPointerForDelegate(temp)); }
StoreTemp(); GC.Collect(); GC.WaitForPendingFinalizers(); invoke_stored(5);
// Process terminated.
// A callback was made on a garbage collected delegate of type 'Interop!IntFn::Invoke'.O ponteiro não enraíza o delegate: guarde-o num campo ou use GC.KeepAlive(d)
depois do último uso nativo (a versão enraizada sobreviveu).
Sua própria biblioteca: NativeLibrary e um resolver
clang -dynamiclib -o libfoo.dylib foo.c (Apple clang 21.0.0) gerou a
biblioteca de teste. NativeLibrary.Load(fullPath) mais GetExport(h, "add")
convertido para delegate* unmanaged<int, int, int> devolveu 42 para (40, 2);
TryGetExport de um nome inexistente devolveu false. Load(string), um
wrapper simples do loader do sistema segundo a documentação, falhou para "c",
que Load(name, assembly, searchPath) encontrou testando variações do nome. Um
resolver por plataforma:
csharpNativeLibrary.SetDllImportResolver(typeof(Program).Assembly, (name, asm, path) =>
{
if (name != "nativefoo") return IntPtr.Zero; // zero: use default probing
string file = OperatingSystem.IsWindows() ? "foo.dll" : OperatingSystem.IsMacOS() ? "libfoo.dylib" : "libfoo.so";
return NativeLibrary.Load(file, asm, path);
});
// two [LibraryImport("nativefoo")] methods, three calls: the resolver ran 2 times
// registering again: InvalidOperationException: A resolver is already set for the assembly.A documentação lista as variações no Unix como foo.dylib, libfoo.dylib,
foo, libfoo (Windows: foo, foo.dll), como no erro acima: cada uma foi
tentada no diretório do framework, no diretório do app e depois num dlopen
puro. Com DllImportSearchPath.System32 o diretório do app foi pulado e "foo"
não foi achado; null ou AssemblyDirectory o encontraram.
Memória nativa e SafeHandle
O malloc_size da libc informou 112 bytes para um bloco de 100 vindo tanto de
Marshal.AllocHGlobal quanto de NativeMemory.Alloc: os dois são malloc no
macOS, mas cada um tem seu próprio free. Um handle num campo IntPtr nunca é
liberado se o dono for esquecido, como mostra um contador nativo de handles:
csharpsealed class FooHandle() : SafeHandle(IntPtr.Zero, ownsHandle: true)
{
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle() { handle_close(handle); return true; }
}
[LibraryImport("libfoo", EntryPoint = "handle_open")] static partial FooHandle open_safe();
// 1000 objects with a raw nint field dropped, full GC: live = 1000 (leaked for good)
// 1000 FooHandle dropped: live = 1000, after full GC 0 (Release; Debug kept 1)
// using (var h = open_safe()) { }: 1 inside, 0 afterCOM só existe no Windows e não foi executado aqui. Segundo a documentação, o
gerador [GeneratedComInterface]/[GeneratedComClass] do .NET 8 emite código
ComWrappers em tempo de compilação no lugar dos stubs de runtime do
[ComImport], o que o torna adequado a AOT.
Trade-offs
- LibraryImport versus DllImport. Stubs legíveis, amigáveis a AOT e erros
no build, ao preço de
partial, código unsafe estring/boolexplícitos. - Assinaturas com marshalling versus blittable. Cada conversão de
string,boolou delegate pode errar (um retornostringliberou memória da libc). - SafeHandle versus IntPtr. Um objeto e um finalizer por handle compram a liberação de handles esquecidos e, segundo a documentação, nunca dupla.
- Alcance de plataforma. Observado só no macOS arm64; pela documentação, o
longde C tem 32 bits no Windows 64 bits (useCLong). Teste cada alvo.