1 points par GN⁺ 2025-10-26 | 1 commentaires | Partager sur WhatsApp
  • Une analyse technique qui explore le processus par lequel le noyau crée et initialise un processus via l’appel système execve, avant même l’exécution d’un programme
  • Cet appel transmet le chemin du fichier exécutable, les arguments et les variables d’environnement, puis le noyau s’en sert pour charger un exécutable au format ELF
  • Un fichier ELF contient le code, les données, les symboles et les informations de liaison dynamique ; le noyau l’interprète pour effectuer le mapping mémoire et l’initialisation de la pile
  • Le noyau transfère ensuite le contrôle au point d’entrée _start, et ce n’est qu’après l’initialisation du runtime propre au langage que la fonction main définie par l’utilisateur est appelée
  • Ce processus montre la structure de coopération entre le système d’exploitation, le compilateur et le runtime, et il est essentiel pour comprendre comment l’exécution d’un programme fonctionne au niveau système

Le point de départ de l’exécution d’un programme : l’appel à execve

  • Sous Linux, l’exécution d’un programme commence par l’appel système execve
    • Sous la forme execve(const char *filename, char *const argv[], char *const envp[]), il transmet le nom du fichier exécutable, la liste des arguments et la liste des variables d’environnement
    • Le noyau détermine ainsi quel programme exécuter et dans quel environnement
  • Dans les langages de haut niveau, cet appel est encapsulé par l’API d’exécution de processus de la bibliothèque standard
    • Exemple : std::process::Command de Rust appelle execve en interne
    • Il effectue un processus semblable à la recherche dans le PATH d’un shell, en convertissant le nom de la commande en chemin complet
  • Dans le cas d’un script contenant un shebang (#!), le noyau exécute le programme à l’aide de l’interpréteur indiqué
    • Exemple : #!/usr/bin/python3 → exécution via l’interpréteur Python

ELF : la structure du fichier exécutable

  • Sous Linux, les fichiers exécutables utilisent le format ELF (Executable and Linkable Format)
    • ELF est un format standard de fichier exécutable qui contient le code, les données, les symboles et les informations de relocalisation
    • D’autres systèmes d’exploitation utilisent des formats distincts, comme Mach-O (macOS) ou PE (Windows)
  • L’en-tête ELF contient des informations sur la structure du fichier et son placement en mémoire
    • Exemples de champs : ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address est l’adresse de la première instruction exécutée par le programme
  • Dans l’exemple d’en-tête ELF, il s’agit d’un fichier exécutable ELF32 pour l’architecture RISC-V, dont le point d’entrée est défini à l’adresse 0x10358

Les composants internes d’ELF

  • Un fichier ELF est composé de plusieurs sections
    • .text : code exécutable
    • .data : variables globales initialisées
    • .bss : variables globales non initialisées
    • .plt : table utilisée pour les appels aux bibliothèques partagées
    • .symtab, .strtab : table des symboles et table des chaînes
  • La PLT (Procedure Linkage Table) prend en charge les appels vers des fonctions de bibliothèques partagées
    • Exemple : printf, malloc de libc
    • La section PT_INTERP d’ELF indique le linker dynamique (interpreter)
  • Le noyau lit l’ELF, place en mémoire les sections chargeables et, si nécessaire, applique des mécanismes de sécurité comme ASLR et le bit NX

Table des symboles et liaison au runtime

  • La table des symboles (symtab) d’un ELF contient les informations d’adresse des fonctions et des variables
    • Exemples d’entrées : _start, main, __libc_start_main
    • Même un simple programme « Hello, World! » peut contenir plus de 2 300 symboles
  • Cela provient en grande partie de la bibliothèque standard et du code d’initialisation du runtime
    • Parce qu’une implémentation de libc comme musl ou glibc y est liée
  • Après avoir chargé chaque section de l’ELF, le noyau transfère le contrôle à l’interpréteur (linker dynamique)
    • L’interpréteur gère la relocalisation, l’aléatorisation des adresses (ASLR), les droits d’exécution (bit NX), etc.

Le processus d’initialisation de la pile

  • Avant d’exécuter le programme, le noyau doit construire directement la pile (stack)
    • La pile sert aux variables locales, aux frames d’appel de fonctions et au passage des arguments
  • Les argv, envp transmis lors de l’appel à execve sont stockés dans la pile
    • Le programme peut ainsi accéder aux arguments de ligne de commande et aux variables d’environnement
  • Le noyau ajoute également à la pile le vecteur auxiliaire ELF (auxv)
    • Il contient une trentaine d’éléments, comme la taille de page, les métadonnées ELF et des informations système
    • Exemple : AT_PAGESZ indique la taille des pages mémoire (par exemple 4 KiB)
  • Dans l’exemple d’émulateur RISC-V, le pointeur de pile (sp) démarre à une adresse élevée, puis les arguments, variables d’environnement et vecteur auxiliaire sont empilés en ordre inverse

Le point d’entrée et la fonction _start

  • Le point d’entrée d’un ELF est défini comme l’adresse de la fonction _start
    • _start est le tout premier code en espace utilisateur auquel le noyau transfère le contrôle
  • Dans la plupart des langages, _start effectue l’initialisation du runtime avant d’appeler main
    • Exemple : std::rt::lang_start en Rust, __libc_start_main en C
  • Dans l’exemple Rust, les attributs #![no_std] et #![no_main] permettent de définir directement _start sans runtime
    • Dans _start, on lit argc, argv, envp depuis la pile puis on appelle le pointeur vers main
  • Le runtime propre au langage effectue des initialisations spécifiques au langage, comme les constructeurs globaux, le stockage local aux threads ou la gestion des exceptions

Le déroulement complet jusqu’à l’appel de main()

  • L’ensemble du processus peut être résumé ainsi
    1. Appel à execve → le noyau charge le fichier ELF
    2. Interprétation de l’ELF → mapping des sections de code/données, désignation de l’interpréteur
    3. Construction de la pile → stockage des arguments, variables d’environnement et vecteur auxiliaire
    4. Exécution du point d’entrée _start
    5. Initialisation du runtime, puis appel à main()
  • Cette série d’étapes montre la structure de coopération entre le noyau du système d’exploitation, le format ELF et le runtime du langage
  • Le noyau Linux réel inclut aussi une logique interne supplémentaire liée à l’espace d’adressage, à la table des processus, à la gestion des groupes, etc., mais cet article se concentre sur le flux essentiel qui précède ces aspects

Conclusion et correction

  • Le processus d’exécution avant main() est une combinaison d’initialisation au niveau noyau et de configuration du runtime
  • Même un simple programme « Hello, World! » s’exécute après être passé par une structure ELF complexe et une initialisation du runtime
  • Dans une version initiale du texte, une partie de la logique de chargement des sections était attribuée au noyau, mais il a été corrigé que ce rôle relève en réalité de l’interpréteur ELF
  • Cette analyse constitue une base utile pour comprendre la programmation système, les compilateurs et l’architecture des systèmes d’exploitation

1 commentaires

 
GN⁺ 2025-10-26
Avis Hacker News
  • Explication du processus de liaison dynamique d’un fichier ELF
    Le noyau mappe les segments PT_LOAD de l’ELF, charge ensuite le linker dynamique (ld.so) indiqué par PT_INTERP, puis lui transmet le contrôle
    Le linker dynamique se relocalise alors lui-même et charge les objets partagés nécessaires avec mmap/mprotect
    Cette structure est comparée au mécanisme du shebang (#!) des scripts

    • Le noyau ne s’intéresse absolument pas aux informations de section et ne traite que les segments PT_LOAD
      Il partage une expérience passée où il avait essayé d’insérer un fichier arbitraire dans un ELF avec objcopy, puis s’était demandé pourquoi le noyau ne le chargeait pas
      Il a finalement créé lui-même un outil de patch de table d’en-têtes de programme, et indique que cette fonctionnalité a aussi été ajoutée au linker mold
      Article lié : Self-contained Lone Lisp Applications
    • L’auteur reconnaît avoir précédemment publié une version modifiée erronée du contenu et dit qu’il va la corriger
    • Il dit s’être toujours demandé pourquoi, sur Linux, alors que le loader fonctionne en espace utilisateur, il n’existe pas une plus grande variété de loaders
  • Il dit avoir expérimenté l’empaquetage de tout le code avant main() ou sans main() du tout
    Article lié : Packing a codebase into a single function

    • Après lecture, il a trouvé cela intéressant car c’était étonnamment simple et pas si fragile
      Il plaisante en disant qu’il suffirait de transformer toutes les fonctions en appels du type main(100+n, ...)
  • Si le sujet vous intéresse, il recommande de consulter cpu.land, qu’il a créé
    Le site traite davantage du multitâche et du processus de chargement du code que de la disposition mémoire

    • Quelqu’un le remercie en disant qu’il aime vraiment beaucoup cpu.land
  • Il se demande à quelle fréquence, dans les projets C, on évite la bibliothèque standard pour appeler directement les syscalls Linux
    Il trouve cette manière de coder bien plus amusante

    • Quelqu’un soutient qu’utiliser directement les syscalls est au contraire inefficace
      Pour des fonctionnalités comme ALSA ou DRM, il y a beaucoup d’avantages à passer par des bibliothèques système plutôt que par les syscalls du noyau
      Il explique que cette approche est meilleure, en matière de portabilité et de maintenabilité, qu’une approche de style Windows
    • Il ajoute que sous Windows, si l’on n’utilise que l’API Win32, il n’est pas nécessaire de lier le runtime C
    • Il dit avoir lui-même créé autrefois un projet liblinux pour écrire des programmes uniquement avec des syscalls
      Il l’a abandonné depuis, car les en-têtes nolibc de Linux sont désormais bien fournis,
      mais il développe actuellement un langage interprété Lisp fondé sur les syscalls
      Il dit que cette expérimentation consistant à construire directement l’espace utilisateur Linux via les appels système a été un parcours très intéressant
    • Il dit essayer de préserver la portabilité, mais que les descripteurs de fichier sont trop pratiques pour y renoncer facilement
    • Il ajoute que beaucoup de code de pilotes utilise en pratique uniquement des syscalls
  • Il explique que l’interpréteur ELF (ld.so) prend en charge tout le chargement après le mapping initial des segments ELF
    execve mappe les segments PT_LOAD, remplit l’aux vector sur la pile, puis
    saute vers le point d’entrée de l’interpréteur ELF
    Le noyau ne sait rien du PLT/GOT

  • Comme enseignant ce sujet à l’université, il dit que les étudiants sont souvent déroutés par les diagrammes mémoire
    Les manuels les dessinent avec les adresses les plus élevées en haut, alors que dans un processus Linux réel,
    les adresses basses sont en haut et les adresses hautes en bas dans l’affichage
    Dans /proc/<pid>/maps, plus on fait défiler vers le bas, plus les adresses augmentent
    Autrement dit, dire que « le heap grandit vers le haut (et la pile vers le bas) » n’est vrai qu’au sens numérique,
    tandis que visuellement c’est plutôt l’inverse
    Il propose qu’un affichage à la manière d’un IDE, où les adresses augmentent vers le bas, soit bien plus intuitif

    • Quelqu’un répond que la pile grandit bien avec une diminution du pointeur de pile, donc l’expression « grandit vers le bas » reste correcte
      Il suggère simplement qu’une visualisation horizontale serait plus naturelle
    • Quelqu’un se souvient avoir eu la même confusion autrefois, et que la notation des adresses en little-endian l’avait troublé
    • Un autre objecte qu’en pensant à la manière dont les objets réels s’empilent, l’expression « la pile grandit vers le bas » n’a rien d’intuitif
  • Il dit aimer faire ce genre d’expériences avec d’anciens microcontrôleurs PIC16
    Il trouve amusant de manipuler directement le pointeur de pile, les timers, les variables, etc.

  • Il partage une expérience liée au shebang (#!)
    Une application Java signalait qu’elle ne trouvait pas le script d’exécution,
    mais le vrai problème venait d’un chemin de shebang incorrect dans le script
    En local, tout fonctionnait, mais sur le serveur distant, le chemin de l’interpréteur était différent

    • Il précise que ce n’est pas propre à Java, et que cela peut arriver à tout programme qui renvoie une erreur ENOENT
      Il conseille d’utiliser strace pour voir immédiatement sur quel syscall l’erreur s’est produite
    • Il partage un article analysant la structure du shebang : What the #! means
    • Il ajoute que, pour que le noyau prenne en charge le shebang, le réglage CONFIG_BINFMT_SCRIPT=y est nécessaire
  • Il dit qu’en phase de débogage, il est toujours dérouté par le moment exact où s’applique l’ordre de relocalisation du binaire principal
    Il décrit comme de la magie noire le fait de savoir si cela se produit avant ou après que le linker résolve ses propres symboles

  • Il signale que le lien de la partie « lang_start function (defined here) » dans le Markdown est cassé