1 points par GN⁺ 2023-07-10 | 1 commentaires | Partager sur WhatsApp
  • En partant d’un exécutable qui affiche seulement "Hello World" avec .NET Framework 4.x, le résultat d’une compilation C# classique de 4,6 Ko est réduit à 834 octets grâce à une construction manuelle du PE
  • L’expérience impose les conditions suivantes : point d’entrée managé, fichier unique exécutable sous Windows, usage uniquement de la BCL ou de bibliothèques présentes dans une installation Windows standard, et exclusion des octets nuls en fin de fichier
  • La réduction de taille passe par la suppression des annotations de types référence nullables et des métadonnées inutiles, puis par le passage à un PE 64 bits afin de supprimer les imports et les base relocations
  • Au lieu de Console.WriteLine, l’exécutable appelle ucrtbase!puts via P/Invoke, ce qui supprime le flux UTF-16 #US, tandis que la chaîne ASCII est déplacée dans la zone de padding entre l’en-tête PE et la première section
  • L’exécutable final atteint 834 o (0x342) hors zéros finaux, et 476 o après compression ZIP, illustrant les limites minimales d’un PE .NET et des métadonnées CLR

Conditions de l’expérience et point de départ

  • L’objectif est de ne placer dans l’exécutable .NET que les octets indispensables pour que le CLR affiche "Hello World" sur la sortie standard
  • Les règles de l’expérience sont les suivantes
    • L’application doit exécuter un point d’entrée managé implémenté en C# ou en CIL
    • Le runtime ciblé est .NET Framework 4.x.x
    • Les dépendances tierces sont interdites ; seuls mscorlib, qui fait partie de la BCL, ou les bibliothèques présentes dans une installation Windows standard sont autorisés
    • En raison des contraintes d’alignement du fichier PE et du CLR, un fichier PE .NET théoriquement minimal exécutable sous Windows 10 ou plus récent ne peut pas faire moins de 1 Ko, mais les trailing zero bytes en fin de fichier sont exclus du calcul de taille
  • En compilant un programme C# Hello World classique avec net472, C# 10 et nullable activé, la taille de l’exécutable est de 4,6 Ko
  • Le code source final se trouve dans Full Source Code

Ce qui peut être réduit dans le résultat de compilation C#

  • Les types référence nullables de C# 8.0 sont inclus statiquement dans l’exécutable sous forme d’attributs personnalisés, et ces attributs augmentent fortement la taille
  • En désactivant la vérification des types référence nullables dans le .csproj avec <Nullable>disable</Nullable>, les attributs sont supprimés, mais la taille totale du fichier reste de 4,6 Ko à cause de l’alignement du fichier PE
  • La décompilation montre que le compilateur C# continue de générer des métadonnées inutiles même quand nullable est désactivé
    • des assembly attributes comme la version du fichier d’assemblage ou les informations de copyright
    • le type caché <Module> en plus de la classe Program
  • Pour une réduction extrême, au lieu de chercher davantage d’options du compilateur C#, le fichier exécutable .NET est créé directement avec AsmResolver
  • Le module créé manuellement place la fonction main dans le type <Module> et construit un corps CIL contenant seulement Ldstr "Hello World", Console.WriteLine et Ret
  • Avec cette approche, la taille du fichier est réduite d’environ moitié par rapport à l’original

Suppression des imports et des base relocations avec un PE 64 bits

  • L’exécutable généré contient deux sections, .text et .reloc, et les répertoires de données Imports et Base Relocations occupent beaucoup d’espace
  • Un exécutable .NET AnyCPU ou 32 bits a besoin d’un répertoire d’imports, car le point d’entrée non managé doit appeler mscoree!_CorExeMain
  • Comme les exécutables .NET par défaut sont relogeables, une base relocation est également nécessaire pour cet appel de fonction importée
  • Les base relocations sont généralement placées dans une section séparée, et chaque section étant alignée au minimum sur 0x200 octets, le fichier gonfle d’au moins 1 Ko
  • Un exécutable .NET 64 bits n’a pas besoin de point d’entrée non managé, ce qui permet de supprimer ces deux répertoires et la section .reloc avec les paramètres suivants
    • module.PEKind = OptionalHeaderMagic.PE64
    • module.MachineType = MachineType.Amd64
  • À ce stade, on atteint la taille minimale théorique d’un fichier PE .NET valide : 1 Ko

