I’ve been on a Rust learning journey lately, and it’s had me thinking about how I can consume Rust libraries from existing .NET applications. The .NET team has done much work regarding interoperability during the .NET 6 to .NET 8 era, and .NET 9 seems poised to continue that trend.
In this post, we’ll create a Rust library and consume it from a .NET application. This post assumes you have installed the .NET SDK and Rust SDK (cargo).
A Simple Rust Library
After creating a .NET Solution, I first created a new Rust library using cargo
. The command is relatively
straightforward.
cargo init --lib calculator
This creates a new calculator
library folder with the most critical files: Cargo.toml
and lib.rs
. Let’s update
the Cargo.toml
file to produce an artifact that our .NET application can consume, a dynamic library.
[package]
name = "calculator"
version = "0.1.0"
edition = "2021"
[lib]
name="calculator"
crate-type=["dylib"]
[dependencies]
rand = "0.8.5"
I’ve also included the rand
dependency for my Rust code later. Running the cargo b
command will now produce a
library we can copy into our .NET application.
Let’s write some Rust code we’ll consume from our .NET application later.
use rand::prelude::*;
use std::ffi::{c_char, CStr};
#[repr(C)]
#[derive(Debug)]
pub struct Point {
pub x: u32,
pub y: u32,
}
#[no_mangle]
pub extern "C" fn add(left: usize, right: usize) -> usize {
left + right
}
#[no_mangle]
pub extern "C" fn say_hello(name: *const c_char) {
let c_str = unsafe { CStr::from_ptr(name) };
println!("Hello, {}", c_str.to_str().unwrap())
}
#[no_mangle]
pub extern "C" fn random_point() -> Point {
let mut rng = rand::thread_rng();
Point {
x: rng.gen::<u32>(),
y: rng.gen::<u32>(),
}
}
#[no_mangle]
pub extern "C" fn distance(first: &Point, second: &Point) -> f64 {
let dx: f64 = (second.x - first.x).into();
let dy: f64 = (second.y - first.y).into();
println!("calculating distance...");
(dx.powf(2.0) + dy.powf(2.0)).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn say_hello_test() {
let _result = say_hello("Khalid".as_ptr() as *const i8);
assert!(true)
}
#[test]
fn get_random_point() {
let point: Point = random_point();
println!("{:?}", point);
assert!(true);
}
#[test]
fn can_calculate_distance() {
let one = Point { x: 1, y: 1 };
let two = Point { x: 2, y: 2 };
let result = distance(&one, &two);
println!("distance between {:?} and {:?} is {}", one, two, result);
assert!((result - 1.414).abs() < 0.001)
}
}
Notable elements of this Rust code include the following.
- The
extern
keyword adds to the list of functions and types in the foreign functions interfaces (FFI). -
no_mangle
tells the Rust compiler not to mangle the function’s name so that it can be referenced externally by a .NET application or similar external consumer. - The
"C"
value afterextern
tells the Rust compiler to compile to something C-compatible. There are other options here as well. - The
repr
attribute onPoint
states that this structure should be stored in memory in a C-compatible way. - All functions should use references to elements, which allows us to marshal information from one technology stack to
another with little to no overhead. You need to be careful not to introduce memory leaks here, hence the use
of
unsafe
.
Now let’s modify our .NET application for some Rust fun.
Building Rust from a .NET Project
I’ve used this trick several times across tools, and it works like a charm here with Rust. You can use the MSBuild task
of Exec
to execute commands.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<LangVersion>preview</LangVersion>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<Target Name="Rust Build " BeforeTargets="Compile">
<Exec Command="echo 'Configuration: $(Configuration)'"/>
<Exec Condition="'$(Configuration)' == 'Release'" Command="cargo b --release" WorkingDirectory="./../calculator"/>
<Exec Condition="'$(Configuration)' != 'Release'" Command="cargo b" WorkingDirectory="./../calculator"/>
</Target>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.48.1-preview.0.38"/>
<Content Include="../calculator/target/$(Configuration)/libcalculator.dylib">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
You’ll also need to set the AllowUnsafeBlocks
element to true
, or you won’t be able to access any library. Every
time we compile, we’ll build our Rust library and copy it to the project root. From there, we’ll copy the library to our
output directory for each build. Depending on your project, feel free to change when the Rust build occurs.
Let’s write some C# code.
Calling Rust from C#
Now, it’s just a matter of giving our C# code something to call. We can do this using .NET’s LibraryImportAttribute
.
public partial class Rust
{
[LibraryImport("libcalculator", EntryPoint = "add")]
public static partial int Add(int left, int right);
[LibraryImport("libcalculator", EntryPoint = "say_hello"
, StringMarshalling = StringMarshalling.Utf8)]
public static partial void SayHello(string name);
[LibraryImport("libcalculator", EntryPoint = "random_point")]
public static partial Point GetRandomPoint();
[LibraryImport("libcalculator", EntryPoint = "distance")]
public static partial double Distance(ref Point one, ref Point two);
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
[DebuggerDisplay("({X}, {Y})")]
public struct Point
{
public UInt32 X;
public UInt32 Y;
public override string ToString()
=> $"(x: {X}, y: {Y})";
}
Your interface must match the expectations set by the Rust library. Things like structures, types, and references must match, or you’ll likely get an error code of 139, and the executing application will halt.
using System.Diagnostics;
using System.Runtime.InteropServices;
using Spectre.Console;
var one = new Point { X = 1, Y = 1 };
var two = new Point { X = 2, Y = 2 };
var distance = Rust.Distance(ref one, ref two);
AnsiConsole.MarkupLine($"Distance between [yellow]{one}[/] and [red]{two}[/] is [green]{distance}[/]");
var (left, right) = (2, 2);
var result = Rust.Add(left, right);
AnsiConsole.MarkupLine($"[yellow]{left} + {right}[/] = [green]{result}![/]");
Rust.SayHello("Khalid");
AnsiConsole.MarkupInterpolated($"[red]{Rust.GetRandomPoint()}[/]");
public partial class Rust
{
[LibraryImport("libcalculator", EntryPoint = "add")]
public static partial int Add(int left, int right);
[LibraryImport("libcalculator", EntryPoint = "say_hello"
, StringMarshalling = StringMarshalling.Utf8)]
public static partial void SayHello(string name);
[LibraryImport("libcalculator", EntryPoint = "random_point")]
public static partial Point GetRandomPoint();
[LibraryImport("libcalculator", EntryPoint = "distance")]
public static partial double Distance(ref Point one, ref Point two);
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
[DebuggerDisplay("({X}, {Y})")]
public struct Point
{
public UInt32 X;
public UInt32 Y;
public override string ToString()
=> $"(x: {X}, y: {Y})";
}
LibraryImport
works with the DllImportAttribute
and is a source generator that correctly handles common memory
management issues with marshaling types like string
or reference types. You should use LibraryImport
instead of
writing the DllImport
code yourself.
The results are as you’d expect.
calculating distance...
Distance between (x: 1, y: 1) and (x: 2, y: 2) is 1.4142135623730951
2 + 2 = 4!
Hello, Khalid
(x: 2497287370, y: 698299366)
There you have it. We could call Rust code from a .NET Application with different levels of complexity. That’s pretty cool!
Conclusion
I want to thank Jeremy Mill, who wrote a blog post 2017 that helped me learn and experiment with this sample. I hope you found this post helpful, and be sure to share it with friends and colleagues. As always, thanks and cheers.