P/Invoke & Native Interop with [LibraryImport]
Key Points
[DllImport]is the legacy P/Invoke attribute — runtime-generated marshalling stubs, AOT-hostile.[LibraryImport](C# 11 / .NET 7+) is the source-generated replacement — compile-time stubs, AOT-friendly, faster, fewer surprises.- Blittable types (primitives +
structs of primitives, no marshalling) move across the boundary as bit copies. Non-blittable (string,bool, arrays) need explicit marshallers. Marshalclass still essential for manual buffer manipulation, GCHandle pinning, COM HRESULT translation.- Function pointers (
delegate*) enable callbacks without delegate allocations — the modern way to pass C-style callbacks across the boundary.
Concepts (deep dive)
DllImport (legacy)
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool DeleteFile(string path);
The runtime generates a marshalling stub via reflection on first call: argument conversion, GC pinning, transition to/from native. Stubs are slow to JIT and hostile to NativeAOT trimming.
LibraryImport (modern)
public partial class FileNative
{
[LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool DeleteFile(string path);
}
A Roslyn source generator emits the marshalling code at compile time. The method must be static partial. AOT-friendly — no reflection, no runtime stub generation.
Blittable types
Move byte-for-byte across the boundary:
byte,sbyte,short,ushort,int,uint,long,ulongfloat,doubleIntPtr,UIntPtr,nint,nuint- Pointers (
int*) structs containing only blittable fields with[StructLayout(LayoutKind.Sequential)]orExplicit
Non-blittable: bool (size differs by ABI), char, string, arrays — all need marshalling hints.
Common marshalling
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
public static partial int MessageBoxW(IntPtr hwnd, string text, string caption, uint type);
[LibraryImport("native.dll")]
public static partial int Sum(
[MarshalUsing(CountElementName = nameof(count))] int[] values,
int count);
StringMarshalling: - Utf16 — Windows wide strings - Utf8 — POSIX, modern APIs - Custom — provide your own marshaller
Layout control
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)]
public struct WIN32_FIND_DATAW
{
public uint dwFileAttributes;
public FILETIME ftCreationTime;
public FILETIME ftLastAccessTime;
public FILETIME ftLastWriteTime;
public uint nFileSizeHigh;
public uint nFileSizeLow;
public uint dwReserved0;
public uint dwReserved1;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string cFileName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
public string cAlternateFileName;
}
Pack = 1 disables padding when the native side packs tightly. Mismatched layout = silent data corruption.
Function pointers
C# 9+ feature; pass a managed method as a delegate*:
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static int Compare(int a, int b) => a.CompareTo(b);
[LibraryImport("native.dll")]
public static partial void QSort(IntPtr data, int count, int size, delegate* unmanaged[Cdecl]<int, int, int> cmp);
QSort(buffer, n, sizeof(int), &Compare);
No Delegate allocation; matches C function-pointer ABI directly. Required for AOT scenarios with callbacks.
GCHandle for pinning
var bytes = new byte[1024];
var h = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
nint ptr = h.AddrOfPinnedObject();
NativeRead(ptr, bytes.Length);
}
finally { h.Free(); }
Or, simpler, use fixed:
fixed only pins for the scope of the block; GCHandle for cross-call pinning.
Marshal essentials
nint p = Marshal.AllocHGlobal(1024);
try { /* native code */ }
finally { Marshal.FreeHGlobal(p); }
string s = Marshal.PtrToStringUTF8(nativeStringPtr)!;
int err = Marshal.GetLastWin32Error(); // pair with SetLastError = true
Marshal.ThrowExceptionForHR(hr); // COM HRESULT → exception
COM interop (sketch)
[ComImport, Guid("…"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface ISomeCom { int DoWork(); }
var obj = (ISomeCom)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("…"))!)!;
Microsoft.Windows.CsWin32 source generator (NuGet) generates [LibraryImport] and COM bindings from Win32 metadata — a much better path than hand-rolling.
Code: correct vs wrong
❌ Wrong: DllImport in AOT app
[DllImport("native.dll")]
public static extern int Process(string input); // runtime stub generation, breaks NativeAOT
✅ Correct: LibraryImport
public partial class Native
{
[LibraryImport("native.dll", StringMarshalling = StringMarshalling.Utf8)]
public static partial int Process(string input);
}
❌ Wrong: passing managed array without pinning
[LibraryImport("native.dll")]
public static partial void Read(byte[] buffer);
// If native side stores the pointer and uses it after return, GC moves the array → corruption.
✅ Correct: pin for cross-call use
var bytes = new byte[1024];
var h = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try { NativeRegisterBuffer(h.AddrOfPinnedObject(), bytes.Length); /* ... */ }
finally { h.Free(); }
Design patterns for this topic
Pattern 1 — partial class for native functions
Group native methods by library/area in a partial class. Source generator emits stubs alongside.
Pattern 2 — SafeHandle over raw IntPtr
public sealed class FileSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public FileSafeHandle() : base(true) { }
protected override bool ReleaseHandle() => CloseHandle(handle);
[LibraryImport("kernel32.dll")] private static partial bool CloseHandle(nint h);
}
SafeHandle ties handle lifetime to GC + finalizer — essential for resource leaks across exceptions and async.
Pattern 3 — Use CsWin32 source generator
NativeMethods.txt lists APIs you want. The generator emits [LibraryImport] declarations from official Win32 metadata — type-safe, AOT-friendly.
Pattern 4 — [UnmanagedCallersOnly] for C-callable exports
Required when exposing managed functions as native exports (e.g., NativeAOT shared libraries consumed by C/C++).
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
DllImport | Familiar, lots of examples | AOT-hostile, slower stubs |
LibraryImport | AOT, faster, fewer surprises | Requires partial, slight learning curve |
| Function pointers | No delegate alloc, AOT | Limited interop scenarios |
Hand-marshalling via Marshal | Maximum control | Verbose, error-prone |
CsWin32 generator | Auto-generated, official metadata | Windows-only |
When to use / when to avoid
- Use
LibraryImportfor new code. MigrateDllImportwhen convenient. - Use
SafeHandlefor any unmanaged resource exposed through P/Invoke. - Avoid hand-rolled COM if
CsWin32or generator-based options exist. - Avoid P/Invoke entirely when a managed alternative exists — fewer ABI surprises.
Interview Q&A
Q1. Why is [LibraryImport] faster than [DllImport]? A. The marshalling stub is generated at compile time by a Roslyn source generator instead of at runtime by the JIT/CLR. Faster startup, smaller working set, and AOT-compatible.
Q2. What does "blittable" mean? A. A type whose managed and unmanaged representations are bitwise identical, so it can cross the boundary by direct copy without conversion. Primitives, pointers, and structs of blittable fields qualify.
Q3. How does bool cross the boundary? A. Default is Win32 BOOL (4 bytes); native APIs may expect 1 byte. Use [MarshalAs(UnmanagedType.U1)] or [MarshalAs(UnmanagedType.I1)] to control width.
Q4. When do you need GCHandle instead of fixed? A. When the native code retains the pointer beyond the call's scope. fixed only pins within the block; GCHandle.Alloc(GCHandleType.Pinned) survives until you Free().
Q5. What's [UnmanagedCallersOnly]? A. Marks a method as callable only from native code with a specified calling convention. Required for native function-pointer scenarios and NativeAOT exports.
Q6. Why use SafeHandle instead of IntPtr? A. GC-tracked, finalizable, exception-safe. The runtime guarantees ReleaseHandle runs on cleanup, even if the consuming code forgets to dispose. Raw IntPtr leaks on exception.
Q7. What does SetLastError = true do? A. After the call, the runtime captures GetLastError() and exposes it via Marshal.GetLastWin32Error() — without it, the value can be clobbered by intervening calls.
Q8. How do you call a varargs C function (int printf(const char* fmt, ...))? A. Mark the P/Invoke as [CallingConvention.Cdecl] and pass arguments as object[] via Marshal-friendly types, or use multiple [LibraryImport] overloads with explicit signatures. Varargs are awkward; prefer wrapper functions.
Gotchas / common mistakes
- Wrong
CallingConvention→ stack corruption (Cdecl vs StdCall). - Mismatched struct layout (
Pack, padding) → silent data corruption. - Forgetting
SetLastError = trueand reading stale errno. - Using
stringparameter without specifyingStringMarshalling. - Passing a managed array to a callback and expecting it to stay put across callback returns.
- Calling P/Invoke on a finalizer thread — finalizer must not block on native calls that take locks.
- Forgetting
[UnmanagedCallersOnly]→ callback won't bind correctly in AOT. Marshal.AllocHGlobalwithout matchingFreeHGlobal→ native leak invisible to GC.