Suppression des noms de métadonnées, du GUID et du public key token

  • À partir de là, la taille n’est plus mesurée avec la taille de fichier affichée par Windows Explorer, mais dans un éditeur hexadécimal, à la position qui suit le dernier octet non nul
  • Avec ce critère, le fichier PE de 1 Ko est déjà réduit à 991 octets (0x3DF)
  • Le tas #Strings est le deuxième plus gros flux de métadonnées dans un binaire .NET ; il stocke les noms de types et de méthodes utilisés par le flux de tables #~
  • Mettre à null les noms qui n’ont pas d’importance à l’exécution n’empêche pas le programme de fonctionner, et permet de gagner 32 octets
  • Le flux #GUID contient le GUID référencé par le Mvid de la table Module, mais l’expérience n’a pas besoin d’identification de version, il est donc supprimé
    • les 16 octets du Mvid sont supprimés
    • le flux #GUID vide et son en-tête de 16 octets sont également supprimés
    • au total, 32 octets sont économisés
  • La référence à mscorlib nécessaire à l’appel de Console::WriteLine comporte généralement un public key token de 8 octets
  • Sans public key token, le CLR ne vérifie pas l’authenticité de ce token d’assembly ; il est donc supprimé dans cette expérience
  • Après cette étape, la taille du fichier est de 918 octets

Suppression de Console.WriteLine et utilisation d’une chaîne ASCII

  • "Hello World" est stocké dans le flux de métadonnées #US de .NET, sous forme de tableau de caractères 16 bits préfixé par une longueur et suivi d’un octet nul supplémentaire
  • Tous les caractères de la chaîne à afficher ont un point de code inférieur à 0xFF et peuvent être représentés en ASCII sur 1 octet, mais il n’existe pas de moyen de faire interpréter au CLR une chaîne du flux #US comme de l’ASCII
  • Au lieu de Console::WriteLine, l’exécutable appelle la fonction non managée ucrtbase!puts via P/Invoke
    • La déclaration C# correspondante est [DllImport("ucrtbase")] static extern int puts(nint str);
    • puts reçoit en argument un pointeur vers une chaîne ASCII terminée par zéro
  • Pour connaître l’adresse d’exécution de la chaîne au moment de la compilation, le drapeau DynamicBase est désactivé dans le champ DllCharacteristics de l’en-tête optionnel PE
    • une image base fixe est définie
    • la chaîne ASCII est placée dans la section .text
    • l’adresse d’exécution est calculée avec module_base_address + rva_ascii_string
  • Le drapeau ILOnly du data directory .NET est également désactivé afin que le CLR respecte ce drapeau
  • Ce changement supprime la chaîne wide-character, le flux #US et la référence à System.Console::WriteLine, tout en ajoutant à la place la définition de puts et les métadonnées P/Invoke
  • La taille obtenue est de 889 octets (0x379)

Dernières micro-optimisations

  • La définition standard de puts renvoie en int32 le nombre de caractères écrits, mais le programme n’utilise pas cette valeur
  • Dans un PE 64 bits, selon la convention d’appel x64 de Microsoft, la valeur de retour est placée dans le registre RAX et non sur la pile d’évaluation .NET
  • En changeant la définition P/Invoke pour un retour void, on peut supprimer l’instruction pop utilisée dans la méthode main pour retirer la valeur de retour
  • Comme l’en-tête PE est plus petit que 0x200, il existe des padding data entre l’en-tête et la première section
  • Le Windows PE Loader mappe l’en-tête PE dans une mémoire lisible, et cette plage inclut également les padding data
  • Déplacer la chaîne ASCII dans cette zone de padding permet de réduire le fichier de 13 octets supplémentaires
  • Comme Console::WriteLine n’est plus référencé, la référence à mscorlib est également supprimée
    • une table disparaît du flux de tables #~
    • le nom mscorlib est aussi retiré du flux #Strings
  • Le VersionString du répertoire de métadonnées .NET vaut par défaut "v4.0.30319" et, une fois paddé sur un multiple de 4 octets, occupe 12 octets au total
  • Même raccourcie à v4.0., cette chaîne permet au CLR 4.0 de démarrer et au programme de s’exécuter ; après padding, cela économise 4 octets supplémentaires
  • Le . final s’avère nécessaire : en réduisant davantage, le programme ne démarre plus correctement

Résultat final

  • La taille du binaire final, hors zéros finaux, est de 834 octets (0x342)
  • Après compression ZIP, il atteint 476 octets, moins que les 582 octets obtenus sans optimisations supplémentaires après avoir atteint la limite de 1 Ko
  • L’expérience part d’un Hello World de 4,6 Ko produit par le compilateur C# et aboutit à un fichier PE construit manuellement de 834 o
  • Le programme final affiche toujours correctement "Hello World"
  • Ce travail relève davantage d’une exploration des limites de configuration minimale du format de fichier des modules .NET et des métadonnées CLR que d’un objectif pratique

