Hello World詳説 (1)

アセンブリを勉強しようと思ってから最初に疑問に思ったのは、プログラムの中心であるスタックやニーモックの動きを解説しているページは少なからずあるものの、おそらくそれらとは本質的に関係がない部分のアセンブリに関する情報がほとんどないことでした。

そんな部分は気にせず読み飛ばすのが正しいのでしょうが、個人的にどうしても納得できないので、様々なページにある断片的な情報をかき集めて、とにかく一つのプログラムに関してすべての行(ニーモック)の意味を納得のゆくようにまとめておきます。

一度その意味を知っておけば、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の)引数という事になります。

次に続きます。

コメントする