3 points par GN⁺ 2024-04-09 | 1 commentaires | Partager sur WhatsApp
  • Même une seule ligne Hello World écrite en C doit passer successivement par le binaire compilé, la bibliothèque standard C, les appels système, le noyau et le terminal avant d’apparaître à l’écran
  • Le résultat produit par gcc hello.c -o hello est un exécutable ELF 64-bit x86-64, et le code _start s’exécute d’abord depuis le point d’entrée 0x1060 de l’en-tête ELF
  • Le main() écrit par l’utilisateur ne démarre pas directement : il passe par _start et __libc_start_main, tandis que printf("Hello World!\n") est simplifié par optimisation en un appel à puts()
  • La chaîne est stockée sous forme d’octets à l’adresse 0x2004 de .rodata, et une chaîne C détermine sa fin avec un terminateur NULL plutôt qu’avec une information de longueur
  • Le chemin réel d’affichage passe par le buffering et les verrous de la libc, l’appel système write ou writev, le noyau Linux, le pseudo-terminal et le rendu de l’émulateur de terminal, avec des variations selon l’environnement d’exécution

En partant d’un Hello World en C

  • Le programme d’exemple est le code suivant, écrit en C
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
  • Il produit le même résultat que print('Hello World!') en Python, mais un programme C ne s’exécute pas directement via un interpréteur : il faut d’abord le compiler
gcc hello.c -o hello
./hello
  • Le résultat de l’exécution est le suivant
Hello World!
  • Avec quelques bases en C ou en assembleur, le déroulé reste facile à suivre

Ce qu’est réellement l’exécutable

  • Dans le résultat de file hello, l’élément clé est ELF executable, x86-64
    • Un exécutable ELF est le format des programmes exécutables sous Linux
    • x86-64 désigne un programme en langage machine pour processeurs x86 64 bits
  • En regardant l’en-tête ELF avec readelf -h hello, on voit Entry point address: 0x1060
    • Cette adresse est l’endroit où le CPU commence l’exécution une fois le programme chargé

_start et l’entrée dans la bibliothèque C

  • Si on désassemble avec objdump -D hello, on trouve _start à l’adresse 0x1060
  • _start n’est pas du code écrit directement par l’utilisateur, mais du code ajouté automatiquement par le compilateur, plus précisément par l’éditeur de liens
  • Ce code effectue l’initialisation puis lance l’appel suivant
call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
  • Cette fonction n’est pas définie directement dans le programme : elle se trouve du côté de la bibliothèque standard C
  • Dans la section dynamique de readelf -d hello, on voit la dépendance à libc.so.6
Shared library: [libc.so.6]
  • libc.so.6 est la bibliothèque standard C du système, et sous Linux un fichier .so contient du code partageable entre plusieurs programmes, à la manière d’une .dll sous Windows
  • La bibliothèque C s’occupe de l’initialisation, comme le traitement des arguments de ligne de commande et des variables d’environnement, puis appelle main() et termine le programme avec sa valeur de retour

Ce qui se passe réellement dans main()

  • Dans le désassemblage, main() se trouve à l’adresse 0x1149
  • Le déroulé de main() est le suivant
    • mise en place de la stack frame
    • préparation des arguments d’appel de fonction
    • appel de la fonction qui affiche Hello World
    • nettoyage de la stack frame
    • retour avec le code de sortie 0
  • L’essentiel se joue dans la préparation de l’adresse de la chaîne comme argument puis l’appel à puts@plt
lea    0xeac(%rip),%rax
call   1050 <puts@plt>
  • Le code source contenait printf(), mais le compilateur l’a optimisé en puts()
    • printf() est une fonction complexe de sortie formatée
    • Comme l’exemple n’utilise pas de fonctionnalités de formatage, comme l’insertion de variables, elle est remplacée par le plus simple puts()
    • puts() ajoute lui-même un saut de ligne à la fin de la chaîne, ce qui permet aussi de retirer le \n de la chaîne d’origine

Comment la chaîne est stockée

  • La chaîne se trouve à l’adresse 0x2004 dans la section .rodata
  • Les octets à cet emplacement sont les suivants
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
  • Cette suite d’octets correspond à "Hello World!" suivie du 0x00 final
  • 0x00 est le terminateur NULL, qui marque la fin d’une chaîne C
  • Une chaîne C ne stocke pas sa longueur : une fonction qui la reçoit la traite donc octet par octet jusqu’à rencontrer ce terminateur NULL
  • S’il n’y a pas de terminateur NULL entre des chaînes, une fonction C peut enchaîner plusieurs chaînes ou lire une zone mémoire non autorisée, jusqu’à provoquer une Segmentation Fault

