z The Flat Field Z
[ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ]

Nuget Packages with Native Dependencies

I often find myself having to consume native libraries in Nuget packages I publish. There are a lot of Nuget packages out there that do this, however there is very little up to date documentation about how you would do it yourself. Given the broad platform and architecture support of dotnet this can be decently complicated. Thankfully recent advancements in tooling have made this considerably easier, so let's get started.

You will need a text editor, Docker, and a way to run shell scripts. If you are on Windows you can run shell scripts with git bash, or even better WSL.

The Native Library

Consider the following C++ library:

#include <stdio.h>

extern "C"
{
  void HelloWorld(char* buffer, size_t bufferSize)
  {
    snprintf(buffer, bufferSize, "Hello World!");
  }
}

This is just assigning the string "Hello World!" to the "buffer" string parameter. This might seem overly complicated, but strings are dynamic in nature and returning them across the native/managed boundary is tricky. This example was chosen to illustrate a performant way to do just that.

Note that we are wrapping the call in an extern. While C technically doesn't have a standard ABI; in practice it is one of the best ways to communicate over boundaries like the native managed one.

The library you are consuming might end up being challenging to invoke from C#. In that case you can write a thin C++ wrapper library around that library that is easier to consume.

CMake

For those unfamiliar CMake is effectively meta build tooling. It can generate makefiles/projects/etc for a large number of targets such has Ninja or Visual Studio. Using it allows us to support a wide variety of toolchains for compilation.

CMake is configured with one or more CMakeLists.txt files. Here is a simple one for the library we just wrote:

cmake_minimum_required(VERSION 3.10)
project(hello CXX)

add_library(hello SHARED hello/hello.cpp)

As you can see we are building a shared library so we can call it from the managed code. Of course the native library might have dependencies of its own. The simplest way to deal with this is to statically link them into the library.

Dockcross

Now we have everything we need to compile the library but a compiler. In this case we will support the following platforms and architectures: win-x86, win-x64, linux-x86, and linux-x64.

Installing the toolchains for all of this would be cumbersome for a workstation, and very cumbersome for every build agent that you want to be able to build the Nuget package with. Thankfully we don't have to because of dockcross. Dockcross allows us to feed a docker image our source and CMakeLists.txt, and get a native library for a particular platform and architecture out. Here's how to install it:

# linux-x86
docker run --rm dockcross/linux-x86 > ./dockcross-linux-x86
chmod +x dockcross-linux-x86

# linux-x64
docker run --rm dockcross/linux-x64 > ./dockcross-linux-x64
chmod +x dockcross-linux-x64

# windows-x86
docker run --rm dockcross/windows-x86 > ./dockcross-windows-x86
chmod +x dockcross-windows-x86

# windows-x64
docker run --rm dockcross/windows-x64 > ./dockcross-windows-x64
chmod +x dockcross-windows-x64

Now lets's put together a build script to simplify the overall build process:

#!/usr/bin/env bash

echo ''
echo 'Building Windows/x86 Native Library'
(./dockcross-windows-x86 cmake -Bbuild-windows-x86 -H. -GNinja &&
./dockcross-windows-x86 ninja -Cbuild-windows-x86) ||
{ echo 'Fail: Could not build Windowx/x86 native library'; exit 1; }
echo 'Success: Windows/x86 native library built'

echo ''
echo 'Building Windows/x64 Native Library'
(./dockcross-windows-x64 cmake -Bbuild-windows-x64 -H. -GNinja &&
./dockcross-windows-x64 ninja -Cbuild-windows-x64) ||
{ echo 'Fail: Could not build Windowx/x64 native library'; exit 1; }
echo 'Succss: Windows/x64 native library built'

echo ''
echo 'Building Linux/x86 Native Library'
(./dockcross-linux-x86 cmake -Bbuild-linux-x86 -H. -GNinja &&
./dockcross-linux-x86 ninja -Cbuild-linux-x86) ||
{ echo 'Fail: Could not build Linux/x86 native library'; exit 1; }
echo 'Success: Linux/x86 native library built'

echo ''
echo 'Building Linux/x64 Native Library'
(./dockcross-linux-x64 cmake -Bbuild-linux-x64 -H. -GNinja &&
./dockcross-linux-x64 ninja -Cbuild-linux-x64) ||
{ echo 'Fail: Could not build Linux/x64 native library'; exit 1; }
echo 'Success: Linux/x64 native library built'

Running this build script will net you a native library for each of our desired platforms and architectures.

The Managed Library

Now we need a managed library that invokes the native library. Here is the code for that:

using System.Buffers;
using System.Runtime.InteropServices;

