Creating Common Intermediate Language projects with .NET SDK

Β· 1300 words Β· 7 minutes to read

When you compile your C#, F# or VB.NET code, which are all high-level managed languages, the relevant compiler doesn’t compile it to native code, but instead it compiles it into the Common Intermedia Language.

The IL code is then just-in-time (not always, but let’s keep things simple) compiled by the CLR/CoreCLR to machine code that can be run on the CPU. What I wanted to show you today, is that with the new Microsoft.NET.Sdk.IL project SDK, it is actually quite easy to create and build projects in pure IL.

Let’s have a look.

Project SDKs πŸ”—

The new project system for .NET allows usage of custom project SDKs to support building a wide array of application types. You add an SDK to your project at the top of the project file, for example, the standard one for libraries and console apps is Microsoft.NET.Sdk.

<Project Sdk="Microsoft.NET.Sdk">

There are quite a few other project SDKs - ASP.NET Core uses Microsoft.NET.Sdk.Web, .NET Core workers use Microsoft.NET.Sdk.Worker and so on. For the most part these things do not really concern the developers, as they are set from the project template and then never change.

One interesting project SDK that is not widely known, is Microsoft.NET.Sdk.IL. It allows us to write .NET code (libraries, applications) using pure IL, instead of a higher level language. This can then be built via dotnet CLI, using ILASM as desktop FX, .NET Core or .NET Standard.

Getting started with Microsoft.NET.Sdk.IL πŸ”—

The SDK is actually not published to the public NuGet feed - therefore to take advantage of it, you need to add a custom NuGet feed of Core CLR to your NuGet.config.

https://dotnet.myget.org/feed/dotnet-core/package/nuget/Microsoft.NET.Sdk.IL

This is the old feed, but it’s where we can find the “old” version of the SDK, that was built for .NET Core 3.0. On the new feed, there is currently only latest build available, that’s already adapted towards .NET 5.0 (or .NET Core 5.0, the name is apparently not set yet).

Once the feed is added, you could also add the following global.json file, indicating that the SDK of this particular version should be used:

{
    "msbuild-sdks": 
    {
      "Microsoft.NET.Sdk.IL": "3.0.0-preview-27318-01"
    }
}

Bootstrapping an IL project πŸ”—

Equipped with such setup, we could create our project. The extension for IL projects is ilproj, but structurally it is very similar to csproj.

In my case it will look the following:

<Project Sdk="Microsoft.NET.Sdk.IL">
    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <MicrosoftNetCoreIlasmPackageVersion>3.0.0-preview-27318-01</MicrosoftNetCoreIlasmPackageVersion>
        <IncludePath Condition="'$(TargetFramework)' == 'netstandard2.0'">include\netstandard</IncludePath>
        <IlasmFlags>$(IlasmFlags) -INCLUDE=$(IncludePath)</IlasmFlags>
    </PropertyGroup>
</Project>

We are specifying here the reference to the Microsoft.NET.Sdk.IL SDK again, as well as the version of the ILASM package, which should be the same as the SDK itself.

I will be building for .NET Standard 2.0 only, but there is nothing preventing you from targeting other frameworks. For each of the supported frameworks, you could create an include folder in order to include certain common references. In my case, I will include the reference to the CorLib. To do that I create the path include\netstandard\coreassembly.h with the following contents:

#define CORE_ASSEMBLY "System.Runtime"

.assembly extern CORE_ASSEMBLY
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
  .ver 4:0:0:0
}

Writing IL code πŸ”—

At this point, the only thing left is to write some IL code. I therefore create a File.il and, first, need to import the inclusion of core lib, as well as define the assembly and module. This is the bare minimum that is needed.

#include "coreassembly.h"

.assembly SampleIL
{
  .ver 1:0:0:0
}

.module SampleIL.dll

The rest will be my regular code - since this is a library not a console app, I do not need to create an entry point. I will, however, create a Hello type, with a World() method (sounds particularly exciting, doesn’t it?).

