Native Bindings in C#
TLDR;
When using [DllImport]
you can only specify a constant string as the library name. If you are running dotnet core 3.0 or newer and you need more flexibilty, you can use the class NativeLibrary
from the System.Runtime.InteropServices
namespace.
If you need to change the name of the native library, a combination of NativeLibrary.SetDllImportResolver()
and NativeLibrary.TryLoad()
is all you need and you can find a code sample further down the article.
Calling Native Libraries from C#
The .NET way of calling native Libraries is P/Invoke (Platform Invoke).
A major part of Platform Invoke is the DllImportAttribute
, which is used to declare where - that is, in which native library - the implementation of the extern static methods can be found.
The following example shows a perfect use case:
internal static class NativeApi
{
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
}
It's a perfect use case, because the exact name of the library is known in advance and the library is also part of the operating system - only a single version exists and it's name will never change.
Cross-Platform support
When Platform Invoke was designed, .NET only ran on windows but with Mono and later dotnetcore cross-platform support was added to Platform Invoke.
To make [DllImport]
work cross-platform the runtime adds some additional logic when trying to determine the name of the native library. In particular, you can skip the file extension when using the DllImport
attribute - the extension is selected automatically depending on the OS it runs on.
For example, when I want to create a C# wrapper that is able to call the method TessBaseAPICreate()
from the tesseract library, I can declare it like this:
internal static class NativeTesseractApi
{
[DllImport("libtesseract-4")]
public static extern IntPtr TessBaseAPICreate();
...
}
Where the name of the library is supplied in the constructor of the DllImportAttribute.
This approach can enable cross-platform bindings, because the file extension is selected based on the executing operating system. On Windows it's looking for a .dll
, on linux it's a .so
file and on OS X it's a .dylib
.
Additionally, it's custom to prefix the library name on linux with "lib" so that when a method is declared using [DllImport("Library")]
the runtime will look for a file named Library.dll
on Windows and on linux it look also for liblibrary.so
.
Naming is hard
The problem is, that altough this pattern applies to many libraries, it's not a strict rule - it may or may not apply to your favorite library as the authors are free to name them in any way they want.
An additional naming pattern for linux libraries is that the version number comes after the file extension. On windows on the other hand, there is usually no indication of the version directly in the file name.
So if you use [DllImport("Library")]
this could lead to liblibrary.so.3
being found and loaded.
In the aforementioned example with the tesseract library, the platform dependent library name resolution doesn't work unfortunately, because it doesn't follow that pattern: eg. the Windows binary is prefixed with lib
and the version number comes after the filename, separated with a dash (libtesseract-4.dll
).
Customizing the library name is unfortunately only possible at compile time, as the information is stored in an Attribute, which only allows compile time constants.
Conditional Compilation
This in turn, often leads to the use of conditional compilation with preprocessor directives like #if eg:
internal static class NativeTesseractApi
{
static class Constants
{
public const string LibraryName =
#if WIN
"libtesseract-4";
#else
"libtesseract.so.4";
#endif
}
[DllImport(Constants.LibraryName)]
public static extern IntPtr TessBaseAPICreate();
...
}
The downside of this approach is platform dependent il-code that needs to be rebuild for all platforms, despite all the code being the same except for this single constant.
There are a plethora of additional information that is sometimes used when coming up with assembly names, eg. debug vs release builds, x86 vs. x64, single vs multithreaded, etc. Although this can be solved by using additional build flags, rebuilding all combinations and storing the resulting artifacts can get unwieldy fast and leads to artifact bloat.
Static classes
On the flip side it's possible to write different bindings for each native assembly and then switch them at runtime to use the correct one. The problem with this approach is that this leads to code duplication, as you will need to write almost identical declarations for all methods for each native assembly, eg.:
internal static class NativeTesseractLinuxApi
{
public const string LibraryName = "libtesseract.so.4";
[DllImport(Constants.LibraryName)]
public static extern IntPtr TessBaseAPICreate();
[DllImport(Constants.PlaceHolderLibraryName)]
public static extern void TessBaseAPIDelete(IntPtr handle);
...
}
internal static class NativeTesseractWindowsApi
{
public const string LibraryName = "libtesseract-4";
[DllImport(Constants.LibraryName)]
public static extern IntPtr TessBaseAPICreate();
[DllImport(Constants.PlaceHolderLibraryName)]
public static extern void TessBaseAPIDelete(IntPtr handle);
...
}
Additionally you will need some kind of runtime logic to switch between those classes, eg:
internal static class NativeTesseract
{
static bool IsWindows()
=> Environment.OSVersion.Platform == PlatformID.Win32NT;
public static IntPtr TessBaseAPICreate()
=> IsWindows()
? NativeTesseractWindowsApi.TessBaseAPICreate()
: NativeTesseractLinuxApi.TessBaseAPICreate();
...
}
To add insult to injury, even the glue code to switch between the different static classes is repetitive and cannot be abstracted succinctly without resorting to reflection (and giving up compile time checks) in C# as static classes cannot implement interfaces which in turn leads to code bloat.
Enter the NativeLibrary class
If you are approaching this from a native application, you would have probably used Run-Time Dynamic Linking to solve that issue, using functions like LoadLibrary() to load the dll and GetProcAddress() to get the address of the function or the linux equivalent dlopen() and dlsym(), respectively.
With dotnet core 3.0, we have that option in managed code as well, thanks to the new class NativeLibrary
.
So the managed counterpart for LoadLibrary()
/dlopen()
is TryLoad()
and GetProcAddress()
/dlsym()
is handled by TryGetExport()
, which is shown in the following table:
Linux | Windows | .NET (NativeLibrary) | |
---|---|---|---|
Load a Library | LoadLibrary() | dlopen() | Load() / TryLoad() |
Get a Symbol | GetProcAddress() | dlsym() | GetExport() / TryGetExport() |
In addition to that, NativeLibrary also supports registering a callback that can be used to resolve dll import request, using the SetDllImportResolver()
method.
With this, it's really easy to select the library name at runtime and I came up with the following solution:
internal static class NativeTesseractApi
{
static class Constants
{
public const string PlaceHolderLibraryName = "NativeTesseractLib";
public const string WindowsAssemblyName = "libtesseract-4";
public const string LinuxAssemblyName = "libtesseract.so.4";
}
static NativeTesseractApi()
=> NativeLibrary.SetDllImportResolver(Assembly.GetExecutingAssembly(), DllImportResolver);
static string GetLibraryName(string libraryName)
=> libraryName switch
{
Constants.PlaceHolderLibraryName => Environment.OSVersion.Platform switch
{
PlatformID.Win32NT => Constants.WindowsAssemblyName,
_ => Constants.LinuxAssemblyName,
},
_ => libraryName,
};
static IntPtr DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
var platformDependentName = GetLibraryName(libraryName);
IntPtr handle;
NativeLibrary.TryLoad(platformDependentName, assembly, searchPath, out handle);
return handle;
}
[DllImport(Constants.PlaceHolderLibraryName)]
public static extern IntPtr TessBaseAPICreate();
...
}
Which basically consists for 4 steps:
- Keep a single static class and keep the declarative approach of using the
[DllImport]
attribute and use a well known string as the library name.
internal static class NativeTesseractApi
{
static class Constants
{
public const string PlaceHolderLibraryName = "NativeTesseractLib";
...
}
[DllImport(Constants.PlaceHolderLibraryName)]
public static extern IntPtr TessBaseAPICreate();
...
}
- Use
NativeLibrary.SetDllImportResolver()
to register a callback function that handles the importing.
internal static class NativeTesseractApi
{
static NativeTesseractApi()
=> NativeLibrary.SetDllImportResolver(Assembly.GetExecutingAssembly(), DllImportResolver);
static IntPtr DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
...
}
...
}
- Implement the library name resolution, based on it's current environment.
internal static class NativeTesseractApi
{
static class Constants
{
public const string PlaceHolderLibraryName = "NativeTesseractLib";
public const string WindowsAssemblyName = "libtesseract-4";
public const string LinuxAssemblyName = "libtesseract.so.4";
}
static string GetLibraryName(string libraryName)
=> libraryName switch
{
Constants.PlaceHolderLibraryName => Environment.OSVersion.Platform switch
{
PlatformID.Win32NT => Constants.WindowsAssemblyName,
_ => Constants.LinuxAssemblyName,
},
_ => libraryName,
};
...
}
- And then implement the resolver by using the resolved library name and calling
NativeLibrary.TryLoad()
to load it.
internal static class NativeTesseractApi
{
static IntPtr DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
var platformDependentName = GetLibraryName(libraryName);
IntPtr handle;
NativeLibrary.TryLoad(platformDependentName, assembly, searchPath, out handle);
return handle;
}
...
}
In a case like this, the approach of using the NativeLibrary
class feels far superior to both the conditional compilation and multiple static classes approach and it saved us from either a lot of builds and their different binaries or a lot copy/pasted code.
Summary
DllImport
combined with naming conventions can often make calling native binaries trivial, but in more complex cases, you can use conditional compilation, multiple static classes and now the new NativeLibrary
class.
From those approaches, NativeLibrary
provides the greatest flexibility to solve issues directly in code.