Native Interop

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Managed code eventually has to meet the operating system, a hardware SDK, a codec, or a decades-old C library that nobody is going to rewrite. .NET’s interop layer — platform invoke, universally called P/Invoke — lets C# declare a native function and call it as if it were a managed method, with the runtime translating the arguments and the return value at the boundary. That translation is called marshalling, and understanding it is most of understanding interop.

For the C side of the boundary — headers, calling conventions, pointers, struct layout — see the C Reference on this site.

P/Invoke

[DllImport]: the Classic Form

A P/Invoke declaration is an extern method with no body, marked [DllImport], whose signature mirrors the native one:

using System.Runtime.InteropServices;

internal static partial class NativeMethodsClassic
{
    // int puts(const char *s);
    [DllImport("libc", EntryPoint = "puts", CharSet = CharSet.Ansi, SetLastError = true)]
    internal static extern int Puts(string message);

    // double pow(double x, double y);
    [DllImport("libm", EntryPoint = "pow")]
    internal static extern double Pow(double x, double y);
}

The important knobs:

Field Meaning

Library name (positional)

The name passed to the platform loader. "libc" becomes libc.so/libc.dylib/libc.dll by probing.

EntryPoint

The native symbol name, when the C# method name differs from it.

CharSet

How string and char are marshalled: Ansi, Unicode, or Auto.

SetLastError

Capture the OS error code immediately after the call, readable with Marshal.GetLastPInvokeError().

ExactSpelling

Suppress the automatic A/W suffix probing on Windows.

CallingConvention

Cdecl, StdCall, Winapi… Mismatching this corrupts the stack.

At the first call, the runtime generates an IL stub that converts every argument, performs the call, and converts the results back. That generation happens at run time, which is why [DllImport] does not work under Native AOT.

[LibraryImport]: the Source-Generated Form

