アセンブリでプログラムの動きを見てみる

142
NO IMAGE

前置き

プログラミングの習いたての頃、プログラムはコンパイルされてマシン語に翻訳されるとか、コンピュータの考え方は人と異なり、マシン語しか解釈できないとか、マシン語はCPUで演算が行われるとか習いましたが、へえー、そうなんだーとなってました。いまだにプログラムがCPUで直接何をしているのか知りません。知らなくても問題が起きないというのもありますが、それはどうなんだという気がしますし、プログラマとしての基礎力を付けたいので、きちんと勉強してみようと思います。

マシン語は単なる数字の羅列なので、人間には読みにくいです。そのためマシン語の機能ごとにその機能を表す略語(ニーモニック)を充てたものがアセンブリです。ですので、マシン語とアセンブリは一対一で対応します。

  • 実行ファイルをテキストで見たものの一部(マシン語)
    image.png

  • ↑のファイルを16進数に変換したものの一部

0040    ba 10 00 0e 1f b4 09 cd  21 b8 01 4c cd 21 90 90   ........!..L.!..
0050    54 68 69 73 20 70 72 6f  67 72 61 6d 20 6d 75 73   This program mus
0060    74 20 62 65 20 72 75 6e  20 75 6e 64 65 72 20 57   t be run under W
0070    69 6e 33 32 0d 0a 24 37  00 00 00 00 00 00 00 00   in32..$7........

マシン語のままだととてつもなくわかりづらいです。
なので、今回はc言語プログラムからアセンブリファイルを作成し、その中でどのような処理を行っているのかを見ていこうと思います。

c言語プログラムからアセンブリを作成する

動作環境

  • OS:windows10 64bit
  • プロセッサ:Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz 2.40GHz
  • コンパイラ:borland c++

Cプログラムからアセンブリを作成

アセンブリにする簡単なプログラムを作成します。

// Sample.c

int AddNum(int a, int b) 
{
    return a + b;
}

コンパイルし、アセンブリを生成します。

$ bcc32c.exe -c -S .\Sample.c
// Sample.s

    .file   ".\\Sample.c"
    .def     _AddNum;
    .scl    2;
    .type   32;
    .endef
    .section    _TEXT,"xr"
    .globl  _AddNum
    .align  16, 0x90
_AddNum:                                # @AddNum
# BB#0:
    pushl   %ebp               
    movl    %esp, %ebp                  
    subl    $8, %esp                    
    movl    12(%ebp), %eax              
    movl    8(%ebp), %ecx               
    movl    %ecx, -4(%ebp)              
    movl    %eax, -8(%ebp)              
    movl    -4(%ebp), %eax              
    addl    -8(%ebp), %eax              
    addl    $8, %esp                    
    popl    %ebp                        
    ret                                 

アセンブリを読む

まず、次の部分から見ていきます。

    .file   ".\\Sample.c"     # ソースファイルのファイル名を記述します。
    .def     _AddNum;
    .scl    2;
    .type   32;
    .endef
    .section    _TEXT,"xr"
    .globl  _AddNum              # 呼び出される関数を指定します。
    .align  16, 0x90

ピリオドから始まる行。.file、.defなどはアセンブラディレクティブといい、アセンブラへこのアセンブリファイルをどのようにアセンブルするかを伝えるための疑似命令です。そのため、マシン語へアセンブルの際は翻訳されません。
疑似命令の意味を調べてみたのですが、でてこないものが多かったです。
とりあえず、直接の処理には関係ないので、ここは飛ばします。

本体の処理を見ていきます。

_AddNum:                                # @AddNum
# BB#0:
    pushl   %ebp               # (1)
    movl    %esp, %ebp                  # (2)
    subl    $8, %esp                    # (3)
    movl    12(%ebp), %eax              # (4)
    movl    8(%ebp), %ecx               # (5)
    movl    %ecx, -4(%ebp)              # (6)
    movl    %eax, -8(%ebp)              # (7)
    movl    -4(%ebp), %eax              # (8)
    addl    -8(%ebp), %eax              # (9)
    addl    $8, %esp                    # (10)
    popl    %ebp                        # (11)
    ret                                 # (12)

