1 Punkte von GN⁺ 2023-07-10 | 1 Kommentare | Auf WhatsApp teilen

-.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.WriteLine wird per P/Invoke ucrtbase!puts aufgerufen, 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-mscorlib oder 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 .csproj mit <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 Klasse Program
  • 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 main im Typ <Module> ab und enthält nur einen CIL-Body mit Ldstr "Hello World", Console.WriteLine und Ret
  • 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 .text und .reloc; die Datenverzeichnisse Imports und Base Relocations belegen dabei viel Platz
  • Bei AnyCPU- oder 32-Bit-.NET-Executables muss der nicht verwaltete Einstiegspunkt mscoree!_CorExeMain aufrufen, 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 0x200 Byte 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 .reloc mit folgenden Einstellungen entfernen
    • module.PEKind = OptionalHeaderMagic.PE64
    • module.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 #Strings ist 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 null setzen, ohne das Programm zu beeinträchtigen; das spart 32 Byte
  • Der Stream #GUID enthält die GUID, auf die Mvid in der Module-Tabelle verweist; in diesem Experiment wird sie zur Versionsidentifikation nicht benötigt und deshalb entfernt
    • die 16 Byte für Mvid werden entfernt
    • der komplette leere #GUID-Stream samt 16-Byte-Header entfällt
    • insgesamt spart das 32 Byte
  • Für den Aufruf von Console::WriteLine wird normalerweise auf mscorlib mit 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 0xFF und 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::WriteLine wird per P/Invoke die nicht verwaltete Funktion ucrtbase!puts aufgerufen
    • Die Deklaration in C#-Form lautet [DllImport("ucrtbase")] static extern int puts(nint str);
    • puts erwartet als Argument einen Zeiger auf eine nullterminierte ASCII-Zeichenkette
  • Um die Laufzeitadresse der Zeichenkette bereits beim Kompilieren zu kennen, wird im PE-Optional-Header das Flag DynamicBase in DllCharacteristics deaktiviert
    • Es wird eine feste Image-Base gesetzt
    • Die ASCII-Zeichenkette wird im Abschnitt .text platziert
    • Die Laufzeitadresse wird als module_base_address + rva_ascii_string berechnet
  • 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 auf System.Console::WriteLine; stattdessen kommen nur die Definition von puts und die P/Invoke-Metadaten hinzu
  • Das Ergebnis ist 889 Byte (0x379) groß

Letzte Mikro-Optimierungen

  • Die Standarddefinition von puts liefert die Anzahl der ausgegebenen Zeichen als int32 zurü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 void geändert wird, kann im main-Methodenrumpf die pop-Instruktion zum Verwerfen des Rückgabewerts entfallen
  • Da der PE-Header kleiner als 0x200 ist, 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::WriteLine nicht mehr referenziert wird, entfällt auch die Referenz auf mscorlib
    • im Tabellen-Stream #~ verschwindet dadurch eine Tabelle
    • im Stream #Strings entfällt auch der Name mscorlib
  • Die VersionString im .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

 
GN⁺ 2023-07-10
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"

    • Um bei dem Thema "Go-inspired" C# zu bleiben: In .NET gibt es System.Threading.Channels
      https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
    • Wenn C#-Apps so klein werden könnten, dass Nutzer die Downloadzeit als sofortig empfinden, könnte das vielleicht dazu beitragen, C# WebAssembly realistischer zu machen
      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
    • Dank Michal und dem CoreRT-Team habe ich mir C# noch einmal angesehen, und ohne NativeAOT heute würde ich C# wohl nie in Betracht ziehen, besonders wenn es Go gibt
  • 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

    • Nicht unbedingt. Das tatsächliche ausgegebene exe verwendet das .NET Framework faktisch gar nicht, außer für den main-Aufruf
      Die Logik zum Schreiben von Bytes auf die Konsole wird über einen P/Invoke-Aufruf in nicht verwalteten Code abgewickelt, den ucrt.dll bereitstellt
      Der 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...
    • Es ist auch ziemlich unfair, ein vollständig statisch gelinktes natives Binärformat mit einem Binärformat zu vergleichen, das einige oder alle Bibliotheken dynamisch lädt
      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
    • Mein Hello World benötigt das "bash"-Framework und ist 0 Byte groß
      Man ruft es einfach so auf: echo "hello world"
    • .NET Framework ist in Windows eingebaut und war zumindest seit Vista eine Voraussetzung
      Konkret dürfte .NET Framework 4.7.2 in unterstützten Windows-Versionen standardmäßig enthalten sein
    • Ich bin mir nicht sicher, ob uns "Fairness" oder "Realität" wichtig sein sollte
      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

    • Ich war einmal auf einer Forschungsreise in McMurdo
      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
    • In diesem Fall verstehe ich die Aussage „einen Samstag dumm verbracht“ nicht
      Was sollte man dort sonst machen?
    • https://brr.fyi/ ist sehenswert
    • Ich frage mich, wie du das herausgefunden hast
      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

    • Die resultierende exe hängt nur von Implementierungsdetails der tieferliegenden ucrt.dll-Bibliothek ab, die über unmanaged pinvoke-Aufrufe offengelegt werden, „verwendet“ das Framework aber nicht wirklich
    • Eine Ausnahme könnten Apps sein, die von Office-/COM-APIs abhängen
  • 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...

  • Glückwunsch. Du hast Unicode-Unterstützung aufgegeben und im Grunde einen C-Aufruf auf puts erzeugt
    Wäre es nicht einfacher gewesen, das einfach mit einem C-Compiler zu kompilieren?
    thats_just_c_with_extra_steps.jpg

    • Das Kompilieren praktisch ganz zu überspringen, nimmt dem Ganzen etwas den Reiz
  • Ich habe es gerade in der Oberon+ IDE ausprobiert und bekomme aus diesem Modul eine 2048-Byte-Hello.dll
    module Hello
    begin
    println("hello world")
    end Hello
    Es verwendet den Assembler-Codegenerator von https://github.com/rochus-keller/Pelib

    • Nicklaus Wirth hatte recht
      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
    • Bei Oberon+ hätte ich erwartet, dass es self-hosting ist
      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
    • Ich frage mich, ob das Dateiende mit Nullbytes aufgefüllt wird
      Vermutlich gibt es eine Option, das abzuschalten
  • Dass sich eine .exe auf unter 1 KB verkleinern lässt, war wirklich augenöffnend
    Es wäre ziemlich beeindruckend, wenn es jemand schaffen würde, die Hello-World-.exe von Flutter auf 1 MB zu reduzieren

    • Der erste Schritt wäre wohl, Microsoft davon zu überzeugen, Flutter in Windows aufzunehmen
    • Ich frage mich, ob es zählt, wenn man mit Dart nur "Hello world" kompiliert