1 commentaires

 
GN⁺ 2023-07-10
Commentaires Hacker News
  • J’adore vraiment les expériences du genre programme exécutable minimal
    Michal Strehovský a créé un jeu Snake de moins de 8 Ko avec le framework aujourd’hui connu sous le nom de .NET, sans dépendance au runtime .NET
    Medium n’est pas terrible et Twitter n’est plus vraiment utile non plus, mais il me semble qu’en gros « hello world » prenait aussi en charge Windows 3.11
    https://medium.com/@MStrehovsky/building-a-self-contained-ga... [https://web.archive.org/web/20200103110836/https://medium.co... | https://archive.ph/b6qXE]
    https://news.ycombinator.com/item?id=22010159 (2020)
    https://news.ycombinator.com/item?id=22104734
    On peut voir ses travaux les plus récents et son financement sur https://flattened.net : "bflat - C# as you know it but with Go-inspired tooling"

    • Pour continuer sur l’idée d’un C# « inspiré de Go », .NET a System.Threading.Channels
      https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
    • Si les applis C# peuvent devenir assez petites pour que les utilisateurs aient l’impression que le temps de téléchargement est instantané, je me demande si cela pourrait servir à rendre C# WebAssembly plus réaliste
      Il me semble qu’une bonne partie de la taille à télécharger venait des bibliothèques .NET elles-mêmes
      Je me demande aussi s’il existe un framework/moteur de jeu 2D qui cible le navigateur, utilise C#, et produit de petits binaires
    • Michal et l’équipe CoreRT m’ont fait reconsidérer C#, et sans NativeAOT aujourd’hui, je ne pense pas que j’aurais envisagé C#, surtout avec Go en face
  • Cet article peut prêter à confusion. L’exécutable en question nécessite un runtime installé sur le système, et utilise en réalité .NET Framework 4.7.2 en 2023
    Pour une comparaison équitable avec cette approche, un fichier .jar ou .py serait probablement plus approprié
    Si l’on parle de véritables exécutables, le bon point de comparaison serait plutôt un binaire Native AOT exécutable sur Windows, Linux et macOS
    Mais il est normal qu’il soit beaucoup plus gros, puisqu’il doit aussi embarquer du code de support comme le GC, ThreadPool, Console, etc.

    • Pas forcément. L’exe produit n’utilise pratiquement pas .NET Framework au-delà de l’appel à main
      La logique qui écrit des octets dans la console passe par un appel pinvoke vers du code non managé exposé par ucrt.dll
      L’article sur la miniaturisation de Snake partagé dans un autre commentaire fait la même chose avec .NET Core, le nouveau .NET non fourni avec Windows, et produit effectivement un binaire non-.NET
      [0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
    • Il est aussi très injuste de comparer un binaire natif entièrement lié statiquement à un binaire qui charge dynamiquement une partie ou la totalité de ses bibliothèques
      Comparer des images de conteneur serait sans doute un bon point de départ. Il faudrait utiliser l’image de base la plus petite possible, ou si possible « FROM scratch », tout en ne comparant que des architectures identiques
      Pour éviter des variables comme faire tourner du 32 bits sur du 64 bits ou la compression de pointeurs, je privilégierais le 32 bits
      Ensuite, on pourrait comparer la taille du tar exporté non compressé. Ce ne serait pas parfaitement équitable, mais cela réduirait les variables évidentes
      En y repensant, il serait peut-être plus juste de comparer la taille maximale de l’image une fois compressée, afin de prendre aussi en compte la compression à l’intérieur du conteneur. Bien sûr, il faudrait utiliser le même algorithme et les mêmes paramètres, ou bien, comme dans les benchmarks de compression, ajouter la taille du décompresseur au résultat final
    • Mon hello world a besoin du framework « bash » et fait 0 octet
      Il suffit de l’appeler ainsi : echo "hello world"
    • .NET Framework est intégré à Windows, et c’était un prérequis au moins depuis Vista
      Plus précisément, .NET Framework 4.7.2 devrait être inclus par défaut dans les versions prises en charge de Windows
    • Je ne sais pas si ce qui nous importe doit être l’« équité » ou le « réalisme »
      Une installation Windows récente ne peut pas ne pas avoir .NET, alors qu’il n’est pas présent par défaut sur Linux et macOS ; en faire un critère d’équité me semble étrange
  • Au passage, je me demande si quelqu’un a remarqué que l’auteur était basé à McMurdo Station, en Antarctique
    Je n’aurais jamais pensé y trouver quelqu’un avec ce genre de stack technique : https://usscar.org/directory?combine=&field_usscar_nsf_progr...
    Ça pourrait faire une histoire assez intéressante

    • J’ai eu l’occasion d’aller à McMurdo pour une mission de recherche
      Ce qui surprend les visiteurs, c’est que la plupart des gens sur place sont du personnel de soutien ; les scientifiques ne représentent qu’une petite part, peut-être 20 %
      Le personnel de soutien vient de tous les métiers nécessaires au fonctionnement d’une ville moderne : pompiers, charpentiers, cuisiniers, mécaniciens, etc.
      Sans doute à cause du processus de sélection, presque tout le monde est extrêmement compétent et fait partie des meilleurs dans son domaine. Il y avait un nombre étonnant de profils couteau suisse aux compétences très larges
      La difficulté de vivre et de travailler là-bas attire, comme la lumière attire les insectes, des personnes atypiques et très motivées, et beaucoup reviennent chaque année. C’est une formidable communauté
    • Dans ce cas, je ne comprends pas la phrase « j’ai bêtement gaspillé mon samedi »
      Qu’est-ce qu’on pourrait faire d’autre là-bas ?
    • Ça vaut le coup de regarder https://brr.fyi/
    • Je me demande comment il l’a trouvé
      On ne le voit ni dans les métadonnées .NET ni dans about
  • Il est dit que le fichier gonfle d’au moins cette taille parce que toutes les sections doivent être alignées sur la plus petite valeur d’alignement de section possible, 0x200 octets, mais 0x200, c’est 512 octets, donc 0,5 KB
    J’avais déjà joué avec ce genre d’optimisation de taille PE il y a longtemps, et dans mon souvenir Windows 9x imposait 512 comme minimum, tandis que NT acceptait des alignements plus petits, jusqu’à 1
    Je n’ai pas non plus vu l’ancienne astuce, autrefois à la pointe, qui consistait à faire se chevaucher l’en-tête DOS MZ et l’en-tête PE : http://www.phreedom.org/research/tinype/
    Et dans la demoscene, ils arrivent carrément à faire ça dans un binaire de 1 KB :
    https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...

  • Ce n’est absolument pas du temps gaspillé bêtement
    En entrant dans les détails d’un binaire, on acquiert une compréhension profonde de la manière dont il est fabriqué, et les concepts appris au passage enrichissent les connaissances
    Si c’est intéressant et qu’on apprend quelque chose, cela vaut toujours le temps et les efforts investis

  • C’est beaucoup plus pertinent et intéressant : https://twitter.com/MStrehovsky/status/1669502394827419648
    Il y est dit qu’un Hello World C# compilé en natif, entièrement autonome, avec le GC et tout le reste, peut descendre à environ 440 KB
    Le framework .NET classique est un vestige legacy de Windows qui n’est plus recommandé, et les nouvelles applications ne devraient pas être construites dessus

    • L’exe produit dépend seulement de détails d’implémentation des bibliothèques sous-jacentes de ucrt.dll, exposées via des appels pinvoke non managés, mais il n’« utilise » pas réellement le framework
    • Cela peut être une exception si l’on développe une application dépendante des API Office/COM
  • Je m’attendais à ce qu’on parle d’un « vrai » binaire, c’est-à-dire d’un exécutable .NET compilé en natif, comme .NET AOT
    https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...

  • Félicitations. Tu as abandonné le support Unicode pour fabriquer un appel C à puts
    Ça n’aurait pas été plus simple de simplement compiler ça avec un compilateur C ?
    thats_just_c_with_extra_steps.jpg

    • Le fait de pratiquement contourner la compilation casse un peu l’effet
  • Je viens d’essayer avec l’IDE Oberon+ et ce module produit une Hello.dll de 2048 octets
    module Hello
    begin
    println("hello world")
    end Hello
    Il utilise le générateur d’assembly https://github.com/rochus-keller/Pelib

    • Niklaus Wirth avait raison
      C’est ce que j’avais ressenti en utilisant Lazarus/FreePascal pour la première fois, et plus je lis ses travaux, plus j’ai l’impression qu’il avait raison
      Ces derniers temps, je me dis que WASM pourrait être une façon d’échapper à cette « réincarnation » de la manière de penser, mais je ne peux pas abandonner le matériel à ce point
      Je suis trop primitif pour accepter une machine spirituelle qu’on ne peut pas fracasser au marteau sur un bureau
      Un ordinateur global indestructible comme Ethereum, qui tourne sur le matériel des autres en consommant des milliers de dollars de calcul et qui reste plus lent qu’un RPi, m’inspire quelque chose de fondamentalement tabou
      J’ai toujours envie d’imprimer « Wirth was right » sur un t-shirt avec le visage de Wirth
    • Avec Oberon+, on pourrait s’attendre à ce que ce soit self-hosting, non ?
      Il y a beaucoup trop de résidus de l’écosystème C++
      Ça me rappelle les projets de forge open source, présentés comme des alternatives à GitHub et GitLab, mais quand on veut cloner le code ou signaler un bug, tout est en fait sur GitHub
    • Je me demande si la fin du fichier est remplie avec des octets nuls
      Il doit sûrement exister une option pour désactiver ça
  • Le fait qu’on puisse réduire un .exe à moins de 1 Ko était assez bluffant.
    Ce serait vraiment impressionnant si quelqu’un parvenait à réduire le .exe Hello World de Flutter à 1 Mo.

    • La première étape serait sans doute de convaincre Microsoft d’inclure Flutter dans Windows.
    • Je me demande si compiler uniquement « Hello world » en Dart serait accepté.