3 points par GN⁺ 2023-07-15 | 3 commentaires | Partager sur WhatsApp
  • Pour compiler davantage de logiciels C existants avec Cosmopolitan Libc, il ne suffisait pas de tromper le système de build : il a fallu un patch GCC d’environ 2 000 lignes afin que le compilateur transforme automatiquement le code problématique
  • L’obstacle venait du fait que des valeurs système comme SIGTERM et EINVAL ne sont pas des constantes connues à la compilation, ce qui entre en conflit avec les règles du C pour les labels case de switch et l’initialisation de static/const struct
  • Des tentatives ont été faites avec sed, des expressions régulières Python, un plugin GCC et l’interception de macros du préprocesseur, mais l’erreur sur les labels case survenant pendant le parsing, il était difficile de l’éviter avec un simple plugin
  • Le patch final s’active avec le flag -fportcosmo : juste avant que GCC n’émette une erreur, il transforme les switch en if/goto, et complète les initialisations de structures en remplissant les valeurs à l’exécution
  • Des limites subsistent, comme les structures anonymes, les enum, certains const int ou les index de tableau dans les expressions d’initialisation, mais cela ouvre la voie à la compilation de logiciels comme bash, curl, git, ninja ou gcc sans modifier leur code source

Objectif du patch GCC

  • Un patch d’environ 2 000 lignes pour gcc a été écrit afin de faciliter la création d’Actually Portable Executables avec Cosmopolitan Libc
  • Après application du patch, les logiciels suivants peuvent être compilés avec des systèmes de build ./configure ou cmake
    • bash
    • curl
    • git
    • ninja
    • gcc lui-même
  • Les exécutables produits devraient pouvoir s’exécuter aussi sous Linux, FreeBSD, MacOS, OpenBSD, NetBSD et Windows
    • Avec la réserve que l’exécution sous Windows n’a pas été testée directement
  • Les binaires générés avec GitHub Actions sont disponibles dans les releases de superconfigure

Les points de blocage du portage vers Cosmopolitan Libc

  • Après que Cosmopolitan Libc a attiré l’attention en 2021 avec le serveur web redbean, une question importante a été de savoir dans quelle mesure les logiciels C existants fonctionnaient correctement sur cette libc
  • Lors du portage de runtimes de langages comme Lua, Wren, Janet ou quickjs, le même schéma s’est répété
  • Utiliser des constantes système comme SIGTERM ou EINVAL dans les labels case d’une instruction switch entre en conflit avec les règles de compilation du C
  • La norme C exige que les labels case soient des constantes connues à la compilation
    • Dans Cosmopolitan Libc, les macros du préprocesseur C liées aux interfaces système devaient être symboliques
    • Résultat : du code courant comme switch(errno) { case EINVAL: ... } pouvait ne pas compiler

Transformer les switch en if et goto

  • Si des labels comme case EINVAL ou case ENOSYS dans switch(errno) ne sont pas des constantes connues à la compilation, GCC émet l’erreur case label does not reduce to an integer constant
  • Le patch transforme automatiquement l’instruction switch selon la structure suivante
    • Ajout d’une branche de type if (errno == EINVAL) goto caselabel_EINVAL; pour chaque valeur de case
    • Ajout d’un goto vers le label default
    • Les blocs case d’origine sont restructurés avec des labels et des goto endofthis_switch
  • Cette approche n’est pas la plus élégante, mais elle a permis de traiter les cas rencontrés dans plusieurs bases de code
  • Comme il fallait aussi gérer des switch mêlant ifdef et fallthrough, une transformation basée sur goto était l’option la plus simple

L’initialisation des struct subit la même contrainte

  • Le problème des constantes connues à la compilation se pose aussi dans les initialisations de static ou de const struct
  • Si EINVAL n’est pas une constante connue à la compilation, certaines des initialisations suivantes ne sont pas valides en C
    • Initialisation d’une struct locale ordinaire dans une fonction : possible
    • const struct
    • static struct
    • const struct globale
    • static struct globale
  • Un exemple réel se trouve dans le module faulthandler de CPython
  • La solution consiste à initialiser la structure avec des valeurs factices, puis à ajouter du code qui renseigne les bonnes valeurs avant utilisation
    • Dans une fonction, en ajoutant une instruction if
    • Pour les initialisations globales ou statiques, en utilisant __attribute__((constructor))

