posh.wiki


What does try/finally lower to in x64 assembly?

2026-07-22

Tags: .NET

So, a thought came across my mind. In C#, you can't have control exit a finally block. Using return, or goto to a point outside the finally block, is a compiler error. But what would happen if you were to modify the variable you're returning? Something like...

int I() 
{
    int i = 0;
    try 
    {
        return i;
    }
    finally 
    {
        i++;
    }
}

Turns out, this returns 0. But why? Even though int isn't a reference type, we're modifying the value of i before we exit the method.

If we lower the method to IL, we can answer this pretty quickly. i becomes local variable [0], and Roslyn introduces a temporary local [1] to hold the return value across the finally block, copying it back at the true end of the method.

Note

Interestingly, the IL compiler creates a separate temporary local for return whenever it's returning a non-literal value, even if there is no try block.

// comments and "nop" omitted for brevity
.method private hidebysig 
    instance int32 I () cil managed 
{
    .maxstack 2
    .locals init (
        [0] int32,
        [1] int32
    )

    IL_0001: ldc.i4.0
    IL_0002: stloc.0
    .try
    {
        IL_0004: ldloc.0
        IL_0005: stloc.1
        IL_0006: leave.s IL_000f
    } 
    finally
    {
        IL_0009: ldloc.0
        IL_000a: ldc.i4.1
        IL_000b: add
        IL_000c: stloc.0
        IL_000e: endfinally
    } 

    IL_000f: ldloc.1
    IL_0010: ret
}

But that still leaves a question open: what really is the try/finally pattern? It exists in IL, but at some point the abstraction has to fall away. To find out, I decided to compile some code all the way down to assembly. See this method, and its corresponding x86-64 (AMD64) assembly for Windows x64:

class Program 
{
    public static int Main(string[] args)
    {
        int i = global::System.Random.Shared.Next(0,0);
        try 
        {
            return i;
        }
        finally 
        {
            global::System.Console.WriteLine("Hello world");
        }
    }
}
G_M000_IG01:
       push     rbp
       push     rbx
       sub      rsp, 40
       lea      rbp, [rsp+0x30]
       mov      qword ptr [rbp-0x10], rsp

G_M000_IG02:
       test     byte  ptr [(reloc)], 1
       je       SHORT G_M000_IG06

G_M000_IG03:
       mov      rcx, 0xD1FFAB1E
       mov      rcx, gword ptr [rcx]
       xor      edx, edx
       xor      r8d, r8d
       mov      rax, qword ptr [rcx]
       mov      rax, qword ptr [rax+0x40]
       call     [rax+0x30]System.Random:Next(int,int):int:this
       mov      ebx, eax

G_M000_IG04:
       mov      rcx, 0xD1FFAB1E
       call     [System.Console:WriteLine(System.String)]
       mov      eax, ebx

G_M000_IG05:
       add      rsp, 40
       pop      rbx
       pop      rbp
       ret

G_M000_IG06:
       mov      rcx, 0xD1FFAB1E
       call     CORINFO_HELP_GET_GCSTATIC_BASE
       jmp      SHORT G_M000_IG03

G_M000_IG07:
       push     rbp
       push     rbx
       sub      rsp, 40
       mov      rbp, qword ptr [rcx+0x20]
       mov      qword ptr [rsp+0x20], rbp
       lea      rbp, [rbp+0x30]

G_M000_IG08:
       mov      rcx, 0xD1FFAB1E
       call     [System.Console:WriteLine(System.String)]
       nop

G_M000_IG09:
       add      rsp, 40
       pop      rbx
       pop      rbp
       ret

Note

This was compiled using Roslyn 5.10.0-1.26367.9 (104895c) on an x86-64 AMD machine running Windows. Resulting assembly may vary based on OS, architecture, Roslyn version, and code context.

The first effects of the try/finally pattern are seen immediately in section IG01. This is the prologue to the method. Because we're using exception handling, the JIT assembler builds a stack frame layout that can be unwound later.

IG02 can be largely ignored here. It checks that the static instance global::System.Random.Shared has been initialised, continuing to IG03 if so and taking a detour via IG06 if not. The use of Random.Shared here is to prevent the JIT assembler from folding the entire method away, eliminating "redundant" instructions that actually help us understand what's going on.

IG03 is where things start to get interesting.

IG04 represents our finally block. It loads a reference to the string "Hello world" into rcx, and passes it to global::System.Console.WriteLine(global::System.String). At the end, it loads ebx back into eax, which is what we return.

Finally, IG05 frees up the stack memory that was allocated by IG01, cleans up the call stack, and returns control to the method's caller, representing the end of our method.

Then, what about IG07-IG09? They exist for the possibility that an exception was thrown during our try clause (IG03). In that case, we wouldn't reach IG04 to execute our finally block. Instead, the runtime will unwind the call stack and move us to: