Compiling Rust for .NET, using only tea and stubbornness!


In this article, I describe my journey creating a barely functional rust backend enabling compilation for the .NET runtime (Usually used to run C# and F#).

It is currently in the proof-of-concept stage, but I believe it still may be of some interest.

Why would one compile Rust for .NET runtime?

At first, one might dismiss the idea right away. Why on earth would you compile Rust to the .NET runtime, if you can compile it to all architectures and OSes that .NET supports? Why even bother? There are a couple of reasons why.

  1. It is pretty fun. I admit, there were moments where I was banging my head on the wall a little bit too much, almost waking my family at night. There were moments where I was reconsidering my life choices. But what is a good programming exercise, without some pain?
  2. It could make Rust/C# inter-op easier and FAR more powerful. Don't get me wrong, it is not all that hard right now. You do have to ship a compiled rust library for all the platforms you want to support, and use P/Invoke to interact with it, but that is all, in terms of complexity. But you are limited to C-ABI. You can't simply pass a managed C# array to Rust (without marshaling), or send managed references to C# objects to Rust. While this codegen does not allow that yet, it will in the future.
  3. It enables using fast Rust libraries from C#. When (and if) the project matures, you could write a fast Rust library, and provide a C#/F# compatible version. To all outsiders, this would seem just like yet another C# library, just faster and with a bit of unsafe sprinkled about.
  4. Much smaller memory footprint. Rust likes to guide you, to allocate all you can on the stack. This reduces memory usage, and produces nothing for GC to collect. This should make its life far easier. Additionally, Rust will allocate only unmanaged heap memory. What does that mean? This is the memory GC knows not to worry about. It is the job of the program to free it. This fits nicely with Rust's memory management.
  5. Ability to use Rust with .NET exclusive tools. Would it not be nice to just use Rust with all the numerous .NET tools? To potentially, far off in the future, write Unity games with Rust?
  6. One executable to rule them all! You could compile your program once and have it run on all platforms supporting .NET. Windows, macOS, Linux? x86, ARM? 64 or 32 bit? All supported by one executable.
  7. There already exists a version of C++ targeting .NET, so doing the same with Rust should be definitely possible! Besides, we don't want to be worse than the C++ people, do we ;)?
  8. I already have a some of experience with Rust/C# interop.

So, now that I explained why I am doing this, let's get right into the how!

How to compile anything for .NET

Compiling things for .NET may seem like a hard thing at first, but it is actually not. .NET uses the Common Intermediate Language, part of Common Language Infrastructure. It is an intermediate representation of C#/F# code. If you can emit fully valid CIL, it should be happily loaded by both the .NET runtime (Core CLR) and mono. There is a small, but important asterisk to this statement (foreshadowing), but more on this later.

How would one emit CIL? You could use the built-in C# APIs for code generation. That is very much what I will likely do in the future, but it has its own Rust-specific issues, and is generally just harder to do for this particular project. The great thing about CIL is that it has a human-readable form. What matters for our use-case is that it can be easily generated from the Rust side, and then turned into a compiled .NET assembly, using a tool called ilasm(Intermediate Language Assembler). This approach has 2 huge benefits: I can read the produced assembly and spot most of the mistakes. The C# decompiler can be used to get C# code equivalent to the assembly produced by my backend. It also helps verify that everything is going OK, because there is no way both ilasm and a C# decompiler would happily accept invalid IL that the .NET runtime would refuse to load, right?

Small snippet of CIL

C# code

public static int Add2(int P_0)
{
    return P_0 + 2;
}

CIL

.method public static hidebysig int32 Add2(int32){
    ldarg.0
    ldc.i4 2
    add
    ret
}

As you can see, CIL is stack based, and consists of a series of opcodes, manipulating this stack. Besides containing code, CIL also contains type info, but this is something I will go more in depth in another article.

How to compile Rust for anything

Rust is a very complicated language. Writing a compiler for it is hard, but we have more options for compiling Rust for any target we desire. Rust normally uses LLVM for code generation. But it does not have to: the rust compiler backend works as a plugin:

You can easily swap it out for another one, by simply pointing rustc to a relevant dynamic library using the -Z codegen-backend=YOUR_CODEGEN_HERE.dll flag. As long as it provides the relevant functions, rustc will pass you all the info needed for generating code for any crate. At this stage, a rust program is represented using the MIR(mid-level IR) format. It its relatively easy to handle, with a few quirks that can be ignored for our purposes. It seems very inspired by LLVM's IR, which makes sense, since it is usually converted to LLVM IR almost right away.

While MIR is not hard, getting to it is not a trivial task, since you first need to familiarize yourself with the relevant internal Rust APIs. I managed to get my backend to emit CIL for a simple identity function in about ~1.5 days, starting with little to no knowledge about how rustc works on the inside. Once you get the hang of it, building more complicated features does not take all that much time. It is, not, however, all rainbows and sunshine.

We are not in Kansas anymore

Rusts standard library, and a lot of the popular crates, are like a museum. While it does change, as new exhibitions are added, it is mostly finished. Each painting has a detailed explanation in 7 different languages underneath. Descriptions below each excitation are written beautifully, with detailed drawings, showing how everything works. It is so easy to navigate, one glance at the map is enough to find exactly what you are looking for. It is so convenient, you almost don't notice that you are learning something.

Internals of rustc are like a build site of a sprawling factory. You can see the scaffolds everywhere, as more production lines come online, and everything gets faster, better, bigger. Workers move around, knowing the place like the back of their hands. They can glance at the signs on the walls, and instantly tell you: where you are, what this place does and what pitfalls you should avoid. And you are a new hire who has just came for his first day at the new job. You look at the sign, and after some thinking, you too are able to tell roughly in which building you are. The signs almost always tell you what you need, just in short, cryptic sentences. You always can tell what is going on, with some thinking, but it is not effortless. The signs on the walls are not bad, just not written for anyone to get right away.

This is roughly the feeling you might get when reading the documentation of rustc internals. I wan't to once again reiterate: it is, by any means, bad. It is simply hard. And that is to be expected, since it is still being worked on.

So, if you want to start working anything related to compiler internals, manage your expectations. Don't just blast in, expecting to get everything right away. Equip yourself with some patience, and a big cup of your preferred beverage.

It is not bad, and is getting even better!

I am going to end this on a positive note: if you are reading this in a couple years time, there is high likelihood that this section is not relevant in any way. If you take a look at the documentation, it is clear that not only is it not in a "finished", state, there is a slow, but steady work made on improving it. Some sections have a little Clarification needed underneath, with an issue linked below. So, while it is not perfect, I am hopeful for the future.

I would like to also thank the people in Rusts zulip for all the help. I am a pretty shy person, who always overthinks everything, so I was pretty reluctant to ask any questions. Thankfully, it turns I once again stressed over nothing. The people there were very helpful, and answered my questions shockingly quick. They even gave me advice about things I had not yet asked! I am certain I would not get nearly as far into this project, without this help.

So, lets now get over this little tangent, and go back on track.

Steady progress

After some initial troubles, I had managed to push on and got the project to a state in which some simple demo libraries could be compiled. A lot of stuff was relatively easy to handle, so over a couple of weeks I had added support for all kinds of stuff. I was regularly checking the disassembled C# code, created from CIL I emitted, to ensure everything looked OK. After about 2 weeks, I had support for all the binary operations (add, bit-shift, e.t.c), some unary ops (e.g. bitwise not) and operations without arguments (e.g. sizeof). The codegen was also able to handle a lot of building blocks related to control flow, like if/else statements, simple match statements, and while loops. For loops are kind of special, since they require support for iterators and generics. Additionally, they are likely to require heavy optimization to work at comparable speed. I had also added support for calls, and a lot of types, such as references, structs, tuples, arrays and slices.

Arrays are harder than they look

It has latter turned out, that the current implementation of arrays is highly flawed, and JIT sometimes seems to just decide to optimize reads/ writes to and from arrays to NOPs. Why exactly? I can't know for certain, but I have an idea that could potentially fix this issue. The .volatile prefix to a CIL instruction disables certain optimizations, so it could help. I won't implement it right now, since this is not the only issue with arrays, which need to be redesigned anyway.

What is the other issue? I based my implementation upon the way C# handles fixed-size arrays. They essentially work by creating a hidden, nested struct type with a .size in definition specifying its size in bytes. Some keen-eyed people may have already seen the problem:

Since the size of a fixed-size array is declared in bytes, there is no way to have support for both 32 bit and 64 bit machines at the same time. Some types, such as pointers, have their size depend on how big the address space is. So an array of 10 pointers is 40 bytes on a 32 bit machine and 80 bytes on a 64 bit one. So, there are 3 options: have separate executables for 32-bit and 64-bit architectures, crash on 64-bit ones, or use far more memory than needed on already memory-limited 32-bit systems. There is an alternative solution, but it is kinda hacky and needs some more consideration. Its biggest drawback is that it would create many unnecessary types.

Even with those kinds of issues, is still pressed on, at a pretty steady pace, successfully compiling more, and more complicated snippets of rust code.

Running the code for the first time

Fairly confident in the project, I had decided to create a small Hello World example. Since using C# APIs from rust is still far off, I had hard-coded some functions from the C standard library. This is something I will need to do anyway, since Rusts stdlib depends on libc. This is the easiest way to bring Rust's stdlib to .NET, since it does not require any rewrites. But I digress. Using the putc function, I can print a simple message to the console. I run my backend, creating a CIL file containing the hello world program. With anticipation, I type ilasm hello. I start to smile as the words Operation completed successfully appear within the terminal. I feel a rush of adrenaline and endorphins. Sure, I had used my backend to compile more advanced libraries before, but all I knew is that they were valid CIL, and decompiled C# suggested they would work. This small hello world program would be the first snippet of Rust code to run within a .NET runtime. I type mono hello.exe, expecting the words Hello CLR from Rust! to appear on my screen. Instead, I am greeted with this little message:

Unhandled Exception:
System.InvalidProgramException: Invalid IL code in <Module>:main (intptr,byte**): IL_0000: ldc.i4.s  72

Oh... that is not a good sign.

ILASM is a filthy liar

You would assume that if some CIL code is blatantly wrong, ilasm would be unable to assemble it. This is sadly the case only with some mistakes. For some, it will not give you any warnings. You know what is worse? There is a subset of malformed CIL that the particular decompiler I was using(I can't say for all of them, I used only one) would turn into FULLY VALID C#, hiding the issue. You know what is even worse? For some issues, both the runtime I was using (mono) and CoreCLR gave me not all that much info about what was wrong. CoreCLR's message was too generic, and while mono at least told me which method caused the issue, it was wrong about what was the problem exactly.

The part of the message about invalid IL code, instruction position and the instruction itself could lead you to believe that the instruction in question ldc.i4.s was the issue. So I tried removing it. Issue persisted. So I removed more. Nothing. Even more. More. MORE. Until all that was left was a single ret opcode. What? How is that possible? There is almost nowhere the issue can be? The only part left, besides the function signature and name, is...

.locals

Any function in CIL has to declare all of its local variables. A valid declaration of locals looks something like this:

.locals(
   [0] int32,
   [1] float32,
   [2] int8,
   [3] int16*,
   [4] void*
)

And one with the particular issue I was facing looks like this:

.locals(
   [0] int32,
   [1] float32,
   [2] int8,
   [3] int16*,
   [4] void
)

You can't have a void type in locals. But why would you even have a void local in the first place? Rust likes to have locals with type void. And a LOT of them at that. Huh?

The reason void locals are so prevalent in MIR is mostly related to function calls (other sources of void locals exist too). The way MIR is designed, each function is assumed to return something. And this is a great assumption, that simplifies a lot of stuff. Both functions returning void and ones returning something are treated exactly the same. Code can just handle functions, not void functions and every other function. Additionally, this is very similar to how LLVM deals with void functions, so converting MIR to LLVM is easier too.

Why didn't I remove those void locals right away, and instead choose to emit CIL with them? Would they not be useless at best, make the compiled assembly larger, and could potentially confuse the JIT? Well, at this stage of the project, I don't actually want to emit optimal code. I want to emit code that is as similar to the original MIR as possible. That just makes debugging easier.

Wasn't the problem obvious right away? I mean, both C#/F# don't allow void locals, so why on earth would they be supported? In hindsight, it seems foolish to assume they would work. I thought that if both the ilasm and a decompiler had no problem with void locals, this would work out just fine. I had quickly written a piece of code to remove any instructions interacting with void locals, and then changed their type. I did not want to outright remove them, at least not for now, since that would make CILs locals not map 1 to 1 to MIR locals. This would make analyzing generated CIL harder, and I still need to do that very often. After this quick and dirty fix, I had reassembled the code, and run it. I sigh with relief, as I see the words:

Hello CLR from Rust

And this is where this story ends, at least for now.

What now?

Now that I know that compiling Rust for the .NET runtime is very much possible, I will need to clean the project up. It is very, very buggy and unstable. There are features that sometimes work, and sometimes do not. If I want to go any further, I need to rewrite some parts of the codegen. Currently, a lot of stuff is written with the assumption that ilasm will be used to assemble the final executable, which makes changing the CIL generator for a better one very hard. I also do things that are OK right now, but become a nightmare, the second I add support for generics. The way I assemble the final executable is also not all that great, since it is not really the way a rustc backend is supposed to create excutables. Overall, what happens now will be just a bit of boring, but necessary work.

Sadly I probably won't be able to work at anything similar to pace I worked on during those 2 weeks. As September comes closer by, so does the start of the school year, and the exams at the end of my high school. It will probably be wise to dial programming down, at least a little bit.

Still, if you want to, you can check out the project in its current state. Just beware of the issues, and that this is a proof-of-concept strung together with gum and tape.

I hope you enjoyed this little article!