1 Punkte von GN⁺ 5 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Das Experiment zur Verkleinerung des nur mit GCC erzeugten Binärprogramms ./a.out begann unter den Bedingungen: erfolgreiche Ausführung, Exit-Code 0 und keine Nachbearbeitung
  • Das einfache int main(){ return 0; } war 15.816 Byte groß; mit -s wurde die Debug-Information entfernt und die Größe auf 14.352 Byte reduziert
  • Mit -nostartfiles wurde der Startcode vor main umgangen, und mit -nostdlib -static -no-pie sowie einem direkten SYS_exit-Systemaufruf wurde die auf dynamischem Linking basierende Struktur entfernt
  • .comment, .eh_frame und .note.gnu.property wurden jeweils mit -fno-ident, -fno-exceptions -fno-asynchronous-unwind-tables und -Wa,-mx86-used-note=no entfernt, um den Section-Overhead zu verringern
  • Das finale Binärprogramm mit reduziertem 0x1000-Ausrichtungs-Padding durch -Wl,--nmagic ist 400 Byte groß; Nachbearbeitung mit objcopy und Ähnlichem liegt außerhalb des Rahmens

Ziel und Grundbedingungen

  • Ziel ist es, ein möglichst kleines ./a.out-Binärprogramm zu erzeugen
  • Für das Programm gelten drei Bedingungen
    • ./a.out muss erfolgreich ausgeführt werden
    • $? muss deterministisch 0 sein
    • Das Binärprogramm darf nur mit GCC erzeugt werden; Nachbearbeitung wie objcopy, Hex-Editor oder manuelle Patches ist verboten
  • Ausgangspunkt ist das denkbar einfachste Programm
// compiled with gcc empty.c
int main() {
return 0;
}
  • Die Dateigröße dieses Basisprogramms beträgt laut stat 15.816 Byte; zum Vergleich wird erwähnt, dass dafür vier RAM-Einheiten des Apollo Guidance Computer nötig wären, um ein Binärprogramm unterzubringen, das nichts tut
  • Die Ausgabe von file a.out zeigt ELF 64-bit LSB pie executable, dynamically linked, den Interpreter-Pfad und den Status not stripped
  • Um den Status not stripped zu ändern, kann das GCC-Flag -s verwendet werden; dann wird ohne Debug-Information kompiliert, und die Größe sinkt auf 14.352 Byte
Anzeige

Startcode umgehen und dynamisches Linking entfernen

  • Zwischen dem Start von ./a.out und dem Erreichen von int main() passiert eine Menge; dieses Thema wird auch in Matt Godbolts einstündigem CppCon-Vortrag behandelt
  • Mit -nostartfiles und _start() wird die Konfiguration auf ein freestanding-Binärprogramm umgestellt, das den Ablauf vor int main() überspringt
// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
  • Nach dieser Änderung beträgt die Größe 13.632 Byte; die Verringerung ist also nicht besonders groß
  • Die Ausgabe von objdump -x a.out zeigt zusammen mit der dynamischen Section NEEDED libc.so.6, den Interpreter-Pfad, die dynamische Symboltabelle, Relokations-Metadaten, die PLT/GOT-Struktur und Verweise auf Shared Libraries
  • Da das Ziel des Programms nur der sofortige Exit ist, werden mit drei Flags große Bestandteile entfernt
    • -nostdlib: Standardbibliotheken nicht linken
    • -static: Struktur für dynamisches Linking vermeiden
    • -no-pie: ein ausführbares Programm mit fester Adresse statt eines position-unabhängigen Executables erzeugen
// compiled with gcc -static -nostdlib -no-pie -s empty.c
extern "C" __attribute__((noreturn)) void _start() {
__asm__ volatile(
"mov $60, %%eax\n" // SYS_exit
"xor %%edi, %%edi\n" // exit status 0
"syscall\n" ::
: "rax", "rdi");
__builtin_unreachable();
}
  • Nach der Umstellung auf einen direkten SYS_exit-Systemaufruf beträgt die Größe 8.704 Byte
Anzeige