Limites des substitutions de texte et des plugins GCC

  • Au départ, l’automatisation de la transformation a été tentée avec un script shell sed, puis avec un script Python et des expressions régulières afin de gérer aussi les fallthrough
  • Mais à cause du préprocesseur C et des ifdef, il était difficile de faire fonctionner correctement les substitutions de texte dans tous les cas
    • Parce qu’il est difficile de savoir à l’avance quels ifdef seront actifs lors de la compilation
  • La tentative suivante a consisté à utiliser un plugin GCC pour une transformation de l’AST
    • Un plugin GCC peut être chargé avec -fplugin=your-plugin.so
    • Il peut accéder à l’AST lors d’événements comme PLUGIN_PRE_GENERICIZE, PLUGIN_FINISH_PARSE_FUNCTION ou PLUGIN_FINISH_DECL
    • debug_tree permet d’afficher l’AST, et walk_tree_without_duplicates de le parcourir
  • Le problème était que la validation des labels case se produit pendant le parsing de GCC
    • Le plugin accède à l’AST une fois le parsing terminé
    • Un label invalide case SIGTERM: ne reste pas dans l’AST
    • Comme GCC a déjà émis une erreur, il est difficile de le corriger depuis le plugin

Expérimentation autour de l’interception des macros du préprocesseur

  • Comme il était difficile d’empêcher les erreurs avant le parsing avec le seul plugin, une méthode interagissant avec le préprocesseur C a été essayée
  • Via la structure cpp_reader des headers de plugins GCC, il était possible d’enregistrer des callbacks lors du #define, du #undef ou de l’utilisation d’une macro
  • En exploitant la structure des macros de valeurs système de Cosmopolitan Libc, des constantes temporaires ont été insérées
    • Exemple : extern const int SIGTERM;
    • Exemple : #define SIGTERM SYMBOLIC(SIGTERM)
    • Pour le plugin, des valeurs temporaires comme static const int __tmpcosmo_SIGTERM = ... étaient définies
  • Le plugin interceptait l’utilisation de SYMBOLIC(SIGTERM) pour la remplacer par une valeur temporaire, puis, à l’étape PLUGIN_PRE_GENERICIZE, remplaçait à nouveau l’AST par le bon VAR_DECL
  • Ce hack de macros a réussi à contourner les erreurs de switch et d’initialisation de struct
  • Cette approche a permis de compiler au moins CPython 3.11

Pourquoi corriger GCC lui-même plutôt qu’utiliser un plugin

  • Après discussion avec Justine Tunney, une fois confirmé que le problème pouvait être résolu, une approche plus simple consistant à modifier directement la base de code de GCC a été proposée
  • Les hacks à base de plugin et de macros imposaient beaucoup de travail inutile, et la gestion des cas limites et des crashs était complexe
  • Par exemple, même dans une situation où seul case ENOSYS devait être transformé, la substitution de macros obligeait le plugin à gérer aussi des usages valides comme ENOSYS + 1 ou printf("ENOSYS = %d", ENOSYS)
  • Avec l’approche par patch GCC, la fonctionnalité s’active via le flag -fportcosmo, sans -fplugin séparé
  • Juste avant que GCC n’émette l’erreur case is not constant, il vérifie si flag_portcosmo est activé et, si c’est le cas, effectue les remplacements nécessaires
  • Le code de transformation d’AST de l’ancien plugin a été déplacé dans GCC pour s’exécuter avant l’appel aux autres callbacks de plugins
  • Ce changement a permis de supprimer les hacks liés aux macros

Builds réels et extension du patch

  • Au 5 juin 2023, le GCC patché passait tous les anciens cas de test du plugin, et un nouveau binaire a été ajouté au monorepo de Cosmopolitan Libc
  • En compilant davantage de code, des corrections et améliorations ont suivi
  • Le build de lua est devenu plus simple, et après avoir trouvé où l’erreur case constant se produisait dans g++, il est aussi devenu possible de compiler ninja
  • De nouveaux problèmes sont apparus lors de la tentative d’ajouter ncurses à Python 3.11
    • Dans ncurses, certains codes contenaient des expressions binaires dans les valeurs d’initialisation de structures, comme DATA2(PARODD | PARENB, PARODD)
    • Le patch existant gérait les initialisations avec constantes simples, mais pas les expressions comme PARODD | PARENB
  • Le patch a ensuite été étendu pour que les labels case du C et les éléments d’initialisation de structures puissent être des expressions arbitraires
    • Selon la norme C, les labels case doivent toujours être des constantes connues à la compilation
    • Le compilateur patché émet un avertissement pour ces usages, mais poursuit la compilation
    • Le C++ est exclu du remplacement d’expressions arbitraires, car cela ne correspond pas au traitement de constexpr par g++11
  • Après ce changement, ncurses a compilé sans problème

Contraintes restantes et conclusion pratique

  • Le patch n’est pas parfait
    • Il ne gère pas certaines structures anonymes
    • Il ne gère pas certains cas d’enum et de const int
    • Il ne gère pas les cas où SIGTERM est utilisé comme index de tableau dans une expression d’initialisation
    • Il peut rester de rares crashs du compilateur avec des initialisations static étranges ou lorsque SIGTERM est placé dans un static const int8_t
  • Malgré cela, de nombreux contre-exemples évidents ont été éliminés, et beaucoup de logiciels populaires se compilent de manière fluide
  • Des environnements de test plus stricts pourraient révéler d’autres points à améliorer
  • Il reste possible de corriger manuellement les instructions switch et les expressions d’initialisation de struct, mais dans beaucoup de cas le compilateur peut s’en charger automatiquement
  • Si un logiciel C peut être compilé statiquement, en particulier s’il compile aussi avec musl, il est probable qu’il puisse aussi être compilé avec Cosmopolitan Libc

3 commentaires

 
ryuheechul 2023-07-15

Neo adopte désormais un format qui encadre les messages dans des blocs de code ? Le besoin de faire défiler horizontalement et la couleur de texte un peu trop dans les tons rouges sont un peu regrettables...

 
xguru 2023-07-16

