1 ポイント 投稿者 GN⁺ 5 시간 전 | 1件のコメント | WhatsAppで共有
  • GCCだけで生成した ./a.out バイナリのサイズを削る実験は、実行成功・終了コード 0後処理禁止 という条件から始まる
  • 基本の int main(){ return 0; } は15,816バイトで、-s により デバッグ情報 を削除して14,352バイトまで減少した
  • -nostartfilesmain 前の起動コードを迂回し、-nostdlib -static -no-pie と直接 SYS_exit システムコールを使って 動的リンク ベースの構造を取り除いた
  • .comment.eh_frame.note.gnu.property をそれぞれ -fno-ident-fno-exceptions -fno-asynchronous-unwind-tables-Wa,-mx86-used-note=no で削除し、セクションのオーバーヘッド を減らした
  • -Wl,--nmagic で 0x1000 アラインメントのパディングを削減した最終バイナリは 400バイト で、objcopy のような後処理は対象外である

目標と基本条件

  • 目標は、可能な限り小さいサイズの ./a.out バイナリを生成すること
  • プログラムの条件は3つある
    • ./a.out が正常に実行されること
    • $? が確定的に 0 であること
    • バイナリは GCC だけで生成し、objcopy、hexエディタ、手動パッチのような後処理は禁止であること
  • 出発点は最も単純なプログラム
// compiled with gcc empty.c
int main() {
return 0;
}
  • この基本プログラムのファイルサイズは stat 基準で15,816バイトで、何もしないバイナリを収めるのに Apollo guidance computer のRAM4個分が必要だという比較が使われている
  • file a.out の出力には、ELF 64-bit LSB pie executabledynamically linked、インタープリタのパス、not stripped 状態が表示される
  • not stripped 状態を小さくするために GCC の -s フラグを使うと、デバッグ情報を保持せずにコンパイルされ、サイズは14,352バイトに減少する
広告

起動コードの迂回と動的リンクの除去

  • ./a.out の実行から int main() に到達するまでには多くの処理があり、この話題は Matt GodboltのCppConでの1時間講演 でも扱われている
  • -nostartfiles_start() を使い、int main() より前の処理を飛ばす freestanding バイナリ構成に変更する
// compiled with gcc empty.c -s -nostartfiles
#include <cstdlib>
extern "C" __attribute((noreturn)) void _start() { exit(0); }
  • この変更後のサイズは13,632バイトで、減少幅はそれほど大きくない
  • objdump -x a.out の出力には、動的セクションとともに NEEDED libc.so.6、インタープリタのパス、動的シンボルテーブル、再配置メタデータ、PLT/GOT 構造、共有ライブラリ参照が含まれている
  • プログラムの目的は即時終了だけなので、3つのフラグで大きな構成要素を取り除く
    • -nostdlib: 標準ライブラリをリンクしない
    • -static: 動的リンク構造を回避する
    • -no-pie: 位置独立実行ファイルではなく固定アドレス実行ファイルを生成する
// 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();
}
  • SYS_exit システムコールを直接呼ぶ方式に変えた後のサイズは8,704バイト
広告

残ったセクションの削除

  • objdump -D a.out の出力には .note.gnu.property.text.eh_frame.comment のようなセクションが残っている
  • .comment セクションはバイナリを生成したコンパイラ情報を格納しており、このケースでは GCC: (GNU) 15.2.0 という文字列を含む
    • objdump はこのデータをアセンブリとして解釈し、奇妙な命令のように表示する
    • -fno-ident を追加すると .comment セクションが削除され、サイズは8,616バイトに減少する
  • .eh_frame セクションはスタックアンワインドに使われ、何もしないプログラムではエラー処理用として不要
    • -fno-exceptions -fno-asynchronous-unwind-tables を使うとサイズは4KB台まで減る
  • 最後に削除する対象は .note.gnu.property セクション
    • readelf -n a.outx86 feature used: x86x86 ISA used: x86-64-baseline の属性を表示する
    • GNU はほかのツールが読めるようにこのセクションへノートを残し、この場合はアセンブラがそのノートを追加している
    • -Wa,-mx86-used-note=no を追加するとサイズは4,320バイトになる
  • この時点での objdump -D a.out.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
広告

アラインメントのパディングと400バイト構成

  • 4,320バイトの状態での readelf -a a.out の出力には、ELFヘッダ、プログラムヘッダ3個、セクションヘッダ3個、.text.shstrtab の構造が示される
  • プログラムヘッダは、OSローダがプログラム開始時にファイルをメモリセグメントへマップする方法を示すテーブル
  • その出力中の LOAD 232バイトは、64バイトの ELF ヘッダと56バイトのプログラムヘッダ3個に対応する
  • LOAD エントリのアラインメント要件が 0x1000 のため、リンカは .text をパディングの後ろに配置する
  • -Wl,--nmagic でリンカにこの前提を置かないよう指示すると、ELFメタデータと .text セクションを一緒にマップでき、LOAD は1つだけになってサイズは400バイトまで減少する
  • 400バイトバイナリの構成は次の通り
