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.
mov rcx, 0xD1FFAB1Ecreates a reference toRandom.Shared, which is then de-referenced bymov rcx, gword ptr [rcx].xor edx, edxsets variableedx(the first argument toRandom.Next) to 0.xor r8d r8dis the same, settingr8d(second argument toRandom.Next) to 0.mov rax, qword ptr [rcx]andmov rax, qword ptr [rax+0x40]configure a pointer to the methodNextof objectRandom.Shared.call [rax+0x30]System.Random:Next(int,int):int:thisis the assignment call, writing into variableeax, which is what will be returned.mov ebx, eaxis the final step, moving our result into the temporary variable that protects from interference infinally.
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
finallyblock - 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.
