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

Ausnahmefilter dekompilieren: das catch (e) when (...), das zu einer filter-Region wird, und das endfilter, das sie ausführt

Ein catch-Block mit einem Typ gehört zum Einfachsten, was ein Dekompiler liest — eine Handler-Klausel mit einem Typ-Token, fertig. Ein catch mit einer when-Klausel gehört zum Kniffligsten, denn es ist eine völlig andere Art von Ausnahme-Region. Der Filter hat kein Typ-Token; der Typtest wandert in einen getrennten IL-Block, der läuft, während der Stack noch abgewickelt wird, die when-Bedingung auswertet und in einem endfilter endet, das 1 zurückgibt, um den Handler auszuführen, oder 0, um die Suche fortzusetzen. catch (IOException e) when (e.HResult == 5) zu rekonstruieren bedeutet, diese Zwei-Block-Struktur zu erkennen, das Ausnahmeobjekt, das die Runtime auf den Stack gelegt hat, in die Variable e zu heben, den Typtest aus dem Filter als catch-Typ herauszuziehen und den Rest als when-Ausdruck zu lesen — und dann zu wissen, dass Ausnahmen, die innerhalb des Filters geworfen werden, verschluckt werden, was eine echte, im IL sichtbare Sprachgarantie ist. Dieser Artikel liest das genaue IL, das ein gefilterter catch emittiert, geht das endfilter-Protokoll durch und zeigt, wie Glass.NET die Klausel wieder aufbaut.

Das meiste, was ein Dekompiler mit Ausnahmebehandlung tut, ist das Mustererkennen des Offensichtlichen. Ein try/finally sind zwei Regionen und ein leave; ein catch (IOException e) ist eine Handler-Klausel, die mit einem Typ-Token etikettiert ist, und der Leser gibt einfach den Typ aus. Das einzige Ausnahmebehandlungs-Konstrukt, das wirklich einen zweiten Blick verdient, ist der gefilterte catch — catch (E e) when (Bedingung) — denn es ist überhaupt nicht dieselbe Art Klausel. Es hat kein Typ-Token, es führt Code aus, bevor der Handler überhaupt gewählt ist, und es endet in einer Anweisung, von der die meisten C#-Entwickler nie gehört haben: endfilter.

Dieser Artikel liest das IL, das ein gefilterter catch wirklich emittiert, geht das endfilter-Protokoll Schritt für Schritt durch und zeigt, wie Glass.NET die Klausel wieder aufbaut — einschließlich der einen echten Sprachgarantie, die der Filter verbirgt: Innerhalb seiner geworfene Ausnahmen werden verschluckt.

Ein gefilterter catch ist eine Zwei-Regionen-Klausel ohne Typ

Hier ist der Quellcode, den wir dekompilieren:

try
{
    DoIo();
}
catch (IOException e) when (e.HResult == 5)
{
    Log("access denied");
}

Ein gewöhnlicher catch (IOException e) würde zu einer Handler-Region mit IOException als Klauseltyp kompilieren. Das when ändert die Art der Klausel. Die Metadaten verzeichnen nun eine filter-Klausel, deren Typfeld leer ist, und die Methode trägt zwei Regionen statt einer: eine filter-Region und eine Handler-Region. Dekompiliert zur Darstellung von ILDasm ist die Struktur:

.try IL_0000 to IL_0007 filter IL_0007 handler IL_0021 to IL_002e

IL_0000: call       void DoIo()
IL_0005: leave.s    IL_002e
// ---- filter region: the runtime pushes the exception object here ----
IL_0007: isinst     [System.Runtime]System.IO.IOException
IL_000c: dup
IL_000d: brtrue.s   IL_0013
IL_000f: pop
IL_0010: ldc.i4.0
IL_0011: br.s       IL_001f
IL_0013: callvirt   instance int32 [System.Runtime]System.Exception::get_HResult()
IL_0018: ldc.i4.5
IL_0019: ceq
IL_001b: br.s       IL_001f
IL_001f: endfilter
// ---- handler region: exception object on the stack again ----
IL_0021: pop
IL_0022: ldstr      "access denied"
IL_0027: call       void Log(string)
IL_002c: leave.s    IL_002e
IL_002e: ret

Lesen Sie die filter-Region als ein kleines Programm, das die Runtime ausführt, bevor sie entscheidet, den Handler zu betreten. Das Ausnahmeobjekt liegt bereits auf dem Stack, wenn die Region beginnt — die Runtime hat es dorthin gelegt. isinst IOException macht den Typtest, nach dem der Teil catch (IOException ...) verlangte; dup/brtrue behält das Cast-Ergebnis, wenn es passte; passte es nicht, lassen pop und ldc.i4.0 eine 0 und springen zu endfilter, womit die Klausel verworfen wird. Passte es, wertet der Code e.HResult == 5 aus und lässt das ceq-Ergebnis — 0 oder 1 — auf dem Stack. Jeder Pfad erreicht endfilter mit genau einem Integer dahinter.

Das endfilter-Protokoll: Filter ausführen, dann entscheidentry wirftRuntime legt Ausnahme abfilter-Region (Stack: Ausnahme)isinst IOException → kein Treffer? 0Treffer? when-Bed. ausw. → ceqendfilter ← nimmt ein int32Wert 1 → Handler ausführenAusnahme in e, Rumpf läuftWert 0 → Suche fortsetzenKlausel verworfen, weiter abwickeln10Eine INNERHALB des Filters geworfene Ausnahme wird gefangen und als 0 (Suche fortsetzen) behandelt.Was Glass wiedergewinntTyp ← isinst-Ziele ← abgelegte Ausnahmewhen ← Ausdr. vorendfilter