構成 サイズ
ELF header 64 B
Program header: PT_LOAD 56 B
Program header: PT_GNU_STACK 56 B
.text section contents 11 B
.shstrtab section contents, "\0.shstrtab\0.text\0" 17 B
section header用 padding 4 B
Section header [0]: NULL 64 B
Section header [1]: .text 64 B
Section header [2]: .shstrtab 64 B
  • PT_LOAD は命令をロードするために必要で、PT_GNU_STACK は GCC が常に生成する
  • .shstrtab は GCC だけでは削除できない
  • 最初のセクションヘッダエントリは System V ABI ELF specification により、値0の未定義セクションインデックス SHN_UNDEF 用に予約されていることが要求される
  • 実際、このエントリは SHT_NULL 型なので、ツールでは NULL セクションとして表示される
  • objcopy のようなツールを使えば一部の項目はさらに削れるが、その方法は対象外である

段階ごとのサイズと最終コード

段階 フラグ / 変更 サイズ
通常の main gcc empty.c 15,816バイト
シンボル削除 -s 14,352バイト
Freestanding -nostartfiles 13,632バイト
libc除去 / 静的リンク / no PIE -nostdlib -static -no-pie 8,704バイト
.comment セクション削除 -fno-ident 8,616バイト
アンワインド情報削除 -fno-asynchronous-unwind-tables -fno-exceptions 4,400バイト
GNU property note削除 -Wa,-mx86-used-note=no 4,320バイト
アラインメント縮小 -Wl,--nmagic / -Wl,-n 400バイト
  • 最終的なコンパイルコマンドとコードは次の通り
// 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();
}
  • objdumpld を初めて使った実践であり、-fno-asynchronous-unwind-tables -fno-exceptions は、エラー時のスタックアンワインド処理が不要だと GCC に伝えるためのもの
  • ld には --no-eh-frame-hdr フラグもある
  • reddit では124バイトまで縮めた事例もある

1件のコメント

 
GN⁺ 5 시간 전
Lobste.rs のコメント
  • どうせアセンブリしか使わないなら、なぜ C コンパイラを使うのか分からない

    • 単なる遊びの実験です :)

    • アセンブリは出発点としてとても良いです。ここでコンパイルした 231 バイトの hello world バイナリがあります:
      https://github.com/Cons-Cat/libCat/blob/main/examples%2Fhello.cpp

      数年前に似たようなチュートリアルから始めて、その後はコードをよりうまく分離しつつ、単純なケースのオーバーヘッドをできるだけ低く保ったまま周辺技術を段階的に積み上げてきました。231 バイトを維持するのが重要なので、それを保証する CI テストまで置いています

      修正: 不要な include を 1 つ残していたのに今気づきました。直さないと

    • 同意します。それでも C 専用のトリックはいろいろありますし、少しのアセンブリがなければ全体像は完成しなかった気がします

  • 関連リンク: https://www.muppetlabs.com/~breadbox/software/tiny/

    • 実際ここには 45 バイトのバイナリがあります。極端に言えば、db の列挙だけでアセンブリにエンコードし、gcc で再び 45 バイトの「生」ファイルとしてアセンブルさせることもできそうです
      偶然 ELF になるでしょうが、gcc がそれを知る必要はありません。これなら元記事のルールを満たすかもしれません

      ただし、たいていの合理的な定義では、もはや C バイナリと呼ぶのは難しくなります

  • 答えは コンパイラ次第になりそうです。ただ、一部の C コンパイラが受け付けるからといって、C ではないコードに依存することを認めていいのかはよく分かりません 😉

  • exit(3) を呼ぶ C++ プログラムと SYS_exit アセンブラ呼び出しの間には中間段階があります。マニュアルのセクション番号からも分かるように、exit(3)ライブラリ関数なので、atexit(3) の仕組みなど多くの libc を引き込みます
    生の exit システムコールを呼ぶ標準的な方法は _exit(2) で、それを _start() に入れて静的リンクすれば、かなり小さな結果になるはずです。C++ の代わりに C で書けば、コンパイラ呼び出しとソースコードのサイズも減らせます

    • まさにそのように試してみました

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

      gcc -Os -nostdlib -static -o x x.c -lc でコンパイルしたところ、strip 済み実行ファイルのサイズは 8912 バイトでしたが、実際に生成されたコードは 96 バイトしかありませんでした。これは _Exit() 用の汎用 syscall() 関数が含まれていたためです