.class public auto ansi beforefieldinit Hello
  extends [CORE_ASSEMBLY]System.Object
{
  .method public hidebysig static string World() cil managed
  {
    ldstr      "Hello World!"
    ret
  }
}

With this code in place, we can now build our program using a regular dotnet build {path-to-my.ilproj} command. The output should look more or less like this:

Z:\Documents\dev\il-sample>dotnet build SampleIL\SampleIL.ilproj
Microsoft (R) Build Engine version 16.4.0+e901037fe for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 27.2 ms for Z:\Documents\dev\il-sample\SampleIL\SampleIL.ilproj.
  SampleIL -> Z:\Documents\dev\il-sample\SampleIL\bin\Debug\netstandard2.0\SampleIL.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:00.93

At that point we have a valid DLL we can load from any project compatible with .NET Standard 2.0 and use it. In this case, at the C# surface level, the usage would simply be:

    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(Hello.World());
        }
    }

What can you do with this? πŸ”—

Well, obviously, you can let your imagination be the limit. The most apparent use case is to do all kinds of micro-optimizations, especially if you feel like the compiler is not doing enough. That said, you could also do some completely absurd things with it too - after all it’s raw IL.

For example, you could remove the inheritance from System.Object:

.class public auto ansi beforefieldinit Hello
{
  .method public hidebysig static string World() cil managed
  {
    ldstr      "Hello World!"
    ret
  }

and add an ILASM switch to disable automatic inheritance to our ilproj:

 <IlasmFlags>$(IlasmFlags) -INCLUDE=$(IncludePath) -noautoinherit</IlasmFlags>

This DLL will now build correctly, and we have managed to achieve the impossible - we now have a DLL, that was successfully emitted, where we have a type that doesn’t inherit from System.Object, which is really forbidden. As thrilling as this is, it is, unfortunately, equally useless, since any attempt to load and use this DLL will result in a type load exception.

We could also, given the fact that we are in IL, do some other things that are forbidden in C#. For example overload by return type. I could add a second World() method to our Hello type - differing from our previous one by return type only:

.class public auto ansi beforefieldinit Hello
  extends [CORE_ASSEMBLY]System.Object
{
  .method public hidebysig static string World() cil managed
  {
    ldstr      "Hello World!"
    ret
  }

    .method public hidebysig static int32 World() cil managed
  {
    ldc.i4      42
    ret
  }
}

We now have two Hello.World() methods, one returning a string and one returning an int. This is impossible to do in C# and the compiler would not allow it, but it’s allowed in IL, since you have to specify the return type when you call a method there.

So this DLL builds, and a quick peek via decompiler reveals that indeed our plot worked and we have a DLL that really is impossible to produce with C# code and the Roslyn compiler:

using System;

public class Hello : Object
{
    public static String World()
    {
        return "Hello World!";
    }

    public static Int32 World()
    {
        return 42;
    }
}

To be fair, again, when we try to use it from a C# program, and invoke Hello.World() there, the compiler will not know which method to select and complain:

CS0121  The call is ambiguous between the following methods or properties: 'Hello.World()' and 'Hello.World()'

What is interesting is that it would work with reflection, for example, the following code compiles and executes just fine:

var intOverload = typeof(Hello).
    GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static).
    First(m => m.Name == "World" && m.ReturnType == typeof(int));
    
var stringOverload = typeof(Hello).
    GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static).
    First(m => m.Name == "World" && m.ReturnType == typeof(string));

Console.WriteLine(intOverload.Invoke(null, null));
Console.WriteLine(stringOverload.Invoke(null, null));

Summary πŸ”—

These are all little useless hacks and quirks, but hopefully they illustrate the really cool aspect of being able to just write raw IL. The code from this article is available on Github. I hope it will help you get started building IL projects.

For “real world” use cases I recommend you check out System.Runtime.CompilerServices.Unsafe which is build using the Microsoft.NET.Sdk.IL SDK. The library can be used to do a bunch of unsafe operations like unsafe casts - in a way that would not be possible to be expressed in C#.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP