This is the first part of the .NET Inside Out series where I play with CLR internals. For your convenience you can find other parts using the links below (or by guessing the address):
Part 1 — Virtual and non-virtual calls in C#
Part 2 — Handling and rethrowing exceptions
Part 3 — How to override sealed function
Part 4 — How to override sealed function revisited
Part 5 — Capture thread creation to handle exceptions
Part 6 — Proxy handling casting
Part 7 — Generating Func from a bunch of bytes
Part 8 — Handling Stack Overflow Exception in C# with VEH

Today we are going to dive into function invocation mechanism in .NET. We will use C# language to prepare few applications, then we will examine IL for these applications, finally, we will see the jitted machine code. Let’s go!

Theory

In C# we have multiple types of functions. We have static functions which we need to call using class name. We have virtual functions which we can override using inheritance. We also have instance functions which are not virtual. Syntax for invoking all these functions in C# is the same — we simply use parenthesis after the full name and we are done. However, in IL there are different instructions for calling different methods. Let’s see the difference.

Static functions

It is usually said that static functions (e.g., class functions) are called using call opcode. This is available because we are able to determine address of a function during compilation so we can hardcode the address in the IL code.

Instance functions

Non-virtual instance functions works almost the same as static functions, however, they need another thing — instance of a class for which we call the method. This is why we cannot use the same opcode — we need to verify whether the reference is null or not. In former case we need to throw appropriate exception. To call non-virtual instance functions we use callvirt opcode. This opcode is also capable of calling virtual instance functions.

Virtual functions

In order to be able to call virtual functions .NET uses dispatch table. This is the most common way of implementing this mechanism, it is also used in most C++ compilers. Basically, every type contains it’s own table of functions with pointers to actual implementation. Imagine that we have a function called Foo in base class, and we override this function in derived class. Both types will contain this method in their tables, however, the pointers to implementation will differ. Having that CLR is able to invoke function basing on the actual type, because it simply examines the dispatch table and calls function. However, this is slower than calling static function because CLR needs to extract the function address from the dispatch table.

Calling instance functions other way

In theory it is possible to call virtual function using call opcode — without checking for null. If we wouldn’t use this in method then everything should work fine.

Practice

Let’s see some xamples. I will use .NET 4.5.2 on Windows 10 x64. I will compile codes as Release with Any CPU and debug them using WinDBG x86. Let’s begin.

Static functions

Let’s start with the following code:

using System;
using System.Runtime.CompilerServices;

namespace Calls
{
    class Program
    {
        static void Main()
        {
            Functions.Static();
        }
    }

    public class Functions
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public static void Static()
        {
            Console.WriteLine("Static");
        }
    }
}

Nothing fancy here. We simply call a static method from other class. We also add attribute which will disable inlining. Let’s disassemble the code using ILSpy:

.method private hidebysig static 
	void Main () cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 6 (0x6)
	.maxstack 8
	.entrypoint

	IL_0000: call void Calls.Functions::Static()
	IL_0005: ret
} // end of method Program::Main

We can see that we indeed call the method using call opcode. Let’s now execute the app and see the generated machine code:

Microsoft (R) Windows Debugger Version 6.3.9600.17298 X86
Copyright (c) Microsoft Corporation. All rights reserved.

CommandLine: "C:\Users\user\Documents\Visual Studio 2015\Projects\Calls\Calls\bin\Release\Calls.exe"
Symbol search path is: *** Invalid ***
****************************************************************************
* Symbol loading may be unreliable without a symbol search path.           *
* Use .symfix to have the debugger choose a symbol path.                   *
* After setting your symbol path, use .reload to refresh symbol locations. *
****************************************************************************
Executable search path is: 
ModLoad: 008f0000 008f8000   Calls.exe
ModLoad: 77a30000 77ba9000   ntdll.dll
ModLoad: 73830000 73889000   C:\windows\SysWOW64\MSCOREE.DLL
ModLoad: 776b0000 777a0000   C:\windows\SysWOW64\KERNEL32.dll
ModLoad: 74b20000 74c96000   C:\windows\SysWOW64\KERNELBASE.dll
ModLoad: 62430000 624c1000   C:\windows\SysWOW64\apphelp.dll
(3e54.36a4): Break instruction exception - code 80000003 (first chance)
*** ERROR: Symbol file could not be found.  Defaulted to export symbols for ntdll.dll - 
eax=00000000 ebx=00000000 ecx=48430000 edx=00000000 esi=008f0080 edi=ff2f9000
eip=77ad3c85 esp=00a8f9f8 ebp=00a8fa24 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
ntdll!LdrInitShimEngineDynamic+0x715:
77ad3c85 cc              int     3

We load executable and we are ready to execute it. Let’s start it and let it work till the end.

0:000> g
ModLoad: 75240000 752bb000   C:\windows\SysWOW64\ADVAPI32.dll
ModLoad: 76900000 769be000   C:\windows\SysWOW64\msvcrt.dll
ModLoad: 774b0000 774f3000   C:\windows\SysWOW64\sechost.dll
ModLoad: 77920000 779cc000   C:\windows\SysWOW64\RPCRT4.dll
ModLoad: 74b00000 74b1e000   C:\windows\SysWOW64\SspiCli.dll
ModLoad: 74af0000 74afa000   C:\windows\SysWOW64\CRYPTBASE.dll
ModLoad: 74a90000 74ae9000   C:\windows\SysWOW64\bcryptPrimitives.dll
ModLoad: 737b0000 73829000   C:\Windows\Microsoft.NET\Framework\v4.0.30319\mscoreei.dll
ModLoad: 750b0000 750f4000   C:\windows\SysWOW64\SHLWAPI.dll
ModLoad: 76bd0000 76d8a000   C:\windows\SysWOW64\combase.dll
ModLoad: 74cb0000 74dfd000   C:\windows\SysWOW64\GDI32.dll
ModLoad: 76d90000 76ed0000   C:\windows\SysWOW64\USER32.dll
ModLoad: 75440000 7546b000   C:\windows\SysWOW64\IMM32.DLL
ModLoad: 769c0000 76ae0000   C:\windows\SysWOW64\MSCTF.dll
ModLoad: 749c0000 749eb000   C:\windows\SysWOW64\nvinit.dll
ModLoad: 74a80000 74a88000   C:\windows\SysWOW64\VERSION.dll
ModLoad: 74970000 749b6000   C:\PROGRA~2\Sophos\SOPHOS~1\SOPHOS~1.DLL
ModLoad: 75480000 75486000   C:\windows\SysWOW64\PSAPI.DLL
ModLoad: 74ca0000 74cac000   C:\windows\SysWOW64\kernel.appcore.dll
ModLoad: 68a80000 69131000   C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll
ModLoad: 73540000 73635000   C:\windows\SysWOW64\MSVCR120_CLR0400.dll
(3e54.36a4): Unknown exception - code 04242420 (first chance)
ModLoad: 67930000 68a7a000   C:\windows\assembly\NativeImages_v4.0.30319_32\mscorlib\225759bb87c854c0fff27b1d84858c21\mscorlib.ni.dll
ModLoad: 777a0000 7788a000   C:\windows\SysWOW64\ole32.dll
ModLoad: 73730000 737ae000   C:\Windows\Microsoft.NET\Framework\v4.0.30319\clrjit.dll
ModLoad: 77410000 774a2000   C:\windows\SysWOW64\OLEAUT32.dll
*** ERROR: Symbol file could not be found.  Defaulted to export symbols for C:\windows\SysWOW64\KERNEL32.dll - 
eax=00000000 ebx=00000001 ecx=00000000 edx=00000000 esi=00000000 edi=77b38920
eip=77a98dcc esp=00a8f908 ebp=00a8f918 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
ntdll!ZwTerminateProcess+0xc:
77a98dcc c20800          ret     8

We can see that our process is about to terminate. Let’s load all symbols and SOS.

0:000> .loadby sos clr
0:000> .symfix
0:000> .reload
Reloading current modules
...............................

We have symbols loaded. Let’s find machine code for Main function. We can do it for instance by finding assemblies:

0:000> !name2ee * Program
Module:      67931000
Assembly:    mscorlib.dll
--------------------------------------
Module:      00dc3fbc
Assembly:    Calls.exe

We have our assembly. Let’s dump its method tables:

0:000> !dumpmodule -mt 00dc3fbc
Name:       C:\Users\user\Documents\Visual Studio 2015\Projects\Calls\Calls\bin\Release\Calls.exe
Attributes: PEFile 
Assembly:   00e5a4e0
LoaderHeap:              00000000
TypeDefToMethodTableMap: 00dc0038
TypeRefToMethodTableMap: 00dc0048
MethodDefToDescMap:      00dc0090
FieldDefToDescMap:       00dc00a4
MemberRefToDescMap:      00000000
FileReferencesMap:       00dc00ac
AssemblyReferencesMap:   00dc00b0
MetaData start address:  008f206c (1504 bytes)

Types defined in this module

      MT  TypeDef Name
------------------------------------------------------------------------------
00dc4cec 0x02000002 Calls.Program
00dc4d54 0x02000003 Calls.Functions

Types referenced in this module

      MT    TypeRef Name
------------------------------------------------------------------------------
67d6cd10 0x02000001 System.Runtime.CompilerServices.CompilationRelaxationsAttribute
67d6d708 0x02000002 System.Runtime.CompilerServices.RuntimeCompatibilityAttribute
67d6cdb0 0x02000003 System.Diagnostics.DebuggableAttribute
67d6d220 0x02000005 System.Reflection.AssemblyTitleAttribute
67d6d2a0 0x02000006 System.Reflection.AssemblyDescriptionAttribute
67d6d260 0x02000007 System.Reflection.AssemblyConfigurationAttribute
67d6cca4 0x02000008 System.Reflection.AssemblyCompanyAttribute
67d6cc64 0x02000009 System.Reflection.AssemblyProductAttribute
67d6d3d4 0x0200000a System.Reflection.AssemblyCopyrightAttribute
67d6d414 0x0200000b System.Reflection.AssemblyTrademarkAttribute
67d6d00c 0x0200000c System.Runtime.InteropServices.ComVisibleAttribute
67d6cdf0 0x0200000d System.Runtime.InteropServices.GuidAttribute
67d6d454 0x0200000e System.Reflection.AssemblyFileVersionAttribute
67d70470 0x0200000f System.Runtime.Versioning.TargetFrameworkAttribute
67d6dbd4 0x02000010 System.Object
67d56980 0x02000011 System.Console

We can see that we have two interesting method tables. Let’s dump the one for Program class:

0:000> !dumpmt -md 00dc4cec 
EEClass:         00dc138c
Module:          00dc3fbc
Name:            Calls.Program
mdToken:         02000002
File:            C:\Users\user\Documents\Visual Studio 2015\Projects\Calls\Calls\bin\Release\Calls.exe
BaseSize:        0xc
ComponentSize:   0x0
Slots in VTable: 6
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
   Entry MethodDe    JIT Name
67cd19c8 679361fc PreJIT System.Object.ToString()
67cd7850 67936204 PreJIT System.Object.Equals(System.Object)
67cdbd80 67936224 PreJIT System.Object.GetHashCode()
67c2dbe8 67936238 PreJIT System.Object.Finalize()
0120003d 00dc4ce4   NONE Calls.Program..ctor()
01200450 00dc4cd8    JIT Calls.Program.Main()

We can see that Main method is already jitted (because it was executed). Let’s dump its machine code:

0:000> !U 01200450 
Normal JIT generated code
Calls.Program.Main()
Begin 01200450, size 7
*** WARNING: Unable to verify checksum for Calls.exe

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 10:
>>> 01200450 ff15484ddc00    call    dword ptr ds:[0DC4D48h] (Calls.Functions.Static(), mdToken: 06000003)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 11:
01200456 c3              ret

We can see that we are calling method directly using call instruction and passing the hardcoded address.

Non-virtual instance function

Let’s modify the code in the following way:

using System;
using System.Runtime.CompilerServices;

namespace Calls
{
    class Program
    {
        static void Main()
        {
            new Functions().NonVirtualInstance();
        }
    }

    public class Functions
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public void NonVirtualInstance()
        {
            Console.WriteLine("Non-virtual instance");
        }
    }
}

We changed the method to non-virtual instance method. In our Main we create object of the class and directly call a method. Let’s see the IL:

.method private hidebysig static 
	void Main () cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 11 (0xb)
	.maxstack 8
	.entrypoint

	IL_0000: newobj instance void Calls.Functions::.ctor()
	IL_0005: call instance void Calls.Functions::NonVirtualInstance()
	IL_000a: ret
} // end of method Program::Main

We still use call instruction here. Let’s examine the machine code:

0:000> !U 00bf0448 
Normal JIT generated code
Calls.Program.Main()
Begin 00bf0448, size 13
*** WARNING: Unable to verify checksum for Calls.exe

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 10:
>>> 00bf0448 b9544dba00      mov     ecx,0BA4D54h (MT: Calls.Functions)
00bf044d e8a22cfaff      call    00b930f4 (JitHelp: CORINFO_HELP_NEWSFAST)
00bf0452 8bc8            mov     ecx,eax
00bf0454 ff15484dba00    call    dword ptr ds:[0BA4D48h] (Calls.Functions.NonVirtualInstance(), mdToken: 06000003)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 11:
00bf045a c3              ret

We can see few interesting things. First, we start by calling constructor for the object. Next, we store this reference in ecx register. Finally, we call method directly using hardcoded address.

This might look a bit strange since in theory we should use callvirt opcode. Let’s modify code a bit:

using System;
using System.Runtime.CompilerServices;

namespace Calls
{
    class Program
    {
        static void Main()
        {
            var instance = new Functions();
            instance.NonVirtualInstance();
        }
    }

    public class Functions
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public void NonVirtualInstance()
        {
            Console.WriteLine("Non-virtual instance");
        }
    }
}

We simply store instance in a variable. Let’s see the IL:

.method private hidebysig static 
	void Main () cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 11 (0xb)
	.maxstack 8
	.entrypoint

	IL_0000: newobj instance void Calls.Functions::.ctor()
	IL_0005: callvirt instance void Calls.Functions::NonVirtualInstance()
	IL_000a: ret
} // end of method Program::Main

And now we can see that we are indeed using callvirt instruction. Interesting! Let’s see the machine code:

0:000> !U 00e60448 
Normal JIT generated code
Calls.Program.Main()
Begin 00e60448, size 13
*** WARNING: Unable to verify checksum for Calls.exe

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 10:
>>> 00e60448 b9544de100      mov     ecx,0E14D54h (MT: Calls.Functions)
00e6044d e8a22cfaff      call    00e030f4 (JitHelp: CORINFO_HELP_NEWSFAST)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 11:
00e60452 8bc8            mov     ecx,eax
00e60454 ff15484de100    call    dword ptr ds:[0E14D48h] (Calls.Functions.NonVirtualInstance(), mdToken: 06000003)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 12:
00e6045a c3              ret

As we can see, the machine code is exactly the same. There is no null check, so in this situation both call and callvirt instructions were jitted to the same code. Let’s modify the program a little more:

using System;
using System.Runtime.CompilerServices;

namespace Calls
{
    class Program
    {
        static void Main()
        {
            var instance = new Functions();
            Call(instance);
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        static void Call(Functions instance)
        {
            instance.NonVirtualInstance();
        }
    }

    public class Functions
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public void NonVirtualInstance()
        {
            Console.WriteLine("Non-virtual instance");
        }
    }
}

We do almost the same, however, we pass object to another function and then we call instance function. The IL is as follows:

.method private hidebysig static 
	void Main () cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 11 (0xb)
	.maxstack 8
	.entrypoint

	IL_0000: newobj instance void Calls.Functions::.ctor()
	IL_0005: call void Calls.Program::Call(class Calls.Functions)
	IL_000a: ret
} // end of method Program::Main

.method private hidebysig static 
	void Call (
		class Calls.Functions 'instance'
	) cil managed noinlining 
{
	// Method begins at RVA 0x205c
	// Code size 7 (0x7)
	.maxstack 8

	IL_0000: ldarg.0
	IL_0001: callvirt instance void Calls.Functions::NonVirtualInstance()
	IL_0006: ret
} // end of method Program::Call

And the machine code:

0:000> !U 02ba0448 
Normal JIT generated code
Calls.Program.Main()
Begin 02ba0448, size 13
*** WARNING: Unable to verify checksum for Calls.exe

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 10:
>>> 02ba0448 b9604db502      mov     ecx,2B54D60h (MT: Calls.Functions)
02ba044d e8a22cfaff      call    02b430f4 (JitHelp: CORINFO_HELP_NEWSFAST)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 11:
02ba0452 8bc8            mov     ecx,eax
02ba0454 ff15ec4cb502    call    dword ptr ds:[2B54CECh] (Calls.Program.Call(Calls.Functions), mdToken: 06000002)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 12:
02ba045a c3              ret

We create an object, put it in the register and call a method. Let’s move on:

0:000> !U 02ba0470 
Normal JIT generated code
Calls.Program.Call(Calls.Functions)
Begin 02ba0470, size d

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 17:
>>> 02ba0470 55              push    ebp
02ba0471 8bec            mov     ebp,esp
02ba0473 3909            cmp     dword ptr [ecx],ecx
02ba0475 ff15544db502    call    dword ptr ds:[2B54D54h] (Calls.Functions.NonVirtualInstance(), mdToken: 06000004)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 18:
02ba047b 5d              pop     ebp
02ba047c c3              ret

And here we have what we wanted to see. First, we prepare a stack frame by storing ebp register. Next, we compare ecx register and perform a null check. Finally, we call a method directly using hardcoded address. Next, we can see a cleanup and exit instruction.

How does null check work?

You might ask what is going on. I said that there is a null check, however, there is neither branch instruction nor null handler. Let’s see the instruction:

02ba0473 3909            cmp     dword ptr [ecx],ecx

Here we compare a register to some extracted value. cmp instruction sets CPU flags so we can later perform conditional jumps based on them. However, in our listing we simply ignore the comparison result so how does it work?
First, let’s assume that we passed a correct reference. We try to compare ecx (which has correct value) with dword ptr [ecx]. The latter tries to dereference the pointer and since it is valid, it extracts some value. We then perform a comparison and store flags in the CPU.
However, imagine that ecx is a null reference (which means that it is equal to zero). If we try to dereference it, we will try to read something from the zero address. Since this is a null pointer memory partition, we will be blocked by the MMU and there will be a hardware interrupt. CLR will handle it and convert to NullReferenceException.
So it looks like we can safely ignore CPU flags after the comparison, because in case of having null reference the CPU will notify us about the problem. Clever — we can perform a null check using one CPU instruction.

Virtual function

Let us now call a virtual function. Let’s use this code:

using System;
using System.Runtime.CompilerServices;

namespace Calls
{
    class Program
    {
        static void Main()
        {
            var instance = new Functions();
            instance.ToString();
        }
    }

    public class Functions
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public override string ToString()
        {
            var message = "Virtual instance";
            Console.WriteLine(message);
            return message;
        }
    }
}

We will utilize ToString method, since it is virtual in System.Object class. IL for this code:

.method private hidebysig static 
	void Main () cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 12 (0xc)
	.maxstack 8
	.entrypoint

	IL_0000: newobj instance void Calls.Functions::.ctor()
	IL_0005: callvirt instance string [mscorlib]System.Object::ToString()
	IL_000a: pop
	IL_000b: ret
} // end of method Program::Main

We use callvirt instruction. Please also notice that we are calling method from System.Object and not from our class. Right now we expect to see invocation using dispatch table. Let’s check it:

0:000> !U 02a30448 
Normal JIT generated code
Calls.Program.Main()
Begin 02a30448, size 18
*** WARNING: Unable to verify checksum for Calls.exe

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 10:
>>> 02a30448 55              push    ebp
02a30449 8bec            mov     ebp,esp
02a3044b b9504d2701      mov     ecx,1274D50h (MT: Calls.Functions)
02a30450 e89f2c83fe      call    012630f4 (JitHelp: CORINFO_HELP_NEWSFAST)

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 11:
02a30455 8bc8            mov     ecx,eax
02a30457 8b01            mov     eax,dword ptr [ecx]
02a30459 8b4028          mov     eax,dword ptr [eax+28h]
02a3045c ff10            call    dword ptr [eax]

c:\users\user\documents\visual studio 2015\Projects\Calls\Calls\Program.cs @ 12:
02a3045e 5d              pop     ebp
02a3045f c3              ret

And we can indeed verify that there is a dispatch table used. These three lines are doing that:

02a30457 8b01            mov     eax,dword ptr [ecx]
02a30459 8b4028          mov     eax,dword ptr [eax+28h]
02a3045c ff10            call    dword ptr [eax]

We first dereference the pointer to an object and store it in the eax register. Since .NET reference points to pointer to type descriptor, we end up with pointer to type descriptor in eax. Next, we dereference the value which is stored 40 bytes after the beginning of the type descriptor and store it in the eax register. This is an address of the method descriptor of implementation of ToString in our custom class. Finally, we call the method using the register value. So we can see that it is indeed using dynamic address instead of hardcoded one.

Dynamic call

For now we were only calling methods using ordinary mechanisms which can be checked during compilation time. However, there is also a dynamic keyword which allows us to defer the call and perform it in runtime. Let’s modify the code a bit and see how it works:

using System;
using System.Runtime.CompilerServices;

namespace Calls
{
    class Program
    {
        static void Main()
        {
            dynamic instance = new Functions();
            instance.ToString();
        }
    }

    public class Functions
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public override string ToString()
        {
            var message = "Virtual instance";
            Console.WriteLine(message);
            return message;
        }
    }
}

Only one change in here. We replaced the var with dynamic so now the compiler should emit code for using DLR mechanisms. Let’s decompile the code:

.method private hidebysig static 
	void Main () cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 87 (0x57)
	.maxstack 9
	.entrypoint
	.locals init (
		[0] object
	)

	IL_0000: newobj instance void Calls.Functions::.ctor()
	IL_0005: stloc.0
	IL_0006: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1> Calls.Program/'<>o__0'::'<>p__0'
	IL_000b: brtrue.s IL_0041

	IL_000d: ldc.i4 256
	IL_0012: ldstr "ToString"
	IL_0017: ldnull
	IL_0018: ldtoken Calls.Program
	IL_001d: call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)
	IL_0022: ldc.i4.1
	IL_0023: newarr [Microsoft.CSharp]Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo
	IL_0028: dup
	IL_0029: ldc.i4.0
	IL_002a: ldc.i4.0
	IL_002b: ldnull
	IL_002c: call class [Microsoft.CSharp]Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo [Microsoft.CSharp]Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo::Create(valuetype [Microsoft.CSharp]Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfoFlags, string)
	IL_0031: stelem.ref
	IL_0032: call class [System.Core]System.Runtime.CompilerServices.CallSiteBinder [Microsoft.CSharp]Microsoft.CSharp.RuntimeBinder.Binder::InvokeMember(valuetype [Microsoft.CSharp]Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags, string, class [mscorlib]System.Collections.Generic.IEnumerable`1, class [mscorlib]System.Type, class [mscorlib]System.Collections.Generic.IEnumerable`1)
	IL_0037: call class [System.Core]System.Runtime.CompilerServices.CallSite`1 class [System.Core]System.Runtime.CompilerServices.CallSite`1>::Create(class [System.Core]System.Runtime.CompilerServices.CallSiteBinder)
	IL_003c: stsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1> Calls.Program/'<>o__0'::'<>p__0'

	IL_0041: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1> Calls.Program/'<>o__0'::'<>p__0'
	IL_0046: ldfld !0 class [System.Core]System.Runtime.CompilerServices.CallSite`1>::Target
	IL_004b: ldsfld class [System.Core]System.Runtime.CompilerServices.CallSite`1> Calls.Program/'<>o__0'::'<>p__0'
	IL_0050: ldloc.0
	IL_0051: callvirt instance void class [mscorlib]System.Action`2::Invoke(!0, !1)
	IL_0056: ret
} // end of method Program::Main

And indeed we can see, that calling dynamic method is much more difficult. We use things like CallSite and lots of DLR magic here.

Summary

In this post we saw how different functions are called. The actual opcode used for invocation depends on type of a method and even on a way of storing the variable which we use to call the method. However, even using different opcode might not result in different machine code since the CLR is able to perform optimizations when jitting the code.