_AddNumの以下のインデントが付いている行は、c言語のAddNum関数の処理のアセンブリになります。
1行でCPUに対する1つの命令を表します。また、アセンブリの構文はオペコード+オペランドというようになっており、オペコードは命令の動作、オペランドはその引数を表します。

それぞれのオペコードがもつ機能は次のようになります。

オペコード オペランド 機能
pushl A Aの値をスタックに格納する。格納先はスタックポインタを参照する
movl A, B AをBの値を格納する。
subl A, B BからAの値を引き、Bに格納する
addl A, B AにBの値を足し、Bに格納する
popl A スタックから最上位の値を取り出し、Aに格納する
ret なし 処理を関数の呼び出し元へ戻す

また、ebpやespといった単語はCPUの中のレジスタを表します。
レジスタはそれぞれ役割を持っています。役割は次のようになります。

レジスタ名 呼び名 主な役割
ebp ベース・ポインタ データの格納領域の基点のメモリ・アドレスを格納する
esp スタック・ポインタ スタック領域の最上位に積まれたデータのメモリ・アドレスを格納する
eax アキュムレータ 演算に使う
ecx カウント・レジスタ ループ回数をカウントする

処理

まず、プログラム実行時メモリ上にスタックと呼ばれるデータ領域が確保されます。

(1)でebpレジスタへ値をプッシュしています。関数の処理前にespレジスタがに何に使われていたかはわかりません。また、AddNum関数の中でもespレジスタが使われるので、関数前の状態に戻せるようにebpレジスタの値を、スタックへ保存します。

(2)でespレジスタの値をebpレジスタへ格納します。

(3)espレジスタのメモリアドレスから8引いた値をespレジスタへ格納し、スタックの最上位のメモリ・アドレスの位置を調整します。subl命令で減算しているのは、スタックは積みあがるとメモリ・アドレスが小さくなるからだと思われます。

(4) 12(%ebp)は、ebpレジスタのアドレスに12を足し、さらにそのアドレスの値を表します。
12(%ebp)には、AddNum関数の引数の値(aかb)が入っていると思われます。それを演算用のレジスタである%eaxレジスタへコピーします。

(5) は(4)とほぼ同様の処理です。8(%ebp)の値を%ecxへコピーします。

(6),(7)の処理は先ほどコピーした%eaxと%ecxの値を他のアドレスにコピーしなおしています。引数から与えられた値があるアドレスでそのまま演算すると元に戻せなくなるので、再度コピーしているのだと思います。

(8) ではもともと%ecxが持っていた値を%eaxへコピーしています。%eaxへ格納されていた値は、(7)で-8(%ebp)が保持したままです。

(9) ようやく関数が本来やりたい処理が出ました。-8(%ebp), %eaxを加算することでa + bを実現してます。

(10) (3)で調整した分を戻します。

(11) %ebpレジスタへ最初にpushしたレジスタの値をpopします。これにより、AddNumが呼び出される前の%ebpの値に戻します。

(12) 処理を呼び出し元の関数に戻します。

あとがき

c言語のプログラムがアセンブリになった様子や中の処理を見ていきました。
しかし、今回取り扱ったプログラムはとてもシンプルなので、まだ見れていないもの(繰り返し処理、分岐処理、関数呼び出し、グローバル変数など)もありますが、今回はこの辺で終わりにしようと思います。

雑記

  • アセンブリには大きく分けて、AT&TとIntelの2種類の構文があるらしいですが、コンパイラの違い?で少し異なる構文が大量に出てきて、調べるのに苦労しました。
  • 今回は書籍「プログラムはなぜ動くのか」の内容を大いに参考にしました。そのため、書籍内で出てきたborland c++を使いましたが、ここを使った方が環境をつくらなくてもいいので、楽かもしれません。

参考文献