プログラミング
Linux向け超小型ELF実行ファイルの作成(あるいは、「サイズこそ全て」)
Creating Teensy ELF Executables for Linux (Or, "Size Is Everything") (muppetlabs.com)
要約
この記事は、Linux向けのELF実行ファイルのサイズを極限まで小さくするための技術を探求します。C言語から始まり、アセンブリ言語、そして標準ライブラリの起動・終了処理をバイパスし、直接システムコールを利用する手法へと進むことで、実行ファイルのサイズを劇的に削減する方法を解説しています。プログラマーがソフトウェアの肥大化に不満を感じている場合に、その解決策となりうる興味深い内容です。
全文翻訳
Linux向け超小型ELF実行ファイルの作成(あるいは、「サイズこそ全て」)
彼女はそれを約15分間注意深く調べた。ついに彼女は口を開いた。「ここに何かが書かれているわ」と彼女は眉をひそめて言った。「でも、それは本当に小さいの。」 [デイヴ・バリー、「コラムニストの悪夢」]
もしあなたがソフトウェアの肥大化にうんざりしているプログラマーなら、ここに完璧な解毒剤を見つけることができるだろう。この文書は、単純なプログラムから余分なバイトを絞り出す方法を探求する。(もちろん、この文書のより実用的な目的は、ELFファイルフォーマットとLinuxオペレーティングシステムの内部構造のいくつかを描写することにある。しかし、その過程で本当に超小型のELF実行ファイルを作成する方法についても何か学べることを願っている。)
ここに記載されている情報と例は、ほとんどの場合、Intel x86アーキテクチャで動作するLinuxプラットフォーム上のELF実行ファイルに固有のものであることに注意してほしい。ELFベースの他のUnix系システムにも多くの情報が適用可能だと想像できるが、私の経験は限られているため、確実なことは言えない。また、アセンブリコードに少しでも馴染みがない場合、この文書の一部を理解するのが難しいかもしれない。(この文書に登場するアセンブリコードはNasmを使用している。http://www.nasm.us/を参照のこと。)
始めるにあたり、プログラムが必要だ。ほとんどどんなプログラムでも良いが、プログラムが何をするかよりも、実行ファイルをどれだけ小さくできるかに興味があるので、プログラムは単純であるほど良い。ここでは、オペレーティングシステムに数値を返すだけの、信じられないほど単純なプログラムを取り上げよう。なぜダメなのか?結局のところ、Unixにはすでにtrueとfalseという2つのプログラムがある。0と1はすでに使われているので、数値42を使おう。では、最初のバージョンを見てみよう。
/* tiny.c */
int main(void) {
return 42;
}
これをコンパイルしてテストすると、次のようになる。
$ gcc -Wall tiny.c
$ ./a.out ; echo $?
42
さて。どれくらいの大きさか?私のマシンでは、次のようになる。
$ wc -c a.out
3998 a.out
(あなたのマシンでは多少異なるだろう。)
確かに、今日の基準ではかなり小さいが、おそらく必要以上に大きいだろう。明白な最初のステップは、実行ファイルをストリップすることだ。
$ gcc -Wall -s tiny.c
$ ./a.out ; echo $?
42
$ wc -c a.out
2632 a.out
確かに改善された。次のステップとして、最適化はどうだろうか?
$ gcc -Wall -s -O3 tiny.c
$ wc -c a.out
2616 a.out
これも役立ったが、わずかだ。もっとも、最適化する要素はほとんどないので、これは理にかなっている。1行のCプログラムでこれ以上できることはあまりなさそうだ。
C言語は諦めて、アセンブラを使う必要があるだろう。これにより、Cプログラムが自動的に発生させるすべての追加オーバーヘッドが排除されることを期待しよう。
では、2番目のバージョンに進もう。main()から42を返すだけでよい。アセンブリ言語では、これはアキュムレータeaxに42を設定し、リターンすることを意味する。
; tiny.asm
BITS 32
GLOBAL main
SECTION .text
main:
mov eax, 42
ret
これをビルドしてテストしよう。
$ nasm -f elf tiny.asm
$ gcc -Wall -s tiny.o
$ ./a.out ; echo $?
42
(アセンブリコードは難しいと言うのは誰だ?)
そして、今どれくらいの大きさか?
$ wc -c a.out
2604 a.out
わずか12バイト削減されたようだ。Cが自動的に発生させるすべての追加オーバーヘッドは、これで終わりか?いや、main()インターフェースを使用することで、まだ多くのオーバーヘッドが発生している。リンカはまだOSへのインターフェースを追加しており、それが実際にmain()を呼び出しているのだ。では、必要ない場合はどうすれば回避できるだろうか?
リンカがデフォルトで使用する実際のエントリーポイントは、_startという名前のシンボルだ。gccでリンクすると、argcとargvを設定し、main()を呼び出す_startルーチンが自動的に含まれる。
では、これをバイパスして、独自の_startルーチンを定義できるか見てみよう。
; tiny.asm
BITS 32
GLOBAL _start
SECTION .text
_start:
mov eax, 42
ret
gccは期待通りに動作するだろうか?
$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostartfiles tiny.o
tiny.o(.text+0x0): _startに対する複数定義です
/usr/lib/crt1.o(.text+0x0): 最初定義されています
/usr/lib/crt1.o(.text+0x36): mainに対する未定義参照です
いや。実際には、そうするだろうが、まず要求する方法を学ぶ必要がある。gccはドキュメントで-nostartfilesというオプションを認識する。
gccのinfopagesより:
-nostartfiles 標準システム起動ファイルを使用せずにリンクする。標準ライブラリは通常通り使用される。
なるほど!では、何ができるか見てみよう。
$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostartfiles tiny.o
$ ./a.out ; echo $?
セグメンテーション違反 139
gccは文句を言わなかったが、プログラムは動作しない。何が間違っていたのか?
間違っていたのは、_startをC関数のように扱い、そこからリターンしようとしたことだ。実際には、それは関数ではない。リンカがプログラムのエントリーポイントを見つけるために使用するオブジェクトファイル内の単なるシンボルだ。プログラムが呼び出されるとき、それは直接呼び出される。
もし見れば、スタックのトップの値が1であることがわかるだろう。これはアドレスとはほど遠い。実際、スタック上にあるのはプログラムのargcの値だ。その後にargv配列の要素(終端のNULL要素を含む)、そしてenvpの要素が続く。それだけだ。スタックにはリターンアドレスがない。
では、_startはどうやって終了するのだろうか?それはexit()関数を呼び出すのだ!結局のところ、それがあるのはそのためだ。実際には、嘘をついた。実際には、_exit()関数を呼び出す。(先頭のアンダースコアに注意。)exit()はプロセスのためにいくつかのタスクを完了する必要があるが、それらのタスクはライブラリの起動コードをバイパスしているため、決して開始されない。したがって、ライブラリのシャットダウンコードもバイパスし、オペレーティングシステムのシャットダウン処理に直接移行する必要がある。
では、もう一度試してみよう。_exit()を呼び出す。これは単一の整数引数を受け取る関数だ。だから、スタックに数値をプッシュして関数を呼び出すだけでよい。(_exit()を外部として宣言する必要もある。)
これがアセンブリコードだ。
; tiny.asm
BITS 32
EXTERN _exit
GLOBAL _start
SECTION .text
_start:
push dword 42
call _exit
そして、以前と同じようにビルドしてテストする。
$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostartfiles tiny.o
$ ./a.out ; echo $?
42
ついに成功した!そして、今どれくらいの大きさか?
$ wc -c a.out
1340 a.out
ほぼ半分のサイズだ!悪くない。まったく悪くない。
うーん…gccには他にどんな興味深い、あまり知られていないオプションがあるのだろうか?さて、ドキュメントで-nostartfilesの直後にあるこれは、確かに目を引く。
-nostdlib 標準システムライブラリと起動ファイルを使用せずにリンクする。指定したファイルのみがリンカに渡される。
これは調査する価値があるはずだ。
$ gcc -Wall -s -nostdlib tiny.o
tiny.o(.text+0x6): _exitに対する未定義参照です
おっと。そうだ…結局、_exit()はライブラリ関数だ。どこかから埋められる必要がある。
しかし、プログラムを終了するためだけにlibcの助けが必要なわけではないだろう?いや、必要ない。
移植性のすべての見せかけを捨て去ることを厭わないなら、何もリンクせずにプログラムを終了させることができる。まず、Linuxでシステムコールを行う方法を知る必要がある。
Linuxは、ほとんどのオペレーティングシステムと同様に、システムコールを通じてホストするプログラムに基本的な機能を提供する。これには、ファイルのオープン、ファイルハンドルへの読み書き、そしてもちろんプロセスのシャットダウンなどが含まれる。
Linuxのシステムコールインターフェースは単一の命令だ:int 0x80。すべてのシステムコールはこの割り込みを通じて行われる。システムコールを行うには、eaxに呼び出されるシステムコールの番号が含まれ、他のレジスタは引数を保持するために使用される。システムコールが1つの引数を取る場合、それはebxに格納される。2つの引数を持つシステムコールはebxとecxを使用する。同様に、3番目、4番目、5番目の引数が必要な場合は、それぞれedx、esi、ediが使用される。
システムコールからのリターン時、eaxにはリターン値が含まれる。