Eine decimal-Konstante dekompilieren: warum 3.14m keine IL-Konstante ist, und das [DecimalConstant]-Attribut, das sie wieder zusammensetzt
Jedes andere numerische Literal in C# — 42, 3.14, 1_000_000L — landet im IL als ldc-Instruktion oder in der Constant-Tabelle der Metadaten, als Wert, den die Laufzeit direkt lädt. decimal kann das nicht: Es ist ein 128-Bit-Struct, kein primitiver Typ, für den die CLR einen Lade-Opcode hat. Eine const decimal oder ein decimal-Standardparameter hat also keinen nativen Platz, und der Compiler löst das mit einem Trick, über den jeder stolpert, der rohes IL liest — er kodiert die Zahl in ein [DecimalConstantAttribute], dessen fünf Konstruktorargumente (Skalierung, Vorzeichen und drei 32-Bit-Ganzzahlen) den exakten Wert buchstabieren. Dieser Beitrag folgt einem Decompiler durch diese Rekonstruktion: warum die Constant-Tabelle decimal ablehnt, wie die Argumente des Attributs auf die vier ints abgebildet werden, aus denen ein decimal gebaut wird, warum ein const-decimal-Feld in keinem Konstruktor einen Feldinitialisierer hat, worin sich ein decimal-Standardparameter von einem int-Standardparameter unterscheidet, und warum das Verwechseln des Attributs mit einer gewöhnlichen Annotation aus 3.14m ein new decimal(314, 0, 0, false, 2) statt eines Literals macht.
Nehmen wir die gewöhnlichste Zeile C#, die man sich vorstellen kann:
public const decimal TaxRate = 0.075m;
Kompilieren Sie sie, öffnen Sie die Assembly und suchen Sie, wo 0.075 gespeichert ist. Es ist nicht im IL — es gibt keine ldc-Instruktion dafür. Es ist nicht in der Constant-Tabelle der Metadaten, wo const int und Standardparameterwerte leben. Die Zahl ist anscheinend verschwunden. Ist sie aber nicht: Sie wurde in fünf Ganzzahlen zerlegt und in ein Attribut gesteckt, und ein Decompiler, der nicht weiß, dass er dort nachsehen muss, wird Ihren Steuersatz als einen Haufen Konstruktorargumente darstellen. Dies ist das einzige numerische Literal in C#, das ein Decompiler rekonstruieren statt lesen muss, und der Mechanismus lohnt sich, in voller Länge betrachtet zu werden.
Warum decimal keinen Platz hat
Jedes andere numerische Literal hat ein natives Zuhause. Ein const int oder ein int-Standardparameter wird in die Constant-Metadatentabelle geschrieben, deren Blob-Format für jeden primitiven Elementtyp eine Kodierung definiert — ELEMENT_TYPE_I4, ELEMENT_TYPE_R8, ELEMENT_TYPE_STRING und so weiter. Ein Literal in einem Methodenrumpf wird zu einer Instruktion: ldc.i4 42, ldc.r8 3.14. Beide Kanäle teilen eine Annahme — dass der Wert ein Typ ist, den die CLR nativ darstellt.
decimal ist das nicht. System.Decimal ist ein 128-Bit-Struct, das in der Klassenbibliothek definiert ist und aus einer 96-Bit-Ganzzahl plus Skalierung und Vorzeichen besteht. Die CLR hat keinen Lade-Opcode dafür, und das Constant-Blob-Format hat keinen Elementtyp dafür. Wenn die Sprache also sagt „dieses decimal ist eine Konstante”, gibt es keinen Metadaten-Slot, der die Antwort aufnehmen könnte. Der Compiler muss den Wert auf einem anderen Weg kodieren — und der Weg, den er gewählt hat, ist System.Runtime.CompilerServices.DecimalConstantAttribute.
Das Attribut ist die Zahl
Hier ist das obige Feld, so wie der Compiler es emittiert, in IL:
.field public static initonly valuetype [System.Runtime]System.Decimal TaxRate
.custom instance void [System.Runtime]System.Runtime.CompilerServices.DecimalConstantAttribute::.ctor(
uint8, uint8, uint32, uint32, uint32) = ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )
Diese fünf Konstruktorargumente sind keine Metadaten über die Zahl — sie sind die Zahl, in genau den Bestandteilen, aus denen System.Decimal zusammengesetzt wird. Die Konstruktorsignatur lautet (byte scale, byte sign, uint hi, uint mid, uint low):
- scale =
3— die Anzahl der Stellen hinter dem Dezimalpunkt (die Ganzzahl wird durch 10³ geteilt). - sign =
0— positiv (1 würde negativ bedeuten). - hi / mid / low =
0 / 0 / 75— die drei 32-Bit-Wörter der 96-Bit-unskalierten Ganzzahl; hier nur75im niedrigsten Wort.
Zusammengesetzt: die unskalierte Ganzzahl 75, skaliert mit 10⁻³, positiv → 0.075. Der Decompiler führt dieselbe Arithmetik aus wie der decimal(int lo, int mid, int hi, bool isNegative, byte scale)-Konstruktor der Laufzeit und gewinnt das Literal 0.075m zurück. Nichts wird gerundet und nichts geht verloren — das Attribut ist eine vollständige, exakte Beschreibung des Werts.
Ein const decimal hat keinen Initialisierer — mit Absicht
Eine zweite Überraschung wartet auf einen Decompiler, der nur Methodenrümpfe liest. Man könnte erwarten, dass const decimal TaxRate = 0.075m zu einem Store im statischen Konstruktor kompiliert — .cctor weist das Feld zu —, aber das tut es nicht. const in C# bedeutet Compilezeitkonstante: Der Wert muss in anderen konstanten Kontexten verwendbar sein, also kann der Compiler ihn nicht dadurch implementieren, dass er Code bei der Typinitialisierung ausführt (das würde ihn readonly machen, nicht const). Stattdessen:
- wird das Feld als
static initonly-Feld emittiert, das das[DecimalConstant]-Attribut trägt, und - wird jede Verwendung von
TaxRateim Quellcode zur Compilezeit durch den rekonstruierten Wert ersetzt, der an der Aufrufstelle inlined wird.
Der .cctor enthält also keinen Store nach TaxRate, und es gibt keinen Instanzinitialisierer. Ein Decompiler, der Konstruktoren nach dem Wert des Feldes durchsucht, findet nichts und würde das Feld fälschlich als nicht initialisiert melden. Der Wert steckt im Attribut, und die ursprüngliche Deklaration zurückzugewinnen heißt, ihn von dort zu lesen — und das Feld dann als const zu präsentieren, nicht als static readonly, denn das initonly-Flag plus das [DecimalConstant]-Attribut sind zusammen die Signatur eines const decimal.
Ein decimal-Standardparameter nimmt einen anderen Weg als ein int-Standardparameter
Dieselbe Lücke formt auch optionale Parameter um. Vergleichen Sie:
void A(int rate = 5) { }
void B(decimal rate = 0.075m) { }
Bei A wandert der Standardwert 5 in die Constant-Tabelle und der Parameter bekommt das HasDefault-Flag; ein Decompiler liest den Constant-Blob und schreibt = 5. Bei B kann die Constant-Tabelle kein decimal aufnehmen, also markiert der Compiler den Parameter als [opt] (das Optional-Flag) ohne HasDefault-Eintrag und hängt [DecimalConstant(3, 0, 0, 0, 75)] an den Parameter:
.method ... void B(valuetype System.Decimal rate)
{
.param [1]
.custom instance void ...DecimalConstantAttribute::.ctor(uint8,uint8,uint32,uint32,uint32)
= ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )
}
Ein Decompiler, der Standardwerte zurückgewinnt, braucht deshalb zwei Codepfade: die Constant-Tabelle für die primitiven Typen lesen und bei decimal auf [DecimalConstant] am Parameter prüfen. Berücksichtigt er nur die Constant-Tabelle, verschwindet der decimal-Standardwert — der Parameter wird so dargestellt, als hätte er keinen. Schlimmer noch: Ein Decompiler, der [DecimalConstant] wie ein gewöhnliches benutzerdefiniertes Attribut behandelt, druckt es wörtlich in den Quellcode:
// WRONG — the attribute is an encoding, not something the author wrote:
void B([DecimalConstant(3, 0, 0, 0, 75)] decimal rate) { }
Diese Zeile hat im Originalcode nie existiert. Das Attribut ist die private Kodierung des Compilers für = 0.075m, und ein treuer Decompiler konsumiert es und gibt das wieder, was der Programmierer tatsächlich getippt hat.
Warum Glass es als Wert behandelt, nicht als Annotation
Die Regel, die ein Decompiler umsetzen muss, lautet: [DecimalConstant] wird nie angezeigt — es wird immer konsumiert. Wo immer es auftaucht — an einem const-Feld, an einem static readonly-Feld, das aus einer Konstante initialisiert wird, oder an einem optionalen Parameter — liest Glass.NET die fünf Argumente, setzt das System.Decimal wieder zusammen und emittiert das Literal, das der Autor geschrieben hat: const decimal TaxRate = 0.075m;, decimal rate = 0.075m. Das Attribut selbst wird unterdrückt, genau wie das [Extension]-Attribut an einer Erweiterungsmethode oder das [IsReadOnly]-modreq an einem readonly struct unterdrückt wird — das ist die Buchführung des Compilers, nicht der Quellcode.
Es ist ein kleines Feature mit einem überproportionalen Fehlermodus. Macht man es falsch, kommt jede Geldkonstante, jeder Steuersatz und jeder Schwellenwert in einer dekompilierten Finanzanwendung als new decimal(75, 0, 0, false, 3) zurück oder, schlimmer, als ein Attribut, das der Programmierer nie geschrieben hat — technisch korrekt, für Menschen unlesbar. Macht man es richtig, liest sich 0.075m wieder als 0.075m, was für den einen numerischen Typ, der in den Metadaten kein natives Zuhause hat, die ganze Aufgabe ist.
Wie Glass die anderen Werte behandelt, die der Compiler in Metadaten statt in IL versteckt, sehen Sie in Optionale und benannte Argumente dekompilieren und Einen Array-Initialisierer dekompilieren.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.