Skip to content
← Alle Beiträge
· Delta1 Labs Glass.NETDekompilierung.NETTiefenanalyse

Optionale und benannte Argumente dekompilieren: Standardwerte in der Constant-Tabelle und die Temporaries, die der Compiler fürs Umordnen erzeugt

Ein optionaler Parameter kostet an der Aufrufstelle, die Sie geschrieben haben, zur Laufzeit nichts — der Standardwert wird nicht in der Methode gespeichert, sondern vom Compiler in jeden Aufrufer eingesetzt. Im IL gibt es daher keine Spur eines Standardarguments außer am Aufruf selbst: ein Literal, das dort gepusht wird, wo Sie nichts geschrieben haben. Der Standardwert selbst liegt weit entfernt, in der Constant-Metadatentabelle, die am Parameter hängt, neben einem Optional-Flag; ein Decompiler, der die Methode liest, muss ihn dort nachschlagen, um = 0 oder = null auszugeben. Benannte Argumente sind noch merkwürdiger: Sie verschwinden vollständig, wenn sie in Reihenfolge stehen, und stehen sie außer der Reihe, schreibt der Compiler sie in geordnete positionale Pushes um — es sei denn, ihre Auswertung außer der Reihe hätte Nebeneffekte; dann lagert er jedes Argument zunächst in ein Temporary aus, um die Links-nach-rechts-Auswertung zu bewahren, und hinterlässt einen Block von Stores, der wie von Ihnen geschriebener Code aussieht. Dieser Beitrag liest IL und Metadaten für beides, zeigt die Zeilen der Constant-Tabelle hinter einem Standardwert, geht die Umordnungs-Temporaries durch und erklärt, wie Glass.NET die optionalen Standardwerte rekonstruiert und entscheidet, wann ein benanntes Argument es wert ist, ausgegeben zu werden.

Die meisten C#-Features hinterlassen im IL eine Form, die man zurücklesen kann. Optionale und benannte Argumente sind ungewöhnlich, weil der interessante Teil die Abwesenheit ist: ein optionales Argument, das Sie weglassen, ein Name, den Sie schreiben. Keines von beiden überlebt als es selbst. Der Standardwert wird in einer Metadatentabelle abgelegt, die der Methodenrumpf nie erwähnt, und der Name ist in dem Moment verschwunden, in dem der Compiler ihn einem Parameter zugeordnet hat. Was ein Decompiler rekonstruiert — und was er stillschweigend als nicht rekonstruierbar einräumt —, macht diese beiden Features zu einer guten Lektion darüber, wo Kompilierung verlustbehaftet ist.

Dieser Beitrag liest Metadaten und IL für beide und zeigt, wie Glass.NET mit jedem davon umgeht.

Ein optionaler Parameter: der Standardwert lebt in der Constant-Tabelle

Hier eine Methode mit zwei optionalen Parametern und ein Aufruf, der sie weglässt:

public static string Format(string value, int width = 0, char pad = ' ')
    => value.PadLeft(width, pad);

static void Use() => Format("hi");   // width and pad omitted

Die Standardwerte stehen nirgends im Rumpf von Format — Format verwendet width und pad einfach als Parameter. Sie liegen in den Metadaten an den Parametern selbst. In der Darstellung von ILDasm:

.method public hidebysig static string Format(string value,
        [opt] int32 width,
        [opt] char pad) cil managed
{
    .param [2] = int32(0x00000000)
    .param [3] = char(0x0020)
    ...
}

[opt] ist das Optional-Flag auf jedem Parameter, und .param [2] = int32(0x00000000) ist die Art, wie ILDasm eine Zeile der Constant-Metadatentabelle ausgibt. Diese Tabelle ist eine dreispaltige Struktur — ein Typ-Tag, ein Parent (hier die Param-Zeile) und ein Blob mit den Literalbytes —, und es ist dieselbe Tabelle, die auch const-Felder und Enum-Member trägt. Die Parameterzeile führt zudem das HasDefault-Flag, den Zeiger, der sagt: „Schau in der Constant-Tabelle nach meinem Parent.“ Drei Teile kodieren also einen Standardwert: Optional am Parameter, HasDefault am Parameter und der Wert in der Constant-Tabelle.

