1 points par GN⁺ 4 시간 전 | 1 commentaires | Partager sur WhatsApp
  • 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-section permet de les manipuler comme des slices Rust
  • En combinant ctor et link-section, on peut préparer avant main des 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’atteint main qu’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_entry de 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 malloc peut devoir précéder celle des E/S de fichiers bufferisées
  • Sous Linux, le runtime glibc moderne 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 start et stop, 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
  • ctor et link-section sont des crates du projet linktime, qui abstrait les différences entre plateformes et la complexité du travail avec le linker
  • inventory et linkme sont des crates largement utilisées, fondées sur le même principe, mais les exemples présentent certaines limites
  • La crate ctor gè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 static du même nom
  • Les symboles de début et de fin ne sont pas des pointeurs *const Type et 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_SECTION entraîne la définition automatique des symboles __start_MY_SECTION et __stop_MY_SECTION
  • macOS suit un motif similaire en synthétisant pour chaque section des symboles section$start et section$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 _start et _stop que si le nom de la section est compatible avec les noms de symboles C
  • our_strings fonctionne, mais our.strings ou .our_strings ne 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 MaybeUninit joue ici le rôle de substitut
  • Créer un pointeur &raw const vers un élément static est toujours valide ; on peut donc récupérer son adresse en toute sécurité sans lire sa valeur
  • link-section abstrait 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 main est 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 main et une phase immuable après main
  • É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 constructeur const et #[section] pour collecter les sous-commandes
  • Des sous-commandes comme list, add ou help peuvent se trouver n’importe où dans le code
  • La fonction main peut effectuer un dispatch dynamique tant qu’elle connaît la définition de la section CLI_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, help joue 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 main peut 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
  • UnsafeCell n’implémente pas Sync par lui-même, donc un type wrapper supplémentaire est nécessaire
  • L’exemple utilise SyncUnsafeCell et MaybeUninit<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 ctor et link-section permettent de construire la même structure plus concisément avec TypedMutableSection et des constructeurs
  • Les éléments d’un TypedMutableSection doivent être const, 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, un Vec ou 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-collect fournit plusieurs équivalents de structures de données avec support du linkage
    • Scattered*Slice désigne différentes structures analogues à Vec qui exposent des slices et prennent éventuellement en charge le tri
    • ScatteredMap et ScatteredSet sont des équivalents de HashMap et HashSet offrant 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-section et linkme marquent 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, inventory ou linkme

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 linktime prend 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-section et scattered-collect

1 commentaires

 
GN⁺ 4 시간 전
Commentaires sur Lobste.rs
  • Go est une exception dans la mesure où il évite le runtime C sur la plupart des plateformes, mais Apple exige le runtime C pour l’accès aux appels système
    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 syscalls
    Sur 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 libc en lecture seule mis en place par le chargeur
    Cependant, comme libSystem.dylib contains the functionality which would normally be libc.so plus 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 enum d’appels système instables » ; de plus, Linux et glibc ne sont pas développés ensemble dans le même dépôt comme ailleurs
    Sous Windows, le runtime C se charge aussi de convertir en tableau argv de 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 enfants
    C’est pourquoi la documentation de Python subprocess contient la section Converting an argument sequence to a string on Windows, qui explique comment transformer un tableau argv en 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éremment
    Le _start de 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 champ e_entry de l’en-tête, à l’offset 0x18, contient l’adresse vers laquelle le chargeur saute après avoir préparé la mémoire
    _start est une convention GCC pour désigner la cible pointée par e_entry quand on n’utilise pas le point d’entrée fourni par libc, et il me semble que des outils comme NASM suivent aussi cette convention
    Sous Windows, _WinMainCRTStartup est également trouvé par le chargeur via AddressOfEntryPoint dans le PE header. Il se trouve à l’offset 0x0028 à partir du début de l’en-tête PE, lequel vient après l’en-tête MZ (EXE DOS) et le DOS Stub
    Pour 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
    • Les appels système de FreeBSD et NetBSD bénéficient d’une stabilité ABI, au même titre que les bibliothèques système
    • Concernant _start, sur les systèmes a.out, le point d’entrée du noyau dans l’exécutable était traditionnellement start, 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é start pour le start() du C
      À l’époque, les programmes démarraient au début du fichier, et l’invocation du linker par cc était organisée pour que crt0 soit placé tout au début. csu signifie code de démarrage C, et crt0 l’objet de support runtime C numéro 0
      Il est plus difficile de trouver exactement comment cela fonctionnait avec System V et l’arrivée d’ELF, mais start ou _start a continué d’être utilisé comme point d’entrée du programme déclaré dans csu/crt0
      Je 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 que start soit ainsi devenu _start pour une raison ou une autre
      Comme paire évidente, il semble qu’ELF ait aussi ajouté _end, qui correspond au sommet du BSS et à l’emplacement que sbrk(0) renverrait avant que malloc() ne crée le tas
  • Le fait qu’il existe une vie avant main en Rust m’intéressait, et je trouvais utile d’en faire un article expliquant ce que c’est et pourquoi c’est utile
    J’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
    • J’ai beaucoup travaillé en Rust embarqué, et dans ces environnements no_std, parfois même sans alloc, main n’est qu’une fonction parmi d’autres et l’initialisation repose généralement sur le développeur
      J’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