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:
- Visit https://cmake.org/download/ and write down the version of the latest stable release. In my case it's
3.14.5
. - 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:
- The header was included in the library itself and the fucntion needs to be exported.
- 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.