CIL as a stack-based instruction set, common opcodes, callvirt, boxing in IL, and tools to inspect it.
IL (Common Intermediate Language, CIL) is the CPU-independent, stack-based instruction set that C# compiles to. Instructions push and pop values on an evaluation stack: ldarg.0, ldarg.1, add, ret. Calls use metadata tokens (call, callvirt, newobj), and conversions such as box are explicit. You read it with ILSpy, SharpLab or ildasm-style tools. Reading IL helps you spot hidden allocations (boxing, closures), see how async, foreach or using are lowered, and settle "what does the compiler really do" arguments.
IL (Intermediate Language, also CIL/MSIL) is the CPU-independent, stack-based instruction set that C# and other .NET compilers emit. The CLR's JIT (or an AOT compiler) turns IL into native machine code. Reading IL is a practical skill: it shows what the compiler really generated — boxing, closures, state machines, lowered foreach/using, hidden copies — which explains many performance and behaviour surprises.
public static int Add(int a, int b) => a + b;
.method public hidebysig static int32 Add(int32 a, int32 b) cil managed
{
.maxstack 2
ldarg.0 // push a
ldarg.1 // push b
add // pop two, push sum
ret // return top of stack
}
Key characteristics:
newobj, callvirt, castclass, isinst, box, unbox.any, ldfld, stfld, newarr, ldelem..try/catch/finally).| Instruction | Meaning | What it tells you |
|---|---|---|
ldloc / stloc, ldarg / starg |
Load/store locals and args | Data flow |
call / callvirt |
Call (non-virtual / virtual or null-checked) | C# uses callvirt for instance calls (null check) |
newobj |
Allocate + call constructor | Heap allocation (for classes) |
box / unbox.any |
Box/unbox value types | Hidden allocations |
constrained. prefix |
Call on generic T without boxing |
Good generic code |
ldftn + newobj Func<...> |
Create a delegate | Delegate allocation |
isinst / castclass |
as/is and casts |
Type checks |
ldstr |
Load string literal | Interned literal |
brtrue / br / switch |
Branches and jump tables | Control flow |
.try / leave / endfinally |
try/finally | using, lock, foreach lowering |
dotnet-ildasm / ildasm: command-line disassembly.Always inspect Release builds — Debug builds include extra nops and less optimised IL.
object o = 42; // box int32
string s = $"Total: {total}"; // DefaultInterpolatedStringHandler calls (C# 10+)
foreach (var x in list) { } // List<T>.Enumerator struct, try/finally
Func<int, bool> f = x => x > limit; // display class + delegate allocation
await Task.Delay(1); // state machine struct + AsyncTaskMethodBuilder
using var s2 = File.OpenRead(path); // try/finally with Dispose
Examples of insights:
string.Format).<>c singleton).foreach over IEnumerable<T> boxes struct enumerators while over List<T> it doesn't.ldobj + stloc before calls) on readonly struct fields.IL isn't the final word on performance — the JIT may optimise further (inline, devirtualise, eliminate boxing) — so for hot paths also look at JIT assembly (SharpLab's JIT Asm, DOTNET_JitDisasm, BenchmarkDotNet's DisassemblyDiagnoser).
A logging wrapper Log(string message, params object[] args) looked harmless, but reading the IL of a hot method showed newarr object plus several box instructions per call for int/Guid arguments — allocations even when logging was disabled. Switching to [LoggerMessage] source-generated methods (which check IsEnabled first and avoid boxing) removed them. IL made the hidden cost visible in seconds.
The interviewer checks whether you can look below C# when it matters.
Green flags: stack-based description, a few opcodes, why callvirt is used, boxing and closures visible in IL, tools named, Release versus Debug.
Red flags: thinks IL is assembly language for the CPU; never inspected compiler output.
Each method declares .maxstack. The verifier (and JIT) tracks the types on the stack at each instruction, ensuring type-safe operations (e.g. add on two int32s, stfld with a compatible object). Unverifiable IL (pointers, some Unsafe operations) is allowed in full trust (which is all .NET Core code) but bypasses safety.
Method bodies have a header (tiny or fat), IL bytes, local variable signature (.locals init), and exception handling clauses. .locals init means locals are zero-initialised — [SkipLocalsInit] removes init.
switch instruction or length/char checks.isinst chains and decision trees.DefaultInterpolatedStringHandler with AppendLiteral/AppendFormatted<T> (generic, no boxing).Equals, GetHashCode, PrintMembers, <Clone>$.AsyncTaskMethodBuilder.Start, MoveNext with AwaitUnsafeOnCompleted.IL is semantic; the JIT applies optimisations: inlining small methods, removing bounds checks, devirtualising sealed calls, eliminating boxing in some generic patterns, and PGO-guided transformations. So "IL has a box" doesn't always mean "allocation at run time" (e.g. box + isinst on known types can be optimised away), but it's a strong signal.
System.Reflection.Emit (DynamicMethod, ILGenerator) lets you generate IL at run time — used by older serializers, mocking libraries and DI containers. It's unavailable under NativeAOT; modern code prefers source generators.
async method into SharpLab and read the IL.foreach over List<T> vs IEnumerable<T>.box instructions in a method using string.Format with ints.public static class IlDemo
{
public static int Add(int a, int b) => a + b;
// IL contains: box [System.Runtime]System.Int32
public static string Boxed(int id) => string.Format("Id {0}", id);
// Interpolated string handler: no box for the int
public static string NotBoxed(int id) => $"Id {id}";
// IL contains: newobj <>c__DisplayClass..::.ctor (closure allocation)
public static Func<int> Captures(int seed)
{
int counter = seed;
return () => ++counter;
}
// IL contains: try { ... } finally { callvirt IDisposable::Dispose }
public static long Length(string path)
{
using var stream = File.OpenRead(path);
return stream.Length;
}
}
What happens: analysing IL from Debug builds.
Why it is wrong: extra nops and unoptimised patterns mislead.
Do instead: inspect Release builds.
What happens: concluding performance from IL alone. Why it is wrong: the JIT inlines, devirtualises and removes some operations. Do instead: check JIT assembly and benchmark.
What happens: guessing about allocations and lowering. Why it is wrong: misses hidden boxing and closures. Do instead: use SharpLab/ILSpy when behaviour is surprising.
call and callvirt meaningWhat happens: assuming callvirt means virtual dispatch.
Why it is wrong: C# uses callvirt for null checks on non-virtual methods too.
Do instead: check the method's virtual flag.
What happens: Reflection.Emit for simple dynamic behaviour. Why it is wrong: complex, not AOT-compatible. Do instead: source generators, delegates, expression trees (where acceptable).
What happens: reading decompiler output as original code. Why it is wrong: decompilers reconstruct approximations. Do instead: use IL view for exact semantics.
.locals initWhat happens: surprised by zeroing costs with large stackalloc.
Why it is wrong: locals are zeroed by default.
Do instead: consider SkipLocalsInit carefully.
What happens: assuming generic code boxes.
Why it is wrong: constrained. avoids boxing for value types.
Do instead: look for constrained. prefixes.
IL is the best evidence in arguments about language features.
| Question | IL answers it |
|---|---|
| Does this allocate? | Look for newobj, box, newarr |
| Is this closure per call? | Look for display class creation inside the method |
What does await cost? |
See the state machine and builder |
Is in copying my struct? |
Look for defensive copies before calls |
Senior engineers use IL with benchmarks: IL shows what the compiler did, BenchmarkDotNet's memory diagnoser confirms the cost.
The JIT (RyuJIT) compiles IL to native code one method at a time, on first call. Each method initially points at a small stub; the first call enters the runtime, which JIT-compiles the IL, stores the code in the code heap, and patches the stub so later calls go straight to native code. The JIT also produces GC info and unwind info so the GC and exception handling work. With tiered compilation a method may be compiled more than once, first quickly then optimised. You can print the generated assembly with DOTNET_JitDisasm="MethodName", which works on release runtimes since .NET 7.
Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.
A closure is a lambda or local function plus the variables it captures. The compiler moves those variables into fields of a hidden display class, so the method and the lambda share the same variable. That's why capture is by variable, not by value. In a for loop all lambdas share one loop variable, while foreach gets a fresh one per iteration since C# 5. Every lambda in a scope shares one display class, and capturing this keeps the whole object alive, which can leak memory through timers and caches. In hot paths I use static lambdas with explicit state.
How to tell it (STAR)
In [our message processor], allocations were [X MB/s] even with debug logging off. I read the IL and saw boxing and params arrays for every log call. Switching to LoggerMessage source generation cut allocations by [N%] and reduced Gen 0 GCs.
Last updated 30 Oct 2026.