When you come from C++ to C# it’s such a relief. C# is a greatly designed language, you can do almost anything you want with it, apart from some design choises like macroses and C++ template<>.
But sometimes we can find ourselves near the wall which you can’t pass with C# itself.
IL Comes To A Rescue
In that case, we can use IL, and build a library or modify existing library with it. C# or other .NET language like VB.NET getting compiled into MSIL which is “Intermediate Language”. Basically it’s something similar to the assembly language, but much easier. All of your DLLs or EXEs built with .NET actually consists of IL.
Let’s make a simple code:
public class C {
public void M() {
Console.WriteLine("Hello there!");
}
}
It’s getting compiled to:
.method public hidebysig
instance void M () cil managed
{
.maxstack 8
IL_0000: ldstr "Hello there!"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: ret
}
You can check it yourself on the sharplab.io
What can we see there? For such a simple example we only have 3 instructions:
IL_0000: ldstr "Hello there!" // load string to the execution stack
IL_0005: call void [System.Console]System.Console::WriteLine(string) //call the function
IL_000a: ret //return
When calling a function, it will use data on execution stack as an arguments. “void [System.Console]System.Console::WriteLine(string)” is a function signature or description.
Well, in simple terms think of that as a reversed queue, LIFO (Last in first out). We put data on the stack to use it later in the functions or with instructions like mul/div/add etc.
Ok, nothing fancy here, how do we actually compile IL?
You might ask
There are couple of options:
- We can create a C# project, and use System.Reflection.Emit class to create assembly, then classes/structs, then methods, fields and finally use ILGenerator to add instructions to the function.
- We can use .NET tool ilasm.exe (usually it comes with .NET SDK in C:\Windows\Microsoft.NET\Framework64\[version]\ilasm.exe) and provide IL file
- Use an existing dll and add the code with dnSpy.
For the same of simplicity, I’ll use dnSpy. Create an empty C# library project in your IDE, and compile it. Then find the output DLL and drag it into dnSpy.
Ok, what’s next? Let’s create a method, click with the right mouse button on Class1 class, and select Add Class Members (C#). And add the following code:
public static int TestCall(int argument)
{
return 550;
}
After you click ok, there would be a new method named TestCall, you can right-click it and select “Edit Method Body”. You’ll end up with this code:
ldc.i4 0x226 //hexadecimal respresentation of 550
ret
Right, what’s going on there? Well, we just put 0x226 on the stack and return. If the function have a return value, the last value from the stack will be returned back to the caller (in that case it’s int32 = 0x226). Let’s create a new function which calling our TestCall to see more.
public static void CallExample()
{
Class1.TestCall(21);
}
Get to the IL:
ldc.i4.s 0x15 // *.s instructions is a "short form", eg we only put small amount of data
call int32 Empty.Class1::TestCall(int32)
pop //we don't need a return value, our function is void
ret //return from the function.
Right, that was simple, I guess, but what if I say you that you can utilize IL to your profit, like calling an arbitrary method by a pointer?
There are couple of ways you can utilize it. First of all: call unmanaged functions by pointer. Second one: call an arbitrary managed function given by pointer without GC allocations for creating new delegates etc.
The answer to a question “What can I use it for?”
Let’s call our method by a pointer, first we start of creating new function “Caller”:
public static void Call(IntPtr pointer)
{
return;
}
Jump to the IL and let’s modify it:
ldc.i4.8 //put integer value of 8 on stack
ldarg.0 //load first argument on the stack (when using calli instruction, pointer of the function should come last)
calli int32 (int32) //call the function by pointer with int32 return value (first) and single argument of type int32 (managed calling convention)
ret //return
Click ok, and see new C# code!
public static void Call(IntPtr pointer)
{
/*
calli is not allowed in C#, but perfectly allowed in IL
with managed call convention, unfortunately dnSpy doesn't show it, nor allows to change that.
*/
calli(System.Int32(System.Int32), 8, pointer);
}
OpCodes.Calli is the opcode (instruction) which can call an arbitrary method with given signature using different calling conventions. Like cdecl/stdcall/thiscall (native) or managed.
Now you can use this wrapper function to call function to pointer with 8 as argument 🙂
This could be extended much, much further. Yes, you can add generics, other input and output types and also use class-object values.
Key moments
Calli opcode can allow you to call an arbitrary function by a pointer avoiding ANY GC allocations which usually come when you use MethodInfo.Invoke(new object[]{..args..}). And you also don’t need to create new delegates, which is not possible to do in AOT environments, like NativeAOT or IL2CPP.
When editing IL for couple of first times, dnSpy is ideal, because it shows you C# code and checks if it’s compilable.
When calling other function, it’s pointer should loaded last, other arguments comes in their respective order.
IL is a good extension where C# doesn’t do the thing
IL allows you to take pointers of class objects: the article on object pointers
List and documentation for all of the IL opcodes: Microsoft docs
In the new C# version you can use unsafe delegates (but that’s not the case for Unity and .NET Framework): C# 9.0 Function Pointers
Home work
Check the ilasm.exe and ildasm.exe tools (you can google where you can find their binaries). First you can start with disasming your dll with ildasm, it allows to export all the library into a single IL file, which then can be compiled back to dll with ilasm.