DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

How Glass reconstructs a C# switch statement from an IL jump table

A C# switch statement looks, in source, like a tidy multi-way branch. Under the hood, over a dense range of integers, it is something more interesting: a single IL instruction — the switch opcode — that carries an ordered table of jump targets and dispatches in constant time by using the switch value as an index. There are no comparisons at all. That makes it fast, and it makes a naive decompiler's output a mess of gotos and numbered labels. Reconstructing the clean switch (x) { case 10: ... } from that table is a small, self-contained decompilation problem, and it is a good lens on what a decompiler actually does.

Glass.NET is a free .NET decompiler and assembly inspector with a Visual Studio-style Avalonia UI and a glass command-line tool. Everything below is static analysis: it reads the metadata and method bodies of an assembly you already have and are entitled to inspect, and it never loads or runs the target.

The switch opcode is a jump table

Start with a dense switch — the cases form a contiguous integer range:

public static string Grade(int score)
{
    switch (score)
    {
        case 10: return "A";
        case 11: return "B";
        case 12: return "C";
        case 13: return "D";
        case 14: return "E";
        default: return "?";
    }
}
Enter fullscreen mode Exit fullscreen mode

The five cases run from 10 to 14 with no gaps. Roslyn does not emit five comparisons. It emits one switch opcode whose operand is a table of five branch targets, preceded by the arithmetic that turns score into a zero-based index:

IL_0000: ldarg.0
IL_0001: ldc.i4.s   10
IL_0003: sub
IL_0004: switch     (IL_001F, IL_0023, IL_0027, IL_002B, IL_002F)
IL_001A: br.s       IL_0033
IL_001F: ldstr      "A"
IL_0021: br.s       IL_0035
IL_0023: ldstr      "B"
IL_0025: br.s       IL_0035
IL_0027: ldstr      "C"
IL_0029: br.s       IL_0035
IL_002B: ldstr      "D"
IL_002D: br.s       IL_0035
IL_002F: ldstr      "E"
IL_0031: br.s       IL_0035
IL_0033: ldstr      "?"
IL_0035: ret
Enter fullscreen mode Exit fullscreen mode

Read the switch line carefully, because it is the whole mechanism. The opcode pops one unsigned integer off the stack — here the normalized index — and treats it as a position in the target list. Index 0 branches to IL_001F, index 1 to IL_0023, index 2 to IL_0027, and so on. If the index is greater than or equal to 5 (the number of entries), the opcode does nothing and execution falls through to the very next instruction, IL_001A, which is an unconditional br.s to the default body at IL_0033. One instruction, O(1) dispatch, and a built-in fallthrough for default.

Normalizing the switch value

The cases start at 10, but a jump table is always indexed from zero. That gap is closed by the two instructions before the switch:

ldc.i4.s   10
sub
Enter fullscreen mode Exit fullscreen mode

The compiler subtracts the minimum case label — 10 — from score. Now score == 10 becomes index 0, score == 11 becomes index 1, up to score == 14 becoming index 4. The subtraction is also what makes the fallthrough handle both ends of the default range at once: if score is 9, the subtraction yields -1, which as an unsigned value is 0xFFFFFFFF — enormous, certainly >= 5 — so it falls through to default. If score is 100, the result is 90, also >= 5, same fallthrough. A single unsigned comparison, implicit in the opcode, covers everything outside the range in both directions.

This normalization is the single most important thing a decompiler must get right here. The jump table records index 0 through index 4, but the source never mentioned 0; it said case 10. To reconstruct the original labels, the decompiler has to find the subtracted constant and add it back: case label = table index + 10. Miss the sub, and every reconstructed case is off by the minimum.

What a naive decompiler produces

If a decompiler stops at the control-flow graph without recognizing the pattern, it will faithfully emit exactly what the IL says: a computed branch into labels, with the normalization left as an arithmetic expression. The result is correct but unreadable:

// naive, pattern-unaware output
int num = score - 10;
switch (num)   // or worse: a cascade of "if (num == 0) goto IL_001F;"
{
    default: goto IL_0033;
    case 0: goto IL_001F;
    case 1: goto IL_0023;
    case 2: goto IL_0027;
    case 3: goto IL_002B;
    case 4: goto IL_002F;
}
IL_001F: return "A";
IL_0023: return "B";
// ...
IL_0033: return "?";
Enter fullscreen mode Exit fullscreen mode

The num = score - 10 leaks the normalization the compiler added. The cases are numbered 0..4 instead of 10..14. And the bodies are reached through goto into labels rather than sitting inside the switch. It compiles and it is correct, but it is nothing like the source.

How Glass recovers the clean switch

