前置き
プログラミングの習いたての頃、プログラムはコンパイルされてマシン語に翻訳されるとか、コンピュータの考え方は人と異なり、マシン語しか解釈できないとか、マシン語はCPUで演算が行われるとか習いましたが、へえー、そうなんだーとなってました。いまだにプログラムがCPUで直接何をしているのか知りません。知らなくても問題が起きないというのもありますが、それはどうなんだという気がしますし、プログラマとしての基礎力を付けたいので、きちんと勉強してみようと思います。
マシン語は単なる数字の羅列なので、人間には読みにくいです。そのためマシン語の機能ごとにその機能を表す略語(ニーモニック)を充てたものがアセンブリです。ですので、マシン語とアセンブリは一対一で対応します。
-
実行ファイルをテキストで見たものの一部(マシン語)
-
↑のファイルを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++を使いましたが、ここを使った方が環境をつくらなくてもいいので、楽かもしれません。
参考文献
- 矢沢久雄, プログラムはなぜ動くのか, 日経BP社, 2007.
- 「Hello World!」の中身を探る意義と環境構築、main(C言語)のアセンブラコードの読み方, https://www.atmarkit.co.jp/ait/articles/1703/01/news166.html, 2021-04-02