Cross-Platform Native Code in .NET
Created on July 01, 2025
Last updated on July 01, 2025
As you may have noticed, I went a little hog-wild wrapping native crypto libraries for use in .NET projects, and did so in such a way that everything is automatic. I promise it's not voodoo. I'm going to show you exactly how to accomplish the same thing with minimal fuss. Here is the official guide, which reads like stereo instructions and even pays a visit to contoso, lol.
Anyway, let's say you have a library in C, like so:
#include
// Define EXPORT macro based on platform
#if defined(_WIN32)
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __attribute__((visibility("default")))
#endif
// Exported function
EXPORT int32_t add(int32_t a, int32_t b) {
return a + b;
}
Now you have a function that can be built on multiple platforms (or compiled with a cross-compiler, but that's beyond my scope here today, fun as it may be!)
Sometimes building linux/unix code is a major pain in the ass using Microsoft Visual C++; if you run into issues just know that you can build Unix-ish code much easier with mingw64's gcc or clang distribution- either will output a .dll that is just as capable as MSVC/link.exe, and has no external dependences like MSys2 or cygwin.
Now building on multiple platforms is always fun, but for simple projects it looks something like this:
# Windows MSVC (.dll):
cl /LD native_add.c /FEnative_add.dll
# MinGW (.dll):
gcc -shared -o native_add.dll native_add.c
# Linux (.so):
gcc -shared -fPIC -o libnative_add.so native_add.c
# MacOS (.dylib)
clang -dynamiclib -o libnative_add.dylib native_add.c
# On MacOS, you can also build for x64 and arm64, so depending upon which type of Mac you're building on, you can build for the other architecture by specifying the -arch arm64 or -arch x86_64 flags as the case may be.
# Then on MacOS you can combine both libraries into a "fat" (universal) binary by using the lipo command like so:
lipo -create -output libnative_add.dylib libnative_add_arm64.dylib libnative_add_x86_64.dylib
At this point you should now have a .dll, an .so, and a Universal .dylib for MacOS. Kind of a pain, but necessary to bring full cross-platform goodness for your .NET library consumers. NOTE: when building .so files for distribution to other platforms, you should use a glibc-based distribution and you should build from a distro that's older- why? because glibc guarantees that anything built against glibc will run on that version and all newer versions. So if you want to hit the widest number of Linux users, you'll use something like Ubuntu 18.04, which will guarantee that virtually all glibc-based distros from the last several years will be able to run your .so unmodified.
Now that the building is out of the way, create a new library project in Visual Studio. You will want to create a folder under that project called "runtimes", then 4 folders beneath that, one for each library architecture: "win-x64", "linux-x64", "osx-x64", and "osx-arm64". If you wanted to round out the six-fecta with win-arm64 and linux-arm64, you can do that too. Now each of these architecture folders should have a "native" folder beneath that. (Hey, I didn't say this wasn't tedious). Once you have your native folders, drag the correct libraries to the aforesaid native folders (and do it twice for the MacOS build, even though we know it's a universal binary).
Once you have all of the libraries copied into their respective native folders, you are essentially half-way done, depending on how complex the actual thing you're wrapping is, how many functions it exposes as part of its external interface, and what you plan to do (simple wrap, add .NET conveniences, etc.)
You need to add the following code to your .csproj file (this tells .NET where your native files live):
<ItemGroup>
<NativeLibrary Include="runtimes\win-x64\native\native_add.dll" />
<NativeLibrary Include="runtimes\linux-x64\native\libnative_add.so" />
<NativeLibrary Include="runtimes\osx-x64\native\libnative_add.dylib" />
<NativeLibrary Include="runtimes\osx-arm64\native\libnative_add.dylib" />
</ItemGroup>
Next, you need to create a class and dllimport all the things. This can be easy or this can be hard, depending upon the exported library functions. Generally speaking, exchanging strings with C is where is gets tricky and you need to notate the function to do string marshalling. Thankfully Microsoft has a guide for that.
Our function, however, is much simpler, as it only takes 2 ints and returns an int:
public static class NativeAdd
{
[DllImport("native_add", CallingConvention = CallingConvention.Cdecl)]
public static extern int add(int a, int b);
}
This tells the C# compiler that we are importing the add function from the native_add library, and that it uses the Cdecl calling convention (args on the stack, return value in EAX).
Now, you still need to provide a mechanism to load the .dll/.so/.dylib. I am not a big fan of using static constructors for this as I've been bitten in the ass more than once by a library not being loaded when it should have been. I prefer to put together an explicit Library.Init() function that takes care of loading the native library (plus this gives you the opportunity to do any init the underlying library might require):
using System;
using System.IO;
using System.Runtime.InteropServices;
public static class NativeLibraryLoader
{
bool _init = false;
public static void Init()
{
if (_init) return;
Load();
// Do any library initialization here
_init = true;
}
public static void Load()
{
string libName = GetLibraryName();
string basePath = AppContext.BaseDirectory;
string nativeSubdir = GetNativeSubdirectory();
string fullPath = Path.Combine(basePath, "runtimes", nativeSubdir, "native", libName);
if (!File.Exists(fullPath))
throw new DllNotFoundException($"Native library not found: {fullPath}");
IntPtr handle = NativeLibrary.Load(fullPath);
if (handle == IntPtr.Zero)
throw new Exception($"Failed to load native library: {fullPath}");
}
private static string GetLibraryName()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "native_add.dll";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "libnative_add.so";
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "libnative_add.dylib";
throw new PlatformNotSupportedException();
}
private static string GetNativeSubdirectory()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "win-x64";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux-x64";
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "osx-x64",
Architecture.Arm64 => "osx-arm64",
_ => throw new PlatformNotSupportedException()
};
}
throw new PlatformNotSupportedException();
}
}
Now, you may be wondering why we're going through all of this ceremony and rigamarole. After all, it seems like a giant PITA. Well, the good news is you don't have to re-do all of this- it's purely for show. Just copy the code above, fill in the blanks where you need to, and voila! Native cross-platform library wrapping for you too!
The thing I have to stress: you will not have to have a "runtimes" folder and all of those libraries in your output folder (although they may show up there), just know that whether you're building for Linux, Windows, or MacOS, the framework will know where to find your library. It will also know that it can toss the above library loader in the trash- if, for example, you AOT build your project, the "runtimes" folder will completely go away and your build directory will just have the dynamic library in the root. How? I don't really know the answer to that one, lol. But I do know that even though you have that loading code and it specifies the individual folders, etc., it will "just work" as long as you follow the pattern- same thing with building NuGet packages.
Now, I know what you're thinking- that I glossed over the hardest part, which is getting your native code libraries built- that this is somehow a toy example. Well, I couldn't really elaborate on that part even if I wanted to given the voluminous number of libraries out there each with their own little idiosyncracies. But I can say that the template with the EXPORT macro above is your friend, and this setup is also your friend. It will allow you to distribute native cross-platform NuGet packages and libraries with relative ease. Maybe not for you in getting the native code built, but certainly for your users who will appreciate the speed of native code coupled with the ease of use in using .NET.
Any questions? Did I mess something up? Let me know- padawan at purplekungfu dot com.
Take care and happy coding!
-padawan, 2025