Entscheidend ist: width = 0 und pad = ' ' erscheinen in den Signatur-Metadaten von Format, nicht in Use. Um Format zu dekompilieren und seine Signatur auszugeben, muss ein Decompiler also die Zeilen der Constant-Tabelle für die Parameter 2 und 3 lesen, int32(0) und char(0x0020) dekodieren und int width = 0, char pad = ' ' rendern. Das ist ein Metadaten-Lesevorgang, keine Schlussfolgerung, und er funktioniert ohne PDB, weil die Constant-Tabelle Teil der Assembly ist, auf die sich die Laufzeit selbst stützt.

Ein Standardwert, drei Metadaten-Teile — und eine SubstitutionParam-Zeile für `width`flag: Optional [opt]flag: HasDefault ──┐name: "width"Zeile der Constant-TabelleType = ELEMENT_TYPE_I4Parent = Param(width)Value = 0x00000000Aufrufstelle in Use()ldstr "hi"ldc.i4.0 // widthldc.i4.s 32 // padUm die Signatur von Format auszugeben, liest der Decompiler die Constant-Tabelle (linker Link).Am Aufruf hat der Compiler die Defaults bereits als Literale eingebacken (rechts) — kein Link zurück zur Tabelle.Folge: Default ändern und Bibliothek neu bauen — Aufrufer behalten den ALTEN Wert, bis SIE selbst neu bauen —weil der Default in sie hineinkopiert und nicht referenziert wurde.

Die Aufrufstelle: Standardwerte werden eingesetzt, nicht referenziert

Nun die subtile Hälfte. Sehen Sie sich an, wozu Use tatsächlich kompiliert wurde:

.method private hidebysig static void Use() cil managed
{
    IL_0000: ldstr      "hi"
    IL_0005: ldc.i4.0            // width  — the default, baked in
    IL_0006: ldc.i4.s   32       // pad    — ' ' is 0x20 = 32, baked in
    IL_0008: call       string Format(string, int32, char)
    IL_000d: pop
    IL_000e: ret
}

Sie haben Format("hi") geschrieben. Der Compiler hat drei Argumente gepusht. Die Standardwerte wurden nicht zur Laufzeit aus der Constant-Tabelle geholt — der Compiler hat sie zur Kompilierzeit gelesen und ldc.i4.0 und ldc.i4.s 32 direkt in Use erzeugt. Der Aufruf ist ein völlig gewöhnlicher Aufruf mit drei Argumenten; nichts unterscheidet die beiden Literale von Argumenten, die ein Programmierer getippt hat.

Das hat eine reale Konsequenz, die sich zu dekompilieren lohnt: Weil der Standardwert in den Aufrufer kopiert wurde, ändert das Umstellen des Standardwerts von Format von 0 auf 1 und das Ausliefern einer neuen Bibliothek das Verhalten von Use nicht, bis Use neu kompiliert wird — es pusht weiterhin ldc.i4.0. Ein Decompiler, der nur den alten Aufrufer liest, kann nicht einmal erkennen, dass sich der Standardwert weiterentwickelt hat. Das ist die klassische Regel „optionale Parameter sind ein Feature der Quell-, nicht der Binärkompatibilität“, und sie ist genau hier im eingebackenen Literal sichtbar.

