Calling Rust from C#
There’s been a general call to arms at work for folks to begin shifting to the Rust programming language. Pair that with my overall distaste for C#, and you find yourself with a motivated software engineer eager to begin transitioning their project to Rust. Our approach for making the transition involves utilizing the foreign function interface capabilities of Rust and C#. I decided to document my entire process here, so that others may follow along and so that I may reference it should I ever find the need.
We will begin with a pairing of very simple C# and Rust programs to establish the connection between the two via the FFI. Once we’ve got that working we’ll make sure the solution is cross-platform, so as not to impede the workflow of any of our colleagues who likely prefer to work in Visual Studio rather than NeoVim.
Finally, we’ll setup the FFI function on the C# side to make it look like a typical C# method.
- Install dependencies
- Setup the project directory structure
- Write the code to make C# call the function implemented in a Rust library
- Modify the Rust library to export a function for C# to call.
- Configure MSBuild to build and relocate our Rust library
Install Dependencies
We’re going to have to install both the .NET SDK and Rust. If you already have these installed, skip to the Setup Project Directory section.
Install .NET SDK
Microsoft provides comprehensive instructions for installing the .NET SDK on their Learn site.
Install Rust
As of this writing, the Rust installation instructions can be found on their website.
~$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Note: Be sure to read directions after the installation succeeds regarding updating your shell environment to be aware of the newly installed Rust toolchain.
Setup Project Directory
Let’s establish the root of our project some place easy: ~/rs-in-cs.
~$ mkdir ~/rs-in-cs && cd ~/rs-in-cs
Create the .NET Solution / Project
Next, let’s create our .NET solution and project files.
~/rs-in-cs$ dotnet new create sln
This will create a solution file named rs-in-cs.sln in the root of our project directory.
Next, let’s create our C# project directory and file.
~/rs-in-cs$ mkdir app && cd app
~/rs-in-cs/app$ dotnet new create console --use-program-main
~/rs-in-cs/app$ cd -
Now an app.csproj and Program.cs files have been created. Let’s add a reference to our
project file into our solution file.
~/rs-in-cs$ dotnet sln add app/app.csproj
Before moving onto create our Rust library, let’s make sure we can build and run our C# program.
~/rs-in-cs$ dotnet run --project app/app.csproj
Create the Rust Library
~/rs-in-cs$ cargo new rustfn --lib
Note: Beware of the
cargo newcommand creating a.gitdirectory and.gitignorefile on your behalf. If you try to create a git repository out of this project later, you’ll get an error when trying to add this folder to your repository. Delete the.gitdirectory to be able to add therustfndirectory to your repository, otherwise you can ignore it.
Likewise with the C# program, let’s make sure our Rust library builds and the provided tests run.
~/rs-in-cs$ cd rustfn
~/rs-in-cs/rustfn$ cargo test
~/rs-in-cs/rustfn$ cd -
Establish the Foreign Function Interface Link
To get C# to call a Rust function, we’re going to have to keep switching back and forth between the two languages, setting them up to be able to interact with each other.
Modify the C# Program
The default Program.cs file generated by the dotnet command should look like the following:
namespace app;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
We’re going to move the Console.WriteLine call into it’s own static method as we prepare to replace
it with a function implemented in our rustfn library.
namespace app;
class Program
{
static void Main(string[] args)
{
PrintHelloWorld();
}
private static void PrintHelloWorld()
{
Console.WriteLine("C#: Hello, World!");
}
}
We’ve added a C#: prefix to the Hello, World message so that we can see from what language our
message is being printed.
We can run it again to verify:
~/rs-in-cs$ dotnet run --project app/app.csproj
C#: Hello, World!
Modify the Rust Library
Now let’s shift gears over to our Rust library. We’re going to strip this down and make it as simple as we can.
No parameters, no return value. Only a single println!().
Open the rustfn/src/lib.rs file and replace its contents with the following:
pub fn print_hello_world() {
println!("RS: Hello, World!")
}
We can run cargo build to verify that we didn’t break anything.
~/rs-in-cs/rustfn$ cargo build
Export the Rust Function
We have our simple Rust function, but as it is written, it isn’t ready for import by another language.
We need to decorate the rust function declaration a bit. First we apply the #[no_mangle] to prevent
the Rust toolchain from mangling the function symbol. We also need to mark the function symbol for export
via the extern "C" keyword.
#[no_mangle]
pub extern "C" fn print_hello_world() {
println!("RS: Hello, World!")
}
If we run cargo build at this point and check our target/debug directory, we will find a librustfn.rlib file
instead of a librustfn.so shared object file that we need for dynamic linking in our C# program.
We need to configure Rust via rustfn/Cargo.toml to generate the .so file.
We’re going to add libc = "*" under [dependencies], and at the end of the file, we’ll add
[lib]
crate-type = ["cdylib"]
Our final rustfn/Cargo.toml file should look like this:
[package]
name = "rustfn"
version = "0.1.0"
edition = "2021"
[dependencies]
libc = "*"
[lib]
crate-type = ["cdylib"]
Now, when we run cargo build it will produce a target/debug/librustfn.so shared ELF file for us.
Import the Rust Function into C#
Next, we switch back to our C# program to setup the FFI
import. This is where the magic happens. We’re going to use the DllImportAttribute to decorate a static
method declaration. Notice that we make sure the FFI declaration
matches the exported function signature from our Rust library.
[DllImport("rustfn")]
public static extern void print_hello_world();
we’re going to replace the implementation of our PrintHelloWorld method with a call to this declaration.
Note: We have to import the
System.Runtime.InteropServicesnamespace for theDllImportAttribute.
namespace app;
using System.Runtime.InteropServices;
class Program
{
[DllImport("rustfn")]
public static extern void print_hello_world();
static void Main(string[] args)
{
PrintHelloWorld();
}
private static void PrintHelloWorld()
{
print_hello_world();
}
}
As this is written, we can build it, but we won’t be able to run it. At least, not successfully.
~/rs-in-cs$ dotnet run --project app/app.csproj
Unhandled exception. System.DllNotFoundException: Unable to load shared library 'rustfn' or one of its dependencies.
It’ll throw a DllNotFoundException when it tries and fails to find librustfn.so.
One of the places it’ll look for the library is in the same directory as our built console application.
What we can do, for now, is copy the librustfn.so file built by cargo into the app/bin/Debug/net8.0 directory
and run our console app.
~/rs-in-cs$ cp rustfn/target/debug/librustfn.so app/bin/Debug/net8.0
~/rs-in-cs$ dotnet run --project app/app.csproj
RS: Hello, World!
Now, instead of seeing the C#: prefix in front of our Hello, World!, we should see RS: , indicating that
our Hello, World! is now coming from Rust instead of C#.
Congratulation! You got a C# program to call a function built in Rust!
Configure MSBuild
We got C# to call Rust, but building a shared object file with cargo build and then copying it into
the potentially changing folder of the console application binary isn’t sustainable. It’s time to configure
our tooling to handle this step for us making it nearly transparent.
Configure Debug Build
We’re going to add this
<Target> element inside of
the <Project> element in
our app/app.csproj file:
<Target Name="Rust Build" BeforeTargets="Compile">
<Exec
Condition="'$(Configuration)' == 'Debug'"
Command="cargo build"
WorkingDirectory="./../rustfn"
/>
</Target>
<Target Name="Rust Build" BeforeTargets="Compile">
We’re creating a new build target.
We’re naming it Rust Build and scheduling it to run before the built-in Compile target.
<Exec
We’re setting up the shell command that MSBuild is going to execute to build our Rust library.
Condition="'$(Configuration)' == 'Debug'"
We’re configuring MSBuild to conditionally execute the command we’re setting up. Only when the built-in
MSBuild variable Configuration is set to the value 'Debug' will we run the following command.
Command="cargo build"
This is the shell command that MSBuild is going to run. This is the same command we’ve been using ourselves to get Rust to build the debug version of our library.
WorkingDirectory="./../rustfn"
This is the directory from which MSBuild will run cargo build. The path is relative to our app.csproj file.
We need to get MSBuild to run cargo build from the Rust library’s root directory.
Configure Release Build
Within the previously created <Target> element, we create another
<Exec> element, only changing the
Condition and the Command attributes:
<Exec
Condition="'$(Configuration)' == 'Release'"
Command="cargo build --release"
WorkingDirectory="./../rustfn"
/>
We change the Condition to only match when the build Configuration is 'Release'. And we add --release to our
cargo build command so that it builds the release version of our Rust library.
Our final <Target> element looks like this:
<Target Name="Rust Build" BeforeTargets="Compile">
<Exec
Condition="'$(Configuration)' == 'Debug'"
Command="cargo build"
WorkingDirectory="./../rustfn"
/>
<Exec
Condition="'$(Configuration)' == 'Release'"
Command="cargo build --release"
WorkingDirectory="./../rustfn"
/>
</Target>
Copy Shared Object File (Linux)
We’re going to add an
<ItemGroup> element
into our <Project> element. This <ItemGroup> is going to detail files that we need to copy from one
place to another.
<ItemGroup>
<Content
Condition="Exists('./../rustfn/target/$(Configuration.ToLower())/librustfn.so')"
Include="./../rustfn/target/$(Configuration.ToLower())/librustfn.so"
>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
The <Content> element
represents files that are to be embedded within our C# project.
Condition="Exists('./../rustfn/target/$(Configuration.ToLower())/librustfn.so')"
Only when this Condition evaluates to true do we Include (copy) our librustfn.so file.
The Condition is that a particular file Exists; if the file doesn’t exist then we ignore this element.
An issue that comes up is that MSBuild uses Release and Debug for their Configuration values, but Rust
uses release and debug for their directory names during the build process. To be able to utilize the
MSBuild Configuration value in these rules, we use the .ToLower() method to convert 'Release' into 'release',
and likewise with 'Debug'.
The <CopyToOutputDirectory> value determines when to copy the Include file to the project output directory.
Permitted values are Never, Always, and PreserveNewest.
Copy Dynamic Link Library (Windows)
The previous <Content> element we just added works when running on Linux. But if we want this to also work in
Visual Studio on Windows, we need to recognize that cargo build will generate rustfn.dll instead of librustfn.so.
So we add another rule to copy the rustfn.dll file when it exists. Within the same <ItemGroup> element, add another
<Content> element:
<Content
Condition="Exists('./../rustfn/target/$(Configuration.ToLower())/rustfn.dll')"
Include="./../rustfn/target/$(Configuration.ToLower())/rustfn.dll"
>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
Test Our Build
Now we should be able to delete all of our previously built binaries, run a single dotnet build and then
dotnet run --project app/app.csproj should “just work.”
~/rs-in-cs$ rm -rf app/bin rustfn/target
~/rs-in-cs$ dotnet build
Determining projects to restore...
All projects are up-to-date for restore.
Configuration: Debug
Compiling libc v0.2.157
Compiling rustfn v0.1.0 (~/rs-in-cs/rustfn)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
app -> ~/rs-in-cs/app/bin/Debug/net8.0/app.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:00.86
~/rs-in-cs$ dotnet run --project app/app.csproj
RS: Hello, World!
Make it Pretty
Finally, let’s make our FFI declaration look more like a C#
method (CamelCase) and less like a Rust function (snake_case).
[DllImport("rustfn", EntryPoint = "print_hello_world")]
private static extern void PrintHelloWorld();
With EntryPoint we specify the exported Rust library symbol, enabling us to name the declaration whatever we want.
Now, our Program.cs file should look like this:
namespace app;
using System.Runtime.InteropServices;
class Program
{
[DllImport("rustfn", EntryPoint = "print_hello_world")]
private static extern void PrintHelloWorld();
static void Main(string[] args)
{
PrintHelloWorld();
}
}
Make sure it works:
~/rs-in-cs$ dotnet build && dotnet run --project app/app.csproj
Determining projects to restore...
All projects are up-to-date for restore.
Configuration: Debug
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
app -> ~/rs-in-cs/app/bin/Debug/net8.0/app.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:01.24
RS: Hello, World!
That was a lot, and this is a good stopping point. I’ll resume this topic in a subsequent post.
To recap, we established a project directory. Created a .NET Solution and Project. Created a Rust library. We
setup our Rust library to export a clean (#[no_mangle]) symbol for our print_hello_world function. We setup
our C# project to dynamically (at runtime) import the print_hello_world symbol into its code space. We called the
print_hello_world method from C# to print out RS: Hello, World!. Then we setup our MSBuild system to build our
Rust library whenever we build the C# project and copy the Rust library into the C# project output directory so
that the C# program is able to find the library at runtime.