endfilter ist die Anweisung, die den Handler wählt

endfilter ist der ganze Kern, und es hat ein Protokoll, das sich präzise zu formulieren lohnt. Die filter-Region muss genau ein int32 auf dem Stack lassen; endfilter nimmt es vom Stack und übergibt es der Runtime. 1 (EXCEPTION_EXECUTE_HANDLER) heißt Handler ausführen; 0 (EXCEPTION_CONTINUE_SEARCH) heißt diese Klausel gilt nicht, wickle weiter ab, um eine andere zu finden. Darum endet jede filter-Region der Welt gleich — irgendein Vergleich, der 0 oder 1 erzeugt, dann endfilter. Für den Dekompiler ist endfilter die Grenzmarke: Alles vom Beginn der filter-Region bis dorthin ist die when-Bedingung (plus der implizite Typtest), und alles nach der Handler-Marke ist der catch-Rumpf.

Dann beginnt die Handler-Region — und das Ausnahmeobjekt liegt wieder auf dem Stack, ein zweites Mal von der Runtime abgelegt, als der Handler betreten wird. In unserem IL verwirft IL_0021: pop es, weil der Rumpf nur den String-Literal nutzt. Hätte der Rumpf e genutzt, hätte der Compiler stloc in den e-Slot emittiert statt pop. Dieses führende pop-oder-stloc zu erkennen ist, wie Glass.NET weiß, wo die Bedingung endet und der von einem Menschen geschriebene catch-Rumpf beginnt, und ob die Ausnahmevariable tatsächlich gelesen wird.

Was Glass.NET rekonstruiert, und woraus

Fügt man die Teile zusammen, ist die Rekonstruktion mechanisch. Glass.NET sieht eine filter-Klausel ohne Typ-Token und weiß sofort, dass es ein when-catch ist. Es liest den Prolog der filter-Region — das isinst gegen IOException, den dup/brtrue/pop/ldc.i4.0-Tanz der Null-Prüfung — und hebt zwei Dinge daraus: Das isinst-Ziel wird zum catch-Typ, und das von der Runtime abgelegte Ausnahmeobjekt wird zur Variable e. Alles zwischen diesem Prolog und endfilter ist der when-Ausdruck. Das führende stloc/pop der Handler-Region bestätigt, ob e genutzt wird. Das Ergebnis:

catch (IOException e) when (e.HResult == 5)
{
    Log("access denied");
}

— getreu wiedergewonnen, ohne PDB, weil jeder Teil davon im IL und in den Ausnahme-Regionen-Metadaten kodiert ist, auf die sich die Runtime selbst verlässt. Ein Dekompiler, der Filter nicht modelliert, würde Ihnen einen typlosen catch mit einem Block Vergleichs-IL zeigen, den er nicht einzuordnen wusste — genau die Art „der Code läuft, aber die Dekompilierung ist falsch”-Lücke, die sich ein Leser, der durch ein unbekanntes Binary navigiert, nicht leisten kann.

Die im Filter versteckte Garantie: geworfene Ausnahmen werden verschluckt

Noch eine Sache, die das IL lesbar macht, und es ist eine echte Sprachregel, keine Eigenart. Wenn die filter-Region selbst wirft — sagen wir, e.HResult wäre durch eine Eigenschaft ersetzt, die werfen kann — fängt die CLR diese Ausnahme und behandelt den Filter so, als hätte er 0 zurückgegeben, Suche fortsetzen. Die when-Bedingung, die wirft, bringt Ihr Programm nicht zum Absturz und entweicht nicht; sie bewirkt nur, dass dieser catch übersprungen wird. Das ist spezifiziertes Verhalten, und darum sollte eine when-Klausel günstig und nebenwirkungsfrei sein: Eine Bedingung, die werfen kann, ist ein subtiler Bug, weil sie still den catch deaktiviert, den Sie geschrieben zu haben glaubten.

Glass.NET gibt die Klausel als das catch ... when (...) wieder, das sie war, und lässt das Verschluck-Verhalten das sein, was es ist — eine Eigenschaft der Filter, die der Leser verstehen muss, nicht etwas, das ein Dekompiler umschreiben sollte. Diese Struktur sichtbar zu machen ist der Wert: Der dekompilierte Code sagt Ihnen, dass dies ein Filter ist, und ein Leser, der Filter kennt, weiß, dass eine werfende Bedingung hier den Abgleich still scheitern lässt. Das ist der Unterschied zwischen einer Dekompilierung, die die Bytes reproduziert, und einer, die die Bedeutung reproduziert.

Fazit

Der gefilterte catch ist das Ausnahme-Konstrukt, bei dem IL und Quelle aufhören, eins-zu-eins zu sein, und es zu lesen ist ein guter Test für einen Dekompiler. Es gibt kein Typ-Token — der Typ wanderte in ein isinst innerhalb einer getrennten filter-Region. Die Bedingung läuft, bevor der Handler gewählt ist, gegen ein Ausnahmeobjekt, das die Runtime auf den Stack legt. endfilter nimmt die 0-oder-1 vom Stack, die alles entscheidet. Und ein throw innerhalb des Filters wird als Sprachgarantie verschluckt. Glass.NET modelliert all das: die Zwei-Regionen-Struktur, den aus dem Filter gehobenen Typ, die Variable aus der abgelegten Ausnahme, die Bedingung bis endfilter und den Handler danach. Was Sie zurückbekommen, ist das catch (E e) when (…), das Sie geschrieben haben — und ein genaues Bild des einen Ortes, an dem C#s Ausnahmebehandlung etwas tut, von dem die meisten nie wussten, dass sie es tut.

Nebula.NET testen

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