Skip to content
← Alle Beiträge
· Delta1 Labs ILGlass

Die IL-Ansicht lesen und Metadaten-Tokens in Glass navigieren

Eine ausführliche Erklärung zum Wechsel in die IL-Ansicht in Glass, zum Dekodieren von Metadaten-Tokens (0x06 MethodDef, 0x0A MemberRef, 0x70 UserString), zum Lesen des Methodenrumpf-Headers und zum Anklicken eines Tokens, um zu seiner Definition zu springen.

Das C#, das dir ein Decompiler zeigt, ist eine Rekonstruktion. Sie ist meist getreu, aber sie ist eine Interpretation von etwas Präziserem: der Intermediate Language (IL), die der Compiler tatsächlich ausgegeben hat, plus den Metadaten-Tabellen, die jedem Namen, Typ und jeder Signatur ihre Bedeutung geben. Wenn die Rekonstruktion sauber ist, musst du nie darunter schauen. Wenn sie mehrdeutig ist — eine Überladung, die du nicht festnageln kannst, eine Boxing-Konvertierung, die verschwunden ist, eine Schleife, deren Form nicht zum erwarteten Quellcode passt — ist die IL-Ansicht die Grundwahrheit, und Metadaten-Tokens sind der Weg, sie zu navigieren.

Dieser Artikel ist eine Tour durch die IL-Ansicht von Glass.NET, dem kostenlosen .NET-Decompiler. Wir nehmen eine kleine C#-Methode, schauen uns ihr IL an, dekodieren die Metadaten-Tokens, die die Anweisungen tragen, und sehen, wie ein Klick auf ein Token in Glass zu dem Element springt, das es benennt. Der Rahmen ist durchweg legitim: das Verstehen des eigenen Codes, das Auditieren einer Abhängigkeit, die du auslieferst, oder die Bearbeitung eines Vorfalls an einem Assembly, für das du verantwortlich bist.

Von der C#-Ansicht zur IL-Ansicht wechseln

Öffne ein Assembly in Glass und wähle eine Methode aus. Standardmäßig siehst du rekonstruiertes C#. Schalte die Ansicht auf IL um (der Ansichtsumschalter im Code-Bereich) und dieselbe Methode wird als der Bytecode neu dargestellt, den der JIT verarbeiten wird. Am Assembly ändert sich nichts; du betrachtest denselben Methodenrumpf durch eine Linse auf niedrigerer Ebene.

Über die CLI ist das Äquivalent eine Option von decompile:

# Rekonstruiertes C# (Standard)
glass decompile MyApp.dll --type MyApp.Billing.Invoice --member Total

# Derselbe Methodenrumpf als IL, mit angezeigten Metadaten-Tokens
glass decompile MyApp.dll --type MyApp.Billing.Invoice --member Total --il

Die Ausgabe von --il druckt jede Anweisung mit ihrem Offset, ihrem Opcode und ihrem Operanden. Wenn ein Operand ein Metadaten-Token ist, druckt Glass den rohen 4-Byte-Wert und annotiert ihn mit dem Element, zu dem er auflöst, sodass du den Rumpf lesen kannst, ohne Tabellen von Hand abzugleichen.

Was ein Metadaten-Token wirklich ist

Jedes Assembly trägt eine Reihe von Metadaten-Tabellen — MethodDef, TypeDef, MemberRef, Field und so weiter —, jede eine Liste von Zeilen. Ein Metadaten-Token ist einfach ein 4-Byte-Handle, das eine Zeile benennt:

  • Das höchstwertige Byte ist die Tabellen-ID.
  • Die drei niederwertigen Bytes sind der Zeilenindex (ab 1) in dieser Tabelle.

Die Tabellen-IDs, denen du ständig begegnen wirst:

Tabellen-IDTabelleWas sie benennt
0x02TypeDefEin Typ, der in diesem Assembly definiert ist
0x01TypeRefEin anderswo definierter, hier referenzierter Typ
0x06MethodDefEine in diesem Assembly definierte Methode
0x0AMemberRefEine Methode oder ein Feld eines externen Typs
0x04FieldEin in diesem Assembly definiertes Feld
0x1BTypeSpecEin konstruierter Typ (z. B. List<int>)
0x11StandAloneSigEine eigenständige Signatur (lokale Variablen)
0x70UserStringEin String-Literal, im #US-Heap

