just foo verarbeitet "\n" aus dem justfile und schreibt das einzelne Byte 0x0A in die Datei bar; der Artikel verfolgt Schritt für Schritt, woher dieser Wert kommt
- Der Rust-Parser von
just ist so implementiert, dass er beim Escape \n den Rust-Zeichen-Escape-Wert '\n' in den String einfügt
- Da auch
rustc derzeit in Rust geschrieben ist, führt die Verfolgung weiter zum Lexer von rustc, doch in der OCaml-Implementierung vor dem Self-Hosting findet sich ein direkterer Hinweis
- Das frühe
rustc in OCaml behandelte den Zeichen-Escape n als Char.code '\n', und der OCaml-Lexer definiert dies als '\010'
- Da
0x0A gleich 10 ist, ist \n im justfile also ein über Compiler-Generationen von Rust weitergereichter Wert, dessen Ursprung bei dem Byte liegt, das der OCaml-Compiler durch Auswertung von '\010' in das frühe rustc-Binary geschrieben hat
Wie \n im justfile zu 0x0A wird
- Führt man
just foo aus, schreibt das folgende justfile das einzelne Byte 0x0A in die Datei bar
x := "\n"
foo:
printf '{{x}}' > bar
just ist in Rust geschrieben, und die Funktion cook_string des Parsers wandelt just-String-Tokens mit Escape-Sequenzen in UTF-8-Strings um
- Steht nach dem Backslash ein
n, führt diese Funktion cooked.push('\n') aus
State::Backslash => {
match c {
'n' => cooked.push('\n'),
…
}
}
- An dieser Stelle überlässt
just rustc, das Ergebnis der Auswertung des Rust-Zeichen-Escapes '\n' in den String einzusetzen
Der Pfad zurück über rustc bis zu OCaml
- Die Escape-Behandlung in
rustc befindet sich in der Funktion scan_escape des Lexers; trifft sie auf n, verarbeitet sie es erneut als den Rust-Zeichen-Escape '\n'
let res: char = match chars.next().ok_or(EscapeError::LoneSlash)? {
…
'n' => '\n',
…
};
- Das heutige
rustc ist in Rust geschrieben und kompiliert sich selbst, daher führt die Suche nach der Bedeutung von '\n' von rustc wieder zu rustc
rustc war jedoch nicht von Anfang an in Rust geschrieben; die frühen Versionen vor dem Self-Hosting waren in OCaml geschrieben
- Der Lexer der OCaml-Version von
rustc behandelte den Zeichen-Escape n so:
| 'n' { end_char (Char.code '\n') lexbuf }
- Auch hier wird der OCaml-Zeichen-Escape
'\n' verwendet, doch im OCaml-Lexer gibt es eine direktere Definition
let char_for_backslash = function
'n' -> '\010'
- Wenn der OCaml-Compiler
\n sieht, setzt er das Ergebnis der Auswertung des dezimalen Zeichen-Escapes '\010' ein; und da 0x0A gleich 10 ist, entspricht dies genau dem gesuchten Bytewert
- Damit führt
\n im justfile zu einer Form des Bytes 0x0A im just-Binary; dieses Byte wurde von rustc eingefügt, wobei frühere rustc-Generationen denselben Wert weitergereicht haben
- Die aktuelle
rustc-Version ist 1.81.0; betrachtet man nur die Versionen seit rustc 1.0, ist dieser Vorgang mindestens 81-mal passiert, und unter Einbeziehung der Zeit vor 1.0 möglicherweise noch öfter
- Der Ausgangspunkt der Verfolgung liegt dort, wo der OCaml-Compiler den dezimalen Zeichen-Escape
'\010' ausgewertet und das Byte 0x0A in das frühe rustc-Binary geschrieben hat
1 Kommentare
Meinungen auf Hacker News
Wo ich diese Idee zum ersten Mal gelesen habe, ging es nicht um das allgemeine „trusting trust“, sondern um das Zeilenumbruchzeichen: Tag 42 von https://www.sigbus.info/how-i-wrote-a-self-hosting-c-compile...
Interessant ist, dass zur Interpretation von
"\n"innerhalb eines String-Literals als tatsächliches Zeilenumbruchzeichen diese ASCII-Code-Information nicht im Quellcode steckt, sondern vom vorherigen Compiler weitergegeben wird, mit dem der Compiler kompiliert wurde.Letztlich lässt sich das Zeilenumbruchzeichen dieses Compilers bis zu dem GCC zurückverfolgen, der ihn kompiliert hat.
'\n'seinem eigenen Compiler überlässt, tatsächlich aber sind die numerischen Werte der Escapes hartcodiert[1], offenbar nur mit Auswahlmöglichkeiten für ASCII- und EBCDIC-Systeme.[1] https://github.com/gcc-mirror/gcc/blob/8a4a967a77cb937a2df45...
Der Originaltext, an den der Autor gedacht hat, dürfte Ken Thompsons Turing-Award-Vortrag Reflections on Trusting Trust gewesen sein.
Es gibt ziemlich viel Forschung, Papers und Erklärungen zu Quines; vielleicht hat der Autor etwas in dieser Richtung gelesen.
https://en.wikipedia.org/wiki/Quine_(computing)
https://www.teamten.com/lawrence/writings/coding-machines/
Ich erinnere mich auch daran, vor ein paar Jahren genau denselben Trivia-Artikel über Rusts
'\n'gesehen zu haben, finde die Quelle aber nicht mehr.Interessant, dass nach 10 Stunden noch kein Thread EBCDIC erwähnt hat.
Frühe C-Compiler gab es auch auf Nicht-ASCII-Systemen, die
\nfür „line feed“ nicht auf den Dezimalwert 10 abbildeten; jede hier diskutierte Theorie muss also auch diese Tatsache erklären.https://en.wikipedia.org/wiki/EBCDIC
Außerdem hatte EBCDIC sowohl ein explizites NextLine-Zeichen als auch ein LineFeed-Zeichen.
In ASCII gibt
for (c = 'A'; c <= 'Z'; ++c) putchar(c);die Buchstaben A bis Z aus; in EBCDIC gibt es dagegen Lücken zwischen den Buchstaben, sodass einschließlich nicht zugewiesener Zeichen 41 Zeichen ausgegeben werden.In der EBCDIC-Sortierung stehen Kleinbuchstaben vor Großbuchstaben und Buchstaben vor Ziffern, also genau umgekehrt zu ASCII.
Der C-Standard garantierte für Zeichenkodierungen nur, dass die Ziffern
'0'bis'9'fortlaufend und aufsteigend abgebildet sind.Theoretisch sollten einfache C-Programme aus demselben Quellcode sowohl unter ASCII als auch unter EBCDIC kompiliert werden und dieselbe Ausgabe erzeugen, praktisch gab es aber viele Fallstricke.
Frühe EBCDIC-Systeme (MVS, VM/CMS, OS/400, DOS/VSE usw.) speicherten Text nicht als Byte-Stream-Dateien, sondern als record-orientierte Dateien; jede Zeile war ein Datensatz fester oder variabler Länge.
Bei Datensätzen fester Länge wurde beim Erstellen der Datei eine Satzlänge wie 80 oder 132 deklariert; kurze Zeilen wurden gewöhnlich mit dem EBCDIC-Leerzeichen 0x40 aufgefüllt, lange Zeilen abgeschnitten oder mit einem Fortsetzungszeichen versehen.
Datensätze variabler Länge hatten ein vorangestelltes Record Descriptor Word (RDW) mit der Länge, waren für Textdateien und Quellcode aber selten; feste Satzlängen waren üblich.
Daher wurde NEL in Dateien auf der Platte normalerweise nicht verwendet, obwohl es existierte.
Zeilenumbruchzeichen wie NEL sind In-Band-Signale für Zeilen-/Datensatzgrenzen, während record-orientierte Dateisysteme diese Grenzen out-of-band darstellen.
Ich weiß nicht, wie stdio in der Laufzeitbibliothek von EBCDIC-C-Compilern genau implementiert war, vermute aber, dass
\nintern auf NEL gemappt wurde, die stdio-Schicht es dann als Datensatztrenner behandelte und jeden Datensatz bei Bedarf mit Padding als separaten Systemaufruf schrieb.Später erhielten die meisten dieser Betriebssysteme POSIX-kompatible Subsysteme und damit auch Byte-Stream-Dateien wie auf Mainstream-Systemen.
IBM-Systeme unterstützen allgemein das Kennzeichnen von Dateien mit Codepage-Tags, sodass Dateien EBCDIC und ASCII gemischt enthalten können und das Betriebssystem in der Ein-/Ausgabeschicht konvertiert.
Dadurch können auch Anwendungen, die zur Laufzeit EBCDIC verwenden, ASCII-Dateien ohne separate Konvertierungs-API-Aufrufe oder explizite Angabe wie EBCDIC lesen.
Neue Anwendungen nutzen zunehmend POSIX-basierte Dateisysteme, aber ältere Anwendungen speichern Daten, Textdateien und sogar Quellcode oft weiterhin in klassischen record-orientierten Dateisystemen.
Soweit ich es verstehe, konnte man EBCDIC NEL in der Praxis am ehesten bei Line-Mode-Terminal-Verbindungen von Hardcopy-Terminals wie IBM 2741 oder IBM 3767 antreffen.
Ein wirklich interessanter Text.
Für mich liest er sich wie eine Mischung aus literarischem Programmieren und Poesie.
Er versucht zu vermitteln, dass genau dieses 0x0A-Byte, das beim Ausführen von
just fooerscheint, möglicherweise durch Hunderte Zyklen der Codegenerierung gegangen ist.Vor langer Zeit hat jemand diese Information auf irgendeine Weise in den OCaml-Compiler kodiert, und Jahre später ist die 0x0A-Information auf meinem Computer aufgrund dieser Geschichte gespeichert.
Dieses Phänomen wird aber mit echtem Code erklärt.
Natürlich ist der Code selbst nicht der Kern der Sache, und es ist auch unwahrscheinlich, dass jemand genau diesen Code ausführt oder kompiliert; er ist dafür da, dass Menschen der Diskussion folgen können.
Ich fragte mich, ob clang dieselbe Eigenschaft hat; in
lib/Lex/LiteralSupport.cppist es ausdrücklich hart auf 10 codiert.ProcessCharEscapeparst die Standard-C-Escape-Sequenzen und behandelt sie etwa mitcase 'n': ResultChar = 10; break;.gcc/libcpp/charset.ccist es hartcodiert und wählt zwischen ASCII oder EBCDIC.Die Werte von
\a \b \e \f \n \r \t \vwerden in das Arraycharconstsgelegt; bei ASCII wird{ 7, 8, 27, 12, 10, 13, 9, 11 }verwendet, bei EBCDIC{ 47, 22, 39, 12, 21, 13, 5, 11 }, und anschließend wird es mitcase 'n': c = charconsts[4]; break;behandelt.Ich erinnere mich an einen ähnlichen Artikel über irgendeinen C-Compiler.
Am Ende stellte sich heraus, dass die einzige Stelle, an der der Wert 0x10 auftauchte, das Compiler-Binary war; im Quellcode stand er nur in Formen wie
"\\n" -> "\n".Das geht über mein Niveau hinaus.
Ich verstehe nicht, warum man so eine lange Reise unternehmen muss, um herauszufinden, warum
\nals Byte mit dem Wert 10 codiert wird.Es wirkt doch selbstverständlich, und weil weder der Autor noch die Kommentare es erklären, fühle ich mich dumm.
Wenn man einen Parser schreibt und einen Zeilenumbruch als Escape-Sequenz
\nparst: Woher kommt dann der Wert 10?Wenn man den Zeilenumbruch als Integer-Literal
10parst: Woher kommt dann der tatsächliche Binärwert1010?Das letztliche Ziel dieses Gedankenexperiments ist, ähnlich wie im berühmten Vortrag Reflections On Trusting Trust, die Sicht auf Compiler zu verändern.
Ein Compiler ist also nicht einfach etwas, das ein Programm ausgibt, sondern auch Eingabe eines Programms.
Da der Compiler selbst ein Programm ist, war der Compiler, der diesen Compiler erzeugt hat, eine Eingabe des aktuellen Compilers und ist transitiv eine Eingabe meines Programms.
Und das setzt sich beim Compiler des Compilers des Compilers und bei den darüberliegenden Compilern immer weiter fort.
'\n'tatsächlich abgebildet wird.Das ist ein interessantes reales Beispiel für den Ken-Thompson-Hack.
Warum nicht 9 oder 11?
Der Code sagt: „Wenn du die Zeichenkette für einen Zeilenumbruch siehst, gib einen Zeilenumbruch aus.“
Aber woher weiß der Compiler, was ein Zeilenumbruch ist?
Auch der Code dieses Compilers sagt wiederum nur: „Wenn du die Zeichenkette für einen Zeilenumbruch siehst, behandle sie als Zeilenumbruch.“
Ein Mensch kann nach „C string escape codes“ suchen, aber diese Tabelle steckt nirgends im Compiler.
Wenn C 2025 Start of Heading als
\hdefiniert, würde dann'h' => cooked.push('\h')auf magische Weise anfangen zu funktionieren?Woher sollte man das überhaupt wissen?
Irgendwann muss irgendjemand die Abbildung
'n' => 10manuell programmiert haben; die Frage ist, wo diese Stelle ist.Wegen C habe ich
\0???immer für einen oktalen Escape gehalten.In meinem Kopf ist
\012daher\x0abeziehungsweise0x0a, und\010ist0x08.Deshalb finde ich diesen Artikel ziemlich verwirrend.
Vielleicht hat OCaml keine oktalen, sondern dezimale Escapes, und
\09ist womöglich ein Tab-Zeichen.Nachgeprüft habe ich es nicht.
Backslash-Escapes sind symbolisch bzw. als Merkhilfe gedacht:
\nsteht für „[Ne]wline“,\rfür „carriage [R]eturn“,\tfür „[T]ab“ und so weiter.Stattdessen sollte man sich die Konventionen für Steuerzeichen wie
^C(Interrupt),^G(Bell) und^M(Carriage Return) ansehen.Diese gehören zum C0-Steuerzeichensatz;
^Cist\0x3,^Gist\0x7,^Mist\0xD.Das ist ein cleveres Verfahren, das bis vor Unix zurückreicht: Um die unsichtbaren C0-Zeichen von ASCII darzustellen, setzten Terminals ein
^davor und wendeten auf das betreffende Zeichen AND-0x40 an, um es in den sichtbaren Bereich zu verschieben und auszugeben.Zum Nachvollziehen lohnt es sich, eine ASCII-Tabelle wie https://www.asciitable.com zu öffnen.
Jedes Steuerzeichen wird dort auf das
^Zeichenzwei Felder daneben abgebildet.Dadurch entstehen schwer zu merkende Äquivalenzen, etwa dass
\0seltsamerweise als^@dargestellt wird und die Esc-Taste zu^[wird.Das war keine Entscheidung der Unix-Autoren, sondern ein Produkt des ASCII-Nummerierungsschemas.
Unter den String-Syntaxen, die ich kenne, fallen mir nur OCaml, Lua und DNS ein.
Wegen der falschen Großschreibung dachte ich kurz, es gäbe eine andere, kaum bekannte Escape-Sequenz
\Nstatt\n.Ich dachte, sie könnte auf ein beliebiges Zeichen außer einem Zeilenumbruch passen, aber nein: Es lag nur an der Darstellung in Kapitälchen im Originalartikel.
\n; nur wegen dieser CSS-Regel wird es nicht so angezeigt:.title { font-variant: small-caps; }\Nverwendet wird.Viele Systeme nutzen
\Nin CSV oder ähnlichen Formaten als NULL, um es von der leeren Zeichenkette zu unterscheiden.Deshalb dachte ich, darum ginge es in diesem Artikel.
\N-Escape-Sequenz.Sie fügt ein Unicode-Zeichen anhand seines Namens ein.
Zum Beispiel ist
'\N{PILE OF POO}'ein Unicode-String mit einem einzelnen Kackhaufen-Emoji.Das ist viel selbsterklärender, als mit
\uoder\Uhexadezimale Sequenzen zu schreiben.Interessant zu lesen war er trotzdem.
Der „andere Artikel“, der diesen Beitrag inspiriert hat, ist vermutlich dieser hier
https://research.swtch.com/nih
Running the "Reflections on Trusting Trust" Compiler - https://news.ycombinator.com/item?id=38020792 - Oktober 2023, 67 Kommentare