Verbleibende Sections entfernen

  • In der Ausgabe von objdump -D a.out bleiben Sections wie .note.gnu.property, .text, .eh_frame und .comment übrig
  • Die Section .comment speichert Informationen über den Compiler, der das Binärprogramm erzeugt hat, und enthält in diesem Fall den String GCC: (GNU) 15.2.0
    • objdump interpretiert diese Daten als Assembler und zeigt sie als seltsame Instruktionen an
    • Fügt man -fno-ident hinzu, wird die Section .comment entfernt und die Größe sinkt auf 8.616 Byte
  • Die Section .eh_frame wird für Stack-Unwinding verwendet und ist für ein Programm, das nichts tut, nicht für Error-Handling nötig
    • Mit -fno-exceptions -fno-asynchronous-unwind-tables sinkt die Größe in den 4-KB-Bereich
  • Als Letztes bleibt die Section .note.gnu.property, die entfernt werden kann
    • readelf -n a.out zeigt Eigenschaften wie x86 feature used: x86 und x86 ISA used: x86-64-baseline
    • GNU hinterlässt in dieser Section Notes, damit andere Tools sie lesen können; in diesem Fall fügt der Assembler die Note hinzu
    • Mit -Wa,-mx86-used-note=no beträgt die Größe 4.320 Byte
  • An diesem Punkt zeigt objdump -D a.out nur noch Instruktionen aus der Section .text
401000: 55 push %rbp
401001: 48 89 e5 mov %rsp,%rbp
401004: b8 3c 00 00 00 mov $0x3c,%eax
401009: 31 ff xor %edi,%edi
40100b: 0f 05 syscall
Anzeige

Ausrichtungs-Padding und die 400-Byte-Struktur

  • Die Ausgabe von readelf -a a.out im Zustand mit 4.320 Byte zeigt den ELF-Header, drei Program Header, drei Section Header sowie die Struktur von .text und .shstrtab
  • Die Program Header sind Tabellen, die dem OS-Loader mitteilen, wie beim Programmstart Dateiinhalte in Speichersegmente gemappt werden sollen
  • Die 232 Byte des betreffenden LOAD entsprechen dem 64 Byte großen ELF-Header und drei Program Headern zu je 56 Byte
  • Die Ausrichtungsanforderung des LOAD-Eintrags ist 0x1000, weshalb der Linker .text nach Padding platziert
  • Übergibt man dem Linker -Wl,--nmagic, muss er diese Annahme nicht mehr treffen; so können ELF-Metadaten und die Section .text gemeinsam gemappt werden, es bleibt nur noch ein LOAD übrig und die Größe sinkt auf 400 Byte
  • Der Aufbau des 400-Byte-Binärprogramms ist wie folgt
Aufbau Größe
ELF header 64 B
Program header: PT_LOAD 56 B
Program header: PT_GNU_STACK 56 B
Inhalt der Section .text 11 B
Inhalt der Section .shstrtab, "\0.shstrtab\0.text\0" 17 B
Padding für Section Header 4 B
Section header [0]: NULL 64 B
Section header [1]: .text 64 B
Section header [2]: .shstrtab 64 B
  • PT_LOAD ist nötig, um die Instruktionen zu laden, und PT_GNU_STACK wird von GCC immer erzeugt
  • .shstrtab lässt sich nicht allein mit GCC entfernen
  • Der erste Section-Header-Eintrag muss laut System V ABI ELF specification für den undefinierten Section-Index SHN_UNDEF mit Wert 0 reserviert sein
  • Tatsächlich ist dieser Eintrag vom Typ SHT_NULL, weshalb Tools ihn als Section NULL anzeigen
  • Tools wie objcopy könnten einige Einträge noch weiter entfernen, aber das liegt außerhalb des Rahmens

Größen pro Schritt und finaler Code

Schritt Flag / Änderung Größe
Normales main gcc empty.c 15.816 Byte
Symbole entfernen -s 14.352 Byte
Freestanding -nostartfiles 13.632 Byte
libc entfernen / statisch linken / no PIE -nostdlib -static -no-pie 8.704 Byte
Section .comment entfernen -fno-ident 8.616 Byte
Unwind-Informationen entfernen -fno-asynchronous-unwind-tables -fno-exceptions 4.400 Byte
GNU-Property-Note entfernen -Wa,-mx86-used-note=no 4.320 Byte
Ausrichtung verkleinern -Wl,--nmagic / -Wl,-n 400 Byte
  • Der finale Compile-Befehl und der Code sehen wie folgt aus
