- Un binaire Rust passe par une phase d’initialisation du runtime avant
fn main(), durant laquelle sont notamment préparés la gestion des paniques et de l’unwinding, ainsi que la conversion des arguments du programme - Lorsque le chargeur du système d’exploitation transfère le contrôle au point d’entrée, le runtime C et le runtime Rust exécutent des fonctions d’initialisation ; avec
#[unsafe(link_section = "...")]et l’approche par constructeurs, on peut placer du code pre-main - Les sections du linker permettent de regrouper en un seul endroit, au moment de la création du binaire, des données fournies par plusieurs crates, et
link-sectionpermet de les manipuler comme des slices Rust - En combinant
ctoretlink-section, on peut préparer avantmaindes schémas comme l’enregistrement de sous-commandes CLI ou le tri d’un pool d’interning de chaînes, puis les lire ensuite sans verrou - Cette approche offre une agrégation sans allocation et une inversion de contrôle, mais il faut choisir soigneusement son périmètre d’usage à cause des difficultés liées à l’élimination du code mort, aux contraintes des constructeurs, aux différences entre plateformes et aux limites de compatibilité avec Miri
Les étapes avant main dans un binaire Rust
- Tous les binaires Rust ont un
fn main(), mais le flux réel d’exécution n’atteintmainqu’après être passé par le chargeur du système d’exploitation et par l’initialisation du runtime - En C, il existe un runtime C généralement identifié à
libc, et Rust possède son propre runtime via la bibliothèque standard, qui construit des abstractions de plus haut niveau au-dessus du runtime C - Le rôle du runtime est d’intégrer le code du développeur avec le système d’exploitation de la plateforme
- Le runtime C met en place, avant
main, des services d’exécution comme l’allocation, l’accès aux fichiers ou le stockage local aux threads - Rust prépare à ce moment-là la gestion des paniques et de l’unwinding, et convertit les arguments de programme au format C vers l’interface
std::env::args - La phase pre-main s’exécute avant le code utilisateur, dans un environnement mono-thread et à l’ordre prévisible, ce qui la rend adaptée à une initialisation déterministe
Point d’entrée
- L’exécution d’un binaire commence lorsque le chargeur du système d’exploitation place le binaire en mémoire, prépare l’environnement, puis lui transfère le contrôle
- Sous Linux, le point d’entrée est stocké dans le champ
e_entryde l’en-tête ELF et, par défaut, le linker y place l’adresse d’un symbole nommé_start - Windows dispose d’un mécanisme similaire, où l’exécutable démarre dans la fonction
_WinMainCRTStartup - Le bootstrap initial du runtime prenait la forme d’un arbre statique d’appels de fonctions, par exemple pour initialiser les E/S de fichiers ou l’allocateur
- À mesure que le runtime s’est complexifié, cet arbre d’initialisation statique a lui aussi grandi, et le binaire a embarqué davantage de fonctionnalités du runtime C, qu’il en ait besoin ou non
- Quand les linkers ont commencé à pouvoir supprimer le code inutilisé avant la production du binaire, il a fallu trouver une autre approche que l’arbre statique d’initialisation
- L’approche GCC avec
__attribute__((constructor))consistait à placer une liste de pointeurs de fonctions d’initialisation dans une zone contiguë du binaire, que le runtime C parcourt et appelle au démarrage - Il est devenu possible d’attribuer des priorités aux constructeurs ; par exemple, l’initialisation de
mallocpeut devoir précéder celle des E/S de fichiers bufferisées - Sous Linux, le runtime
glibcmoderne stocke les pointeurs de fonctions dans.init_array, avec un suffixe numérique pour définir l’ordre d’exécution - Les valeurs de priorité inférieures ou égales à 100 sont réservées au runtime lui-même ; le code utilisant le runtime C doit donc employer 101 ou plus
- En Rust, on peut placer des pointeurs vers des fonctions d’initialisation avec des attributs comme
#[used]et#[unsafe(link_section = ".init_array.101")]
linktime : ctor, link-section, etc.
- Les exemples fonctionnent sous Linux et plusieurs BSD, mais n’ont pas été conçus comme démonstrations multiplateformes
- macOS prend en charge des symboles
startetstop, mais avec des noms différents ; Windows ne les prend pas en charge, mais suit en pratique des règles d’alignement de section équivalentes ctoretlink-sectionsont des crates du projetlinktime, qui abstrait les différences entre plateformes et la complexité du travail avec le linkerinventoryetlinkmesont des crates largement utilisées, fondées sur le même principe, mais les exemples présentent certaines limites- La crate
ctorgère le boilerplate nécessaire à l’enregistrement multiplateforme de constructeurs - Une fonction annotée avec un attribut comme
#[ctor(unsafe, priority = 101)]sera appelée par le runtime C après le passage du linker, même si elle n’est jamais invoquée directement dans le code
Sections et scripts de linkage
- Un compilateur peut placer des données ou du code à des emplacements précis dans le binaire, généralement appelés sections sur la plupart des plateformes
- Rust permet la même organisation via l’attribut
link_section - De nombreux linkers permettent au développeur de fournir un script de linkage, un fichier texte qui indique comment les fichiers objets doivent être assemblés
- Avec un script de linkage, un simple fichier C peut devenir un exécutable Linux ou un bloc assembleur brut destiné à un secteur d’amorçage de disque dur
- Un script de linkage peut définir des symboles virtuels absents des fichiers source, mais utilisables depuis du code C pour accéder aux pointeurs de base de données du binaire chargé
- Dans l’exemple de script de linkage,
_TEXT_START_et_TEXT_END_sont définis pour pointer vers le début et la fin de la section.text - Dans
_TEXT_START_ = .;, le point représente le compteur de position, interprété comme une valeur proche de l’adresse de sortie courante du binaire
Symboles du linker
- Le linker ne définit pas la valeur des symboles de début et de fin comme des pointeurs ; il définit l’adresse à laquelle serait placé un
staticdu même nom - Les symboles de début et de fin ne sont pas des pointeurs
*const Typeet n’ont pas de données propres ; seule leur adresse a un sens - Une section est constituée des données comprises dans l’intervalle qui inclut le symbole de début et exclut le symbole de fin
- De nombreux linkers ont fini par intégrer la définition automatique des frontières de toutes les sections d’un exécutable
- Dans la toolchain GNU, une section nommée
MY_SECTIONentraîne la définition automatique des symboles__start_MY_SECTIONet__stop_MY_SECTION - macOS suit un motif similaire en synthétisant pour chaque section des symboles
section$startetsection$end - Dans le linker GNU, une section non explicitement mentionnée dans un script de linkage est appelée section orpheline
- Le linker ne définit automatiquement les symboles préfixés
_startet_stopque si le nom de la section est compatible avec les noms de symboles C our_stringsfonctionne, maisour.stringsou.our_stringsne fonctionnent pas de la même manière- Comme les symboles de frontière ne contiennent pas de données et que seule leur adresse importe, l’exemple les représente avec
MaybeUninit<()> - Rust stable n’implémente pas encore le type externe opaque idéal, donc
MaybeUninitjoue ici le rôle de substitut - Créer un pointeur
&raw constvers un élémentstaticest toujours valide ; on peut donc récupérer son adresse en toute sécurité sans lire sa valeur link-sectionabstrait ces détails sur les sections du linker et les convertit en slices Rust sur lesquelles on peut appliquer les opérations standard- La force des sections de linkage est que n’importe quelle crate fournissant du code au binaire peut soumettre des éléments à la même section, puis le linker les regroupe tous juste avant la production du binaire final
Injection de dépendances
- Le schéma d’enregistrement fondé sur les sections fonctionne selon le même principe que l’injection de dépendances
- Des frameworks comme Dagger et Spring reposent eux aussi sur l’idée que le consommateur des données d’enregistrement ne doit pas être couplé au fournisseur
- Le fournisseur enregistre les données là où il les définit, et le consommateur lit le registre
- Dans l’injection de dépendances traditionnelle, le framework doit souvent parcourir le graphe des modules au démarrage ou scanner les classes chargées pour trouver fournisseurs et consommateurs
- Avec les sections du linker, c’est le linker qui collecte les données des fournisseurs à la construction du binaire et les rend faciles à lire pour le consommateur
- L’exemple d’enregistrement de sous-commandes CLI illustre ce schéma en enregistrant les sous-commandes avec
link_section::section - Turbopack utilise ce schéma pour les constantes de pool de chaînes, les mécanismes d’enregistrement de sérialisation et désérialisation, et l’enregistrement des fonctions de compilation incrémentale turbotask
- Un serveur web hypothétique pourrait lui aussi utiliser ce schéma pour collecter automatiquement routes et middlewares au moment de la build
Utiliser les sections pour l’enregistrement
- L’un des avantages du travail avant
mainest qu’aucun thread ne s’exécute tant qu’on ne les démarre pas explicitement - Dans cet environnement, on peut souvent éviter la complexité des verrous et des primitives de synchronisation
- Le cycle de vie des données peut être nettement séparé entre une phase modifiable avant
mainet une phase immuable aprèsmain - Éviter de prendre et relâcher des verrous lors de l’accès aux données dans un programme en cours d’exécution peut simplifier la structure et améliorer l’efficacité
- L’exemple utilise une structure
CliSubcommand, une fonction constructeurconstet#[section]pour collecter les sous-commandes - Des sous-commandes comme
list,addouhelppeuvent se trouver n’importe où dans le code - La fonction
mainpeut effectuer un dispatch dynamique tant qu’elle connaît la définition de la sectionCLI_SUBCOMMANDS, sans connaître à l’avance le nom ni l’emplacement des sous-commandes enregistrées - S’il n’existe aucune sous-commande enregistrée, on retombe sur une sous-commande par défaut ; dans l’exemple,
helpjoue ce rôle
Au-delà des données immuables
- L’exemple précédent suppose que les données liées sont immuables, mais l’organisation des données via le linker peut aussi s’appliquer à des données mutables
- La mutabilité des données statiques globales est un problème classique en Rust, généralement traité avec des outils de mutabilité intérieure comme les mutex ou les types atomiques
- Les mutex et les types atomiques ne sont pas coûteux en l’absence de contention, mais ils ne sont pas gratuits pour autant
- En Rust, modifier des données de façon sûre impose que la mutation soit thread-safe et qu’aucune autre référence vers les mêmes données n’existe pendant qu’une référence mutable est active
- L’environnement pre-main est mono-thread tant qu’aucun thread n’est explicitement lancé, donc il n’a pas besoin d’opérations atomiques
- En environnement mono-thread, la relation happens-before entre une écriture et une lecture ultérieure est automatiquement satisfaite
- Une modification des données de section de linkage avant
mainpeut ensuite être consultée sans verrou en toute sécurité depuis n’importe quel thread - Si les références mutables ne sont créées et utilisées qu’avant
main, la condition d’absence d’autres références pendant leur existence est également satisfaite - Les slices issues d’une section de linkage sont des alias vers des éléments statiques de la section ; les règles d’aliasing s’appliquent donc à la fois à la slice et aux éléments statiques
- Pour permettre une mutation sûre via la slice, les éléments statiques doivent impérativement être placés dans un
UnsafeCell - Sans enveloppe
UnsafeCell, LLVM peut mettre en cache les valeurs, les réordonner ou faire des hypothèses sur les données UnsafeCelln’implémente pasSyncpar lui-même, donc un type wrapper supplémentaire est nécessaire- L’exemple utilise
SyncUnsafeCelletMaybeUninit<SyncUnsafeCell<...>>pour construire les symboles de frontière et les éléments - L’exemple de pool d’interning de chaînes triable définit un pool de chaînes au moment du linkage, trie la slice au début de l’exécution, puis retrouve les chaînes par recherche binaire
- L’implémentation manuelle comporte beaucoup de boilerplate, mais
ctoretlink-sectionpermettent de construire la même structure plus concisément avecTypedMutableSectionet des constructeurs - Les éléments d’un
TypedMutableSectiondoivent êtreconst, car il repose en interne sur un code similaire à celui de l’exemple d’implémentation manuelle
Avantages du schéma par sections de linkage
- Ce schéma garantit l’agrégation d’éléments marqués et place toutes les données dans une mémoire contiguë préallouée
- Il permet de disperser les points d’enregistrement n’importe où dans le code
- Il permet d’obtenir un nombre garanti d’éléments dans la section
- Les sections de linkage ne nécessitent aucune allocation supplémentaire
- Sans elles, il faudrait allouer une
HashMap, unVecou une autre structure, puis la redimensionner plusieurs fois au fil de la collecte des éléments - Avec une collecte traditionnelle, les dépendances entre module de type partagé, modules contributeurs et module collecteur s’entremêlent fortement
- Avec les sections de linkage, le collecteur peut se trouver n’importe où sans avoir à se soucier des modules qui contribuent les données
scattered-collectfournit plusieurs équivalents de structures de données avec support du linkageScattered*Slicedésigne différentes structures analogues àVecqui exposent des slices et prennent éventuellement en charge le triScatteredMapetScatteredSetsont des équivalents deHashMapetHashSetoffrant des recherches clé-valeur basées sur le hash avec une initialisation pre-main minimale
Quand ne pas utiliser cette approche
- Le calcul au moment du linkage est puissant, mais ce n’est pas toujours l’outil approprié
- Au lieu d’une approche fondée sur le linkage, on peut collecter manuellement les données dans une crate qui a visibilité sur toutes les crates susceptibles de contribuer des données
- La collecte manuelle peut être peu pratique et impose souvent une crate collectrice avec de nombreuses références à d’autres crates, plutôt qu’un point de contribution unique dans la crate centrale
- L’élimination du code mort devient plus difficile
link-sectionetlinkmemarquent les éléments avec#[used], ce qui empêche le linker de supprimer les données inutilisées- Cela peut être acceptable pour de petites données comme des atomes de chaînes internées, mais l’interning de fragments JSON bruts, de JavaScript ou de grandes structures peut accumuler beaucoup de code mort difficile à identifier
- Les fonctions constructrices pre-main ont aussi des contraintes
- Elles ne doivent pas provoquer de panique, et Rust ne garantit pas que toutes les fonctions de la bibliothèque standard y soient utilisables
- L’ordre d’appel des fonctions d’initialisation partageant la même priorité n’est pas garanti et dépend fortement de la plateforme
- On peut contourner ces limites par une conception prudente, mais l’approche pre-main peut rester mauvaise à cause de sa subtilité et de sa difficulté de débogage
- Miri ne prend pas totalement en charge l’ensemble des constructeurs pre-main et des configurations de sections de linkage
- À l’heure actuelle, Miri ne voit l’exécution pre-main que de façon très basique et ne modélise pas les sections de linkage
- Pour tester les comportements indéfinis, il est recommandé d’utiliser les sanitizers LLVM comme ASan ou TSan
- Les schémas d’inversion de contrôle peuvent rendre difficile l’audit de tous les endroits qui contribuent des données aux sections de linkage
- De nombreux programmes Rust largement diffusés et très utilisés dépendent déjà de fonctionnalités pre-main comme
ctor,link-section,inventoryoulinkme
Brève note sur WASM
- WASM, en raison de choix historiques, ne prend pas nativement en charge les sections de linkage
- L’annotation
#[link_section]ne place pas un élément dans une vraie section de code, mais dans une section personnalisée WASM inaccessible depuis le code WASM lui-même - La crate
linktimeprend en charge WASM et fournit une solution d’émulation pour faire fonctionner cette approche aussi dans les binaires WASM - Une prise en charge WASM plus appropriée pourra être proposée à l’avenir
Conclusion
- Avant
main, il est possible d’effectuer de nombreux traitements qui apportent, dans certains cas, des avantages substantiels - L’environnement pre-main offre un ordre d’exécution très contrôlé et hautement maîtrisable, ce qui permet d’effectuer de nombreuses opérations avec plus de confiance, sans verrous, types atomiques ni autres primitives de synchronisation
- Les sections de linkage permettent d’agréger arbitrairement des données liées à l’échelle de tout le binaire et de les placer ensemble, en évitant des ordres de dépendance maladroits entre crates
- Dans bien des cas, on peut éviter complètement les allocations, ce qui éloigne des problèmes d’allocateur comme la fragmentation due aux allocations répétées
- Parmi les crates associées, on trouve
ctor,dtor,link-sectionetscattered-collect
1 commentaires
Commentaires sur Lobste.rs
Apple utilise libSystem.dylib comme frontière de stabilité ABI pour les appels système, et Windows de la famille NT place cette frontière de stabilité ABI non pas au niveau des appels système mais de
ntdll.dll: not syscallsSur OpenBSD, il semble que Go ait défini un drapeau de métadonnées désactivant l’application forcée du bit NX afin d’éviter la politique du noyau qui tue le processus si celui-ci tente d’effectuer des appels système en dehors du mapping
libcen lecture seule mis en place par le chargeurCependant, comme libSystem.dylib contains the functionality which would normally be
libc.soplus other things, cela revient, de ce point de vue, à l’approche des BSD où « libc est la frontière de stabilité »Et As of Go 1.16, Go utilise libc sur OpenBSD afin de respecter la politique de ce système en matière d’appels système
Linux est relativement rare de ce point de vue, car il dispose de numéros d’appels système stables et n’a pas, comme d’autres OS, une architecture où « un fragment du noyau chargé comme bibliothèque dynamique dans l’espace d’adressage du processus partage avec le code en mode noyau des définitions
enumd’appels système instables » ; de plus, Linux et glibc ne sont pas développés ensemble dans le même dépôt comme ailleursSous Windows, le runtime C se charge aussi de convertir en tableau
argvde style POSIX la chaîne de commande héritée de CP/M, reprise par MS-DOS puis par l’API Windows de création de processus enfantsC’est pourquoi la documentation de Python
subprocesscontient la section Converting an argument sequence to a string on Windows, qui explique comment transformer un tableauargven chaîne selon les règles de guillemets intégrées au runtime C de Microsoft. Le parseur propre au processus enfant appelé peut, s’il le souhaite, se comporter différemmentLe
_startde Linux ne signifie pas non plus, à proprement parler, que l’éditeur de liens insère automatiquement dans le binaire un symbole de ce nom. Si un binaire au format ELF est un exécutable et non une bibliothèque, le champe_entryde l’en-tête, à l’offset0x18, contient l’adresse vers laquelle le chargeur saute après avoir préparé la mémoire_startest une convention GCC pour désigner la cible pointée pare_entryquand on n’utilise pas le point d’entrée fourni par libc, et il me semble que des outils comme NASM suivent aussi cette conventionSous Windows,
_WinMainCRTStartupest également trouvé par le chargeur viaAddressOfEntryPointdans le PE header. Il se trouve à l’offset0x0028à partir du début de l’en-tête PE, lequel vient après l’en-tête MZ (EXE DOS) et le DOS StubPour apprendre les détails du format PE, Making the smallest Windows application et Tiny PE sont de bonnes ressources. Tiny PE enfreint même parfois la spécification PE d’une manière que Windows accepte, par exemple en faisant se chevaucher des parties que l’OS ne lit pas, ou en mettant du code dans des champs d’en-tête inutilisés. À ce niveau, la taille minimale d’un fichier que Windows accepte varie selon la version de Windows qui l’exécute
Pour les exécutables ELF Linux très petits, A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux vaut aussi le détour
_start, sur les systèmes a.out, le point d’entrée du noyau dans l’exécutable était traditionnellementstart, déclaré dans csu/crt0. Voir par exemple 7th edition et VAX BSDÀ cette époque, le compilateur C ajoutait
_devant les symboles globaux, donc on voit que V7 déclare_main, tandis que BSD déclare le nom assembleur non décoréstartpour lestart()du CÀ l’époque, les programmes démarraient au début du fichier, et l’invocation du linker par
ccétait organisée pour quecrt0soit placé tout au début.csusignifie code de démarrage C, etcrt0l’objet de support runtime C numéro 0Il est plus difficile de trouver exactement comment cela fonctionnait avec System V et l’arrivée d’ELF, mais
startou_starta continué d’être utilisé comme point d’entrée du programme déclaré dans csu/crt0Je n’ai jamais vraiment compris comment ELF a changé la gestion du préfixe
_, mais il semble qu’un niveau supplémentaire ait été ajouté, peut-être pour le folklore, et questartsoit ainsi devenu_startpour une raison ou une autreComme paire évidente, il semble qu’ELF ait aussi ajouté
_end, qui correspond au sommet du BSS et à l’emplacement quesbrk(0)renverrait avant quemalloc()ne crée le tasmainen Rust m’intéressait, et je trouvais utile d’en faire un article expliquant ce que c’est et pourquoi c’est utileJ’ai aussi des idées de suite, par exemple sur la manière d’exploiter l’agrégation par l’éditeur de liens pour construire des collections plus rapides, mais j’aimerais d’abord avoir des retours sur ce sujet d’introduction
no_std, parfois même sansalloc,mainn’est qu’une fonction parmi d’autres et l’initialisation repose généralement sur le développeurJ’ai pas mal de code répétitif fait maison dans notre base de code pour des usages proches, donc je me demande comment ces crates s’articulent avec les environnements embarqués