[LibraryImport] (.NET 7+) is the modern replacement. A Roslyn source generator writes the marshalling code at compile time into a partial method, so there is no run-time code generation, the marshalling is visible and debuggable, and it works under trimming and Native AOT. Prefer it for all new code:

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    // The method is `partial`; the generator supplies the body.
    [LibraryImport("libc", EntryPoint = "puts", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int Puts(string message);

    [LibraryImport("libm", EntryPoint = "pow")]
    internal static partial double Pow(double x, double y);

    // SetLastError works the same way, and is captured by generated code.
    [LibraryImport("libc", EntryPoint = "read", SetLastError = true)]
    internal static partial nint Read(int fd, Span<byte> buffer, nuint count);
}

Differences from [DllImport] worth knowing when migrating:

  • The method must be static partial (and its containing type partial), not extern.

  • There is no CharSet; use StringMarshalling (Utf8, Utf16, or Custom with StringMarshallingCustomType).

  • bool is not marshalled implicitly — annotate it with [MarshalAs(UnmanagedType.Bool)] or use an explicit integer.

  • Only blittable types and types with a known marshaller are allowed; the generator reports anything else as a compile error rather than failing at run time. That is a feature.

The SYSLIB1054 analyzer flags [DllImport] declarations that can be converted, and the IDE offers the fix.

Blittable Types

A type is blittable when its managed and unmanaged representations are identical, so no conversion is needed and the runtime can pass a pointer straight through. Blittable: byte, sbyte, short, ushort, int, uint, long, ulong, nint, nuint, float, double, pointers, and structs and single-dimensional arrays composed entirely of those. Not blittable: bool (1 byte managed, 4 bytes in Win32), char and string (encoding), decimal, DateTime, arrays of non-blittable types, and anything with a reference field.

Designing the interop layer so that everything crossing the boundary is blittable is the single biggest performance lever in interop code.

Marshalling

Numbers and Pointer-Sized Integers

Map C’s int to int, unsigned int to uint, long long to long. For anything pointer-sized — size_t, intptr_t, a handle, long on 64-bit Unix — use nint/nuint, which are 32 or 64 bits to match the platform:

using System.Runtime.InteropServices;

internal static partial class SizeAware
{
    // size_t strlen(const char *s);
    [LibraryImport("libc", EntryPoint = "strlen", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial nuint StringLength(string value);
}

Strings

Strings are the most common source of interop bugs, because C has no single string type. The questions to answer for every string parameter: what encoding, and who frees it.

using System.Runtime.InteropServices;

internal static partial class StringInterop
{
    // Passing a string IN: the marshaller allocates a native copy, and frees it after the call.
    [LibraryImport("libc", EntryPoint = "puts", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int Puts(string message);

    // Receiving a pointer OUT: return nint and convert by hand, because ownership
    // (who calls free) is a decision the marshaller cannot make for you.
    [LibraryImport("libc", EntryPoint = "getenv", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial nint GetEnvironmentRaw(string name);

    internal static string? GetEnvironment(string name)
    {
        nint pointer = GetEnvironmentRaw(name);

        // The C library owns this memory: copy it, and do NOT free it.
        return pointer == 0 ? null : Marshal.PtrToStringUTF8(pointer);
    }
}

The Marshal conversions come in a matching family: PtrToStringUTF8, PtrToStringUni, PtrToStringAnsi, and in the other direction StringToCoTaskMemUTF8/…Uni/…Ansi, each of which must be released with the corresponding Marshal.FreeCoTaskMem.

For an out-parameter that the native function fills into a caller-supplied buffer — by far the most common Win32 shape — pass a span or an array:

using System.Runtime.InteropServices;

internal static partial class BufferOut
{
    // char *getcwd(char *buf, size_t size);
    [LibraryImport("libc", EntryPoint = "getcwd")]
    private static partial nint GetCurrentDirectoryRaw(Span<byte> buffer, nuint size);

    internal static string? GetCurrentDirectory()
    {
        Span<byte> buffer = stackalloc byte[1024];

        return GetCurrentDirectoryRaw(buffer, (nuint)buffer.Length) == 0
            ? null
            : System.Text.Encoding.UTF8.GetString(buffer[..buffer.IndexOf((byte)0)]);
    }
}

Structs

A struct crossing the boundary needs a layout that matches the C declaration exactly. [StructLayout] controls it — see also Unsafe Code, Spans and Performance:

using System.Runtime.InteropServices;

// struct timespec { time_t tv_sec; long tv_nsec; };
[StructLayout(LayoutKind.Sequential)]
internal struct TimeSpec
{
    internal nint Seconds;
    internal nint Nanoseconds;
}

// A struct with a fixed char array and an embedded struct.
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
internal struct DeviceInfo
{
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
    internal string Name;                       // char name[64], inline

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
    internal int[] Capabilities;                // int capabilities[4], inline

    internal TimeSpec LastSeen;
}

internal static partial class StructInterop
{
    // int clock_gettime(clockid_t clk_id, struct timespec *tp);
    [LibraryImport("libc", EntryPoint = "clock_gettime")]
    internal static partial int ClockGetTime(int clockId, out TimeSpec time);
}

Marshal.SizeOf<T>() reports the unmanaged size and is the way to check a struct against a C header; sizeof(T) in an unsafe context reports the managed size. When they differ, the layout is wrong.

[MarshalAs] covers the cases the default rules cannot express: ByValTStr and ByValArray for inline fixed-size members, LPArray with SizeParamIndex for a pointer-plus-length pair, Bool versus I1 versus VariantBool for the three different C booleans, and FunctionPtr for callbacks.

Arrays

An array parameter is marshalled as a pointer to its first element. For blittable element types the runtime pins the managed array and passes its address directly — no copy. A Span<T> parameter does the same and is the modern spelling:

using System.Runtime.InteropServices;

internal static partial class ArrayInterop
{
    // void qsort(void *base, size_t nmemb, size_t size,
    //            int (*compar)(const void *, const void *));
    [LibraryImport("libc", EntryPoint = "qsort")]
    internal static unsafe partial void QSort(
        Span<int> data, nuint count, nuint elementSize, delegate* unmanaged[Cdecl]<void*, void*, int> comparer);
}

A caller-allocated, callee-filled array must be sized correctly by the caller; the marshaller cannot know the size the native code will write and will not bounds-check it.

Callbacks

Native code that calls back into managed code needs a function pointer. There are two mechanisms.

Delegates are the classic route. The critical rule is lifetime: the marshaller creates a native thunk for the delegate, but the GC does not know the native side holds it. If the delegate becomes unreachable while native code still has the pointer, the next callback crashes the process:

using System.Runtime.InteropServices;

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate int CompareCallback(nint left, nint right);

internal sealed class SortSession
{
    // The field keeps the delegate alive for as long as native code can call it.
    private readonly CompareCallback _comparer;

    internal SortSession() => _comparer = Compare;

    internal nint FunctionPointer => Marshal.GetFunctionPointerForDelegate(_comparer);

    private static int Compare(nint left, nint right) => left.CompareTo(right);

    internal void KeepAlive() => GC.KeepAlive(_comparer);
}

[UnmanagedCallersOnly] is the modern, allocation-free route: a static method compiled with a native calling convention, whose address can be taken as a raw function pointer. It has no thunk and no lifetime problem, but it may only be called from native code — never from C#:

using System.Runtime.InteropServices;

internal static unsafe class ModernCallback
{
    [UnmanagedCallersOnly(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
    private static int Compare(void* left, void* right)
    {
        // Must not throw: an exception escaping into native code terminates the process.
        int a = *(int*)left;
        int b = *(int*)right;
        return a.CompareTo(b);
    }

    internal static delegate* unmanaged[Cdecl]<void*, void*, int> Pointer => &Compare;
}

Both forms share one absolute rule: an exception must never escape a callback into native code. Wrap the body in try/catch and translate failures into whatever error code the native API expects.

Marshal and SafeHandle

The Marshal class is the manual toolbox for everything the declarative marshaller does not cover:

using System.Runtime.InteropServices;

internal static class ManualMarshalling
{
    internal static unsafe void Show()
    {
        // Allocate, write and free native memory.
        nint block = Marshal.AllocHGlobal(sizeof(int) * 4);

        try
        {
            Marshal.WriteInt32(block, 0, 42);
            Console.WriteLine(Marshal.ReadInt32(block, 0));
        }
        finally
        {
            Marshal.FreeHGlobal(block);
        }

        // Copy a managed array to and from native memory.
        int[] managed = [1, 2, 3];
        nint native = Marshal.AllocHGlobal(sizeof(int) * managed.Length);

        try
        {
            Marshal.Copy(managed, 0, native, managed.Length);
            Marshal.Copy(native, managed, 0, managed.Length);
        }
        finally
        {
            Marshal.FreeHGlobal(native);
        }

        // Struct conversions, and the size the marshaller will use.
        Console.WriteLine(Marshal.SizeOf<TimeSpec>());

        // The OS error from the most recent SetLastError = true call.
        Console.WriteLine(Marshal.GetLastPInvokeError());
    }
}

NativeMemory.Alloc/Free (.NET 6+) is the newer, malloc-shaped alternative to AllocHGlobal and should be preferred in new code.

SafeHandle

A raw nint handle is a liability: it leaks if an exception interrupts the cleanup path, and it is vulnerable to handle recycling — if the handle is closed while another thread is using it, a reused value silently refers to a different object. SafeHandle solves both: it is reference-counted across P/Invoke calls, and its release runs as a critical finalizer, which the runtime guarantees even during shutdown:

using System.Runtime.InteropServices;

internal sealed partial class FileDescriptor : SafeHandle
{
    private FileDescriptor() : base(invalidHandleValue: -1, ownsHandle: true)
    {
    }

    public override bool IsInvalid => handle == -1;

    protected override bool ReleaseHandle() => Close((int)handle) == 0;

    [LibraryImport("libc", EntryPoint = "close")]
    private static partial int Close(int fd);
}

internal static partial class SafeHandleInterop
{
    // The marshaller increments the handle's ref count for the duration of the call,
    // so it cannot be released underneath the native code.
    [LibraryImport("libc", EntryPoint = "read", SetLastError = true)]
    internal static partial nint Read(FileDescriptor handle, Span<byte> buffer, nuint count);
}

The rule: every native handle that crosses into managed code should be a SafeHandle subclass, never a bare nint. See Memory Management and Disposal for how it relates to IDisposable and finalizers.

Library Loading and Cross-Platform Probing

The runtime turns a library name into a file through a probing sequence: the name as given, then with the platform prefix and suffix (lib…so, lib…dylib, ….dll), in the application directory, then the NuGet runtime-specific folders, then the OS search path. For a library whose name differs across platforms, take control with a resolver:

using System.Reflection;
using System.Runtime.InteropServices;

internal static partial class Resolving
{
    private const string LibraryName = "imageproc";

    [ModuleInitializer]
    internal static void Register() =>
        NativeLibrary.SetDllImportResolver(Assembly.GetExecutingAssembly(), Resolve);

    private static nint Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
    {
        if (libraryName != LibraryName)
        {
            return nint.Zero;       // fall back to the default probing
        }

        string candidate = OperatingSystem.IsWindows() ? "imageproc-x64.dll"
                         : OperatingSystem.IsMacOS()   ? "libimageproc.2.dylib"
                                                       : "libimageproc.so.2";

        return NativeLibrary.TryLoad(candidate, assembly, searchPath, out nint handle) ? handle : nint.Zero;
    }

    [LibraryImport(LibraryName, EntryPoint = "ip_version")]
    internal static partial int Version();
}

NativeLibrary also supports explicit loading and symbol lookup (NativeLibrary.Load, NativeLibrary.GetExport), which is the route for optional dependencies and for choosing an implementation at run time based on CPU features.

For packaging, native assets belong under runtimes/<rid>/native/ in the NuGet package so the right binary is copied for the target runtime identifier. See Namespaces, Assemblies and Projects.

COM Interop

On Windows, COM is a binary object model with reference counting (IUnknown) and interface-based dispatch. .NET bridges it in both directions: a runtime callable wrapper (RCW) is the managed proxy that lets C# use a COM object, and a COM callable wrapper (CCW) is the native proxy that lets COM use a managed object.

The classic declaration style mirrors the interface definition:

using System.Runtime.InteropServices;

[ComImport]
[Guid("00000000-0000-0000-C000-000000000046")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal partial interface IUnknownLike
{
    void DoWork([MarshalAs(UnmanagedType.LPWStr)] string argument);
}

Two things to know in modern .NET:

  • dynamic with COM. Automation APIs (Office, WMI) are late-bound through IDispatch, which is exactly what dynamic was designed for — see Expression Trees and Dynamic. It removes the need for generated interop assemblies, at the cost of compile-time checking.

  • Source-generated COM. [GeneratedComInterface] and [GeneratedComClass] (.NET 8+) are the [LibraryImport] equivalent for COM: the marshalling code is generated at compile time and works under Native AOT, where the built-in COM support does not.

COM lifetime is reference-counted, so an RCW must be released — Marshal.ReleaseComObject or, better, letting a using scope handle it — rather than left to the GC, which will not release it promptly.

C++/CLI, Briefly

C/CLI is a Microsoft language extension that compiles C and managed code into the same assembly, so a class can hold native members and managed members and call both directly with no marshalling declarations at all. It is the lowest-friction option for wrapping a large C++ API — but it is Windows-only and does not support Native AOT, so for cross-platform work a C-shaped extern "C" surface plus [LibraryImport] is the portable choice.

Calling C# from Native Code

Native AOT can compile a C# library into a native shared library exporting ordinary C symbols, so C, C++, Python or anything else can load it with no .NET runtime involved:

using System.Runtime.InteropServices;

public static class Exports
{
    // Exported as the C symbol `add_numbers` in the produced .so/.dylib/.dll.
    [UnmanagedCallersOnly(EntryPoint = "add_numbers")]
    public static int AddNumbers(int left, int right)
    {
        try
        {
            return left + right;
        }
        catch
        {
            return -1;          // never let an exception reach the native caller
        }
    }
}
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <NativeLib>Shared</NativeLib>
  </PropertyGroup>
</Project>

dotnet publish -r linux-x64 -c Release then produces a shared library with the exported entry points. The constraints of the exported surface are the ones already listed: blittable parameters, no exceptions escaping, and no managed objects handed out except as opaque handles.

Practical Guidance

  • Prefer [LibraryImport] over [DllImport]; it is checked at compile time and works under AOT.

  • Keep every type crossing the boundary blittable where you can; measure before accepting a non-blittable signature on a hot path.

  • Wrap every native handle in a SafeHandle.

  • Decide ownership explicitly for every pointer: who allocates, who frees, and with which allocator.

  • Never let an exception escape a callback; never let a delegate passed to native code become unreachable.

  • Keep P/Invoke declarations internal in one NativeMethods class and expose a safe, idiomatic C# API above them.

  • Set SetLastError = true where the native API reports errors that way, and read it with Marshal.GetLastPInvokeError() immediately.

See Also