Le parcours avant l’exécution de la fonction `main()`
(amit.prasad.me)- 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 fonctionmaindé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
- Sous la forme
- 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::Commandde Rust appelleexecveen interne - Il effectue un processus semblable à la recherche dans le PATH d’un shell, en convertissant le nom de la commande en chemin complet
- Exemple :
- 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
- Exemple :
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 addressest l’adresse de la première instruction exécutée par le programme
- Exemples de champs :
- 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,mallocdelibc - La section
PT_INTERPd’ELF indique le linker dynamique (interpreter)
- Exemple :
- 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
- Exemples d’entrées :
- Cela provient en grande partie de la bibliothèque standard et du code d’initialisation du runtime
- Parce qu’une implémentation de
libccommemuslouglibcy est liée
- Parce qu’une implémentation de
- 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,envptransmis lors de l’appel àexecvesont 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_PAGESZindique 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_startest le tout premier code en espace utilisateur auquel le noyau transfère le contrôle
- Dans la plupart des langages,
_starteffectue l’initialisation du runtime avant d’appelermain- Exemple :
std::rt::lang_starten Rust,__libc_start_mainen C
- Exemple :
- Dans l’exemple Rust, les attributs
#![no_std]et#![no_main]permettent de définir directement_startsans runtime- Dans
_start, on litargc,argv,envpdepuis la pile puis on appelle le pointeur versmain
- Dans
- 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
- Appel à
execve→ le noyau charge le fichier ELF - Interprétation de l’ELF → mapping des sections de code/données, désignation de l’interpréteur
- Construction de la pile → stockage des arguments, variables d’environnement et vecteur auxiliaire
- Exécution du point d’entrée
_start - Initialisation du runtime, puis appel à
main()
- Appel à
- 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
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
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
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
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
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
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 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 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 augmententAutrement 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
Il suggère simplement qu’une visualisation horizontale serait plus naturelle
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 conseille d’utiliser strace pour voir immédiatement sur quel syscall l’erreur s’est produite
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é