連載
» 2017年05月18日 05時00分 公開

「Hello World!」の中身を探る意義と環境構築、main(C言語)のアセンブラコードの読み方main()関数の前には何があるのか(1)(4/4 ページ)

[坂井弘亮,著]
前のページへ 1|2|3|4       

アセンブラを読んでみる

 いよいよ次章(※編集部注:連載第2回)からはデバッガなどを駆使したハロー・ワールドの解析に入っていくわけだが、その前の準備として、ここで手始めにアセンブラの読みかたについて簡単に説明しておく。

 本書では全般において、アセンブラが多く出現する。プログラムはC言語で書かれていてもCPUは結局のところ機械語コードを実行しているわけなので、これは解析を行う上で自然のことだ。

 このためある程度はアセンブラを読めたほうが理解しやすいし、少なくとも抵抗感無く触れられる程度には慣れておきたい。

 そもそもサンプル・プログラムのmain()関数は、どのようなアセンブラに変換されているのだろうか。ここではデバッガによる解析の手始めにmain()関数のアセンブラを見ることで、アセンブラについて説明しておこう。

main()のアセンブラを読む

 実行ファイルの逆アセンブル結果は「逆アセンブル結果を見る」の項で説明したように、objdumpというコマンドで見ることができる。

 本書向けのVM環境の上で、実行ファイルhelloを逆アセンブルしてみよう。なおVM環境のCentOS上では、サンプル・プログラムのアーカイブであるhello.zipが展開済みであるものとする。

[user@localhost ~]$ cd hello
[user@localhost hello]$ objdump -d hello | less

 「main」で検索しmain()に相当する部分を探していくと、以下のような部分が見つかる。これがmain()関数のアセンブラだ。

080482bc <main>:
 80482bc:	55			push	%ebp
 80482bd:	89 e5			mov	%esp,%ebp
 80482bf:	83 e4 f0		and	$0xfffffff0,%esp
 80482c2:	83 ec 10		sub	$0x10,%esp
 80482c5:	8b 45 0c		mov	0xc(%ebp),%eax
 80482c8:	8b 10			mov	(%eax),%edx
 80482ca:	b8 0c 36 0b 08		mov	$0x80b360c,%eax
 80482cf:	89 54 24 08		mov	%edx,0x8(%esp)
 80482d3:	8b 55 08		mov	0x8(%ebp),%edx
 80482d6:	89 54 24 04		mov	%edx,0x4(%esp)
 80482da:	89 04 24		mov	%eax,(%esp)
 80482dd:	e8 7e 10 00 00		call	8049360 <_IO_printf>
 80482e2:	b8 00 00 00 00		mov	$0x0,%eax
 80482e7:	c9			leave
 80482e8:	c3			ret
 80482e9:	90			nop
 80482ea:	90			nop
 80482eb:	90			nop
 80482ec:	90			nop
 80482ed:	90			nop
 80482ee:	90			nop
 80482ef:	90			nop

 左端の16進数の列は命令である機械語コードが配置されているアドレス、中央の16進数の数値はそのアドレスに配置されている機械語コードだ。

 そして右側の「push %ebp」や「mov %esp,%ebp」は、「アセンブリ言語」「ニーモニック」などと呼ばれるものだ。

 C言語のソースコードはコンパイラによってアセンブリ言語に変換され、アセンブリ言語はアセンブラによって機械語コードに変換される。アセンブリ言語は人間の読み書き向けに機械語コードを書き下したものであり、CPUが実際に実行するのは16進数の数値列である機械語コードだ。そして機械語コードからアセンブリ言語に戻すのが逆アセンブルだ。

 またアセンブリ言語のことを「アセンブラ」と言ってしまうことも多く、本書でもそのように表現している。「アセンブラ」と言ったときに、アセンブリ言語を指している場合と、機械語コードへの変換ツールのことを指している場合があるので要注意だ。

 我々が利用しているPCの多くはIntelのx86アーキテクチャというものだ。アセンブラはアーキテクチャによって異なるものだが、本書ではx86が主な対象になる。よってここで説明するのは、x86アーキテクチャのアセンブラだ。

