Wochenendprojekt: eine vergnügliche Herausforderung in C
(lcamtuf.substack.com)- C ist einfach und ausdrucksstark, aber schon allein mit der
switch-Anweisung und den Regeln für Labels lässt sich Code schreiben, der selbst erfahrene Entwickler aus dem Konzept bringt - Obfuskation im IOCCC-Stil versteckt Code mit Makros und Formatierung, doch allein mit der C-Grammatik sind lesbarer, aber fremdartiger Code und ungewöhnliche Konstrukte möglich
switch (...)braucht keine geschweiften Klammern, und weil zu einem passendencase-Label gesprungen wird, kann das Verhalten von normalen Initialisierungsabläufen in Blöcken abweichencase-Labels müssen nicht nur auf der obersten Ebene einesswitch-Blocks stehen, sodass sogar ein klammerlosesswitchin Kombination mitif (0) case...und einerelse if-Kette kompiliert- Die GNU-C-Erweiterung für Label-Adressen mit
&&ermöglicht sogar selbst gebauteswitch-Konstrukte oder labelbasierte Schleifen, aber einige Beispiele sind nur für GCC und möglicherweise nicht sicher gegenüber undefiniertem Verhalten
Fremdartiger Code durch die C-Syntax
- C hat viele Schwächen, wird aber dank seiner einfachen Syntax und Ausdrucksstärke als Sprache genutzt, mit der sich große Software wie Betriebssysteme schreiben lässt
- Die knappe Syntax hat auch die Codestruktur vieler späterer Mainstream-Sprachen von Java bis Go beeinflusst
- Ein typisches Beispiel für Code-Obfuskation ist der IOCCC
- Beiträge, die beim IOCCC ausgezeichnet werden, bestehen oft aus Präprozessor-Makros, ungewöhnlicher Formatierung, nichtssagenden Variablennamen und schwer verständlichen arithmetischen Ausdrücken
- Solcher Code ist beeindruckend, kann zum Lernen aber weniger Spaß machen, weil man ihn erst in normalen Code zurückübersetzen muss
Die tiefen Ecken von C, sichtbar in switch, case und goto
switch (...)kann wieif (...)oderfor (...)ohne geschweifte Klammern geschrieben werdenswitch (i) case 1: puts("i = 1");lässt sich kompilieren- Ohne Klammern gehört aber nur genau eine Anweisung zu
switch, daher liegt ein folgendescase 2:nicht mehr innerhalb desswitchund führt zu einem Fehler
switchist im Kern eher eine goto-artige Struktur, die zu einem passendencase-Label springt- Selbst wenn im
switch-Blockint a = 123;undputs(...)stehen, wird beim Sprung zudefault:der davorstehende Initialisierungscode nicht ausgeführt - In diesem Fall wird
anicht mit 123 initialisiert; technisch gesehen ist das undefiniertes Verhalten - Das Beispiel ist auf Godbolt zu sehen
- Selbst wenn im
case-Labels müssen nicht nur auf der obersten Ebene des zugehörigenswitch-Blocks stehen- Auch eine Form wie
if (0) case 0: puts("i = 0");funktioniert - Weil
switchdirekt zu diesemcasespringt, wird das vorangestellteif (0)übersprungen - Andere Ausgaben nach diesem
puts(...)bleiben aber weiter durch die Bedingungif (0)blockiert, sodass sich Fallthrough auch ohnebreakvermeiden lässt - Das Beispiel ist auf Godbolt zu sehen
- Auch eine Form wie
- Eine
if ... else-Kette verhält sich syntaktisch wie eine einzige Anweisung auf oberster Ebene und lässt sich daher mitswitchzu einem bizarrenswitchohne Klammern kombinieren- Das Beispiel enthält sogar
case 1 ... 10als Bereichs-casesowiedefault - Das Beispiel ist auf Godbolt zu sehen
- Das Beispiel enthält sogar
- Der GNU-spezifische Operator
&&liefert die Adresse eines Labels und erlaubt eingotozu dieser Adresse- Damit lässt sich ein selbst gebautes
switchwiegoto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i];implementieren - Das Beispiel ist auf Godbolt zu sehen
- Damit lässt sich ein selbst gebautes
- Mit derselben GNU-Erweiterung lässt sich sogar ohne
for (...)eine labelbasierte Schleife innerhalb einer Variablendeklaration umsetzen- Das Beispiel besteht aus einer Schleife, die von
i = 0bisi = 5ausgibt und mit Labels sowiegoto *&&_aufgebaut ist - Das Beispiel ist auf Godbolt zu sehen
- Dieses letzte Snippet ist nur für GCC und möglicherweise nicht sicher gegenüber undefiniertem Verhalten
- Das Beispiel besteht aus einer Schleife, die von
- In C lassen sich auch ohne absichtliche Obfuskation Formen erzeugen, die völlig fremd und verwirrend wirken
1 Kommentare
Meinungen auf Hacker News
Im obigen Beispiel wird zwar der Wert von
aausgegeben, aber gesagt, dass er nicht mit 123 initialisiert wird; in C kann das tatsächlich trotzdem passieren.Eine nicht initialisierte Variable zu verwenden bedeutet nicht, „den Wert zu lesen, der zufällig noch in diesem Speicher lag“, sondern ist undefiniertes Verhalten; der Compiler darf also machen, was er will.
Er könnte zum Beispiel diesen Speicher grundsätzlich mit 123 initialisieren, oder den gesamten Abschnitt als undefiniertes Verhalten ansehen und alle Anweisungen entfernen, sodass gar nichts ausgegeben wird. Darüber hinaus kann er sogar das nachfolgende
returnoder vorherige Anweisungen wegoptimieren, sodass undefiniertes Verhalten so wirken kann, als würde es „in der Zeit zurück“ Einfluss nehmen.Es kann zwar frühere Anweisungen beeinflussen, aber Code-Reordering und komplexe Transformationen passieren auch ohne undefiniertes Verhalten.
Statische Objekte werden immer initialisiert, daher entsteht diese Situation dort nicht.
Übrig bleiben dynamische Objekte, etwa nicht initialisierte Member einer mit
mallocallozierten Struktur. Das Lesen von nicht initialisiertem dynamischem Speicher ist in C kein undefiniertes Verhalten; man erhält den Wert, den die nicht initialisierten Bits bedeuten. Wenn der entsprechende Typ keine Trap-Repräsentation hat, kann es nicht fehlschlagen.Auch solche Konstrukte sind möglich:
switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }Damit kann man eine Art
switchbauen, bei dem man am Ende jedes Abschnitts keinbreakschreiben muss; auch ein Makro wie#define brkcase if (0) casewäre möglich. Dem Compiler dürfte der Kontrollfluss nicht gefallen, aber vermutlich optimiert er das meiste davon gut weg.#define brkcase break;casescheint ähnlich zu funktionieren, aber dann geht der Zweck des Makros ein Stück weit verloren.gotozu schreiben.case-Labels aus einer Zeile besteht oder in geschweifte Klammern eingeschlossen ist.Früher habe ich diese Struktur einmal verwendet, um auszudrücken: „Nur die erste Zeile des nächsten
case-Labels überspringen und den Rest per Fallthrough weiter ausführen.“Wenn man
case-Labels nicht als Trenner zwischen Anweisungen, sondern einfach als Labels betrachtet, ergibt alles Sinn.Mit dieser Technik lassen sich in C Koroutinen implementieren: https://stackoverflow.com/questions/24202890/switch-based-co...
Ich weiß nicht, warum mir nicht bewusst war, dass
case 1 ... 10:gültiges C ist.Ich habe C jahrelang verwendet und frage mich, aus welchem Standard das stammt.
Zur Geschichte der Erweiterung habe ich nichts gefunden, und soweit ich weiß, gibt es sie in Standard-C nicht. Bei
clangbin ich mir nicht sicher.Vor einiger Zeit habe ich aus Spaß merkwürdigen C-Code geschrieben, der von 10 bis 1 herunterzählt.
Die C-, Python- und Shell-Versionen lassen sich alle mit
&&und rekursiven Aufrufen als Einzeiler bauen.sys.stdout.write(f"{n}\n")kann durchprint(n)ersetzt werden.Der aktuelle Code wirkt, abgesehen vom
f-string, wie Python-2-Stil, alsprintnoch ein Statement war. In Python 3 istprinteine normale Funktion und gibtNonezurück, was als falsch ausgewertet wird; daher müsste auch das ersteanddurchorersetzt werden.Eine weitere Überraschung ist, dass
4[arr]dasselbe ist wiearr[4].*(array_label+offset), und hier wird daraus*(offset+array_label).Es geht also darum, dass
*(arr+4)und*(4+arr)gleich sind.arr[i][j]undj[i[arr]]exakt gleich.Man muss nur wissen, dass
a[x][y]dasselbe ist wie(a[x])[y], und dassa[x]dasselbe ist wiex[a].arr[i][j]→(arr[i])[j]→(i[arr])[j]→j[i[arr]]Das letzte obfuskierte Codefragment des Artikels zeigt noch eine weitere GCC-Erweiterung: https://stackoverflow.com/questions/34559705/ternary-conditi...
Ich habe solche spielerischen Techniken dieses Blogautors zuerst auf Twitter gesehen.
Mit einer
switch-Anweisung kann man auch eine Schleife bauen: https://twitter.com/lcamtuf/status/1807129116980007037Ich denke, solche
switch-Spielereien sind ein wichtiger Teil von Duff's device.switch-Blöcke und Schleifen mischen kann, dasscaseohne explizitesbreakper Fallthrough weiterläuft, und dass eine Schleife in C wieder ins Innere einesswitchspringen kann.Duff wollte speicherabgebildete Ein-/Ausgabe (MMIO) optimieren, und in heutigem C würde man das wohl nicht mehr so machen. MMIO ist heute nicht mehr ähnlich schnell wie CPU-Instruktionen, und schon bei etwas mehr Daten kann man DMA verwenden.
Moderne Sprachen würden MMIO vermutlich auch nicht als einfache Pointer-Indirektion behandeln; C hat dafür immer wieder Umgehungen in sein Typsystem eingebaut, um dieses Modell beizubehalten.
Persönlich sehe ich den Nachfolger von Tom Duffs „Device“ im iterate loops-Mechanismus von WUFFS. Dabei legt man fest, wie die N Schritte einer Schleife teilweise entrollt werden, mit dem Versprechen, dass das Ergebnis dem N-maligen Ausführen des Hauptschleifenbodys entspricht, aber schneller sein kann. Vektorisierung kann die Absicht leichter erkennen, und lästige Randfälle wie
M % N != 0werden korrekt vom Werkzeug statt von einem Menschen behandelt.