namespace HelloSharp
{
  public static class Hello
  {
    public static string HelloWorld()
    {
      var bufferSize = 200u;
      var buffer = ArrayPool<byte>.Shared.Rent((int)bufferSize);
      try
      {
        HelloWorld(buffer, bufferSize);
        return BufferToString(buffer);
      }
      finally
      {
        ArrayPool<byte>.Shared.Return(buffer, true);
      }
    }

    [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
    [DllImport("libhello", CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)]
    internal static extern void HelloWorld(byte[] buffer, uint bufferSize);

    private static string BufferToString(byte[] buffer)
    {
      var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
      try
      {
        return Marshal.PtrToStringAnsi(handle.AddrOfPinnedObject());
      }
      finally
      {
        handle.Free();
      }
    }
  }
}

There is actually a fair bit going on here. The call with extern is the one that actually calls the native library, and we'll start there. We set "DefaultDllImportSearchPaths" to "SafeDirectories". This is a layer of security to make sure the native library that gets loaded is the one we expect. We also exclude the extension of the native library in DllImport so the code will work for native libraries ending in "dll" and "so".

We then wrap the extern call up into something more friendly for callers. This call uses an ArrayPool to efficiently allocate and free the memory the native code needs, and then marshals it to a string.

We'll also need a project file:

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <AssemblyName>HelloSharp</AssemblyName>
    <RootNamespace>HelloSharp</RootNamespace>
    <Authors>Jonathan Hope</Authors>
    <AssemblyVersion>1.0.0.0</AssemblyVersion>
    <PackageVersion>1.0.0</PackageVersion>
    <Description>Prints "Hello World!".</Description>
  </PropertyGroup>

  <ItemGroup>
    <None Include="../build-windows-x64/libhello.dll" Pack="true" PackagePath="runtimes/win-x64/native" />
    <None Include="../build-windows-x86/libhello.dll" Pack="true" PackagePath="runtimes/win-x86/native" />
    <None Include="../build-linux-x64/libhello.so" Pack="true" PackagePath="runtimes/linux-x64/native" />
    <None Include="../build-linux-x86/libhello.so" Pack="true" PackagePath="runtimes/linux-x86/native" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="System.Buffers" Version="4.5.1" />
  </ItemGroup>

</Project>

The most important thing to call out here is the lines involving build. They copy the native libraries into the right directories in the Nuget package. As you can see the pattern is "runtimes/<runtime identifier>/native".

Tests

Now we'll obviously want to test this devilishly complicated code as well. The test:

using Xunit;
using HelloSharp;

namespace HelloSharp.Tests;

public class HelloTests
{
    [Fact]
    public void PrintsHelloWorld()
    {
        Assert.Equal("Hello World!", Hello.HelloWorld());
    }
}

There isn't anything worth pointing out in the tests Project so we'll skip it.

Build

Much like we use Docker to build the native libraries we will use Docker to build the Nuget package as well. To do that we'll need a Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build

COPY build-linux-x86/libhello.so ./build-linux-x86/
COPY build-linux-x64/libhello.so ./build-linux-x64/
COPY build-windows-x86/libhello.dll ./build-windows-x86/
COPY build-windows-x64/libhello.dll ./build-windows-x64/
COPY HelloSharp ./HelloSharp
COPY HelloSharp.Tests ./HelloSharp.Tests

RUN dotnet build HelloSharp/HelloSharp.csproj -c Release
RUN dotnet pack HelloSharp/HelloSharp.csproj -c Release -o /build

RUN dotnet nuget add source /build

RUN dotnet add HelloSharp.Tests/HelloSharp.Tests.csproj package HelloSharp
RUN dotnet build HelloSharp.Tests/HelloSharp.Tests.csproj -c Release
RUN dotnet test HelloSharp.Tests/HelloSharp.Tests.csproj -c Release

There is one bit of cleverness here. After we build the Nuget package we add the directory it is in to the tests project. We then add the freshly built Nuget package to the tests project. With this we can test the latest Nuget package in a controlled environment before it gets pulled out of the container.

We'll also need to add one more section to the build script:

echo ''
echo 'Building Nuget package'
(docker build -t hello-package -f Dockerfile.package . &&
CID=$(docker create hello-package) &&
docker cp ${CID}:/build . &&
docker rm ${CID}) ||
{ echo 'Fail: Could not build Nuget package'; exit 1; }
echo 'Success: Nuget package built'

All this does is spin up a docker container from the Dockerfile above, pulls the resulting Nuget package out of the container, and deletes it since we don't need it anymore.

After running the full build.sh script a Nuget package will be in the build directory.

Working Example

A working example of this can be found here.