CLR Method Internals (.NET 2.0)

Simple tasks that you typically take for granted can be surprisingly complex when you peek under the hood. Method calls are the bread and butter of C# and VB programming, but a lot of moving pieces go into making that all work. In this article, we’ll take a quick look at how all of it works.

When a method call on the CLR is made, the caller and callee must communicate a set of information with each other. The abstraction that contains this information is called an activation frame. The caller supplies the this pointer for instance methods, additional arguments for the method, and return address information, while the receiver must give back the return value of the method, ensure that the stack has been cleaned up, and return to the caller’s address. All of this requires that a standard method-calling process be in place. This is referred to as a calling convention, of which there are several options on Windows.

Activation frames are implemented using a combination of registers and the physical OS stack, and are managed by the CLR’s JIT Compiler. There isn’t a single “activation frame object”; as noted above, it’s just a convention followed by the caller and callee. In addition to that, the CLR manages its own stack of frames to mark transitions in the stack, for example unmanaged to native calls, security asserts, and uses the information to mark the addresses of GC roots that are active in the call stack. These are stored on the stack and referred to by the Thread Environment Block (TEB).

There are a number of ways to make method calls on the CLR. From entirely static to entirely dynamic and everywhere in between (e.g. call, callvirt, calli, delegates), we’ll take a look at each. The primary difference between the various method calls is the mechanism used to find the target address to which the generated native code must call.

We’ll use this set of types in our examples below:

using System;
using System.Runtime.CompilerServices;

class Foo
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    public int f(string s, int x, int y)
    {
        Console.WriteLine("Foo::f({0},{1},{2})", s, x, y);
        return x*y;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public virtual int g(string s, int x, int y)
    {
        Console.WriteLine("Foo::g({0},{1},{2})", s, x, y);
        return x+y;
    }
}

class Bar : Foo
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    public override int g(string s, int x, int y)
    {
        Console.WriteLine("Bar::g({0},{1},{2})", s, x, y);
        return x-y;
    }
}

delegate int Baz(string s, int x, int y);

Furthermore, we’ll imagine the following variables are in scope for examples below:

Foo f = new Foo();
Bar b = new Bar();

The CLR’s jitted code uses the fastcall Windows calling convention. This permits the caller to supply the first two arguments (including this in the case of instance methods) in the machine’s ECX and EDX registers. Registers are significantly faster than using the machine’s stack, which is where the remaining arguments are supplied, in right-to-left order (using the push instruction).

More by Author

Get the Free Newsletter!

Subscribe to Developer Insider for top news, trends & analysis

Must Read