// gcc -Wl,--nmagic -Wa,-mx86-used-note=no -static -nostdlib -no-pie -s -fno-ident -fno-exceptions -fno-asynchronous-unwind-tables empty.c
extern "C" __attribute__((noreturn)) void _start() {
__asm__ volatile(
"mov $60, %%eax\n" // SYS_exit
"xor %%edi, %%edi\n" // exit status 0
"syscall\n" ::
: "rax", "rdi");
__builtin_unreachable();
}
  • Es war eine erste praktische Übung mit objdump und ld; -fno-asynchronous-unwind-tables -fno-exceptions teilt GCC mit, dass im Fehlerfall kein Stack-Unwinding benötigt wird
  • Für ld gibt es außerdem das Flag --no-eh-frame-hdr
  • Auf reddit gibt es ein Beispiel, das auf 124 Byte reduziert wurde

1 Kommentare

 
GN⁺ 5 시간 전
Lobste.rs-Kommentare
  • Wenn man sowieso nur Assembler verwenden will, verstehe ich nicht, warum man einen C-Compiler benutzt

    • Ist einfach ein Experiment aus Spaß :)

    • Assembler ist ein sehr guter Ausgangspunkt. Ich habe von hier aus ein kompiliertes Hello-World-Binary mit 231 Byte:
      https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp

      Ich habe vor ein paar Jahren mit einem ähnlichen Tutorial angefangen und danach nach und nach umgebende Techniken darauf aufgebaut, während ich den Overhead in einfachen Fällen so gering wie möglich gehalten habe, indem ich den Code besser getrennt habe. Die 231 Byte beizubehalten ist wichtig, deshalb gibt es sogar einen CI-Test, der das garantiert.

      Edit: Ich habe gerade gesehen, dass ich ein unnötiges Include dringelassen habe. Muss ich korrigieren.

    • Stimme zu. Trotzdem gibt es einige C-spezifische Tricks, und ohne ein wenig Assembler wäre das Gesamtbild wohl nicht vollständig gewesen.

  • Verwandter Link: https://www.muppetlabs.com/~breadbox/software/tiny/

    • Dort gibt es tatsächlich ein 45-Byte-Binary. Im Extremfall könnte man es wohl direkt in Assembler kodieren, nur durch Auflisten von db, und dann gcc dazu bringen, es wieder in eine 45-Byte-„Rohdatei“ zu assemblieren.
      Zufällig wäre es dann ELF, aber gcc müsste das nicht wissen. Damit würden vielleicht die Regeln des Originaltexts erfüllt.

      Nach den meisten vernünftigen Definitionen wäre es dann allerdings schwer, das noch ein C-Binary zu nennen.

  • Die Antwort hängt wohl vom Compiler ab. Ich bin mir aber nicht sicher, ob man es gelten lassen kann, sich auf nicht-C-Code zu stützen, nur weil einige C-Compiler ihn akzeptieren 😉

  • Zwischen einem C++-Programm, das exit(3) aufruft, und einem Assembler-Aufruf von SYS_exit gibt es eine Zwischenstufe. Wie die Abschnittsnummer im Handbuch schon sagt, ist exit(3) eine Bibliotheksfunktion und zieht daher viel libc mit hinein, etwa den atexit(3)-Mechanismus.
    Die standardmäßige Art, den rohen Exit-Systemaufruf zu verwenden, ist _exit(2), und wenn man das in _start() setzt und statisch linkt, sollte ein ziemlich kleines Ergebnis herauskommen. Wenn man es statt in C++ in C schreibt, lassen sich außerdem der Compileraufruf und die Größe des Quellcodes reduzieren.

    • Genau das habe ich ausprobiert.

      #include <stdlib.h>
      void _start(void)
      {
      _Exit(0); /* C99 function to call SYS_exit() */
      }

      Kompiliert mit gcc -Os -nostdlib -static -o x x.c -lc; die gestrippte ausführbare Datei war 8912 Byte groß, aber der tatsächlich erzeugte Code umfasste nur 96 Byte. Der Grund ist, dass die allgemeine syscall()-Funktion für _Exit() mit eingebunden wurde.