Le chemin de puts() dans Glibc

  • puts@plt finit par rediriger l’exécution vers la bibliothèque standard
  • Dans Glibc, puts() est relié à _IO_puts
  • _IO_puts effectue les opérations suivantes
    • calculer la longueur de la chaîne
    • obtenir le verrou du flux de sortie stdout
    • vérifier les conditions puis appeler _IO_sputn
    • écrire le caractère de saut de ligne
    • libérer le verrou et renvoyer le nombre de caractères écrits
  • L’implémentation interne de Glibc étant volumineuse et complexe, l’explication enchaîne ensuite sur le flux d’une bibliothèque C plus légère, musl libc

Comment l’écriture descend dans musl libc

  • Le puts() de musl prend le verrou de stdout, appelle fputs() et putc_unlocked('\n', stdout), puis libère le verrou
  • fputs() calcule la longueur de la chaîne et appelle fwrite()
  • fwrite() reprend un verrou puis appelle __fwritex()
  • __fwritex() vérifie l’état du buffer et, si nécessaire, appelle le pointeur de fonction write du flux de sortie
  • stdout est défini avec fd = 1, et la fonction write pointe initialement vers __stdout_write
  • __stdout_write() effectue un ioctl TIOCGWINSZ puis appelle __stdio_write()
  • __stdio_write() exécute un appel système via SYS_writev

Les appels système et le noyau

  • La bibliothèque C seule ne peut pas communiquer directement avec le matériel : c’est le noyau du système d’exploitation qui s’en charge
  • Au bout du chemin, la demande d’affichage devient donc un appel système demandant au système d’exploitation d’écrire du texte dans un flux de sortie
  • En général, la sortie passe par l’appel système write, et musl utilise writev, qui permet d’écrire un ensemble de buffers sous forme de tableau
  • L’implémentation x86-64 des appels système dans musl est répartie de __syscall0 à __syscall6 selon le nombre d’arguments
  • Chaque fonction place ses arguments dans les registres du CPU puis exécute l’instruction syscall
    • le contrôle passe alors au noyau
    • le noyau lit les paramètres dans les registres et exécute l’appel système demandé

Après le noyau, jusqu’à l’affichage à l’écran

  • Le noyau Linux reçoit l’appel système write et écrit les données dans le fichier ou le flux ouvert
  • L’appel système write prend comme arguments le descripteur de fichier, le buffer à écrire et le nombre d’octets à écrire
  • Dans l’environnement d’exemple, le programme hello s’exécute dans l’émulateur de terminal GNOME, et stdout est relié au pseudo-terminal /dev/pts/0
  • Le noyau stocke le message Hello World dans un buffer, puis l’émulateur de terminal le lit et l’affiche à l’écran
  • L’émulateur de terminal rend le texte sous forme de frame, puis le serveur X ou le compositeur le combine avec les autres fenêtres avant que le noyau ne l’envoie à l’écran
  • La suite du parcours peut varier selon l’environnement d’exécution
    • en connexion distante, le noyau envoie le texte à sshd, qui le renvoie au noyau sous forme de paquets chiffrés pour l’envoyer sur Internet
    • avec un terminal physique et un adaptateur serial-to-USB, le noyau envoie le texte sous forme de paquets USB
    • dans une framebuffer console, le noyau rend directement le texte en frame avant l’affichage

La complexité cachée derrière une toute petite sortie

  • L’envoi du message Hello World ne représente qu’un seul appel système émis par un seul programme
  • Les logiciels et le matériel modernes sont pourtant composés de couches si nombreuses, précises et complexes qu’il devient difficile de suivre complètement même une action minuscule
  • Cette explication laisse de côté de nombreux détails, cas particuliers et mécanismes internes du noyau pour se concentrer sur le flux principal