Ah, le fait que cela soit entouré d’un bloc de code est un bug, snif.

 
GN⁺ 2023-07-15
Commentaires sur Hacker News
  • C’est moi qui ai écrit cet article, mais le titre devrait être « Patching GCC to build Actually Portable Executables », car il fait référence à Cosmopolitan Libc et au format Actually Portable Executable de jart
    Grâce à mon patch GCC, il est désormais possible de compiler des logiciels comme vim, emacs, ninja, bash, git et gcc avec Cosmopolitan Libc en passant par les systèmes de build habituels de type autotools/cmake. Les exécutables générés devraient pouvoir fonctionner sur Linux, FreeBSD, MacOS, OpenBSD, NetBSD et Windows. Je n’ai toutefois pas encore testé Windows
    La liste des logiciels que j’ai compilés avec cette méthode est ici : https://github.com/ahgamut/superconfigure
    Le script superconfigure n’est qu’un wrapper autour du script configure classique utilisé pour compiler des logiciels, auquel il ajoute des flags comme --enable-static
    Si vous voulez essayer de compiler GCC avec Cosmopolitan Libc, vous pouvez utiliser ce dépôt : https://github.com/ahgamut/musl-cross-make/tree/gccbuild

    • Je me demande s’il y a une chance que ce patch soit intégré en amont dans GCC. Les exécutables APE et cosmopolitan libc sont des technologies vraiment géniales, donc ce serait bien qu’elles deviennent plus faciles à utiliser
    • Sympa. Je me demande s’il y a un risque de problème lorsqu’on utilise ça derrière des langages transpilers comme Nim
    • Je me demande si tu as aussi essayé de compiler busybox
    • J’ai vu récemment que tu avais été banni de lobste.rs parce qu’on pensait que tu étais le sockpuppet de jart, et c’est seulement maintenant que je réalise que tu es bien une autre personne
      J’espère que tu seras mieux traité sur HN
    • Le simple fait que ça compile n’est pas si impressionnant que ça ; ce qui est plus intéressant, c’est de savoir si ça fonctionne réellement
      Par exemple, je me demande s’il y a eu assez de tests pour démontrer l’absence de régressions par rapport à une version glibc
  • Il y a une issue liée à ce sujet dans le dépôt Go : https://github.com/golang/go/issues/51900

  • Dans l’ensemble, c’est assez impressionnant, mais j’aimerais comprendre la théorie derrière. Si l’on crée une nouvelle cible de système d’exploitation, il semblerait logique de définir de nouvelles constantes
    Au lieu de transmettre telles quelles les constantes propres à chaque système d’exploitation sous-jacent, on pourrait les convertir pour qu’elles aient partout les mêmes valeurs. Ainsi, le code qui utilise ces constantes comme indices de tableau ou dans des instructions case continuerait à fonctionner correctement
    Cela pourrait introduire plus de surcharge que l’approche décrite dans l’article, mais ça semblerait bien plus simple, y compris pour les personnes qui veulent compiler de nouveaux programmes avec Cosmopolitan libc

    • Créer de nouvelles constantes rendrait la compilation bien plus simple, mais à mon avis la surcharge liée à la conversion entre les constantes existantes et les nouvelles à l’exécution serait trop importante
      On peut voir la liste des constantes ici : https://github.com/jart/cosmopolitan/blob/master/libc/sysv/c...
      À chaque utilisation de l’une de ces constantes, il faudrait embarquer dans le binaire un grand nombre de constantes sous forme de grosse table de correspondance, puis passer par cette table chaque fois que le programme doit les vérifier. Ce ne serait peut-être pas extrêmement lent, mais ce serait clairement perceptible
      L’objectif était de faciliter le portage sans modifier excessivement le code source de la libc ou du logiciel à porter, tout en produisant des binaires aux performances comparables, voire meilleures. Avec ces contraintes, ce patch GCC semblait être la meilleure manière de simplifier le processus
      Si je tombe sur suffisamment de bases de code qui utilisent SIGHUP pour initialiser des indices de tableau, j’essaierai peut-être l’approche proposée pour mesurer le compromis. Sinon, n’hésite pas à l’essayer toi-même et à me dire si un ensemble distinct de constantes donne de meilleurs résultats
  • Si « pas mal de temps a été consacré à éliminer les contre-exemples évidents et beaucoup de logiciels populaires se compilent proprement », alors publier une liste des logiciels compilés avec succès serait utile. Il y en a peut-être déjà une

    • Il est possible de compiler des logiciels comme vim, emacs, ninja, bash, git et gcc. La liste de ce que j’ai compilé avec cette méthode est ici : https://github.com/ahgamut/superconfigure
      Le script superconfigure n’est qu’un wrapper autour du script configure classique utilisé pour compiler des logiciels, auquel il ajoute des flags comme --enable-static
  • Je me demande s’il existe un usage pratique des APE. Y a-t-il réellement des cas où l’on distribue un seul exécutable censé fonctionner sur plusieurs systèmes d’exploitation ?

    • Logfile Navigator(https://lnav.org) utilise des exécutables APE comme agents pour communiquer avec des hôtes distants
      lnav lui-même n’est pas compilé en APE, mais l’agent embarqué à l’intérieur l’est. Quand un utilisateur veut lire les logs de cet hôte, cet agent lui est envoyé à distance
      Cela évite d’avoir à détecter le type d’OS séparément ou d’ajouter une étape supplémentaire pour inclure plusieurs versions de l’exécutable. Il y a un court billet à ce sujet ici : https://lnav.org/2021/05/03/tailing-remote-files.html
    • Ça fait penser aux programmeurs Java/Clojure/Kotlin, aux programmeurs .Net, ainsi qu’à JS, Python, Ruby, Erlang et à tous les programmeurs de langages basés sur une machine virtuelle
      Les programmes compilés statiquement sont à la base de tout, mais au niveau applicatif ils restent minoritaires
      APE pose une grande question : « si un langage AOT bas niveau comme le C peut être compilé une fois et exécuté partout, qui a encore besoin d’une machine virtuelle ? »
    • Par exemple, cela pourrait simplifier un peu la distribution d’exécutables dans des endroits où il est pénible d’avoir plusieurs artefacts pour une même combinaison id/version, comme un dépôt Maven. PyPI semble avoir un problème similaire
      En plus, la cross-compilation est pénible. Les petits projets open source qui n’ont pas de ferme de build avec tous les BSD finissent souvent par ne pas distribuer de binaires pour ces plateformes
      Même kubectl propose des binaires pour Linux, mais pas pour BSD. À moins de compter OSX comme un BSD. Donc, pour ce genre de projet, cela semble être un gain facile
    • Je compte publier mes prochains side projects de cette manière. Je compile même des exécutables Redbean pour des SPA purement côté client. L’idée que ce genre de choses fonctionne tout simplement sur plusieurs systèmes est étonnante
    • Il existe des compilateurs de shaders pour lesquels un format d’exécutable immédiatement lançable, sans dépendance à la plateforme, serait utile
      Ils sont trop gros pour être compilés à la volée sur la machine de l’utilisateur. Ils embarquent plusieurs grosses dépendances tierces en C++, et la compilation prend entre 2 et 20 minutes selon le matériel
      Il faut que cela fonctionne sur macOS x86-64+ARM, Linux x86-64 et Windows x86-64. ARM Linux serait un plus
      Pour l’instant, j’étudie WASM, mais cela nécessite un runtime WASI installé
  • Pour ceux qui sont perdus : « Actually Portable Executables » https://justine.lol/ape.html

    • J’ai ajouté un commentaire qui le mentionne, mais il n’a pas encore été pris en compte. Je pense que cela va changer bientôt. Est-ce que je peux ping @dang ici ?
  • Remplacer switch par if est assez grossier. On pourrait probablement corriger ça en mappant vers un ensemble de constantes neutres vis-à-vis de la plateforme et en déréférençant les symboles runtime via des fonctions
    Par exemple, avec quelque chose comme switch(CosmoMappErrno(EINVAL)) et case COSMO_EINVAL:, on éviterait de transformer le code en amas de goto

    • Mais cela n’aiderait pas à compiler du code non modifié. Il n’y a pas lieu de s’inquiéter des goto générés à l’intérieur du compilateur ; c’est déjà comme ça qu’il procède un peu partout
      Bien sûr, si tu veux dire que le compilateur pourrait reconnaître ce genre de switch et, au lieu de le réécrire en arbre de if, remplacer les labels par des constantes spéciales propres à cosmo pour chaque valeur, puis envelopper l’entrée du switch dans un appel de fonction qui mappe la valeur de la plateforme runtime actuelle vers cette constante cosmo dédiée, avec passage inchangé des autres valeurs, c’est différent
      Ce serait peut-être en réalité une transformation du compilateur plus simple. Mais cela ajoute la complexité de devoir reconnaître quel ensemble de constantes est utilisé et d’appliquer l’appel de mapping approprié à l’entrée du switch. Il faut aussi créer ces mappings côté bibliothèque, donc cela demande de la complexité
      Comme il existe déjà une implémentation qui fonctionne avec une autre méthode de transformation, je ne sais pas si l’auteur aura envie de mettre cela en œuvre
      Enfin, cette approche par mapping introduit un risque de mauvais mapping, avec aussi des subtilités comme la nécessité d’inverser l’entrée avant le mapping pour du code qui utilise des valeurs errno négatives
    • Le fait de déréférencer les symboles runtime via des fonctions me semble intéressant. Si tu peux montrer un exemple fonctionnel, j’essaierai volontiers
      Si j’aime le tableau if-else-goto, c’est parce qu’il s’imbriquait parfaitement avec d’autres parties de GCC. Si tu regardes mon patch[1], tu verras qu’il n’a fallu modifier qu’une toute petite partie du code existant de GCC pour ajouter cette fonctionnalité
      [1]: https://github.com/ahgamut/gcc/tree/portcosmo-11.2
    • Faut-il vraiment se préoccuper du fait que le remplacement soit automatique ?
  • Je ne comprends pas pourquoi on est si surpris par le code qui utilise SIGxxx comme index. Les numéros de signal sont bien définis, généralement continus, et réputés commencer à 1

    • Avant d’essayer busybox, j’avais compilé plusieurs codebases comme git, bash, gcc et curl, et je n’avais jamais vu ce genre de code
      En plus, cette erreur est apparue en même temps qu’une erreur du compilateur dans mon patch qui m’a pris longtemps à déboguer, donc ça m’est resté comme une surprise marquante
      Je me demande si vous considérez vraiment ce pattern comme courant. J’aimerais aussi savoir dans quelles codebases on le retrouve, en dehors de busybox. S’il y a beaucoup d’exemples, je pourrais passer du temps à mettre à jour le patch pour gérer ce pattern
  • Si errno est un problème à ce point, je ne vois pas bien quel espoir il reste pour faire tourner la bibliothèque standard C++ et std::error_code
    Là, il n’y a aucun moyen d’utiliser des macros magiques

    • Le problème semble justement venir de ces macros magiques. En temps normal, les macros se développent en constantes, mais ce n’est plus le cas maintenant, donc certaines choses cassent
      std::error_code n’est pas une constante, donc il ne devrait pas y avoir le même problème