Wie klein kann eine minimale .NET-Hello-World-Binärdatei werden?
(blog.washi.dev)-.NET Framework 4.x wurde bei einer ausführbaren Datei, die nur "Hello World" ausgibt, das normale C#-Kompilat von 4,6 KB durch manuell aufgebautes PE auf 834 Byte reduziert
- Das Experiment setzte folgende Bedingungen: verwalteter Einstiegspunkt, einzelne unter Windows ausführbare Datei, nur BCL oder Bibliotheken aus einer normalen Windows-Installation, und 0-Bytes am Dateiende ausgenommen
- Die Verkleinerung erfolgte, indem Annotationen für Nullable-Referenztypen und unnötige Metadaten entfernt und auf 64-Bit-PE umgestellt wurde, um Imports und Base Relocations loszuwerden
- Statt
Console.WriteLinewird per P/Invokeucrtbase!putsaufgerufen, um den UTF-16-#US-Stream zu vermeiden; die ASCII-Zeichenkette wurde in den Padding-Bereich zwischen PE-Header und erstem Abschnitt verschoben - Die finale ausführbare Datei schrumpfte ohne nachgestellte Nullbytes auf 834 B (0x342), mit ZIP-Kompression sogar auf 476 B, und zeigt damit die Grenzen der Minimalstruktur von .NET-PE und CLR-Metadaten
Versuchsbedingungen und Ausgangsbasis
- Ziel war es, nur die absolut nötigen Bytes in eine .NET-Executable zu packen, damit die CLR
"Hello World"auf die Standardausgabe schreibt - Für das Experiment galten folgende Regeln
- Die Anwendung muss einen verwalteten Einstiegspunkt ausführen, der in C# oder CIL implementiert ist
- Die Ziel-Laufzeit ist .NET Framework 4.x.x
- Drittanbieter-Abhängigkeiten sind verboten; erlaubt sind nur BCL-
mscorliboder Bibliotheken aus einer normalen Windows-Installation - Wegen der Alignment-Vorgaben von PE-Datei und CLR kann eine theoretisch minimale .NET-PE-Datei, die unter Windows 10 oder neuer läuft, nicht kleiner als 1 KB sein; trailing zero bytes am Dateiende werden bei der Größenberechnung aber nicht mitgezählt
- Wenn man ein normales C#-Hello-World-Programm mit
net472, C# 10 und aktivierten Nullable-Checks baut, ist die ausführbare Datei 4,6 KB groß - Der finale Quellcode steht unter Full Source Code
Was sich aus dem C#-Kompilat entfernen lässt
- Nullable-Referenztypen aus C# 8.0 werden in Form benutzerdefinierter Attribute statisch in die Executable eingebettet, was die Größe deutlich erhöht
- Wenn man Nullable-Referenztyp-Prüfungen in der
.csprojmit<Nullable>disable</Nullable>abschaltet, verschwinden diese Attribute zwar, wegen des PE-Datei-Alignments bleibt die Gesamtgröße aber weiterhin bei 4,6 KB - Die Dekompilierung zeigt, dass der C#-Compiler auch bei deaktiviertem Nullable weiterhin unnötige Metadaten erzeugt
- Assembly-Attribute wie Assembly-Dateiversion oder Copyright-Informationen
- den versteckten Typ
<Module>zusätzlich zur KlasseProgram
- Statt weiter nach Compiler-Optionen für extreme Verkleinerung zu suchen, wurde die .NET-Executable mit AsmResolver direkt erzeugt
- Das manuell erzeugte Modul legt die Funktion
mainim Typ<Module>ab und enthält nur einen CIL-Body mitLdstr "Hello World",Console.WriteLineundRet - Damit schrumpfte die Datei bereits auf ungefähr die Hälfte der ursprünglichen Größe
Imports und Base Relocations mit 64-Bit-PE entfernen
- Die erzeugte Executable enthält die beiden Abschnitte
.textund.reloc; die Datenverzeichnisse Imports und Base Relocations belegen dabei viel Platz - Bei AnyCPU- oder 32-Bit-.NET-Executables muss der nicht verwaltete Einstiegspunkt
mscoree!_CorExeMainaufrufen, daher ist ein Imports-Verzeichnis nötig - Da normale .NET-Executables standardmäßig relocatable sind, wird für diesen Aufruf der importierten Funktion zusätzlich eine Base Relocation benötigt
- Base Relocations landen standardmäßig in einem separaten Abschnitt; da jeder Abschnitt mindestens auf
0x200Byte ausgerichtet wird, bläht sich die Datei dadurch auf mindestens 1 KB auf - Bei einer 64-Bit-.NET-Executable ist kein nicht verwalteter Einstiegspunkt nötig, daher lassen sich beide Verzeichnisse und der Abschnitt
.relocmit folgenden Einstellungen entfernenmodule.PEKind = OptionalHeaderMagic.PE64module.MachineType = MachineType.Amd64
- In diesem Schritt wird die theoretische Minimalgröße einer gültigen .NET-PE-Datei von 1 KB erreicht
Metadatennamen, GUID und Public Key Token entfernen
- Danach wird die Größe nicht mehr anhand der Dateigröße im Windows Explorer gemessen, sondern im Hex-Editor anhand der Position direkt nach dem letzten von Null verschiedenen Byte
- Eine 1-KB-PE-Datei ist nach diesem Maßstab bereits auf 991 Byte (0x3DF) geschrumpft
- Der Heap
#Stringsist der zweitgrößte Metadaten-Stream in einer .NET-Binärdatei und speichert Typ- und Methodennamen, die der Tabellen-Stream#~verwendet - Namen, die zur Laufzeit nicht wichtig sind, lassen sich auf
nullsetzen, ohne das Programm zu beeinträchtigen; das spart 32 Byte - Der Stream
#GUIDenthält die GUID, auf dieMvidin der Module-Tabelle verweist; in diesem Experiment wird sie zur Versionsidentifikation nicht benötigt und deshalb entfernt- die 16 Byte für
Mvidwerden entfernt - der komplette leere
#GUID-Stream samt 16-Byte-Header entfällt - insgesamt spart das 32 Byte
- die 16 Byte für
- Für den Aufruf von
Console::WriteLinewird normalerweise aufmscorlibmit einem 8-Byte-Public-Key-Token verwiesen - Ohne Public Key Token prüft die CLR die Echtheit dieses Assembly-Tokens nicht; im Experiment wird es daher entfernt
- Nach diesem Schritt ist die Datei 918 Byte groß
Console.WriteLine entfernen und ASCII-Zeichenkette verwenden
"Hello World"wird im#US-Metadaten-Stream von .NET gespeichert, als 16-Bit-Zeichenarray mit Längenpräfix und zusätzlichem Nullbyte- Alle Zeichen der auszugebenden Zeichenkette haben einen Codepunkt kleiner als
0xFFund könnten als 1-Byte-ASCII dargestellt werden, aber es gibt keine Möglichkeit, die CLR den String aus dem#US-Stream als ASCII interpretieren zu lassen - Statt
Console::WriteLinewird per P/Invoke die nicht verwaltete Funktionucrtbase!putsaufgerufen- Die Deklaration in C#-Form lautet
[DllImport("ucrtbase")] static extern int puts(nint str); putserwartet als Argument einen Zeiger auf eine nullterminierte ASCII-Zeichenkette
- Die Deklaration in C#-Form lautet
- Um die Laufzeitadresse der Zeichenkette bereits beim Kompilieren zu kennen, wird im PE-Optional-Header das Flag DynamicBase in
DllCharacteristicsdeaktiviert- Es wird eine feste Image-Base gesetzt
- Die ASCII-Zeichenkette wird im Abschnitt
.textplatziert - Die Laufzeitadresse wird als
module_base_address + rva_ascii_stringberechnet
- Damit die CLR dieses Flag berücksichtigt, wird im .NET-Datenverzeichnis zusätzlich das Flag ILOnly deaktiviert
- Durch diese Änderung entfallen Wide-Character-String,
#US-Stream und die Referenz aufSystem.Console::WriteLine; stattdessen kommen nur die Definition vonputsund die P/Invoke-Metadaten hinzu - Das Ergebnis ist 889 Byte (0x379) groß
Letzte Mikro-Optimierungen
- Die Standarddefinition von
putsliefert die Anzahl der ausgegebenen Zeichen alsint32zurück, aber das Programm nutzt diesen Wert nicht - In einer 64-Bit-PE landet der Rückgabewert gemäß Microsofts x64 calling convention nicht auf dem .NET-Evaluationsstack, sondern im Register
RAX - Wenn die P/Invoke-Definition auf Rückgabetyp
voidgeändert wird, kann immain-Methodenrumpf diepop-Instruktion zum Verwerfen des Rückgabewerts entfallen - Da der PE-Header kleiner als
0x200ist, entsteht zwischen Header und erstem Abschnitt Padding - Der Windows-PE-Loader mappt den PE-Header in lesbaren Speicher; dieser Bereich umfasst auch die Padding-Daten
- Wenn die ASCII-Zeichenkette in diesen Padding-Bereich verschoben wird, lassen sich weitere 13 Byte sparen
- Da
Console::WriteLinenicht mehr referenziert wird, entfällt auch die Referenz aufmscorlib- im Tabellen-Stream
#~verschwindet dadurch eine Tabelle - im Stream
#Stringsentfällt auch der Namemscorlib
- im Tabellen-Stream
- Die
VersionStringim .NET-Metadatenverzeichnis ist standardmäßig"v4.0.30319"und belegt durch Padding auf ein Vielfaches von 4 insgesamt 12 Byte - Kürzt man diesen String auf
v4.0., startet CLR 4.0 immer noch und das Programm läuft; nach dem Padding spart das weitere 4 Byte - Der abschließende
.ist offenbar nötig; kürzt man noch weiter, startet das Programm nicht mehr korrekt
Endergebnis
- Die finale Binärdatei ist ohne trailing zero bytes 834 Byte (0x342) groß
- Als ZIP komprimiert sind es 476 Byte, also weniger als die 582 Byte, die ohne weitere Optimierungen nach Erreichen der 1-KB-Grenze möglich waren
- Das Experiment begann bei einem 4,6-KB-Hello-World aus dem C#-Compiler und reduzierte es bis auf eine manuell aufgebaute PE-Datei mit 834 B
- Das fertige Programm gibt weiterhin korrekt
"Hello World"aus - Es handelt sich weniger um eine praktische Technik als um ein Experiment zur Erkundung der Minimalgrenzen des .NET-Modulformats und der CLR-Metadaten
1 Kommentare
Hacker-News-Kommentare
Solche Experimente mit minimal lauffähigen Programmen sind wirklich großartig
Michal Strehovský hat mit dem Framework, das heute als .NET bekannt ist, ein Snake-Spiel mit unter 8 KB gebaut, ohne Abhängigkeit von der .NET-Laufzeit
Medium ist nicht besonders toll und auch Twitter ist heute nicht nützlich, aber ich meine mich zu erinnern, dass das ungefähre "hello world" sogar Windows 3.11 unterstützte
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
Neuere Arbeiten und Unterstützung gibt es auf https://flattened.net: "bflat - C# as you know it but with Go-inspired tooling"
https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
Soweit ich mich erinnere, war ein erheblicher Teil der Downloadgröße die .NET-Bibliothek selbst
Ich frage mich auch, ob es ein 2D-Spiele-Framework/-Engine gibt, das den Browser anvisiert, C# verwendet und dabei kleine Binärdateien erzeugt
Dieser Beitrag kann irreführend sein. Die betreffende ausführbare Datei benötigt eine auf dem System installierte Laufzeit und verwendet tatsächlich im Jahr 2023 .NET Framework 4.7.2
Für einen fairen Vergleich mit dieser Art von Ansatz wären vielleicht eher .jar- oder .py-Dateien passend
Als Maßstab für echte ausführbare Dateien wären Native-AOT-Binärdateien, die unter Windows, Linux und macOS laufen, sinnvoller
Dann ist es aber natürlich, dass sie viel größer werden, weil auch Hilfscode wie GC, ThreadPool und Console enthalten sein muss
Die Logik zum Schreiben von Bytes auf die Konsole wird über einen P/Invoke-Aufruf in nicht verwalteten Code abgewickelt, den
ucrt.dllbereitstelltDer in einem anderen Kommentar verlinkte Beitrag zur Minimierung von Snake macht dasselbe auf Basis von .NET Core, also dem neuen .NET, das nicht mit Windows gebündelt ist, und erzeugt tatsächlich ein Nicht-.NET-Binärformat
[0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
Container-Images zu vergleichen wäre wohl ein guter Ausgangspunkt. Man sollte das kleinstmögliche Basis-Image verwenden oder, wenn möglich, "FROM scratch", aber nur innerhalb derselben Architektur vergleichen
Um Variablen wie 32 Bit auf 64 Bit ausführen oder Pointer-Komprimierung auszuschließen, wäre 32 Bit vorzuziehen
Danach könnte man die Größe des unkomprimierten exportierten tar vergleichen. Das wäre nicht völlig fair, würde aber die offensichtlichen Variablen reduzieren
Wenn man weiter darüber nachdenkt, wäre es vielleicht fairer, die maximal komprimierte Image-Größe zu vergleichen, um auch die Komprimierung innerhalb des Containers einzubeziehen. Dann müsste man natürlich denselben Algorithmus und dieselben Parameter verwenden oder, wie bei Kompressions-Benchmarks, die Größe des Dekomprimierers zum Endergebnis addieren
Man ruft es einfach so auf:
echo "hello world"Konkret dürfte .NET Framework 4.7.2 in unterstützten Windows-Versionen standardmäßig enthalten sein
Auf einer aktuellen Windows-Installation kann .NET praktisch nicht fehlen, unter Linux und macOS ist es standardmäßig nicht vorhanden; das als Fairness-Maßstab zu nehmen, wirkt seltsam
Außerdem frage ich mich, ob jemand bemerkt hat, dass der Autor sich in der McMurdo Station in der Antarktis befindet
Ich hätte nicht erwartet, dort jemanden mit so einem Tech-Stack zu finden: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
Könnte eine ziemlich interessante Geschichte sein
Was Außenstehende überrascht: Die meisten Menschen dort sind Support-Personal, Wissenschaftler machen nur einen kleinen Anteil aus, vielleicht etwa 20 %
Zum Support-Personal gehören Feuerwehrleute, Zimmerleute, Köche, Mechaniker und alle möglichen anderen Berufe, die man braucht, um eine moderne Stadt am Laufen zu halten
Vielleicht wegen des Auswahlprozesses sind fast alle extrem kompetent und in ihrem Bereich spitze. Es gab erstaunlich viele Schweizer-Taschenmesser-Typen mit breitem Skillset
Die besonderen Schwierigkeiten des Lebens und Arbeitens dort ziehen einzigartige und hochmotivierte Menschen an wie Motten ans Licht, und viele kommen Jahr für Jahr zurück. Eine großartige Gemeinschaft
Was sollte man dort sonst machen?
In den .NET-Metadaten oder in About ist das nicht zu sehen
Es hieß, die Datei werde mindestens so weit aufgebläht, weil alle Sektionen auf den minimal möglichen Section-Alignment-Wert von 0x200 Byte ausgerichtet werden müssten, aber 0x200 sind 512 Byte, also 0,5 KB
Diese Art von PE-Größenoptimierung habe ich vor langer Zeit einmal gemacht; soweit ich mich erinnere, war unter Windows 9x 512 das Minimum, während NT auch kleinere Alignments bis hinunter zu 1 akzeptieren konnte
Auch der früher moderne Trick, den DOS-MZ- und den PE-Header zu überlappen, ist nicht zu sehen: http://www.phreedom.org/research/tinype/
Und dann merkt man in der Demoszene, dass das sogar innerhalb eines 1-KB-Binärfiles geschafft wird:
https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...
Das ist ganz sicher keine dumm verbrachte Zeit
Wenn man in die Details von Binärdateien eintaucht, gewinnt man tiefe Einsichten darin, wie sie aufgebaut werden, und die Konzepte, die man dabei lernt, bereichern das eigene Wissen
Wenn es interessant ist und man etwas dabei lernt, ist es die investierte Zeit und Mühe immer wert
Das hier ist viel relevanter und interessanter: https://twitter.com/MStrehovsky/status/1669502394827419648
Dort heißt es, dass ein vollständig eigenständiges nativ kompiliertes C#-Hello-World inklusive GC und allem Drum und Dran auf etwa 440 KB schrumpfen kann
Das klassische .NET Framework ist veraltetes Legacy von Windows, das nicht mehr empfohlen wird, und neue Apps sollten nicht darauf aufbauen
Ich hatte gehofft, es ginge um eine „echte“ Binärdatei, also um ein nativ kompiliertes .NET-Executable wie bei .NET AOT
https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...
Es sieht so aus, als würde der am stärksten verkleinerte AOT-Kompilier-Build bald die 1-MB-Grenze knacken:
https://github.com/AustinWise/SmallestDotnetHelloWorlds
Glückwunsch. Du hast Unicode-Unterstützung aufgegeben und im Grunde einen C-Aufruf auf
putserzeugtWäre es nicht einfacher gewesen, das einfach mit einem C-Compiler zu kompilieren?
thats_just_c_with_extra_steps.jpgIch habe es gerade in der Oberon+ IDE ausprobiert und bekomme aus diesem Modul eine 2048-Byte-Hello.dll
module Hellobeginprintln("hello world")end HelloEs verwendet den Assembler-Codegenerator von https://github.com/rochus-keller/Pelib
Das dachte ich schon, als ich Lazarus/FreePascal zum ersten Mal benutzt habe, und je mehr ich von ihm lese, desto mehr scheint es zu stimmen
In letzter Zeit denke ich allerdings, dass WASM vielleicht ein Weg sein könnte, aus dieser „Wiedergeburt“ der Denkweise auszubrechen, aber ich kann die Hardware nicht so einfach aufgeben
Ich bin zu primitiv, um spirituelle Maschinen zu akzeptieren, die ich nicht mit einem Hammer auf meinem Schreibtisch zerschlagen kann
So etwas wie Ethereum, ein unzerstörbarer globaler Computer, der auf fremder Hardware läuft, dabei Rechenzeit im Wert von Tausenden Dollar verbraucht und langsamer ist als ein RPi, fühlt sich für mich auf grundlegende Weise tabu an
Ich will immer noch „Wirth was right“ zusammen mit Wirths Gesicht auf ein T-Shirt drucken
Da hängt zu viel Ballast aus dem C++-Ökosystem dran
Das erinnert mich an Open-Source-Software-Hosting-Forges, die als Alternative zu GitHub und GitLab beworben werden. Wenn man dann tatsächlich den Code klonen oder einen Bug melden will, liegt am Ende doch alles auf GitHub
Vermutlich gibt es eine Option, das abzuschalten
Dass sich eine
.exeauf unter 1 KB verkleinern lässt, war wirklich augenöffnendEs wäre ziemlich beeindruckend, wenn es jemand schaffen würde, die Hello-World-
.exevon Flutter auf 1 MB zu reduzieren"Hello world"kompiliert