1 commentaires

 
GN⁺ 2024-04-09
Commentaires sur Hacker News
  • Par ennui, j’ai essayé de faire quelque chose de similaire en Rust sur macOS, avec #![no_std], #![no_main] et des appels système WRITE/EXIT directs : même un « Hello, world! » vu dans Ghidra faisait environ 16 Ko, quoi qu’on fasse
    On peut sans doute pousser le code golf plus loin, mais il y a de grandes chances que quelqu’un l’ait déjà fait et documenté

    • Sous Windows, un programme similaire faisait 3072 octets, compilé avec rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main
      Il appelait directement ExitProcess, GetStdHandle et WriteFile de kernel32, et comme c’est un hello world, le gestionnaire de panique était laissé assez approximatif. Il reste encore pas mal de padding dans l’exécutable, donc on pourrait y ajouter davantage sans augmenter sa taille, et on pourrait aussi le réduire par des méthodes plus « criminelles », mais cela ne semble pas avoir grand intérêt
      À titre de référence, la base de données de débogage PDB associée faisait 208 896 octets
    • Pour obtenir la taille minimale, il faut abandonner complètement main et utiliser _start, ainsi que passer des flags au linker pour éviter l’alignement des sections
      En regardant https://darkcoding.net/software/a-very-small-rust-binary-ind..., on peut facilement descendre autour de 500 octets avec cette approche
    • Le code golf est amusant, mais il faut aussi regarder quelle est aujourd’hui la taille de page
      Pour un langage avec une pile, l’exécutable finira probablement par charger au moins deux pages environ, une en lecture seule et une en lecture-écriture
    • Le projet min-sized-rust rassemble de nombreuses optimisations pour réduire la taille des binaires Rust
      Si je me souviens bien, en appliquant toutes les optimisations, un hello world finissait autour de 8 Ko : https://github.com/johnthagen/min-sized-rust
    • XNU ne charge pas les Mach-O plus petits qu’une page ; sur cette plateforme, il n’y a donc malheureusement pas beaucoup de marge pour jouer avec de tout petits binaires
  • Il y a un autre terrier dans lequel Musl n’est pas descendu. Sous Linux, appeler des fonctions système ne se limite pas à utiliser directement syscall
    La méthode plus « polie » consiste à appeler le vDSO. C’est une petite bibliothèque un peu magique que le noyau mappe automatiquement dans l’espace d’adressage, ce qui lui permet de fournir le code optimal pour effectuer les appels système
    Certains appels système peuvent être exécutés en espace utilisateur, au point que syscall lui-même n’est plus nécessaire ; et autrefois, le vDSO choisissait aussi entre plusieurs mécanismes d’appel au noyau comme int 0x80 ou sysenter
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • Ce n’est que sur x86 32 bits que le vDSO contient une couche d’appels système rapide générique
      Sur x86-64, la méthode standard d’appel système est l’instruction SYSCALL, et le vDSO ne contient que des fonctions liées au temps ainsi que quelques fonctions liées à SGX
  • Cet article comparant l’overhead des programmes « Hello World » selon les langages vaut aussi le détour : https://drewdevault.com/2020/01/04/Slow.html
    Article de suivi : https://drewdevault.com/2020/01/08/Re-Slow.html
    Il existe aussi un article légendaire sur le plus petit programme sous Linux. Le programme se contente de quitter avec le code d’état 42 : https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
    On peut aussi trouver sur le même site le plus petit programme « Hello World »

  • L’article passe presque entièrement sur le rôle du linker dynamique, que l’on peut pourtant considérer comme le véritable point d’entrée du programme
    Si cette perspective vous intéresse, voir https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2...

  • Pour les amateurs de DOS, un « hello, world » écrit en assembleur/langage machine pour DOS pouvait descendre jusqu’à 23 octets : https://github.com/susam/hello
    Sur ces 23 octets, 15 sont occupés par la chaîne elle-même, terminée par un signe dollar ; le vrai code machine ne fait donc que 8 octets, soit quatre instructions x86

  • L’article était bon, mais j’aurais aimé deux choses en plus. Il aurait mieux valu désactiver l’optimisation et l’inlining qui ont transformé printf en puts, ou bien écrire dès le départ un hello world utilisant directement puts
    Il aurait aussi été utile de découper la compilation en quatre étapes — prétraitement, compilation, assemblage et édition de liens — ou d’ajouter --save-temps à cc pour expliquer les fichiers générés. Voir le pipeline directement rend les parties qui semblent magiques beaucoup moins mystérieuses

  • Cela me rappelle un devoir que j’aimais bien dans un cours de programmation système à l’université : on nous donnait un fragment de « C++ hello world » et il fallait rendre le plus petit binaire compilé possible.
    Je me souviens avoir inspecté le programme avec des outils comme readelf et objdump, puis avoir retiré peu à peu des couches et des optimisations du compilateur jusqu’à obtenir le plus petit binaire qui affichait encore « hello world ».
    Évidemment, en cherchant, je suis tombé sur quelqu’un qui avait fait bien mieux que les étudiants : https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....

    • Je me demande si le fait que ce fragment soit en C++ a vraiment une importance.
      On pourrait simplement produire le plus petit binaire qui affiche hello world et affirmer qu’il est sémantiquement équivalent. Même en incluant les données de la chaîne, j’ai l’impression qu’une dizaine d’instructions x86 suffiraient.
    • Si c’est un exercice aussi apprécié, je me demande pourquoi on ne voit pas davantage de gens chercher à produire le plus petit binaire possible pour des programmes autres que « hello world ».
      Personnellement, j’aime économiser l’espace de mon ordinateur, donc je trouve ça amusant, mais aujourd’hui on écrit beaucoup de programmes qui dépassent 10 MiB, 20 MiB, 50 MiB, 100 MiB. Certains sont faits dans un contexte commercial, à des fins commerciales, mais beaucoup se présentent aussi comme des programmes écrits par pur plaisir. N’y a-t-il donc aucun plaisir à utiliser de petits programmes ?
  • La conclusion du genre « il est passé minuit, je devrais aller dormir » était au contraire une fin parfaite pour cet article.

  • Malheureusement, comme beaucoup d’explorations en profondeur de « hello world », cet article s’arrête lui aussi à l’appel système write et survole le reste.
    Jusqu’à l’appel système, il s’agit essentiellement d’une chaîne d’appels de fonctions où printf appelle puts, puts appelle write, transmet un char const* et fait un peu de comptabilité ; ce n’est pas, personnellement, la partie la plus intéressante.
    Ce qui devient vraiment intéressant et complexe, c’est après l’appel système. Le noyau relie le stdout du processus à l’entrée côté émulateur de terminal, puis le terminal prépare le framebuffer avec une bibliothèque de rendu de polices et le pilote GPU. Il lit sur le disque les contours de police correspondant aux octets des caractères, les adapte au viewport, applique le redimensionnement, le crénage et les métriques de police, puis le GPU effectue la rastérisation et l’anticrénelage.
    Ensuite, le gestionnaire de fenêtres compose le cadre de la fenêtre du terminal et le bureau ; s’il y a de la transparence ou un effet de verre dépoli, c’est traité par des shaders. Le framebuffer résultant est empaqueté en signal HDMI ou DisplayPort adapté à la résolution et à la profondeur de couleur du moniteur, puis passe par le câble et les circuits d’entrée de l’écran avant d’être transformé en signaux d’adressage des pixels. Le mode de rafraîchissement diffère selon qu’il s’agit de LCD, d’OLED, de plasma ou de CRT ; par exemple, un OLED WRGB 3840×2400 doit gérer environ 36,86 millions de sous-pixels.
    Tout cela se produit en 16,67 ms, soit la durée d’une frame à 60 Hz.

    • L’explication est bonne, mais au final elle s’arrête au système visuel humain, et c’est encore une partie vraiment intéressante et complexe :)
      https://en.wikipedia.org/wiki/Visual_system
    • Si vous aimez ce niveau d’exploration, vous devriez aussi apprécier l’article de Gynvael Coldwind qui décortique l’exécution d’un hello world Python sous Windows.
      Il couvre les entrailles de CPython, le conhost de Windows, la rastérisation des polices, le rendu GPU, etc. : https://gynvael.coldwind.pl/?id=754
    • La majeure partie de tout cela n’a rien à voir avec le programme lui-même. Par exemple, si la sortie était redirigée vers /dev/null, rien de tout cela ne se produirait.
    • Il manque aussi ce qui se passe avant _start. Par exemple, la manière dont un processus naît sous Linux, en particulier le très étrange execve, le chargement du programme en mémoire, binfmt_* et le puissant binfmt_misc, les relocations, les frames de gestion des exceptions, les sections, l’ensemble du chargeur ELF, ainsi que l’allocation de ressources par le système d’exploitation, y compris le malloc nécessaire.
  • Dire que « contrairement à Python, on ne peut pas appeler un interpréteur pour exécuter ce programme » n’est pas tout à fait exact.
    C’est possible avec tcc -run hello.c. À strictement parler, ce n’est pas un interpréteur, mais plutôt un compilateur en mémoire.
    Pour des points de geek supplémentaires, il suffit de faire en sorte que le programme dise « Hellorld » au lieu de « Hello world ».