レジスタの扱い

 まずx86はEAX、EBX、ECX、EDX、EBPといったレジスタを持っている。レジスタというのはCPUが持っている記憶領域のことだが、まあよくわからなければとりあえずはCPUの固定の変数だと思っていただければわかりやすいだろう。

 さらにx86はESPというレジスタを持っており、これはスタックの使用位置を指す「スタックポインタ」になっている。

 逆アセンブル結果のニーモニック中には「%ebp」や「%esp」などが見られるが、これらがそれぞれEBPレジスタやESPレジスタ(スタックポインタ)を示すことになる。

 そしてニーモニック中には「(%eax)」のような表現があるが、このようにレジスタ名に括弧が付くと、レジスタの保持する値をアドレスとしたときの、その指す先のメモリ上の値を示す。「(%eax)」をC言語風に書きなおすと「*(int *)EAX」ということになるだろうか。

 また「0xc(%ebp)」のような表現は、EBPに0xcを加算してから、同様にその指す先のメモリ上の値を示すことになる。C言語風に書くと「*(int *)((char *)EBP+0xc)」という具合だろう。なお「0x」は16 進数表記を示す。「0xc」ならば16進数の「c」、つまり10進数での「12」を表すことになる。

 次に命令についてだ。ニーモニック中には「mov」という命令が瀕出している。これは第1引数から第2引数への、値の代入を指す。

 これだけの知識で、上記のアセンブラの多くの部分を読むことができる。例えば2命令目の「mov %esp,%ebp」はESP(スタックポインタ)の値をEBPレジスタに代入するという意味になる。また5命令目の「mov 0xc(%ebp),%eax」は、EBP+12の位置のメモリ上の値を読み込み、EAXに格納するという意味になる。

スタックの扱い

 movの他にも「and」や「sub」という命令があるようだ。これらは演算の命令で、それぞれ論理積と減算を示す。

 and命令ではスタックポインタに0xFFFFFFF0を論理積でかけあわせることで、スタックポインタを16バイト境界にそろえているようだ。キャッシュ効率を向上させて高速化することを目的に、スタックの先頭をキャッシュラインにそろえているのだろうか。このようにある値(ここではアドレスだが、他にも領域サイズなど)を特定の値(ここでは16)の倍数にそろえることを「アラインメント」と呼ぶ。

 またsub命令ではスタックポインタから0x10という値を減算している。これはスタックポインタを移動させてスタックに16バイトの空き領域を作成することで、main()関数内で利用するための領域を確保している。x86ではスタックは下方伸長(ゼロアドレスに向かって伸びる)のため、スタックポインタを減算することが、スタック領域の獲得になる。

 このように関数のためにスタック上に作成される領域は「スタックフレーム」と呼ばれる。ここではmain()のために16バイトのスタックフレームを獲得しているわけだ。関数内の自動変数などはスタックフレーム上に作成されることになる。

 先頭には「push」という命令がある。これはスタックに値を積む命令だ。具体的にはスタックポインタを4だけ減算することでスタックを4バイト拡張し、さらにスタックポインタの指す先のメモリ上にEBPレジスタの値を書き込む、という動作を行う。ここでの「4」という値は、32ビットCPUを前提にしているためint型が32ビット(つまり4バイト)ということから来ている。

