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
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
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
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:
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:
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:
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!