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 "?";
}
}
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
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
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 "?";
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:
-
Read the jump table. The
switchoperand is an ordered list of offsets:IL_001F, IL_0023, IL_0027, IL_002B, IL_002F. Indeximaps to the body at offseti. -
Recover the normalization. Glass sees the
ldc.i4.s 10; subfeeding the switch value and records the subtracted minimum as10. The case label for table indexiis thereforei + 10. -
Match the default. The instruction immediately after the
switchis the fallthrough — herebr.s IL_0033. That offset is thedefaultbody. Glass also folds the implicit out-of-range behavior intodefaultrather than emitting an explicit bounds check. -
Attach the bodies. Each target offset becomes a
casewhose body is the straight-line code at that offset, with the trailingbrto 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 "?";
}
}
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";
}
}
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
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
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
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)