So dekodiert sich das Token 0x0A000012: höchstwertiges Byte 0x0A → Tabelle MemberRef; drei niederwertige Bytes 0x000012 → Zeile 18. Es benennt den achtzehnten MemberRef — eine Methode oder ein Feld eines Typs aus einem anderen Assembly. Das Token 0x06000004 ist MethodDef-Zeile 4 — die vierte in dem Assembly definierte Methode, das du gerade betrachtest. Und 0x70000001 ist ein Token in den UserString-Heap bei Offset 1, überhaupt keine Tabellenzeile — mehr dazu unten.

Wie IL-Anweisungen Tokens tragen

Nimm diese gewöhnliche Methode:

public string Describe(Customer c)
{
    var sb = new StringBuilder();
    sb.Append("Customer: ");
    sb.Append(c.Name);
    return sb.ToString();
}

In der IL-Ansicht zeigt Glass etwa Folgendes (Tokens in der rechten Spalte annotiert):

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

Lies die Operanden als Tokens und die Abhängigkeiten der Methode werden explizit:

  • newobj 0x0A000011 — Konstruktion über einen MemberRef: den Konstruktor von StringBuilder, der in einem anderen Assembly lebt.
  • ldstr 0x70000001 — ein String-Literal auf den Stack legen. Der Operand ist ein UserString-Token; der Literaltext lebt im #US-Heap.
  • callvirt 0x0A000012 — StringBuilder.Append(string) über einen MemberRef aufrufen. Beachte, dass dasselbe Token zweimal erscheint: Beide Append-Aufrufe binden an die eine Überladung Append(string), was genau die Art von Sache ist, die das IL eindeutig macht.
  • callvirt 0x06000007 — Customer.get_Name() über einen MethodDef aufrufen, weil Customer in diesem Assembly definiert ist, sodass der Getter seiner Eigenschaft eine lokale Methode ist, keine Referenz.

Andere token-tragende Anweisungen folgen derselben Regel: ldfld/stfld tragen ein Field- (0x04…) oder MemberRef-Token, ldtoken trägt das Handle, das du typeof/fieldof erfassen lassen hast, castclass und box tragen ein Typ-Token (0x01…, 0x02… oder 0x1B…), und ldsfld auf einem externen Statischen trägt einen MemberRef.

Ein Token auflösen und zu seinem Ziel springen

Hier wird die IL-Ansicht zu einem Navigator statt zu einer Auflistung. Wenn Glass callvirt 0x0A000012 darstellt, tut es, was die Runtime tut: Es teilt das Token in die Tabellen-ID 0x0A und die Zeile 0x12, schlägt Zeile 18 der MemberRef-Tabelle nach und löst Eltern, Namen und Signatur auf — StringBuilder.Append(string). In der Oberfläche ist das aufgelöste Element ein Hyperlink. Klicke, und Glass navigiert zur Definition dieses Elements (es folgt dem MemberRef hinaus zum referenzierten Assembly, falls es geladen ist, oder zeigt die externe Signatur, falls nicht). Ein MethodDef-Token wie 0x06000007 löst innerhalb des aktuellen Assemblys auf, sodass du mit einem Klick direkt zu get_Name im selben Baum springst.

callvirt 0x0A000012 das 4-Byte-Token teilen hohes Byte 0x0A MemberRef 3 niedrige Bytes 0x000012 Zeile 18 MemberRef-Tabelle, Zeile 18 StringBuilder::Append (string) Klick → springen zur Definition ldstr 0x70000001 Token in den #US-Heap → Literal "Customer: " Heaps #Strings #US #Blob #GUID

Die Heaps: wo Strings und Blobs leben

Die Tabellen sind nicht die ganze Geschichte. Neben ihnen haben die Metadaten vier Heaps:

  • #Strings — die Namen der Bezeichner (Typnamen, Methodennamen, Namespaces). Tabellenzeilen speichern Offsets in diesen Heap, nicht den Text selbst.
  • #US (UserString) — die String-Literale, die dein Code verwendet. Ein ldstr-Operand ist ein 0x70…-Token, dessen niederwertige Bytes ein Offset in #US sind.
  • #Blob — binäre Blobs: Signaturen, Konstantenwerte, Argumente benutzerdefinierter Attribute.
  • #GUID — die MVID des Assemblys und andere GUIDs.

So wird ldstr 0x70000001 anders aufgelöst als ein Tabellen-Token: Glass liest Offset 0x000001 im #US-Heap und holt das Literal heraus — "Customer: " — und zeigt es inline. (Wenn du das in Code verankern willst, modelliert System.Reflection.Metadata das direkt: MetadataReader.GetUserString(UserStringHandle) gibt genau dieses Literal zurück, und MetadataReader.GetBlobReader gibt dir rohen Zugriff auf den #Blob-Heap. MetadataTokens.EntityHandle(int token) verwandelt ein rohes Token in ein typisiertes Handle, was im Wesentlichen die Teilung ist, die Glass durchführt.)

Den Methodenrumpf-Header lesen

Über den Anweisungen beginnt jeder Methodenrumpf mit einem Header, und die IL-Ansicht spiegelt ihn wider. Es gibt zwei Formate:

  • Tiny-Format. Verwendet, wenn die Methode keine lokalen Variablen, keine Ausnahme-Handler und eine Stacktiefe von 8 oder weniger hat. Der gesamte Header ist ein einzelnes Byte: Die beiden niederwertigen Bits markieren „tiny“ und die oberen sechs Bits enthalten die Codegröße. Es gibt nichts weiter zu lesen.
  • Fat-Format. Verwendet für alles andere. Ein 12-Byte-Header trägt die Flags, die Max-Stack-Tiefe, die Codegröße und ein Signatur-Token für lokale Variablen.

Dieses Signatur-Token für Lokale ist selbst ein Metadaten-Token in die StandAloneSig-Tabelle (0x11…). Es benennt weder eine Methode noch einen Typ: Es verweist auf ein Blob, das die lokalen Variablen der Methode beschreibt. In unserem Describe-Beispiel kommen die Zeile .locals init (…StringBuilder V_0) und die Zeile .maxstack 2 beide direkt aus dem Fat-Header; der Typ der einen lokalen Variable wurde durch Auflösen des StandAloneSig-Tokens zu seinem Signatur-Blob wiederhergestellt. Wenn du .maxstack und eine .locals-Liste in der IL-Ansicht siehst, liest du einen dekodierten Fat-Header.

Was die IL-Ansicht zeigt und das C# verbirgt

Der Grund, zum IL hinabzusteigen, ist, dass C# in Richtung Rekonstruktion verlustbehaftet ist. Einige Dinge, die es routinemäßig glättet:

  • Explizites Boxing. object o = 42; verschwindet in C# in einer Zuweisung, aber das IL zeigt box [System.Runtime]System.Int32 mit einem Typ-Token. Wenn du einer Allokation oder einer überraschenden InvalidCastException nachgehst, sind die Anweisungen box/unbox.any nur hier sichtbar.
  • Verzweigungsstruktur. Ein foreach, ein while, ein switch mit Mustervergleich und ein ternärer Ausdruck können alle zu ähnlich aussehendem C# rekonstruieren, aber das IL zeigt die exakten Anweisungen br, brtrue, blt und switch und ihre Ziel-Offsets. Wenn eine rekonstruierte Schleife falsch aussieht, lies die Verzweigungen.
  • Die exakte gebundene Überladung. In C# könnte Append(...) jede von einem Dutzend StringBuilder.Append-Überladungen sein. Im IL löst das call/callvirt-Token zu einer Signatur auf. Dekodiere es und du weißt genau, welche Überladung der Compiler gewählt hat — die Mehrdeutigkeit ist weg.

Das ist der praktische Nutzen. Wenn die C#-Rekonstruktion von Glass sicher ist, vertraue ihr. Wenn sie mehrdeutig ist — Generics, die keinen sauberen Round-Trip gemacht haben, ein optimierter Release-Build, ein Assembly, dem die Namen entfernt wurden — wechsle zu IL, lies die Tokens und klicke dich durch, um aufzulösen, was die höhere Ansicht raten musste. Das IL plus die Metadaten ist die Grundwahrheit des Assemblys; die C#-Ansicht ist die Bequemlichkeit, die darauf aufgebaut ist.

Wie es weitergeht

Öffne in Glass ein Assembly, das dir gehört, wähle eine beliebige Methode mit ein paar externen Aufrufen und schalte in die IL-Ansicht. Dekodiere ein call-Token von Hand — teile das hohe Byte, finde die Tabelle, zähle bis zur Zeile — und klicke dann, um zu bestätigen, dass Glass dich beim selben Element landen lässt. Sobald sich dieser Round-Trip natürlich anfühlt, hört die IL-Ansicht auf einzuschüchtern und wird zum schnellsten Weg, „was tut das wirklich?“ zu beantworten, wenn das C# es nicht sagt. Glass ist kostenlos; hol es dir von der Produktseite und lies deine Binärdateien bis ganz nach unten.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.