Production Runtime Case Study: .NET CLR and IL

C# is compiled into an assembly — a .dll file containing Common Intermediate Language (IL) and detailed metadata describing every type and method.

Learning Objectives

Follow C# through the compiler to assemblies with CIL and metadata; read the evaluation stack, arguments, locals and metadata tokens.

Explain IL verification and type safety, CLR services, JIT compilation, ReadyToRun and Native AOT, GC, exceptions, value and reference types, boxing, reflection, assembly loading, P/Invoke, IDisposable.

Inspect IL with tooling; debug and profile .NET apps; choose runtime versions and deployment models; understand trimming and dynamic-code caveats and start-up trade-offs.

Plain-Language Explanation

C# is compiled into an assembly — a .dll file containing Common Intermediate Language (IL) and detailed metadata describing every type and method. When the program runs, the .NET runtime loads the assembly, and its JIT compiler turns each method's IL into machine code the first time the method is called — or the code was compiled ahead of time when the app was published.

Precise Technical Explanation

C# source compilation The Roslyn compiler (csc, driven by dotnet build) produces IL plus metadata; Debug builds include extra nops and locals for debugging; Release builds (Optimize=true) are leaner.

Assemblies and metadata An assembly is a PE file with a manifest (name, version, references) and metadata tables (TypeDef, MethodDef, MemberRef, …); reflection and the runtime read them.

Common Intermediate Language (CIL/IL) A stack-based, mostly untyped-encoding instruction set (add, call, ldarg.0) standardised in ECMA-335 — the CLI specification.

Like the JVM's operand stack; each method declares a maximum stack depth. Methods with tiny headers implicitly have a maximum depth of 8.

Local variables and arguments ldarg.n/starg, ldloc.n/stloc; for instance methods argument 0 is this.

32-bit tokens identify metadata rows: the top byte is the table (0x06 MethodDef, 0x0A MemberRef, 0x02 TypeDef, 0x70 user string), the rest the row — call operands are tokens.

IL verification and type safety overview ECMA-335 defines verifiable IL (type-safe stack use); modern.NET runs only fully trusted code and does not verify IL at load time (unverifiable unsafe code is allowed), unlike the JVM. Type safety comes from the compiler and from runtime checks (casts, array bounds).

CLR runtime services Type loading, JIT, GC, exception handling, threading, interop, reflection, security checks — the Common Language Runtime (CoreCLR in modern.NET).

RyuJIT compiles a method on its first call; tiered compilation starts with quickly compiled tier-0 code, then recompiles hot methods at tier 1 (with dynamic PGO since .NET 8 by default) and uses OSR for long-running loops.

ReadyToRun and ahead-of-time concepts ReadyToRun (R2R) assemblies contain precompiled native code alongside IL to speed start-up; the JIT may still recompile hot methods.

Native AOT overview Compiles the whole app and runtime pieces to a native executable: fast start-up, small memory, no JIT at run time, restrictions on dynamic code (Reflection.Emit) and unannotated reflection.

Generational (gen 0, 1, 2) with a large-object heap; workstation and server modes; background GC for gen 2.

Exceptions and stack unwinding Two-pass model: first find a handler (running exception filters), then unwind running finally/fault blocks.

Value types and reference types struct values live inline (on the stack or inside other objects) and are copied; class instances live on the heap and are referenced.

Boxing and unboxing Converting a value type to object or an interface allocates a box (box IL instruction); unbox.any copies it back.

System.Reflection reads metadata; MethodInfo.Invoke is slow, compiled expression trees and source generators are faster alternatives.

Assembly loading and dependency resolution AssemblyLoadContext loads assemblies on demand; .deps.json and probing paths locate dependencies.

P/Invoke and interop [DllImport]/[LibraryImport] call native functions; marshalling converts arguments; COM interop on Windows.

IDisposable and deterministic resource cleanup using calls Dispose at the end of the scope; finalizers are a safety net only.

dotnet tooling and IL inspection dotnet --info, dotnet build, ILSpy or ildasm/ilspycmd for IL, System.Reflection.Metadata and MethodBody.GetILAsByteArray() programmatically (used below).

Debugging and profiling.NET apps Visual Studio / dotnet-trace, dotnet-counters, dotnet-dump, dotnet-gcdump, PerfView, EventPipe.

Runtime versions and deployment models Framework-dependent (needs an installed runtime) or self-contained (bundles it); roll-forward policies select the runtime version.

Trimming and dynamic-code caveats The trimmer removes unused code; reflection-based code needs annotations or it may break; Native AOT forbids runtime code generation.

Performance and startup trade-offs JIT with tiering gives good steady state after warm-up; R2R improves start-up; Native AOT gives the fastest start-up and smallest footprint but loses dynamic PGO.

