アセンブリを勉強しようと思ってから最初に疑問に思ったのは、プログラムの中心であるスタックやニーモックの動きを解説しているページは少なからずあるものの、おそらくそれらとは本質的に関係がない部分のアセンブリに関する情報がほとんどないことでした。
そんな部分は気にせず読み飛ばすのが正しいのでしょうが、個人的にどうしても納得できないので、様々なページにある断片的な情報をかき集めて、とにかく一つのプログラムに関してすべての行(ニーモック)の意味を納得のゆくようにまとめておきます。
一度その意味を知っておけば、2回目以降は納得してそこは無視することができるでしょうし、忘れてしまったらいつでもここに戻ってくればいいのです。
ソースプログラムの準備
今回はプログラムの内容自体ではなく、それに関連するお作法的な部分の学習が目的なので、題材は世界でもっともも有名なプログラムであるところの”Hello World”を使います。
#include int main() { printf ("Hello World\n"); return 0; }
アセンブリはCPUなどに依存しますが、ここでは私の目の前にあるCent OS 5.6 x86版の環境を利用します。当然ですが、同じソースでもOSによって出力されるアセンブリは異なるので、気が向いたらWindows環境で同じソースをコンパイルして生成されたアセンブリも扱いたいと思います。なお、このソースの解説は省略します。C言語がほとんどわからない私でさえ、この程度はわかります。
コンパイルとアセンブリの表示
上記ソースをhello.cという名前で保存したら、GCCでコンパイルします。何も考えない = 何のオプションもつけないでコンパイルすると、
[Ryu@www Assembly]$ ls hello.c [Ryu@www Assembly]$ gcc hello.c [Ryu@www Assembly]$ ls a.out hello.c
a.outというファイルが生成され、実行すると”Hello World!”と表示されます。このバイナリのアセンブリを見るには、Linux環境ではobjdumpを利用するのが便利です。objdumpの起動には最低一つはオプションが必要なので、ここでは-dを指定します。-dの意味は以下の通りです。
objfile の機械語命令に対応するアセンブラのニーモニックを表示する。このオプションは、命令を含むと思われるセクションのみを逆アセンブルする。
現時点では、 objdumpでニーモックを参照する際には、常に-dを利用すると覚えておけばいいと思います。その他のオプションは今のところ書いている私にも利用場面がわかりません(^^;
さて、先ほど生成したa.outを指定してobjdumpを起動すると、以下の出力が得られるはずです。環境が同じならばニーモックも全く同じになるはずです。以下は今回書いたコードと直接関係がある部分のみの抜粋です。先頭にある”Disassembly of section .text”は、”.textセクションのディスアセンブリ”という意味です。.textセクションとは、”プログラムの「テキスト」すなわち実行可能命令”が格納される部分のことです。
Disassembly of section .text: (中略) 080483a4 80483a4: 8d 4c 24 04 lea 0x4(%esp),%ecx 80483a8: 83 e4 f0 and $0xfffffff0,%esp 80483ab: ff 71 fc pushl 0xfffffffc(%ecx) 80483ae: 55 push %ebp 80483af: 89 e5 mov %esp,%ebp 80483b1: 51 push %ecx 80483b2: 83 ec 04 sub $0x4,%esp 80483b5: c7 04 24 a0 84 04 08 movl $0x80484a0,(%esp) 80483bc: e8 f3 fe ff ff call 80482b4 <puts@plt> 80483c1: b8 00 00 00 00 mov $0x0,%eax 80483c6: 83 c4 04 add $0x4,%esp 80483c9: 59 pop %ecx 80483ca: 5d pop %ebp 80483cb: 8d 61 fc lea 0xfffffffc(%ecx),%esp 80483ce: c3 ret 80483cf: 90 nop
なお、上記以外の部分を見ると、”Disassembly of section .???”というのは何度も登場します。それぞれのセクションの意味はこちらで詳説されているので、興味があれば参照してください(SPARC版Linuxに関するOracleの文書ですが、ELFの形式についての記述なのでプラットフォームを問わず共通のはずです)。
アセンブリを読む (1)
まず最初に、実際にa.outを起動した際にどこから実行が開始されるかを確認します。これには、ELFファイルを読み込んでその情報を表示するreadelfコマンドを、ヘッダ情報を表示する-hオプションをつけて実行すればわかります。
[Ryu@www Assembly]$ readelf -h a.out ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Intel 80386 Version: 0x1 Entry point address: 0x80482d0 Start of program headers: 52 (bytes into file) Start of section headers: 2800 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 8 Size of section headers: 40 (bytes) Number of section headers: 37 Section header string table index: 34
“Entry point address”が実際に開始されるアドレスになります。”objdump -d a.out”を参照すると、当該アドレスは_startセクションになりますので、ここから読み始めましょう。
080482d0 80482d0: 31 ed xor %ebp,%ebp 80482d2: 5e pop %esi 80482d3: 89 e1 mov %esp,%ecx 80482d5: 83 e4 f0 and $0xfffffff0,%esp 80482d8: 50 push %eax 80482d9: 54 push %esp 80482da: 52 push %edx 80482db: 68 d0 83 04 08 push $0x80483d0 80482e0: 68 e0 83 04 08 push $0x80483e0 80482e5: 51 push %ecx 80482e6: 56 push %esi 80482e7: 68 a4 83 04 08 push $0x80483a4 80482ec: e8 b3 ff ff ff call 80482a4 <__libc_start_main@plt> 80482f1: f4 hlt 80482f2: 90 nop 80482f3: 90 nop
まず、”xor %ebp, %ebp”でEBPがクリア(= 0がセット)され、続く”pop %esi”で、スタックの一番上に積まれていた値がESIに復元されます。この時点でスタックの一番上に積まれているのはmain()の引数で、gdbで確認すると”1″が格納されています。main()は第一引数は引数の数、第二引数として起動したプログラムに渡される引数の配列のポインタ、第三引数として環境変数の配列のポインタを取ります。複数の引数がある場合、後ろの引数からスタックに積まれてゆくので、スタックの先頭にある”1″はmain()の第一引数、つまりmain()の引数の数ということになります。”1″になっているのは、実行されたプログラム名自体が引数にカウントされるためです。
続けてESPのアドレスがECXにコピーされます。直前でスタックからmain()の第一引数がpopされているので、ECXに格納されたのは第二引数であるmain()の引数の配列へのポインタということになります。
この時点でのスタックと主要レジスタの状態を参照すると、以下の通りになっています。ESIに1が格納されていることがわかります(gdbの起動以降の手順もまとめています)。
$ gdb a.out (中略) Reading symbols from /home/Ryu/C_Study/Assembly/a.out...done. (gdb) b _start (中略) Breakpoint 1 at 0x80482d0 (gdb) run Starting program: /home/Ryu/C_Study/Assembly/a.out Breakpoint 1, 0x080482d0 in _start () (gdb) ni (gdb) ni (gdb) ni 0x080482d5 in _start () (gdb) info r eax 0x29d20b 2740747 ecx 0xbfffeae4 -1073747228 edx 0x294880 2705536 ebx 0x2a1fc0 2760640 esp 0xbfffeae4 0xbfffeae4 ebp 0x0 0x0 esi 0x1 1 edi 0x80482d0 134513360 eip 0x80482d5 0x80482d5 <_start+5> (以下略)
次の”and $0xfffffff0, %esp”ではESPのアドレスと$0xfffffff0($は即値を表す)とのANDを取っていますが、AND演算はビット単位で行われて両方が1の場合のみ1が成立するので、結果的にESPの最初の7桁は現状維持、最後の1桁に0がセットされます。別の言い方をすると、ESPのアドレスの最後の桁が1~fの場合に0が設定される、つまりアドレスで言えば1~f、データ量で言えば1-15バイト分上(=若いアドレス)に移動することになります。この行の意味について色々調べたところ、”関数が”適切に調整されていない”スタックと共に呼ばれると性能が大きく落ち込むため”と書いてあるページがありました。曰く、16バイト = x86系CPUのキャッシュラインサイズだからとのことですが、RAM上のすべてのデータは2、4、8、または16で割り切れる番地にアラインするべきと書かれているページや、SSEが単精度浮動小数点を並列で実行するために必須と書かれているページもあり、真相は不明です。実際にniで1行進めてからinfo rすると、ESPの値が0xbfffeae4 → 0xbfffeae0になっていることを確認できます。
ここからpushが連発されていますが、この目的は次に呼び出す関数の引数を積むのが目的です。一つ一つ見てゆくようなものではないので、積み終わって関数を呼びだす直前のスタックの状態を確認することにします。”info r”で確認できるESPのアドレスから12バイト分の情報を16進で表示させるのに、”x/12x $esp”を利用しています。
(gdb) info r eax 0x29d20b 2740747 ecx 0xbfffeae4 -1073747228 edx 0x294880 2705536 ebx 0x2a1fc0 2760640 esp 0xbfffeac0 0xbfffeac0 ebp 0x0 0x0 esi 0x1 1 edi 0x80482d0 134513360 eip 0x80482ec 0x80482ec <_start+28> (gdb) x/12x $esp 0xbfffeac0: 0x080483a4 ...push $0x80483a4 0x00000001 ...push %esi 0xbfffeae4 ...push %ecx 0x080483e0 ...push $0x80483e0 0xbfffead0: 0x080483d0 ...push $0x80483d0 0x00294880 ...push %edx 0xbfffeadc ...push %esp 0x0029d20b ...push %eax 0xbfffeae0: 0x00000001 ...(pop %esiで取り出された1) 0xbfffebf2 0x00000000 0xbfffec13
pushが連続した後に__libc_start_main@pltがコールされているので、スタックに積まれたのはその(厳密に言えばその中で呼ばれている __libc_start_mainの)引数という事になります。
次に続きます。