Jusqu’où peut-on réduire un binaire .NET Hello World minimal ?
(blog.washi.dev)- 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 appelleucrtbase!putsvia 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
.csprojavec<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 classeProgram
- 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
maindans le type<Module>et construit un corps CIL contenant seulementLdstr "Hello World",Console.WriteLineetRet - 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,
.textet.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
0x200octets, 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
.relocavec les paramètres suivantsmodule.PEKind = OptionalHeaderMagic.PE64module.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
#Stringsest 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 à
nullles 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
#GUIDcontient le GUID référencé par leMvidde la table Module, mais l’expérience n’a pas besoin d’identification de version, il est donc supprimé- les 16 octets du
Mvidsont supprimés - le flux
#GUIDvide et son en-tête de 16 octets sont également supprimés - au total, 32 octets sont économisés
- les 16 octets du
- La référence à
mscorlibnécessaire à l’appel deConsole::WriteLinecomporte 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#USde .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 à
0xFFet 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#UScomme de l’ASCII - Au lieu de
Console::WriteLine, l’exécutable appelle la fonction non managéeucrtbase!putsvia P/Invoke- La déclaration C# correspondante est
[DllImport("ucrtbase")] static extern int puts(nint str); putsreçoit en argument un pointeur vers une chaîne ASCII terminée par zéro
- La déclaration C# correspondante est
- 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
DllCharacteristicsde 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
#USet la référence àSystem.Console::WriteLine, tout en ajoutant à la place la définition deputset les métadonnées P/Invoke - La taille obtenue est de 889 octets (0x379)
Dernières micro-optimisations
- La définition standard de
putsrenvoie enint32le 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
RAXet non sur la pile d’évaluation .NET - En changeant la définition P/Invoke pour un retour
void, on peut supprimer l’instructionpoputilisée dans la méthodemainpour 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::WriteLinen’est plus référencé, la référence àmscorlibest également supprimée- une table disparaît du flux de tables
#~ - le nom
mscorlibest aussi retiré du flux#Strings
- une table disparaît du flux de tables
- Le
VersionStringdu 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
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"
https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
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
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.
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...
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
Il suffit de l’appeler ainsi :
echo "hello world"Plus précisément, .NET Framework 4.7.2 devrait être inclus par défaut dans les versions prises en charge de Windows
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
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é
Qu’est-ce qu’on pourrait faire d’autre là-bas ?
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
ucrt.dll, exposées via des appels pinvoke non managés, mais il n’« utilise » pas réellement le frameworkJe 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...
Les builds AOT compilés les plus réduits semblent sur le point de franchir la barrière du 1 MB :
https://github.com/AustinWise/SmallestDotnetHelloWorlds
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.jpgJe viens d’essayer avec l’IDE Oberon+ et ce module produit une Hello.dll de 2048 octets
module Hellobeginprintln("hello world")end HelloIl utilise le générateur d’assembly https://github.com/rochus-keller/Pelib
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
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
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
.exeHello World de Flutter à 1 Mo.