Calling a cross-platform C++ library from .NET Core

This is the second post in a series of posts where we will learn how to build a cross-platform C++ library which can be seamlessly called from .NET Core applications using P/Invoke on all supported platforms. In the previous post we created a C++ library which exports a simple function. We then compiled this library under Windows, Linux and MacOs. In this post we will create a .NET Core console application which will bundle all native binaries and seamlessly call our exported function in a uniform way.

All source code is available at my GitHub repo: https://github.com/olegtarasov/CrossplatformNativeTest. You can easily inspect the code for each post using tags. For this post the tag is 2_hello_world.

Creating a .NET Core project

Since we are using .NET Core, the workflow to create and compile a project will be identical for all three platforms. Assume we want to create our project in a CoreNativeTest directory. Let's use dotnet command:

mkdir CoreNativeTest && cd CoreNativeTest
dotnet new console

And just like that you have a brand new cross-platform console app! Let's build and test it:

dotnet build
dotnet run

It should output a standard “Hello world!” message.

Bundling native dependencies

As we discussed in the previous post, we could just place our native binaries in the same directory as our new console app, and it would work. But I suggest we give it a little more effort and embed native binaries into our app as resources. With this approach we will have less files to distribute and we will circumvent potential problems with native dependencies if we decide to reference our assembly in some other .NET project.

First of all, let's place native binaries in our project directory. If you are following the tutorials closely, these will be TestLib.dll, libTestLib.so and libTestLib.dylib. Now we need to tell the compiler to embed these files into CoreNativeTest.dll assembly as resources. To do so, we open the project file CoreNativeTest.csproj in text editor and change it as follows:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp2.2</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
      <EmbeddedResource Include="libTestLib.dylib" />
      <EmbeddedResource Include="libTestLib.so" />
      <EmbeddedResource Include="TestLib.dll" />
    </ItemGroup>
</Project>
CoreNativeTest.csproj

If you are using Visual Studio, you can just select these files in Project Explorer and set their type to “Embedded Resource”.

Now when we build the project, native binaries will be embedded into the assembly. But how do we get them out and call our exported function? We can write some code to do it by hand, or we can use NativeLibraryManager that I made specifically for this purpose. This library lets you define which binary to use under which platform, and it will handle all the work of detecting the target platform and extracting the binary.

Let's add NativeLibraryManager to our project with dotnet add package command.

dotnet add package NativeLibraryManager

Extracting and calling the native library

Now let's open Program.cs in text editor and change it as follows:

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

namespace CoreNativeTest
{
    internal class Program
    {
        [DllImport("TestLib")]
        private static extern void hello();
    
        private static void Main(string[] args)
        {
            var accessor = new ResourceAccessor(Assembly.GetExecutingAssembly());
            var libManager = new LibraryManager(
                Assembly.GetExecutingAssembly(),
                new LibraryItem(Platform.MacOs, Bitness.x64,
                    new LibraryFile("libTestLib.dylib", accessor.Binary("libTestLib.dylib"))),
                new LibraryItem(Platform.Windows, Bitness.x64, 
                    new LibraryFile("TestLib.dll", accessor.Binary("TestLib.dll"))),
                new LibraryItem(Platform.Linux, Bitness.x64,
                    new LibraryFile("libTestLib.so", accessor.Binary("libTestLib.so"))));
            
            libManager.LoadNativeLibrary();
            
            hello();
            Console.WriteLine("Hello from C#!");
        }
    }
}

First of all, we use standard P/Invoke declaration to import hello() function from the native library. We don't need to use any cross-platform specifics, since all shared library loaders will find our native library as long as it's placed in a well-known directory. The easiest well-known directory is the directory in which our .NET assembly is located. What we need to do is to extract a corresponding binary from assembly resources depending on current platform.

To do this, we create an instance of ResourceAccessor. This is a simple helper that reads embedded resources as arrays of bytes. We use Assembly.GetExecutingAssembly() to tell ResourceAccessor in which assembly to look for resources.

After that we create an instance of LibraryManager. This is the main class that detects target platform and extracts the appropriate binary. It receives an assembly reference as well, along with any number of LibraryItem object. Each LibraryItem specifies a bundle of files that should be extracted for a specific platform. It this case we create 3 instances to support Windows, Linux and MacOs — all 64-bit. LibraryItem takes any number of LibraryFile objects. With these objects you specify the extracted file name and an actual binary file in the form of byte array. This is where ResourceAccessor comes in handy.

We should note that resource name you pass to ResourceAccessor is just a path to original file relative to project root with slashes \ replaced with dots .. So, for example, if we place some file in Foo\Bar\lib.dll project folder, we would adderss it as:

accessor.Binary("Foo.Bar.lib.dll")

So back to native library management. After we define binaries for all platforms that we want to support, we just call libManager.LoadNativeLibrary() to extract the appropriate native binary and optionally call LoadLibrary() on it under Windows. File extractor is smart: first it checks if target binary already exists on disk and then it computes its hash to make sure there is a newer version that needs to be extracted. With this approach there is no unnecessary writes to file system and library updates are handled gracefully.

After we are done extracting the binary, we just call our hello() exported function with standard P/Invoke.

Building and running the project

Now that we took care of our dependencies and put in some P/Invoke code, we can just build and run our project.

dotnet build
dotnet run

If everything went fine, you will see two lines printed to standard output. For example, under MacOs:

Hello from MacOS!
Hello from C#!

Hooray! We finally made a cross-platform .NET Core app that calls into native library! In the next post we will discuss more advanced topics of native interop like string, class and structure manipulations. Stay tuned!