The C# that a decompiler shows you is a reconstruction. It is usually faithful, but it is an interpretation of something more precise: the Intermediate Language (IL) the compiler actually emitted, plus the metadata tables that give every name, type and signature its meaning. When the reconstruction is clean you never need to look underneath it. When it is ambiguous — an overload you cannot pin down, a boxing conversion that vanished, a loop whose shape does not match the source you expected — the IL view is the ground truth, and metadata tokens are how you navigate it.
This article is a tour of the IL view in Glass.NET, the free .NET decompiler. We will take a small C# method, look at its IL, decode the metadata tokens the instructions carry, and see how clicking a token in Glass jumps you to the member it names. The framing throughout is legitimate: understanding your own code, auditing a dependency you ship, or working an incident on an assembly you are responsible for.
Switching from the C# view to the IL view
Open an assembly in Glass and select a method. By default you see reconstructed C#. Switch the view to IL (the view toggle on the code pane) and the same method re-renders as the bytecode the JIT will consume. Nothing about the assembly changes; you are looking at the same method body through a lower-level lens.
From the CLI the equivalent is a flag on decompile:
# Reconstructed C# (default)
glass decompile MyApp.dll --type MyApp.Billing.Invoice --member Total
# The same method body as IL, with metadata tokens shown
glass decompile MyApp.dll --type MyApp.Billing.Invoice --member Total --il
The --il output prints each instruction with its offset, opcode and operand. Where an operand is a metadata token, Glass prints the raw 4-byte value and annotates it with the member it resolves to, so you can read the body without cross-referencing tables by hand.
What a metadata token actually is
Every assembly carries a set of metadata tables — MethodDef, TypeDef, MemberRef, Field, and so on — each a list of rows. A metadata token is simply a 4-byte handle that names one row:
- The high byte is the table id.
- The low three bytes are the 1-based row index into that table.
The table ids you will meet constantly:
| Table id | Table | What it names |
|---|---|---|
0x02 |
TypeDef | A type defined in this assembly |
0x01 |
TypeRef | A type defined elsewhere, referenced here |
0x06 |
MethodDef | A method defined in this assembly |
0x0A |
MemberRef | A method or field on an external type |
0x04 |
Field | A field defined in this assembly |
0x1B |
TypeSpec | A constructed type (e.g. List<int>) |
0x11 |
StandAloneSig | A standalone signature (local variables) |
0x70 |
UserString | A string literal, in the #US heap |
So the token 0x0A000012 decodes as: high byte 0x0A → MemberRef table; low three bytes 0x000012 → row 18. It names the eighteenth MemberRef — some method or field on a type from another assembly. The token 0x06000004 is MethodDef row 4 — the fourth method defined in the assembly you are looking at. And 0x70000001 is a token into the UserString heap at offset 1, not a table row at all — more on that below.
How IL instructions carry tokens
Take this ordinary method:
public string Describe(Customer c)
{
var sb = new StringBuilder();
sb.Append("Customer: ");
sb.Append(c.Name);
return sb.ToString();
}
In the IL view Glass shows something like this (tokens annotated in the right column):
.method public instance string Describe(class Customer c) cil managed
{
.maxstack 2
.locals init (class [System.Runtime]System.Text.StringBuilder V_0)
IL_0000: newobj instance void [System.Runtime]System.Text.StringBuilder::.ctor() // 0x0A000011 MemberRef
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: ldstr "Customer: " // 0x70000001 UserString
IL_000c: callvirt instance class ...StringBuilder StringBuilder::Append(string) // 0x0A000012 MemberRef
IL_0011: pop
IL_0012: ldloc.0
IL_0013: ldarg.1
IL_0014: callvirt instance string Customer::get_Name() // 0x06000007 MethodDef
IL_0019: callvirt instance class ...StringBuilder StringBuilder::Append(string) // 0x0A000012 MemberRef
IL_001e: pop
IL_001f: ldloc.0
IL_0020: callvirt instance string StringBuilder::ToString() // 0x0A000013 MemberRef
IL_0025: ret
}
Read the operands as tokens and the method's dependencies become explicit:
-
newobj 0x0A000011— construct via a MemberRef: theStringBuilderconstructor, which lives in another assembly. -
ldstr 0x70000001— push a string literal. The operand is a UserString token; the literal text lives in the#USheap. -
callvirt 0x0A000012— callStringBuilder.Append(string)through a MemberRef. Note the same token appears twice: bothAppendcalls bind to the oneAppend(string)overload, which is exactly the kind of thing the IL makes unambiguous. -
callvirt 0x06000007— callCustomer.get_Name()via a MethodDef, becauseCustomeris defined in this assembly, so its property getter is a local method, not a reference.
Other token-carrying instructions follow the same rule: ldfld/stfld carry a Field (0x04…) or MemberRef token, ldtoken carries whatever handle you asked typeof/fieldof to capture, castclass and box carry a type token (0x01…, 0x02… or 0x1B…), and ldsfld on an external static carries a MemberRef.
Resolving a token and jumping to its target
This is where the IL view becomes a navigator rather than a listing. When Glass renders callvirt 0x0A000012, it does what the runtime does: splits the token into table id 0x0A and row 0x12, looks up row 18 of the MemberRef table, and resolves the parent, name and signature — StringBuilder.Append(string). In the UI the resolved member is a hyperlink. Click it and Glass navigates to that member's definition (following the MemberRef out to the referenced assembly if it is loaded, or showing the external signature if it is not). A MethodDef token like 0x06000007 resolves inside the current assembly, so clicking it jumps straight to get_Name in the same tree.
callvirt
0x0A000012
split the 4-byte token
high byte
0x0A
MemberRef
low 3 bytes
0x000012
row 18
MemberRef table, row 18
StringBuilder::Append
(string)
click →
jump to
definition
ldstr 0x70000001
token into the #US heap
→ literal "Customer: "
heaps
#Strings #US #Blob
#GUID
The heaps: where strings and blobs live
Tables are not the whole story. Alongside them, the metadata has four heaps:
-
#Strings— identifier names (type names, method names, namespaces). Table rows store offsets into this heap, not the text itself. -
#US(UserString) — the string literals your code uses. Aldstroperand is a0x70…token whose low bytes are an offset into#US. -
#Blob— binary blobs: signatures, constant values, custom attribute arguments. -
#GUID— the assembly's MVID and other GUIDs.
So ldstr 0x70000001 is resolved differently from a table token: Glass reads offset 0x000001 in the #US heap and pulls out the literal — "Customer: " — and shows it inline. (If you want to ground this in code, System.Reflection.Metadata models it directly: MetadataReader.GetUserString(UserStringHandle) returns exactly that literal, and MetadataReader.GetBlobReader gives you raw access to the #Blob heap. MetadataTokens.EntityHandle(int token) turns a raw token into a typed handle, which is essentially the split Glass performs.)
Reading the method body header
Above the instructions, every method body starts with a header, and the IL view reflects it. There are two formats:
- Tiny format. Used when the method has no locals, no exception handlers, and a stack depth of 8 or less. The whole header is a single byte: the low two bits flag "tiny" and the upper six bits hold the code size. There is nothing else to read.
- Fat format. Used for everything else. A 12-byte header carries flags, the max stack depth, the code size, and a local variable signature token.
That local signature token is itself a metadata token into the StandAloneSig table (0x11…). It does not name a method or type — it points at a blob describing the method's locals. In our Describe example the .locals init (…StringBuilder V_0) line and the .maxstack 2 line both come straight out of the fat header; the single local's type was recovered by resolving the StandAloneSig token to its signature blob. When you see .maxstack and a .locals list in the IL view, you are reading a decoded fat header.
What the IL view shows that C# hides
The reason to drop to IL is that C# is lossy in the reconstruction direction. A few things it routinely smooths over:
-
Explicit boxing.
object o = 42;disappears into an assignment in C#, but the IL showsbox [System.Runtime]System.Int32with a type token. If you are chasing an allocation or a surprisingInvalidCastException, thebox/unbox.anyinstructions are visible only here. -
Branch structure. A
foreach, awhile, a pattern-matchingswitchand a ternary can all reconstruct to similar-looking C#, but the IL shows the exactbr,brtrue,bltandswitchinstructions and their target offsets. When a reconstructed loop looks wrong, read the branches. -
The exact overload bound. In C#,
Append(...)could be any of a dozenStringBuilder.Appendoverloads. In IL thecall/callvirttoken resolves to one signature. Decode it and you know precisely which overload the compiler chose — the ambiguity is gone.
This is the practical payoff. When Glass's C# reconstruction is confident, trust it. When it is ambiguous — generics that did not round-trip cleanly, an optimized release build, an assembly where names were stripped — switch to IL, read the tokens, and click through to resolve what the higher-level view had to guess. The IL plus the metadata is the assembly's ground truth; the C# view is the convenience built on top of it.
Where to go next
Open an assembly you own in Glass, pick any method with a few external calls, and flip to the IL view. Decode one call token by hand — split the high byte, find the table, count to the row — then click it and confirm Glass lands you on the same member. Once that round-trip feels natural, the IL view stops being intimidating and becomes the fastest way to answer "what does this actually do?" when the C# will not say. Glass is free; grab it from the product page and read your binaries all the way down.
Top comments (0)