Glass.NET treats the whole sequence — the load, the subtract, the switch opcode, the fallthrough br, and the target bodies — as one recognizable idiom, and reverses each step:

  1. Read the jump table. The switch operand is an ordered list of offsets: IL_001F, IL_0023, IL_0027, IL_002B, IL_002F. Index i maps to the body at offset i.
  2. Recover the normalization. Glass sees the ldc.i4.s 10; sub feeding the switch value and records the subtracted minimum as 10. The case label for table index i is therefore i + 10.
  3. Match the default. The instruction immediately after the switch is the fallthrough — here br.s IL_0033. That offset is the default body. Glass also folds the implicit out-of-range behavior into default rather than emitting an explicit bounds check.
  4. Attach the bodies. Each target offset becomes a case whose body is the straight-line code at that offset, with the trailing br to the common exit recognized as the end of the case.

The reconstructed output is exactly the original:

public static string Grade(int score)
{
    switch (score)
    {
        case 10: return "A";
        case 11: return "B";
        case 12: return "C";
        case 13: return "D";
        case 14: return "E";
        default: return "?";
    }
}
Enter fullscreen mode Exit fullscreen mode

No goto, no leaked score - 10, cases numbered from 10 as written.

Sparse switches are not jump tables

The jump table only pays off when the cases are dense. Spread them out and a table would be mostly empty slots — a switch over 1, 1000, and 100000 would need a hundred-thousand-entry table to hold three bodies. So the compiler switches strategy. Consider:

public static string Classify(int code)
{
    switch (code)
    {
        case 1:    return "low";
        case 1000: return "mid";
        case 100000: return "high";
        default:   return "none";
    }
}
Enter fullscreen mode Exit fullscreen mode

Here Roslyn emits no switch opcode at all. Instead it builds a binary-search tree of comparisons, testing the middle value first to halve the search:

IL_0000: ldarg.0
IL_0001: ldc.i4     1000
IL_0006: bgt.un.s   IL_0015        // code > 1000 ? go check the high half
IL_0008: ldarg.0
IL_0009: ldc.i4.1
IL_000A: beq.s      IL_0024        // code == 1 ?
IL_000C: ldarg.0
IL_000D: ldc.i4     1000
IL_0012: beq.s      IL_002A        // code == 1000 ?
IL_0014: br.s       IL_0036        // default
IL_0015: ldarg.0
IL_0016: ldc.i4     100000
IL_001B: beq.s      IL_0030        // code == 100000 ?
IL_0023: br.s       IL_0036        // default
IL_0024: ldstr      "low"
// ... each body returns
IL_0036: ldstr      "none"
IL_0038: ret
Enter fullscreen mode Exit fullscreen mode

The bgt.un.s at the top splits the range, and the beq.s instructions test individual values. bgt.un — the unsigned greater-than — is the same trick as before: comparing as unsigned lets one branch catch everything below the case as well, which is why you see .un even on ostensibly signed int switches. For a larger sparse set the tree gets deeper, but the shape is always this comparison ladder, never the single-opcode table.

Glass.NET reconstructs this one into the identical switch (code) { case 1: ... case 1000: ... } as well. The job is different — there is no table to read and no minimum to re-add; instead Glass has to recognize the binary-search comparison tree and collapse it back into case labels — but the output is the clean C# switch either way. A real-world switch often compiles to a hybrid: dense clusters of labels become small jump tables, and the stray outliers become beq comparisons bolted onto the tree. Glass handles the mixed form by reconstructing each region with the matching strategy and merging them under one switch.

Trying it with the glass CLI

To see any of this yourself, point the CLI at the type. The decompiled C# view gives you the reconstructed switch:

glass decompile MyApp.dll --type MyApp.Grading.Scorer
Enter fullscreen mode Exit fullscreen mode

To see the machinery underneath — the switch opcode with its target list, the sub normalization, and the fallthrough br — switch to the IL view for a single method:

glass decompile MyApp.dll --type MyApp.Grading.Scorer --member Grade --il
Enter fullscreen mode Exit fullscreen mode

Reading the two side by side is the point. The C# shows you the shape the author wrote; the IL shows you whether the compiler chose a jump table or a comparison ladder to implement it. Both are static reads of the same assembly — nothing is loaded or executed — which makes this a safe, legitimate way to understand and debug compiled code you are entitled to inspect, whether it is your own build or a third-party library you depend on.

Takeaway

A dense C# switch is not a chain of ifs — it is a jump table dispatched by a single switch IL opcode, with the switch value normalized by subtracting the minimum case so the table can be indexed from zero, and an implicit unsigned-range fallthrough standing in for default. A sparse switch is the opposite: a binary-search ladder of beq/bgt.un comparisons. A naive decompiler shows you the gotos and the leaked subtraction; Glass.NET reads the table, re-adds the subtracted minimum, matches the default target, and hands back the clean switch (x) { case 10: ... } the author actually wrote.

Top comments (0)