---
title: What does try/finally lower to in x64 assembly? 
date: 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...

```csharp
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. 

```csharp
// 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: 

```csharp
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");
        }
    }
}
```

```asm
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. 

* `mov rcx, 0xD1FFAB1E` creates a reference to `Random.Shared`, which is then de-referenced by `mov rcx, gword ptr [rcx]`. 
* `xor edx, edx` sets variable `edx` (the first argument to `Random.Next`) to 0.
* `xor r8d r8d` is the same, setting `r8d` (second argument to `Random.Next`) to 0. 
* `mov rax, qword ptr [rcx]` and `mov rax, qword ptr [rax+0x40]` configure a pointer to the method `Next` of object `Random.Shared`. 
* `call [rax+0x30]System.Random:Next(int,int):int:this` is the assignment call, writing into variable `eax`, which is what will be returned. 
* `mov ebx, eax` is the final step, moving our result into the temporary variable that protects from interference in `finally`. 

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: 

* IG07, which will perform allocation similar to IG01;
* then through IG08, the `finally` block - except it doesn't switch out the return variable at the end;
* and finally IG09, memory cleanup and control flow return to the exception context, similar to IG05.