Für den Decompiler entsteht eine Mehrdeutigkeit, die er nicht vollständig auflösen kann: ldc.i4.0 an der Stelle von width ist sowohl mit Format("hi") als auch mit Format("hi", 0) vereinbar. Das IL ist identisch. Glass.NET löst den Aufgerufenen auf, sieht, dass die hinteren Literal-Argumente den Standardwerten der Parameter entsprechen, und bevorzugt das kürzere Format("hi") als wahrscheinlichsten Quelltext — behält aber ein Argument, wenn sein Wegfall die Überladungsauflösung ändern würde, und zeigt alle Argumente, wenn der Aufgerufene in einer Assembly liegt, die es nicht auflösen kann, um die Standardwerte daraus zu lesen. Es gibt den Aufruf aus, der ein gültiger Quelltext für das IL ist, und bleibt dabei ehrlich, dass „weggelassen oder geschrieben“ etwas ist, das die Kompilierung tatsächlich auslöscht.

Benannte Argumente in Reihenfolge: gar nichts

Benannte Argumente sind ein Zuordnungsmechanismus zur Kompilierzeit. Stehen sie bereits in Deklarationsreihenfolge, hinterlassen sie keinerlei Spur:

Format(value: "hi", width: 4, pad: '.');

kompiliert zu exakt demselben ldstr / ldc.i4.4 / ldc.i4.s 46 / call wie die positionale Form. Die Namen dienten nur dazu, jeden Ausdruck an einen Parameter zu binden; sobald gebunden, erzeugt der Compiler positionale Pushes. Nichts in der Assembly sagt, dass ein Name geschrieben wurde, also kann ein Decompiler width: und pad: nicht rekonstruieren — und sollte es auch nicht vorgeben. Glass.NET gibt den positionalen Aufruf aus, der sich identisch verhält.

Benannte Argumente außer der Reihe: Umordnen und manchmal Auslagern

Der einzige Fall, in dem benannte Argumente die Codeerzeugung berühren, ist, wenn sie außerhalb der Parameterreihenfolge geschrieben sind, denn der Stack muss trotzdem in Parameterreihenfolge aufgebaut werden. Es gibt zwei Unterfälle, und sie sehen im IL unterschiedlich aus.

Ist jedes außer der Reihe stehende Argument nebeneffektfrei — eine Konstante, ein Lokal, ein Feldzugriff, den der Compiler als sicher einstuft —, pusht der Compiler sie schlicht in Parameterreihenfolge:

Format(width: 4, value: "hi");       // 'pad' omitted → default
IL_0000: ldstr      "hi"             // value pushed first — parameter order
IL_0005: ldc.i4.4                    // width
IL_0006: ldc.i4.s   32               // pad default
IL_0008: call       string Format(string, int32, char)

Beachten Sie, dass ldstr "hi" zuerst kommt, obwohl width: 4 zuerst geschrieben wurde: Für eine Konstante und ein Literal spielt die beobachtbare Reihenfolge keine Rolle, also ordnet der Compiler stillschweigend in Parameterreihenfolge um, und die Quellform ist nicht mehr rekonstruierbar.

Wäre die Auswertung außerhalb der Schreibreihenfolge jedoch beobachtbar — ein Nebeneffekt, ein Getter, ein Inkrement —, zwingt die Links-nach-rechts-Garantie von C# den Compiler, in Temporaries auszulagern:

int n = 0;
Format(width: Next(ref n), value: Read(ref n));   // both have side effects, written out of order
.locals init ([0] int32 n, [1] int32 V_1, [2] string V_2)
IL_...: ldloca.s   n
IL_...: call       int32 Next(int32&)
IL_...: stloc.1                      // V_1 = Next(ref n)   — evaluated FIRST (written first)
IL_...: ldloca.s   n
IL_...: call       string Read(int32&)
IL_...: stloc.2                      // V_2 = Read(ref n)   — evaluated SECOND
IL_...: ldloc.2                      // now push in PARAMETER order: value …
IL_...: ldloc.1                      // … then width
IL_...: ldc.i4.s   32                // pad default
IL_...: call       string Format(string, int32, char)

