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.

  1. Install dependencies
  2. Setup the project directory structure
  3. Write the code to make C# call the function implemented in a Rust library
  4. Modify the Rust library to export a function for C# to call.
  5. 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 new command creating a .git directory and .gitignore file 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 .git directory to be able to add the rustfn directory 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.InteropServices namespace for the DllImportAttribute.

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.

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.