Building a cross-platform C++ library to call from .NET Core

This is the first 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. We will gradually build a library which will take us from a simple “Hello world” to more complex tasks like string and structure manipulations.

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 1_hello_world.

In this post will set up the environment and cover basic things we will need to compile our native library.

The design

As you probably know, C++ libraries are generally compiled straight to machine code without the use of any intermediate language. This prevents us from compiling a single binary and using it on all platforms. Instead, we will compile a separate binary for each supported platform.

We will also create a single .NET Core application which will call the appropriate native library depending on the platform. This takes us to the task of managing the native binaries. We could just bundle those binaries along with our assembly and it would work. But I prefer to pack native dependencies inside the .NET assembly and then unpack the needed native library at runtime depending on the platform.

Now let's setup our environments under Windows, Linux and MacOs. You can easily omit platforms you are not interested in.

Setting up the environment

Windows

Under Windows things are pretty simple: just install Visual Studio 2019 with the following workloads:

  • Desktop development with C++
  • .NET Core cross-platform development

Make sure to select C++ CMake tools for Windows in “Installation details” pane under “Desktop development with C++” workload.

Linux

In this post we will use Ubuntu to build and test our library. First of all, we will install essential build tools:

sudo apt install build-essential libssl-dev libcurl4-openssl-dev

Then we will need to install latest CMake. In this post I will cover CMake 3.14 which is the latest version at the time of writing. We will have to compile CMake from source, since most Ubuntu distributions still use older versions in their package repos.

The steps to build CMake are easy:

  1. Visit https://cmake.org/download/ and write down the version of the latest stable release. In my case it's 3.14.5.
  2. Execute these commands substituting the version from step 1:
wget https://github.com/Kitware/CMake/releases/download/v3.14.5/cmake-3.14.5.tar.gz
tar xf cmake-3.14.5.tar.gz
cd cmake-3.14.5
./configure
make
sudo make install

You can now execute cmake --version. If everything went smoothly, you will see the version you've just installed.

After this we will install .NET Core. There is always an up-to-date version of .NET Core installation instructions here: https://dotnet.microsoft.com/download/linux-package-manager/ubuntu18-04/sdk-current. You can choose your Linux version from the drop-down list and follow the instructions.

MacOs

Under MacOs we will need to install XCode Command Line Tools. If you already have full XCode installed, skip this step.

Open the Terminal and run

gcc --version

If you already have the tools installed, it will pring gcc version. If not, the dialog will appear which will prompt to install either XCode or just the Command Line Tools. You can install full XCode, but Command Line Tools alone will suffice for our job.

Now let's install Homebrew. It's a command-line package manager for MacOs, much like apt for Ubuntu. We will use Homebrew to install CMake.

Go to Homebrew page at https://brew.sh and execute the installation command at the top of the page.

Now just run:

brew cask install cmake

And after that install latest .NET Core SDK using instructions at https://dotnet.microsoft.com/download.

Creating a cross-platfrom C++ library

Now we will create a simple C++ library with hello() exported function which will print “Hello from [OS]” to console depending on the platform it's run on.

First of all, let's create a heder file for our library.

#ifndef TESTLIB_LIBRARY_H
#define TESTLIB_LIBRARY_H

#if defined DLL_EXPORTS
    #if defined WIN32
        #define LIB_API(RetType) extern "C" __declspec(dllexport) RetType
    #else
        #define LIB_API(RetType) extern "C" RetType __attribute__((visibility("default")))
    #endif
#else
    #if defined WIN32
        #define LIB_API(RetType) extern "C" __declspec(dllimport) RetType
    #else
        #define LIB_API(RetType) extern "C" RetType
    #endif
#endif

LIB_API(void) hello();

#endif //TESTLIB_LIBRARY_H

Let's see what happened here.

Include guard

There is a standard include guard to prevent our header to be included multiple times. I'm aware of #pragma once, but we will use old-style include guards for reasons stated here: caveats.

Import-export block

Library functions are not exported by default on Windows, so if you want to make some function available to be called from outside the library, you need to decorate this function with __declspec(dllexport). And if you want to call some exported function, you need to declare it with __declspec(dllimport) in your code.

Taking this into account we will have to distinguish between two cases:

  1. The header was included in the library itself and the fucntion needs to be exported.
  2. The header was included in some client code and the function needs to be imported.

Also, we always need to export and import our function with plain C semantics, as C++ tends to mangle function names in order to support classes. We can only call unmangled functions from .NET, so we need to always use extern "C" on our functions.

To make our function declarations easy, we define the LIB_API macro that takes the return type of the function as an argument. We also check for DLL_EXPORTS macro, which we will always define when compiling our library. This way, our library will export functions defined with LIB_API macro, and client applications will import those functions since there will be no DLL_EXPORTS macro defined.

As you can see, we also check for WIN32 macro, which means that our code is being compiled on Windows.

The function

And finally, we declare our function:

LIB_API(void) hello();

This is a simple function which doesn't receive any arguments and doesn't return anything.

The source

Now let's write the actual source code for our library:

#include "library.h"

#include <iostream>

#if defined(_WIN32)
#define OS "Windows"
#elif defined(__linux__)
#define OS "Linux"
#elif defined(__APPLE__)
#define OS "MacOS"
#else
#define OS "Unknown OS"
#endif

void hello() {
    std::cout << "Hello from " << OS << "!" << std::endl;
}

This is a dead-simple source file. First of all, we use some compiler predefined macros to distinguish between platforms: Windows, Linux or MacOs. If you want to have more fine-grained checks and detect stuff like iOS, you will have to write more sophisticated checks. We define the OS macro to substitute a string depending on the platform.

Then we just implement our hello() function and print a greeting to standard output.

Makefile

The last piece of the puzzle we need to compile our library is the makefile. We are using CMake, so we will have something like this:

cmake_minimum_required(VERSION 3.14)
project(TestLib)

set(CMAKE_CXX_STANDARD 17)

add_compile_definitions(DLL_EXPORTS)

add_library(TestLib SHARED library.cpp library.h)

This is, again, a very simple CMake file which will let us build our shared library under all three platforms. Notice the DLL_EXPORTS that we define for our build.

Building the library

Now we will build our native library for each platform. Note that to use CMake under Windows, you will need to use Developer Command Prompt for VS. Find it in the Start menu. Under Linux and MacOs you can just use your terminal of choice.

We will perform an out-of-source CMake build. This means that we will create a separate build directory and store all build artifacts there. Commands are identical for all three platforms:

cd [clone_dir]/TestLib
mkdir build
cd build
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_GENERATOR_PLATFORM=x64 ..
cmake --build . --config Release

You will find a compiled binary in build directory on Linux and MacOs and in build\Release directory on Windows. The library will have the .dll extension on Windows, .so extension on Linux and .dylib on MacOs.

Congratulations! You've just compiled a completely cross-platfrom library ready to be called from .NET Core!

In the next post we will create a .NET Core Console app, pack our native binaries as resources and learn how to extract and call them under different platforms.