関数呼び出しの手順

 そして12命令目の「call」は、関数呼び出しになる。ここでは「_IO_printf」という関数にジャンプしているようだ。これがprintf()関数の呼び出しに相当するようだ。

 実はcall命令の直前のmov命令の連続は、関数呼び出しのための引数の準備になる。

 まず5命令目の「mov 0xc(%ebp),%eax」では先述したようにEBP+12の位置の値をEAXに代入するわけだが、EBPは2 命令目でスタックポインタの値がコピーされているので、これはスタック上の+12の位置の値をEAXレジスタに格納することになる。

 x86では関数呼び出し時には、スタック経由で引数が渡される。関数呼び出しがされたとき、スタックポインタの指す先には関数からの戻り先アドレスが格納されている。さらに続けて+4の位置に第1引数、+8の位置に第2引数のようにして引数が格納されている。実際には先頭のpush命令によりスタックが4バイト拡張されているので、+4の位置に戻り先アドレス、+8の位置に第1引数が格納されていることになる。よってEAXには、第2引数の値が格納される。これによってmain()の第2引数である「argv」がEAXレジスタに格納されることになる。

 次に6命令目の「mov (%eax),%edx」によって、EAXレジスタの指す先の値がEDXレジスタに格納される。これはargv[0]に相当する。

 7命令目はちょっと忘れて、次に8命令目を見てみよう。「mov %edx,0x8(%esp)」となっているので、EDXレジスタの値がスタックポインタ+8の位置に格納されることになる。ということはこれはスタック上に、printf()の第3引数としてargv[0]を設定していることになる。第3引数なのに+8になっているのは、関数からの戻り先アドレスはcall命令によって自動的にスタックに積まれそこでスタックポインタが減算されるので、呼び出し前には第1引数をスタックポインタの指す先に設定しておくためだ。

 次に9命令目と10命令目を見てみよう。「mov 0x8(% ebp),% edx」「mov %edx,0x4(%esp)」のようになっており、まずmain()の第1引数がEDXレジスタに格納され、さらにその値がスタックポインタ+4の位置、つまりprintf()の第2引数として設定されることになる。つまりEDXレジスタを経由して、printf()の第2引数を準備しているわけだ。

 最後に7命令目と11命令目の「$0x80b360c,%eax」「mov %eax,(%esp)」によって、「0x80b360c」という値がprintf()の第1引数に設定される。これはprintf()の第1引数であるフォーマット文字列の設定だ。つまりフォーマット文字列は「0x80b360c」というアドレスに配置されていることになる。

関数からのリターン

 call命令の後は、「mov $0x0,%eax」によってゼロがEAXレジスタに格納されている。

 x86アーキテクチャでは、関数からの戻り値はEAXレジスタで返される。つまりこれはmain()関数の終端にある「return 0」の戻り値を準備していることになる。

 さらに「ret」によって、関数の呼び出し元に戻る。ret命令はスタックポインタの指す先から戻り先アドレスを取得し、そこにジャンプする。さらにスタックポインタを取得したアドレスのサイズぶんだけ加算することで、スタックに積まれていた戻り先アドレスを無効化する。

 なおret命令の後には「nop」という命令が連続している。これは「no operation」の略であり、「何もしない」という命令だ。

 関数の先頭は16バイトや32バイトなどのキャッシュラインにアラインメントされていたほうがキャッシュ効率が良くなり、高速性が高まる。よってnop命令で埋めることで次に続く関数の先頭アドレスをアラインメントする、といった最適化が行われているようだ。

 末尾のnopは0x80482efというアドレスにあるので、次の関数は0x80482f0というアドレスから開始することになる。たしかに16バイトにアラインメントされているようだ。

書籍紹介

ハロー“Hello,World” OSと標準ライブラリのシゴトとしくみ

ハロー“Hello, World” OSと標準ライブラリのシゴトとしくみ

坂井弘亮著
秀和システム 3,200円

C言語の入門書では、"Hello, World"と出力するプログラムを最初に作るのが定番です。"Hello, World"は、たった7行の単純なプログラムですが、printf()の先では何が行われているのか、main()の前にはいったい何があるのか、考えてみると謎だらけです。本書は、基礎中の基礎である"Hello, World"プログラムを元に、OSと標準ライブラリの仕組みをあらゆる角度からとことん解析します。資料に頼らず、自分の手で調べる方法がわかります。


注文ページへ

前のページへ 1|2|3|4       

Copyright © ITmedia, Inc. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。