just foo traite "\n" dans un justfile et écrit un seul octet 0x0A dans le fichier bar, et l’article remonte étape par étape l’origine de cette valeur
- Le parseur Rust de
just est implémenté de sorte que, lorsqu’il rencontre l’échappement \n, il insère dans la chaîne la valeur de l’échappement de caractère Rust '\n'
- Comme
rustc est aujourd’hui lui aussi écrit en Rust, la recherche se poursuit jusqu’au lexer de rustc, mais on peut trouver un indice plus direct dans l’implémentation OCaml d’avant le self-hosting
- La première version OCaml de
rustc traitait l’échappement de caractère n comme Char.code '\n', et le lexer OCaml le définissait comme '\010'
- Comme
0x0A vaut 10, le \n du justfile correspond donc à une valeur transmise à travers des générations de compilateurs Rust, dont le point de départ remonte à l’octet placé dans le binaire initial de rustc lorsque le compilateur OCaml a évalué '\010'
Comment le \n d’un justfile devient 0x0A
- Quand on exécute
just foo, le justfile suivant écrit un seul octet 0x0A dans le fichier bar
x := "\n"
foo:
printf '{{x}}' > bar
just est écrit en Rust, et la fonction cook_string du parseur convertit les tokens de chaîne just contenant des séquences d’échappement en chaînes UTF-8
- Si un
n suit l’antislash, cette fonction exécute cooked.push('\n')
State::Backslash => {
match c {
'n' => cooked.push('\n'),
…
}
}
- À cette étape,
just laisse à rustc le soin d’évaluer l’échappement de caractère Rust '\n' avant d’insérer le résultat dans la chaîne
Le chemin en remontant jusqu’à rustc et OCaml
- Le traitement des échappements dans
rustc se trouve dans la fonction scan_escape du lexer, qui, lorsqu’elle rencontre n, le traite à nouveau comme l’échappement de caractère Rust '\n'
let res: char = match chars.next().ok_or(EscapeError::LoneSlash)? {
…
'n' => '\n',
…
};
- Comme
rustc est aujourd’hui écrit en Rust et se compile lui-même, la recherche du sens de '\n' mène de rustc à nouveau vers rustc
- Toutefois,
rustc n’a pas toujours été écrit en Rust, et les premières versions antérieures au self-hosting étaient écrites en OCaml
- Dans la version OCaml de
rustc, le lexer traitait l’échappement de caractère n comme ceci
| 'n' { end_char (Char.code '\n') lexbuf }
- Là encore, on utilise l’échappement de caractère OCaml
'\n', mais le lexer OCaml en donne une définition plus directe
let char_for_backslash = function
'n' -> '\010'
- Quand le compilateur OCaml voit
\n, il insère le résultat de l’évaluation de l’échappement de caractère décimal '\010', et comme 0x0A vaut 10, cela correspond exactement à l’octet recherché
- Le
\n du justfile mène donc à une forme d’octet 0x0A dans le binaire de just, octet qui a été inséré par rustc, tandis que les versions précédentes de rustc ont transmis cette même valeur de génération en génération
- La version actuelle de
rustc est la 1.81.0 ; même en ne remontant qu’à partir de rustc 1.0, ce processus s’est donc produit au moins 81 fois, et probablement davantage si l’on inclut les versions antérieures à la 1.0
- Le point de départ de cette traque se situe au moment où le compilateur OCaml a évalué l’échappement de caractère décimal
'\010' pour placer l’octet 0x0A dans le binaire initial de rustc
1 commentaires
Avis de Hacker News
Le premier endroit où j’ai lu cette idée ne portait pas sur le « trusting trust » en général, mais sur le caractère de nouvelle ligne : c’était le jour 42 de https://www.sigbus.info/how-i-wrote-a-self-hosting-c-compile...
Ce qui est intéressant, c’est que pour interpréter
"\n"dans un littéral de chaîne comme un véritable caractère de nouvelle ligne, le code source ne contient pas l’information de son code ASCII ; elle est transmise par le compilateur précédent qui a compilé le compilateurEn fin de compte, le caractère de nouvelle ligne de ce compilateur peut remonter jusqu’à GCC, qui l’a compilé
'\n'à son propre compilateur, mais en réalité il code en dur les valeurs numériques des échappements[1], et semble ne prévoir que des options pour les systèmes ASCII et EBCDIC[1] https://github.com/gcc-mirror/gcc/blob/8a4a967a77cb937a2df45...
Le texte original auquel l’auteur pense pourrait bien être la conférence de Ken Thompson pour le prix Turing, Reflections on Trusting Trust
Il existe pas mal de recherches, articles et explications sur les quines ; l’auteur a donc pu lire un texte de ce côté-là
https://en.wikipedia.org/wiki/Quine_(computing)
https://www.teamten.com/lawrence/writings/coding-machines/
Je me souviens moi aussi avoir vu il y a quelques années un billet de trivia exactement similaire à propos de
'\n'en Rust, mais je n’arrive plus à en retrouver la sourceIl est intéressant qu’au bout de 10 heures, aucun fil n’ait mentionné EBCDIC
Les premiers compilateurs C existaient aussi sur des systèmes non ASCII qui ne faisaient pas correspondre
\n, le « saut de ligne » (line feed), au nombre décimal 10 ; toutes les théories évoquées ici doivent donc expliquer ce faithttps://en.wikipedia.org/wiki/EBCDIC
En plus, EBCDIC avait à la fois un caractère explicite NextLine et un caractère LineFeed
En ASCII,
for (c = 'A'; c <= 'Z'; ++c) putchar(c);affiche de A à Z, mais en EBCDIC, comme il y a des intervalles vides entre les lettres, cela finit par afficher 41 caractères, y compris des caractères non attribuésDans l’ordre EBCDIC, les minuscules précèdent les majuscules, et les lettres précèdent les chiffres, soit l’exact inverse de l’ASCII
La seule chose que la norme C garantissait à propos de l’encodage des caractères était que les chiffres de
'0'à'9'étaient mappés de manière contiguë et croissanteEn théorie, un programme C simple devait pouvoir être compilé depuis le même source et produire la même sortie, que ce soit en ASCII ou en EBCDIC ; en pratique, il y avait beaucoup de pièges
Les premiers systèmes EBCDIC (MVS, VM/CMS, OS/400, DOS/VSE, etc.) stockaient le texte sous forme de fichiers orientés enregistrements plutôt que comme des fichiers en flux d’octets, et chaque ligne était un enregistrement de longueur fixe ou variable
Dans les enregistrements de longueur fixe, on déclarait à la création du fichier une longueur d’enregistrement comme 80 ou 132 ; les lignes courtes étaient généralement complétées avec le caractère espace EBCDIC 0x40, tandis que les lignes longues étaient tronquées ou utilisaient un caractère de continuation
Les enregistrements de longueur variable étaient précédés d’un Record Descriptor Word (RDW) contenant la longueur, mais ils étaient rares pour les fichiers texte et le code source ; les enregistrements de longueur fixe étaient la norme
Ainsi, même si NEL existait, il n’était généralement pas utilisé dans les fichiers sur disque
Les caractères de nouvelle ligne comme NEL sont un signal dans la bande indiquant une frontière de ligne/enregistrement, alors que les systèmes de fichiers orientés enregistrements exprimaient cette frontière hors bande
Je ne sais pas exactement comment stdio était implémenté dans la bibliothèque runtime des compilateurs C EBCDIC, mais j’imagine qu’en interne,
\nétait mappé sur NEL, puis que la couche stdio le traitait comme un séparateur d’enregistrements, écrivait chaque enregistrement via un appel système distinct et ajoutait du padding si nécessairePar la suite, la plupart de ces systèmes d’exploitation ont obtenu des sous-systèmes compatibles POSIX et, avec eux, des fichiers en flux d’octets comme sur les systèmes dominants
Les systèmes IBM prennent généralement en charge l’ajout de balises de page de code aux fichiers, ce qui permet à un fichier de mêler EBCDIC et ASCII, le système d’exploitation effectuant la conversion dans la couche d’entrée/sortie
Ainsi, une application utilisant EBCDIC au runtime peut lire des fichiers ASCII comme s’ils étaient en EBCDIC, sans appel à une API de conversion distincte ni spécification explicite
Les nouvelles applications utilisent de plus en plus des systèmes de fichiers basés sur POSIX, mais les anciennes applications stockent encore souvent leurs données, fichiers texte et même leur code source dans des systèmes de fichiers classiques orientés enregistrements
D’après ce que je comprends, l’endroit où l’on avait le plus de chances de voir EBCDIC NEL en pratique était les connexions de terminaux en mode ligne de terminaux à impression comme l’IBM 2741 et l’IBM 3767
C’est un texte vraiment fascinant
Pour moi, il se lit comme un mélange de programmation littéraire et de poésie
Il essaie d’expliquer l’idée que l’octet 0x0A précis qui apparaît quand on exécute
just fooa peut-être traversé des centaines de cycles de génération de codeIl y a longtemps, quelqu’un a encodé cette information d’une manière ou d’une autre dans le compilateur OCaml, et quelques années plus tard, l’information 0x0A présente sur mon ordinateur existe à cause de cette histoire
Et pourtant, ce phénomène est expliqué par du vrai code
Bien sûr, le code lui-même n’est pas l’essentiel, et il est peu probable que quelqu’un exécute ou compile ce code précis ; il est là pour permettre au lecteur de suivre la discussion
Je me demandais si clang avait la même propriété ; dans
lib/Lex/LiteralSupport.cpp, c’est explicitement codé en dur à 10ProcessCharEscapeparse les séquences d’échappement C standard et traite cela comme danscase 'n': ResultChar = 10; break;gcc/libcpp/charset.cc, où c’est codé en dur, et choisit entre ASCII ou EBCDICIl met les valeurs de
\a \b \e \f \n \r \t \vdans le tableaucharconsts, utilise{ 7, 8, 27, 12, 10, 13, 9, 11 }pour ASCII et{ 47, 22, 39, 12, 21, 13, 5, 11 }pour EBCDIC, puis traite le cas aveccase 'n': c = charconsts[4]; break;Je me souviens d’un article similaire à propos d’un compilateur C
Au final, le seul endroit où apparaissait la valeur 0x10 était le binaire du compilateur ; dans le code source, cela n’apparaissait que sous une forme du genre
"\\n" -> "\n"C’est au-dessus de mon niveau
Je ne comprends pas pourquoi il faut un périple aussi long pour découvrir pourquoi
\nest encodé comme un octet de valeur 10J’ai l’impression que ça devrait être évident, et comme l’auteur et les commentaires ne l’expliquent pas, je me sens idiot
Si, en écrivant un parseur, on parse un saut de ligne comme la séquence d’échappement
\n, d’où vient la valeur 10 ?Si l’on parse un saut de ligne comme le littéral entier
10, d’où vient la valeur binaire réelle1010?Le but ultime de cette expérience de pensée est de changer notre perception des compilateurs, comme dans la célèbre conférence Reflections On Trusting Trust
Autrement dit, un compilateur n’est pas seulement quelque chose qui produit un programme, c’est aussi une entrée du programme
Comme le compilateur lui-même est un programme, le compilateur qui a construit ce compilateur était une entrée du compilateur actuel et, par transitivité, devient une entrée de mon programme
Et cela continue avec le compilateur du compilateur du compilateur, puis encore celui d’au-dessus, et ainsi de suite
'\n'correspond réellementC’est un exemple réel intéressant du hack de Ken Thompson
Pourquoi pas 9 ou 11 ?
Le code dit : “quand vous voyez la chaîne du caractère de nouvelle ligne, affichez le caractère de nouvelle ligne”
Mais comment le compilateur sait-il ce qu’est un caractère de nouvelle ligne ?
Le code de ce compilateur, lui aussi, se contente de dire : “quand vous voyez la chaîne du caractère de nouvelle ligne, traitez-la comme un caractère de nouvelle ligne”
Un humain peut chercher “codes d’échappement des chaînes C”, mais ce tableau n’existe nulle part dans le compilateur
Si C 2025 définissait Start of Heading comme
\h, est-ce que'h' => cooked.push('\h')commencerait à marcher par magie ?Comment pourrait-il le savoir ?
Il est clair qu’à un moment donné, quelqu’un a programmé manuellement le mapping
'n' => 10; la question est de savoir oùÀ cause du C, j’ai toujours pensé que
\0???était un échappement octalDonc, dans ma tête,
\012vaut\x0aou0x0a, et\010vaut0x08Du coup, cet article me déroute pas mal
Peut-être qu’OCaml n’a pas d’échappements octaux mais des échappements décimaux, et que
\09est peut-être le caractère tabulationJe n’ai pas vérifié
Les échappements par backslash sont symboliques/mnémotechniques :
\npour “[Ne]wline”,\rpour “carriage [R]eturn”,\tpour “[T]ab”, etc.Il faut plutôt regarder la convention des caractères de contrôle comme
^C(interruption),^G(bell),^M(carriage return)Ils font partie du jeu de caractères de contrôle C0, et
^Cvaut\0x3,^Gvaut\0x7,^Mvaut\0xDC’est une méthode astucieuse qui remonte à avant Unix : pour représenter les caractères C0 invisibles d’ASCII, les terminaux préfixaient avec le caractère
^et appliquaient AND-0x40 au caractère concerné afin de le déplacer dans la plage affichablePour suivre, il peut être utile d’ouvrir une table ASCII comme https://www.asciitable.com
Chaque caractère de contrôle y est associé au
^caractèresitué deux colonnes plus loin dans la tableC’est ainsi que
\0se retrouve bizarrement représenté par^@, que la touche Esc devient^[, et que naissent d’autres équivalences difficiles à mémoriserCe n’est pas un choix des auteurs d’Unix, mais un produit de la numérotation ASCII
Parmi les syntaxes de chaînes que je connais, il n’y a guère que OCaml, Lua et DNS
À cause de la capitalisation incorrecte, j’ai cru qu’il existait peut-être une autre séquence d’échappement
\N, presque inconnue et différente de\nJe me suis demandé si cela correspondait à n’importe quel caractère sauf une nouvelle ligne, mais non : c’était à cause des petites capitales dans l’article d’origine
\n, mais cette règle CSS empêche de l’afficher ainsi.title { font-variant: small-caps; }\Nest utiliséBeaucoup de systèmes s’en servent comme NULL dans du CSV ou des formats similaires, pour le distinguer d’une chaîne vide
Je pensais donc que l’article parlait de ça
\NElle insère un caractère Unicode par son nom
Par exemple,
'\N{PILE OF POO}'est une chaîne Unicode contenant un seul emoji cacaC’est bien plus auto-descriptif que d’utiliser une séquence hexadécimale avec
\uou\UMais je l’ai quand même lu avec plaisir
L’« autre article » qui a inspiré ce billet est probablement celui-ci
https://research.swtch.com/nih
Running the "Reflections on Trusting Trust" Compiler - https://news.ycombinator.com/item?id=38020792 - octobre 2023, 67 commentaires