- GCCだけで生成した
./a.out バイナリのサイズを削る実験は、実行成功・終了コード 0・後処理禁止 という条件から始まる
- 基本の
int main(){ return 0; } は15,816バイトで、-s により デバッグ情報 を削除して14,352バイトまで減少した
-nostartfiles で main 前の起動コードを迂回し、-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 executable、dynamically linked、インタープリタのパス、not stripped 状態が表示される
not stripped 状態を小さくするために GCC の -s フラグを使うと、デバッグ情報を保持せずにコンパイルされ、サイズは14,352バイトに減少する
起動コードの迂回と動的リンクの除去
// 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.out は x86 feature used: x86、x86 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();
}
objdump と ld を初めて使った実践であり、-fno-asynchronous-unwind-tables -fno-exceptions は、エラー時のスタックアンワインド処理が不要だと GCC に伝えるためのもの
ld には --no-eh-frame-hdr フラグもある
- reddit では124バイトまで縮めた事例もある
1件のコメント
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()関数が含まれていたためです