C# example The C# compiler produces an assembly containing IL and metadata; how the runtime executes it (tier-0 JIT, tier-1 JIT, R2R code or Native AOT) depends on deployment and configuration. The complete example below prints 42 and then decodes its own IL.

using System;

public static class Program
{
    public static int Add(int a, int b) => a + b;

    public static void Main()
    {
        Console.WriteLine(Add(20, 22));
    }
}

Why It Matters in Real Systems

Understanding IL, boxing and tiering explains allocation hot spots, start-up behaviour, AOT compatibility warnings and what profilers and source generators do.

Key Terms

.NET deployment unit: IL + metadata + manifest Stack-based intermediate language (ECMA-335)

Table + row identifier used by IL operands Tier-0 quick JIT, tier-1 optimised JIT ReadyToRun / Native AOT

Precompiled code alongside IL / whole-app native compilation Copying a value type into a heap object

Diagram, Execution Trace or State Transition

Text equivalent: Roslyn emits an assembly; at run time methods are JIT-compiled at tier 0 on first call and recompiled at tier 1 when hot. ReadyToRun ships precompiled code inside the assembly; Native AOT produces a native executable with no JIT.

flowchart LR
  CS[C# source] --> R[Roslyn] --> A[Assembly: IL + metadata]
  A --> L[Runtime loads assembly] --> T0[Tier-0 JIT on first call]
  T0 -->|hot| T1[Tier-1 optimised JIT + PGO]
  A --> R2R[ReadyToRun: precompiled code used at start-up]
  CS --> NA[Native AOT publish] --> EXE[Native executable, no JIT]

Syntax or Instruction Format

IL listing format: IL_offset: opcode operand; short forms (ldarg.0, ldc.i4.s) save bytes.

Complete Example

The program prints Add(20, 22) and then decodes the IL of its own methods at run time from MethodBody.GetILAsByteArray(), using the runtime's OpCodes table to name each instruction (.NET 10 SDK, Release build via Optimize=true):

Deterministic cleanup with using and the collector's generations (.NET 10 SDK; collection counts are one real run and depend on the GC configuration):

#:property Optimize=true
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;

public static class Program
{
    public static int Add(int a, int b) => a + b;

    public static object Box(int value) => value;                 // boxing: int -> object

    static readonly Dictionary<short, OpCode> Codes = typeof(OpCodes)
        .GetFields(BindingFlags.Public | BindingFlags.Static)
        .Select(f => (OpCode)f.GetValue(null)!)
        .ToDictionary(o => o.Value);

    static void Dump(MethodInfo m)
    {
        var body = m.GetMethodBody()!;
        byte[] il = body.GetILAsByteArray()!;
        Console.WriteLine($"{m.Name}: {il.Length} bytes of IL, max evaluation-stack depth {body.MaxStackSize}");
        for (int i = 0; i < il.Length;)
        {
            int start = i;
            short value = il[i] == 0xFE ? (short)(0xFE00 | il[i + 1]) : il[i];
            i += il[i] == 0xFE ? 2 : 1;
            OpCode op = Codes[value];
            string operand = "";
            switch (op.OperandType)
            {
                case OperandType.InlineNone: break;
                case OperandType.ShortInlineI: case O
…
42
Add: 4 bytes of IL, max evaluation-stack depth 8
  IL_0000: ldarg.0
  IL_0001: ldarg.1
  IL_0002: add
  IL_0003: ret
Box: 7 bytes of IL, max evaluation-stack depth 8
  IL_0000: ldarg.0
  IL_0001: box        System.Int32
  IL_0006: ret
boxed is Int32; value type? True; same box twice? False
using System;

sealed class Resource : IDisposable
{
    private readonly string _name;
    public Resource(string name) { _name = name; Console.WriteLine($"open {_name}"); }
    public void Dispose() => Console.WriteLine($"dispose {_name}");
}

public static class Program
{
    public static void Main()
    {
        using (var r = new Resource("file A"))
        {
            Console.WriteLine("work with file A");
        }                                            // Dispose runs here, deterministically
        try
        {
            using var b = new Resource("file B");
            throw new InvalidOperationException("failure while using B");
        }
        catch (InvalidOperationException e)
        {
            Console.WriteLine($"caught: {e.Message}");    // B was disposed before the catch ran
        }
        int before = GC.CollectionCount(0);
        for (int i = 0; i < 200_000; i++) _ = new byte[100];
        Console.WriteLine($"gen0 collections during the loop: {GC.CollectionCount(0) - before} (gen2 so far: {GC.CollectionCount(2)})");
    }
}
open file A
work with file A
dispose file A
open file B
dispose file B
caught: failure while using B
gen0 collections during the loop: 6 (gen2 so far: 0)

Line-by-Line Explanation

Add: ldarg.0 and ldarg.1 push the arguments, add pops both and pushes the sum, ret returns it — the same shape as JVM bytecode. The method uses a *tiny* IL header, which implies a maximum stack depth of 8.

Box: box System.Int32 allocates an object containing the int; boxing the same value twice gives two different objects (ReferenceEquals is false).

call operands are metadata tokens; 0x06… tokens are rows of the MethodDef table in this assembly.

using disposes A at the end of its block and B before the catch runs, independent of garbage collection; the allocation loop triggers several gen-0 collections.

Step-by-Step Runtime Behavior

First call to Add: the runtime's method stub triggers the JIT → tier-0 machine code → stub patched → later calls go straight to compiled code; after 30 calls (default threshold) and a delay, tier-1 recompilation can replace it.

Common Misconceptions

"IL is machine code." It must be JIT- or AOT-compiled.

".NET verifies all IL like the JVM." Modern .NET does not verify IL at load time.

"Structs are always on the stack." They live wherever their container lives (inside heap objects, arrays, boxes).

Common Bugs and Failure Modes

Hidden boxing in hot paths (interfaces on structs, object parameters, older string.Format), forgetting Dispose, reflection breaking under trimming or Native AOT, assembly version conflicts, async methods allocating unexpectedly.

Corrected or Safer Patterns

Generic constraints instead of object, using declarations, trimming and AOT analyzers, source generators instead of runtime reflection, pinned SDK versions (global.json).

Debugging and Inspection Techniques

ILSpy, ilspycmd, dotnet-counters monitor, dotnet-trace, DOTNET_TieredCompilation=0 to compare tiers, BenchmarkDotNet with [MemoryDiagnoser] for allocations.

Performance Considerations

Allocation (boxing, closures, LINQ), tiering warm-up, GC mode, R2R for start-up, Native AOT for minimal start-up and footprint.

Security Considerations

Unverified IL and unsafe code are trusted; isolate untrusted plugins in separate processes; P/Invoke crosses into native code; BinaryFormatter is removed for security reasons.

Portability and Version Caveats

IL is portable across platforms and runtime versions (with roll-forward); R2R code is platform-specific; Native AOT binaries are per-OS/architecture; tiering defaults change across .NET versions.

Real-World Use Cases

ASP.NET Core services, Unity scripting (IL2CPP converts IL to C++), desktop apps, Azure Functions (start-up tuning), cross-platform CLI tools with Native AOT.

Best Practices

Measure allocations, dispose deterministically, avoid reflection on hot paths, choose deployment (JIT, R2R, AOT) per workload.

Exercises

E1 · Beginner. Which IL instruction converts an int to object?

Answer: box System.Int32.

E2 · Intermediate. Why is ReferenceEquals(Box(42), Box(42)) false?

Answer: Each box allocates a new heap object; two boxes holding equal values are distinct objects.

E3 · Advanced challenge. What breaks when publishing an app that uses Type.GetType(nameFromConfig) with Native AOT, and how do you fix it?

Answer: The trimmer cannot see which types are needed, so they may be removed; dynamic code generation is unavailable. Fix by annotating with [DynamicallyAccessedMembers], registering known types explicitly, or replacing reflection with a source generator or a switch over known types.

Quiz

? What does `ldarg.0` do in a static method's IL?
+ Pushes the first argument onto the evaluation stack. :: Static methods have no `this`.
- Pushes `this`. :: Only in instance methods.
- Loads a constant 0. :: That is ldc.i4.0.
- Allocates a local. :: Locals are declared in the method header.

? What is ReadyToRun?
+ Precompiled native code stored alongside IL to speed start-up. :: The JIT can still optimise hot methods.
- A test framework. :: No.
- The .NET garbage collector. :: No.
- An IL verifier. :: No.

? Which statements about .NET are correct? (select all that apply)
+ Boxing allocates a heap object. :: Each box is a new object.
+ `using` calls Dispose deterministically. :: At the end of the scope, even on exceptions.
- Modern .NET verifies all IL before running it. :: It does not.
+ Native AOT removes run-time JIT compilation. :: Code is compiled ahead of time.

Summary and Key Takeaways

.NET compiles C# to IL plus rich metadata in assemblies; the CLR JIT-compiles IL in tiers (or uses R2R/AOT code), manages a generational GC and runs exceptions in two passes. Boxing, disposal and deployment model are the practical levers.

Further Reading

ECMA-335, *Common Language Infrastructure*, Partition III (CIL instruction set).

Microsoft Learn, "Tiered compilation", "ReadyToRun", "Native AOT deployment".

Kokosa, *Pro .NET Memory Management*.