AdaskoTheBeAsT.Interop.Unmanaged
3.0.0
dotnet add package AdaskoTheBeAsT.Interop.Unmanaged --version 3.0.0
NuGet\Install-Package AdaskoTheBeAsT.Interop.Unmanaged -Version 3.0.0
<PackageReference Include="AdaskoTheBeAsT.Interop.Unmanaged" Version="3.0.0" />
<PackageVersion Include="AdaskoTheBeAsT.Interop.Unmanaged" Version="3.0.0" />
<PackageReference Include="AdaskoTheBeAsT.Interop.Unmanaged" />
paket add AdaskoTheBeAsT.Interop.Unmanaged --version 3.0.0
#r "nuget: AdaskoTheBeAsT.Interop.Unmanaged, 3.0.0"
#:package AdaskoTheBeAsT.Interop.Unmanaged@3.0.0
#addin nuget:?package=AdaskoTheBeAsT.Interop.Unmanaged&version=3.0.0
#tool nuget:?package=AdaskoTheBeAsT.Interop.Unmanaged&version=3.0.0
AdaskoTheBeAsT.Interop.Unmanaged
π Typed, safe, lifetime-managed dynamic native library loading for .NET β on Windows, Linux, and macOS.
π¬ Code quality β SonarCloud
π Hello, native-loving friend
You've got a native library. The static DllImport attribute is fine, right up until it isn't:
- 𧬠the DLL name depends on runtime config or an installer location
- π― some exports only exist on certain versions of the SDK
- π’ you need
LOAD_LIBRARY_SEARCH_SYSTEM32and friends to survive DLL-hijacking audits - π§ you want the same loader story on Linux and macOS
- πͺ native code wants a function pointer to a managed callback
- β»οΈ you want
SafeHandlelifetime β not aMarshal.FreeLibrarylandmine
AdaskoTheBeAsT.Interop.Unmanaged is the tiny, focused library that parks all that plumbing somewhere safe so your code stays readable. π¦
β¨ Why you'll love this
- π
SafeHandle-backed lifetime.SafeLibraryHandlecleans up on its own β no more "did I remember toFreeLibrary?" moments. - π― Strongly typed delegates. Resolve exports into real delegate types, not
IntPtrsoup. - πͺ Managed-to-native callbacks.
GetFunctionPointerForDelegategives you a stable pointer plus abinderroot so the GC can't rip it out from under native code. - π Open-generic delegate support. Callbacks typed as
Delegate<T>? The library IL-emits a concrete proxy on the fly and copies your[UnmanagedFunctionPointer]attribute onto it so the calling convention matches. - π§ͺ Runtime export probing. Missing function?
GetUnmanagedFunction<T>returnsnull,TryGetExportreturnsfalse. No exceptions, no try/catch dances. - π§ Modern
delegate* unmanagedfriendly.TryGetExporthands you the rawIntPtrso you can cast todelegate* unmanaged[Stdcall]<int, int>onnet5+. - πͺπ§π Windows + Linux + macOS. Uses
LoadLibraryExon Windows; delegates toNativeLibraryon modern .NET and rawdlopenon .NET Framework via Mono. - βοΈ Full
LoadLibraryFlagscontrol.LOAD_LIBRARY_SEARCH_SYSTEM32,LOAD_WITH_ALTERED_SEARCH_PATH,LOAD_LIBRARY_AS_DATAFILEβ all there, faithfully honored on Windows. - 𧬠9 TFMs, all green.
net10.0,net9.0,net8.0,net481,net48,net472,net471,net47,net462β the full matrix on every build. - π‘οΈ Quality-first.
TreatWarningsAsErrors=true, deterministic builds, nullable annotations, generated XML docs, and a SonarCloud quality gate on every commit. - βοΈ Source Link + snupkg. Step into the library from your debugger without guessing.
π¦ Installation
dotnet add package AdaskoTheBeAsT.Interop.Unmanaged
Or via Package Manager:
Install-Package AdaskoTheBeAsT.Interop.Unmanaged
Symbols ship as .snupkg with Source Link and embedded untracked sources. Step in. Look around. It's fine. π
πΊοΈ Target framework matrix
| TFM | Status | Notes |
|---|---|---|
net10.0 |
β | Uses System.Runtime.InteropServices.NativeLibrary under the hood on non-Windows. |
net9.0 |
β | Same. |
net8.0 |
β | Same. |
net481 |
β | Windows desktop; other platforms go through hand-rolled dlopen/dlsym P/Invokes under Mono. |
net48 |
β | Same. |
net472 |
β | Same. |
net471 |
β | Same. |
net47 |
β | Same. |
net462 |
β | Same. |
Every cell is built with TreatWarningsAsErrors=true, ContinuousIntegrationBuild=true, Deterministic=true, and exercised by the test suite.
πͺπ§π Platform behavior
| Platform | Loader path | LoadLibraryFlags honored? |
|---|---|---|
| πͺ Windows (all TFMs) | LoadLibraryEx / GetProcAddress / FreeLibrary from kernel32.dll |
β fully |
π§ Linux on net8+ |
NativeLibrary.Load |
β flags silently ignored (RTLD_NOW semantics) |
π macOS on net8+ |
NativeLibrary.Load |
β flags silently ignored (RTLD_NOW semantics) |
π§ Linux on net4.x (Mono) |
dlopen(..., RTLD_NOW) from libdl.so.2 |
β flags silently ignored |
π macOS on net4.x (Mono) |
dlopen(..., RTLD_NOW) from libSystem.dylib |
β flags silently ignored |
π‘ The
LoadLibraryFlagsargument is accepted on every platform for call-site compatibility β it's only applied on Windows, which is exactly what most interop callers expect.
π Quick start
1οΈβ£ Load a DLL and call an export
using System;
using System.Runtime.InteropServices;
using AdaskoTheBeAsT.Interop.Unmanaged;
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
delegate uint GetCurrentProcessIdDelegate();
using var library = new UnmanagedLibrary("kernel32.dll");
var getCurrentProcessId = library.GetUnmanagedFunction<GetCurrentProcessIdDelegate>("GetCurrentProcessId");
if (getCurrentProcessId is not null)
{
Console.WriteLine($"Current PID: {getCurrentProcessId()}");
}
2οΈβ£ Probe for optional exports safely
GetUnmanagedFunction<TDelegate> returns null when the export is missing, which makes feature-probing trivial β no exceptions, no Marshal.GetLastWin32Error rituals.
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
delegate IntPtr OptionalExportDelegate();
using var library = new UnmanagedLibrary("SomeNativeSdk.dll");
var optionalExport = library.GetUnmanagedFunction<OptionalExportDelegate>("OptionalExport");
if (optionalExport is null)
{
Console.WriteLine("This version of the native SDK does not expose OptionalExport.");
}
3οΈβ£ Load from an explicit path with explicit flags
Use a fully qualified path when you want deterministic loading behavior for a specific DLL. Combine flags with |.
var flags =
LoadLibraryFlags.LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR |
LoadLibraryFlags.LOAD_LIBRARY_SEARCH_SYSTEM32;
using var library = new UnmanagedLibrary(@"C:\Native\MyLibrary.dll", flags);
4οΈβ£ Static, handle-based API
Prefer to manage the handle yourself? The static helpers are there.
using System;
using System.Runtime.InteropServices;
using AdaskoTheBeAsT.Interop.Unmanaged;
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
delegate uint GetTickCountDelegate();
using var handle = UnmanagedLibrary.LoadLibrary("kernel32.dll");
var getTickCount = UnmanagedLibrary.GetUnmanagedFunction<GetTickCountDelegate>(handle, "GetTickCount");
if (getTickCount is not null)
{
Console.WriteLine($"Tick count: {getTickCount()}");
}
5οΈβ£ Pass a managed callback to native code
When you hand a managed delegate to unmanaged code, keep the returned binder alive for as long as native code may store or invoke the pointer.
using System;
using AdaskoTheBeAsT.Interop.Unmanaged;
Func<int, int, int> callback = (a, b) => a + b;
var callbackPointer = UnmanagedLibrary.GetFunctionPointerForDelegate(callback, out var binder);
// Pass callbackPointer into native code here.
// ...
GC.KeepAlive(binder);
π‘ For open-generic delegate types (e.g.
Action<T>), the library IL-emits a non-generic proxy delegate at runtime and copies your[UnmanagedFunctionPointer]attribute onto the proxy so the generated function pointer uses the correct unmanaged calling convention.
6οΈβ£ Modern delegate* unmanaged via TryGetExport
On net5+ you can skip the Marshal layer entirely and use a function-pointer type (delegate* unmanaged[Stdcall]<...>). TryGetExport gives you the raw IntPtr.
using System;
using AdaskoTheBeAsT.Interop.Unmanaged;
using var library = new UnmanagedLibrary("kernel32.dll");
if (library.TryGetExport("GetCurrentProcessId", out var addr))
{
unsafe
{
var fn = (delegate* unmanaged[Stdcall]<uint>)addr;
Console.WriteLine($"PID: {fn()}");
}
}
Static overload exists too:
using var handle = UnmanagedLibrary.LoadLibrary("kernel32.dll");
UnmanagedLibrary.TryGetExport(handle, "GetCurrentProcessId", out var addr);
π§ API at a glance
UnmanagedLibrary
Main entry point for loading DLLs and resolving exports.
// Instance API
new UnmanagedLibrary(string fileName, LoadLibraryFlags flags = ...);
TDelegate? GetUnmanagedFunction<TDelegate>(string functionName);
bool TryGetExport(string functionName, out IntPtr functionPointer);
// Static / handle-based API
static SafeLibraryHandle LoadLibrary(string fileName, LoadLibraryFlags flags = ...);
static void FreeLibrary(SafeLibraryHandle? safeLibraryHandle);
static TDelegate? GetUnmanagedFunction<TDelegate>(SafeLibraryHandle handle, string functionName);
static bool TryGetExport(SafeLibraryHandle handle, string functionName, out IntPtr functionPointer);
// Managed-to-native callbacks
static IntPtr GetFunctionPointerForDelegate<T>(T delegateCallback, out object binder);
// IL-emit re-wrap (advanced; prefer `Marshal.GetDelegateForFunctionPointer<T>`)
static T? GetDelegateForFunctionPointer<T>(IntPtr ptr, CallingConvention callingConvention);
SafeLibraryHandle
Wraps the native module handle using the .NET SafeHandle pattern, which helps prevent leaks and double-free mistakes. using-friendly.
LoadLibraryFlags
[Flags] enum mirroring the Windows LoadLibraryEx flags β LOAD_LIBRARY_SEARCH_SYSTEM32, LOAD_WITH_ALTERED_SEARCH_PATH, LOAD_LIBRARY_AS_DATAFILE, LOAD_IGNORE_CODE_AUTHZ_LEVEL, and friends.
DelegatePin
Internal helper that roots generic-delegate proxies so the JIT-generated bridge stays alive. Most consumers only need to keep the returned binder rooted β DelegatePin is exposed for edge cases where you're doing the wrapping yourself.
π‘οΈ Safety and lifetime rules
These are the few things you actually need to remember:
- β
Keep the
UnmanagedLibrary/SafeLibraryHandlealive while retrieved delegates or function pointers are still in use - β
Keep the callback
binderalive while native code may call the function pointer - β Use the exact delegate signature and calling convention expected by the native export
- β
Prefer explicit
LOAD_LIBRARY_SEARCH_*flags when loading third-party binaries (audit-friendly) - β Do not unload the library and continue using delegates or function pointers you obtained from it
- β Do not load untrusted DLLs π«
β οΈ Important behavior notes
- Export names are case-sensitive π (this matches native loader semantics on Linux/macOS and avoids surprises on Windows)
- Invalid file names throw
ArgumentException - Failed loads throw
Win32ExceptionwithFailed to load library '<name>'; the trailing message is the native loader error string on Windows and on the .NET Framework/Monodlopenpath, and the wrapped managed-exception message (DllNotFoundException/BadImageFormatException/FileLoadException) on .NET 8+ non-Windows - Missing exports return
null(classic API) orfalse(TryGetExport) β never throw FreeLibraryis safe to call withnullor an already closed handle (idempotent)LoadLibraryFlagsare silently ignored on Linux/macOS; the library always passesRTLD_NOWon those platforms- The IL-emit path in
GetDelegateForFunctionPointer<T>does not perform parameter marshaling β for string / struct marshaling useMarshal.GetDelegateForFunctionPointer<T>(ptr)instead
π€ When to reach for this library
Use this when you need any of:
| Scenario | Why DllImport isn't enough |
|---|---|
| π DLL path chosen at runtime | DllImport wants a compile-time string |
| π― Optional / version-specific exports | DllImport throws EntryPointNotFoundException |
π’ Explicit LOAD_LIBRARY_SEARCH_* flags |
DllImport uses default loader search order |
| πͺ Managed callback β native function pointer | Marshal.GetFunctionPointerForDelegate is OK, but you have to manage the GC root yourself |
𧬠delegate* unmanaged[X]<...> from a dynamically loaded DLL |
DllImport doesn't apply; you need dlsym/GetProcAddress |
π SafeHandle-backed native module lifetime |
Marshal.FreeLibrary is a foot-gun |
If the DLL is fixed at compile time and every export is always present, DllImport (or [LibraryImport] on net7+) is absolutely the right tool. π
π‘ Common use cases
- π§ Loading Windows system DLLs such as
kernel32.dlloruser32.dll - π’ Dynamically integrating with third-party native SDKs whose install path you read at runtime
- π― Supporting optional native features across multiple versions of the same DLL
- πͺ Registering managed callbacks with unmanaged code
- π§π Writing cross-platform interop that speaks to platform-specific shared libraries (
libfoo.so,libfoo.dylib,foo.dll) - π§± Choosing DLL resolution behavior explicitly to reduce DLL-hijacking risk
π§ͺ Build and test
dotnet build .\AdaskoTheBeAsT.Interop.Unmanaged.slnx
dotnet test .\AdaskoTheBeAsT.Interop.Unmanaged.slnx --no-build
The test suite runs across the full 9-TFM matrix. Windows-specific tests (things that call kernel32) self-skip on non-Windows hosts.
π§ͺ Quality notes
This project is built with quality-oriented defaults:
- π‘οΈ Nullable reference types enabled
- π Generated XML documentation in every package
- π¨
TreatWarningsAsErrors=trueacross all projects - π§ͺ Automated tests across
net462βnet481andnet8βnet10 - π¬ Static analysis via Roslyn analyzers + SonarCloud quality gate
- 𧬠Deterministic,
ContinuousIntegrationBuild=truepackages - π Source Link +
snupkgsymbols for step-in debugging
β FAQ
Do I need to pass a full DLL path?
Not always. A bare module name like kernel32.dll works when the selected flags can resolve it. Use a fully qualified path when you want deterministic loading from a specific location.
What happens if the export does not exist?
GetUnmanagedFunction<TDelegate> returns null. TryGetExport returns false with IntPtr.Zero. No exceptions.
Do I need to use DelegatePin directly?
Usually no. Most consumers only need to keep the binder returned by GetFunctionPointerForDelegate rooted for the required lifetime.
Can I use this on Linux and macOS?
Yes. π On net8+ the non-Windows path delegates to System.Runtime.InteropServices.NativeLibrary; on .NET Framework under Mono it falls back to direct dlopen/dlsym P/Invokes. Note that LoadLibraryFlags are silently ignored on non-Windows platforms and RTLD_NOW is used.
Which should I use: GetUnmanagedFunction<T> or TryGetExport + delegate*?
On net5+ with unsafe code, TryGetExport + delegate* unmanaged[Stdcall]<...> gives you zero-alloc direct calls. On older TFMs, or when you want marshaling (strings, structs), stick with GetUnmanagedFunction<T>.
Can I re-wrap a raw IntPtr back into a delegate?
Yes β GetDelegateForFunctionPointer<T>(ptr, callingConvention) IL-emits a thunk using calli. Note: this path does not marshal parameters. For marshaling (e.g. string β LPWStr), use Marshal.GetDelegateForFunctionPointer<T>(ptr) instead.
Does it support musl-based Linux (Alpine)?
On net8+ it does β NativeLibrary.Load handles the search. On .NET Framework under Mono, the current hard-coded soname is libdl.so.2, which works on glibc distros; Alpine/musl is not explicitly supported on the Mono path.
π Contributing
Found a bug? Got an idea? Spotted a typo that's been haunting you? π»
- π Open an issue describing the problem or the proposal.
- π οΈ Fork + branch (
feature/your-idea). - β
Run
dotnet build+dotnet testacross the full matrix. - β¨ Add/update tests β the strict-build settings will tell you if something's off.
- π Open a PR β the CI will do the rest.
π License
This project is licensed under the MIT License.
<p align="center"> Because <em>dynamic</em> native DLL loading in .NET shouldn't feel like defusing a bomb. π£β‘οΈποΈ<br/> Made with β€οΈ (and a lot of coffee β) by <a href="https://github.com/AdaskoTheBeAsT">AdaskoTheBeAsT</a>. </p>
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Framework | net462 is compatible. net463 was computed. net47 is compatible. net471 is compatible. net472 is compatible. net48 is compatible. net481 is compatible. |
-
.NETFramework 4.6.2
- System.Memory (>= 4.6.3 && < 5.0.0)
- System.Reflection.Emit (>= 4.7.0 && < 5.0.0)
- System.Reflection.Emit.Lightweight (>= 4.7.0 && < 5.0.0)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2 && < 7.0.0)
-
.NETFramework 4.7
- System.Memory (>= 4.6.3 && < 5.0.0)
- System.Reflection.Emit (>= 4.7.0 && < 5.0.0)
- System.Reflection.Emit.Lightweight (>= 4.7.0 && < 5.0.0)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2 && < 7.0.0)
-
.NETFramework 4.7.1
- System.Memory (>= 4.6.3 && < 5.0.0)
- System.Reflection.Emit (>= 4.7.0 && < 5.0.0)
- System.Reflection.Emit.Lightweight (>= 4.7.0 && < 5.0.0)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2 && < 7.0.0)
-
.NETFramework 4.7.2
- System.Memory (>= 4.6.3 && < 5.0.0)
- System.Reflection.Emit (>= 4.7.0 && < 5.0.0)
- System.Reflection.Emit.Lightweight (>= 4.7.0 && < 5.0.0)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2 && < 7.0.0)
-
.NETFramework 4.8
- System.Memory (>= 4.6.3 && < 5.0.0)
- System.Reflection.Emit (>= 4.7.0 && < 5.0.0)
- System.Reflection.Emit.Lightweight (>= 4.7.0 && < 5.0.0)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2 && < 7.0.0)
-
.NETFramework 4.8.1
- System.Memory (>= 4.6.3 && < 5.0.0)
- System.Reflection.Emit (>= 4.7.0 && < 5.0.0)
- System.Reflection.Emit.Lightweight (>= 4.7.0 && < 5.0.0)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2 && < 7.0.0)
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on AdaskoTheBeAsT.Interop.Unmanaged:
| Package | Downloads |
|---|---|
|
AdaskoTheBeAsT.WkHtmlToX
AdaskoTheBeAsT.WkHtmlToX c# wrapper |
GitHub repositories
This package is not used by any popular GitHub repositories.