Die beiden Temporaries V_1 und V_2 sind der Fingerabdruck. Sie werden in Schreibreihenfolge erzeugt (Next zuerst, weil width: zuerst geschrieben wurde) und in Parameterreihenfolge konsumiert (value zuerst auf dem Stack). Diese Überkreuzung — in einer Reihenfolge auswerten, in einer anderen pushen — ist der einzige Beleg dafür, dass benannte Argumente umgeordnet wurden. Und sie reicht nicht aus, um die Namen zu rekonstruieren: Exakt dasselbe Temporary-Spill-Muster erzeugt der Compiler für die gewöhnliche Links-nach-rechts-Auswertung positionaler Argumente, die zufällig sequenziert werden müssen, und ein Programmierer könnte diese Temporaries von Hand geschrieben haben. Glass.NET rendert den Spill daher getreu als Temporaries plus positionalen Aufruf, statt eine width:/value:-Syntax zu erraten, die das IL tatsächlich nicht festlegt.

SituationErzeugtes ILGlass.NET rekonstruiertbenannt, in Reihenfolgeeinfache positionale Pushes— keine Spur der Namenpositionaler Aufrufbenannt, außer der Reihe,ohne NebeneffektePushes umgeordnet inParameterreihenfolge, keine Tempspositionaler Aufruf;Schreibreihenfolge verlorenbenannt, außer der Reihe,mit NebeneffektenSpill in Temporaries inSchreibreihenfolge, Push inParameterreihenfolgeTemporaries + positionalerAufruf; Namen weiterhinnicht rekonstruierbarIn jedem Fall werden die Argumentnamen selbst beim Kompilieren gelöscht — ein Decompiler kann sie nicht zurückholen.

Was Glass.NET ausgibt und was es nicht erfindet

Nimmt man beide Features zusammen, teilt sich die Rekonstruktion sauber entlang der Linie dessen, was die Metadaten tatsächlich enthalten:

  1. Optionale Standardwerte an einer Signatur werden exakt rekonstruiert, weil sie in der Constant-Tabelle stehen: Glass.NET liest die HasDefault/Optional-Flags und die Constant-Zeile jedes Parameters und gibt int width = 0, char pad = ' ' aus, wobei das Literal nach seinem Typ-Tag dekodiert wird — char(0x0020) als ' ', ein Enum-Standardwert als der benannte Member, ein default-Struct als default.
  2. Weggelassene Argumente an einem Aufruf werden rekonstruiert, indem der Aufgerufene aufgelöst und die hinteren Literale mit den Standardwerten verglichen werden; bevorzugt wird dann die kürzeste Form, die noch an dieselbe Überladung bindet — mit Rückfall auf die Anzeige aller Argumente, wenn der Aufgerufene nicht gelesen werden kann oder ein Weglassen die Überladungsauflösung ändern würde.
  3. Namen benannter Argumente werden nicht erfunden. Entspricht die Push-Reihenfolge der Parameterreihenfolge, wird der Aufruf positional ausgegeben. Zeigt ein Temporary-Spill eine Überkreuzung von Schreib- und Parameterreihenfolge, werden die Temporaries als das gezeigt, was sie sind, und der Aufruf bleibt positional. Die Namen wurden zur Kompilierzeit ausgelöscht, und ein Decompiler, der width:/value: erfände, würde eine Vermutung als Tatsache verkleiden.

Dieser letzte Punkt ist das Thema, das beide Features teilen. Dekompilierung rekonstruiert, was der Compiler gespeichert hat — und bei optionalen und benannten Argumenten hat der Compiler den Standardwert gespeichert (in einer Tabelle, die man lesen kann), den Namen aber verworfen und ebenso das „das haben Sie weggelassen“ (ersetzt durch ein Literal, das sich nicht unterscheiden lässt). Ein guter Decompiler zieht diese Linie offen: Er gibt den Standardwert aus, weil er wirklich da ist, und er verzichtet darauf, den Namen zu rekonstruieren, weil er wirklich nicht da ist.

Nebula.NET testen

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