📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is IL (Intermediate Language), and how do you read the IL the C# compiler produces?

CIL as a stack-based instruction set, common opcodes, callvirt, boxing in IL, and tools to inspect it.

Mid-Level Theory Advanced cil clr il ilspy sharplab

🎯 Key points

  • IL is a CPU-independent, stack-based instruction set defined by ECMA-335
  • Calls and fields are referenced through metadata tokens
  • C# emits callvirt for most instance calls to get a null check
  • IL reveals boxing, closure allocations and lowering of using, foreach and async
  • Inspect Release IL with ILSpy or SharpLab; the JIT does most optimisation
  • IL shows what the compiler did; JIT disassembly shows what runs

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. What IL looks like

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:

  • Stack machine: instructions push/pop an evaluation stack (no registers).
  • Typed and verifiable: instructions and metadata let the runtime check type safety.
  • Object-oriented instructions: newobj, callvirt, castclass, isinst, box, unbox.any, ldfld, stfld, newarr, ldelem.
  • References metadata tokens for types, methods and fields.
  • Exception handling via protected regions (.try/catch/finally).

2. Common instructions to recognise

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

3. How to read IL

  • SharpLab (sharplab.io): paste C#, view IL, the lowered C# ("C#" output after compiler lowering), and JIT assembly — fastest for learning.
  • ILSpy (or the ILSpy extension in VS/VS Code): open any DLL, switch between C# and IL views; great for libraries and NuGet packages.
  • Rider/ReSharper: IL Viewer tool window for the current file.
  • dotnet-ildasm / ildasm: command-line disassembly.
  • Visual Studio: "Go to Definition" with decompilation enabled shows decompiled C#; for IL, use ILSpy.
  • Roslyn APIs for programmatic inspection.

Always inspect Release builds — Debug builds include extra nops and less optimised IL.

4. What reading IL reveals

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:

  • Whether a struct is boxed (e.g. via interface calls or string.Format).
  • Whether a lambda captures (display class) or is cached (<>c singleton).
  • How async methods are split into state machines.
  • That foreach over IEnumerable<T> boxes struct enumerators while over List<T> it doesn't.
  • Defensive copies (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).

5. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. The evaluation stack and verification

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.

2. IL method body format

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.

3. Compiler patterns in IL

  • Switch on strings: hash computation + switch instruction or length/char checks.
  • Pattern matching: isinst chains and decision trees.
  • Interpolated strings: DefaultInterpolatedStringHandler with AppendLiteral/AppendFormatted<T> (generic, no boxing).
  • Records: generated Equals, GetHashCode, PrintMembers, <Clone>$.
  • Async: AsyncTaskMethodBuilder.Start, MoveNext with AwaitUnsafeOnCompleted.

4. IL vs JIT output

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.

5. Emitting IL

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.

Try it yourself

  1. Paste a capturing lambda and an async method into SharpLab and read the IL.
  2. Compare IL for foreach over List<T> vs IEnumerable<T>.
  3. Find box instructions in a method using string.Format with ints.
  4. Compare Debug vs Release IL for the same method.

💻 Code

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;
    }
}

⚠ Common mistakes

1. Reading Debug IL

What happens: analysing IL from Debug builds. Why it is wrong: extra nops and unoptimised patterns mislead. Do instead: inspect Release builds.

2. Treating IL as final performance truth

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.

3. Ignoring IL entirely

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.

4. Confusing call and callvirt meaning

What 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.

5. Hand-writing IL unnecessarily

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).

6. Decompiled C# assumed identical to source

What happens: reading decompiler output as original code. Why it is wrong: decompilers reconstruct approximations. Do instead: use IL view for exact semantics.

7. Overlooking .locals init

What happens: surprised by zeroing costs with large stackalloc. Why it is wrong: locals are zeroed by default. Do instead: consider SkipLocalsInit carefully.

8. Not checking constrained calls in generics

What happens: assuming generic code boxes. Why it is wrong: constrained. avoids boxing for value types. Do instead: look for constrained. prefixes.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says the JIT turns IL into machine code Why: Checks how JIT compilation and inspection work
    How to answer

    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.

  2. When: Candidate points out a box instruction Why: Probes the cost of boxing and how to avoid it
    How to answer

    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>.

  3. When: Candidate mentions display classes for lambdas Why: Checks understanding of closures and captured variables
    How to answer

    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.

🛠 Real-world problems: how you handle them

Debug logging allocated in a hot loop even when disabled
Situation
A message-processing loop handled [tens of thousands] of messages per second and allocation rate was high.
Signals
`dotnet-counters` showed a high allocation rate; a memory profiler attributed it to `object[]` and boxed `Int32`.
Investigation
Inspected the IL of the loop in ILSpy and found `box int32` and `newarr object` for each `LogDebug` call with structured arguments.
Resolution
Moved hot log calls to `[LoggerMessage]` source-generated methods, which check `IsEnabled` and avoid boxing.
Prevention
Added an analyser rule for logging in hot paths and a BenchmarkDotNet test with the memory diagnoser.

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.

↩ Usually asked after

More in CLR Fundamentals

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me