16 May 2017
Rusting up Some Iron
Rust is one of my favourite languages. One of it’s great strengths is its ecosystem of crates, another is the FFI system which enables writing native extensions when performance or safety is required. Over the last week I’ve been pulling these two together in IronRure, a .NET Standard wrapper around the Rust
I started this with zero knowledge of FFI in C#. I was pleasantly surprised with how easy P/Invoke was to use. There’s plenty of documentation on its use from both Microsoft and Mono. I feel I should pull together the lessons I’ve learned though. Don’t hesitate to comment if you think I’m Doing it Wrong.
Stage 1 - Quick and Dirty
The first step is to get the compiled Rust code next to your managed assembly so that P/Invoke can find it and load it. The simplest way to get this working is to compile the Rust ahead of time with
cargo and add a direct
Content reference to the output. Here’s what I started with:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard1.4</TargetFramework> </PropertyGroup> <ItemGroup> <Content Include="..\..\thirdparty\regex\regex-capi\target\release\rure.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup> </Project>
Since I was targeting .NET Standard I wanted this to work on Windows, Mac and Linux. To solve that we’ll need to wrap the native code in a NuGet package. Content files will get you a long way though when messing about with an idea.
Going Cross Platform
The first step in making sure that your bindings will work on different platforms is to make sure that
DllImport statements aren’t platform specific. Luckily Rust libraries have a well known naming structure which matches up with what .NET expects.
[DllImport("foo")] public static extern void hello();
DllImport wihout a
lib prefix and
.dylib suffix the runtime will pick the correct file on Linux, Windows and Mac!
Packing it Up
Although .NET code is not targeted to a specific CPU your native Rust code is. In .NET the underlying target architecture is called the Runtime Identifier, or RID for short. RIDs are arranged into a tree. If you’re running on
win10-x64 the runtime will try to load native code for the
win RIDs when installing a NuGet package. We can exploit this to create a set of native-only NuGet packages containing just the compiled Rust code which are then referenced as NuGet dependencies in the managed FFI wrapper.
You could create a different NuGet package for each runtime identifier being targeted. I chose however to create a single package for each operating system and include all the runtime identifiers I was supporting for that OS. On Mac there is only a single RID to target really:
osx. After compiling the crate (using the
--release flag) the output needs to be copied so it has the following structure:
contents/ └── runtimes └── osx └── native ├── librure.a ├── librure.d └── librure.dylib
On Windows, where we are targeting more than one RID it is a bit more complex:
contents/ └── runtimes ├── win-x64 │ └── native │ ├── rure.lib │ ├── rure.d │ └── rure.dll └── win-x86 └── native ├── rure.lib ├── rure.d └── rure.dll
We can then pack this up into a NuGet package with the following
<?xml version="1.0" encoding="utf-8"?> <package> <metadata> ... </metadata> <files> <file src="contents\**" /> </files> </package>
The FFI wrapper library just needs to add a
PackageReference like any other library. When you compile the appropriate native library will be picked from its NuGet and placed next to the output program ready for P/Invoke to find.