スタック
CALL命令とRET命令が対になって動作しています。RET命令が、正しくCALL命令を実行したアドレスにIPレジスタの値を戻せるのは、スタックを使って、最後にCALL命令を実行したアドレスを保存しているためです。スタックのアルゴリズムは、先入れ後出しです。つまり、最後にCALL命令でプッシュされたアドレスが、RET命令によってポップされるのです。
CPUは、アドレスの計算を必要とせずに値をスタックにプッシュしたり、ポップする機能を提供しています。スタックに値をプッシュするにはPUSH命令を、スタックから値をポップするにはPOP命令を使います。
push src
pop dest
PUSH命令は、ソース・オペランドにプッシュする任意のレジスタ、メモリアドレス、または即値を指定します。POP命令は、ポップした値を格納するディスティネーション・オペランドを指定します。最後にプッシュされた値が、POP命令に指定したディスティネーションに保存されます。
CPUは、スタックを管理するためのESPレジスタを提供しています。ESPレジスタは、現在のスタックのアドレスを保存し、PUSH命令やPOP命令のたびに32ビット単位でインクリメント、またはデクリメントされます。
CALL命令とRET命令のいずれも、スタックを利用してサブルーチンの呼び出した復帰を行っているので、スタックは無秩序に利用できる空間ではありません。
#include <stdio.h> int main() { int r, v; __asm mov r, esp; printf("ESP=%p\n", r); __asm { push 10; mov r, esp; } printf("Push ESP=%p\n", r); __asm { push 20; mov r, esp; } printf("Push ESP=%p\n", r); __asm { push 30; mov r, esp; } printf("Push ESP=%p\n", r); __asm { pop v; mov r, esp; } printf("Pop V=%d, ESP=%p\n", v, r); __asm { pop v; mov r, esp; } printf("Pop V=%d, ESP=%p\n", v, r); __asm { pop v; mov r, esp; } printf("Pop V=%d, ESP=%p\n", v, r); return 0; }
ESP=0013FF68 Push ESP=0013FF64 Push ESP=0013FF60 Push ESP=0013FF5C Pop V=30, ESP=0013FF60 Pop V=20, ESP=0013FF64 Pop V=10, ESP=0013FF68
「sample08」は、PUSH命令とESPレジスタの関係を確認するためのサンプルプログラムです。PUSH命令を使ってスタックに値をプッシュすると、ESPレジスタのアドレスが32ビット単位、つまり4バイト単位でデクリメントされていることが確認できます。スタックにプッシュすると、自動的にESPレジスタのアドレスがオペランドのサイズに従ってデクリメントされています。これに対して、スタックから値をポップすると4バイト単位でESPレジスタがインクリメントされています。
このように、PUSH命令と POP命令は、ESPレジスタを操作しながら、値をスタックに積んだり、スタックから値を取り出したりしています。CALL命令と RET命令も、PUSHとPOP同様に、スタックに呼び出し元のアドレスをプッシュし、復帰するときにポップしているのです。
こうした内情から、サブルーチンを呼び出しているコードの中で、無秩序にスタックを操作してはいけません。RETが行われる前にアドレスがポップされてしまったり、復帰するためのアドレス以外がスタックに積まれている状態でRET命令を実行するなどした場合、適切なアドレスに復帰することができず強制終了することになるでしょう。
関数の呼び出し
CALL命令のオペランドに渡す値は、本来ならばサブルーチンが配置されているメモリ上のアドレスです。CALL命令のオペランドにはC言語のラベルを指定することができましたが、そもそもC言語には関数へのポインタというものが存在します。関数へのポインタは、その関数のメモリアドレスを表します。よって、関数へのポインタをCALL命令のオペランドに指定することで、C言語の関数を呼び出すことができます。
#include <stdio.h> void func() { printf("I have no interest in ordinary humans.\n"); } int main() { __asm call func; return 0; }
I have no interest in ordinary humans.
「sample09」は、引数を受け取らず、戻り値を返さないfunc()関数をCALL命令で呼び出しています。関数名だけを指定した場合、その関数へのポインタを表します。通常、C言語のインラインアセンブラでは、別の関数をアセンブリ言語から呼び出す目的でCALL命令を使います。
関数に引数を渡す場合、スタックを利用します。引数を取得する関数は、スタックから引数を受け取ります。関数は、仮引数リストの逆から順番に、スタックから引数を取り出します。よって、引数を持つ関数を呼び出す場合は、関数の仮引数リストの宣言とは逆の順番で、スタックに引数をプッシュします。
関数から制御が戻ってきた後、関数を呼び出したコードは適切にスタックを戻さなければなりません。前述したように、スタックはRET命令で利用されているため、関数に渡した引数がスタックにそのまま残っている場合は、適切に現在の関数からRET命令で復帰することができなくなってしまいます。関数を呼び出すためにスタックにプッシュした引数の数だけ、使っていない適当なレジスタにポップしてください。
関数が戻り値を返す場合、EAXレジスタに戻り値の値が格納されています。
#include <stdio.h>
int func(int arg1, int arg2, int arg3) {
printf("arg1=%d, arg2=%d, arg3=%d\n", arg1, arg2, arg3);
return arg1 + arg2 + arg3;
}
int main() {
int result;
__asm {
push 1000;
push 100;
push 10;
call func;
pop ebx;
pop ebx;
pop ebx;
mov result, eax;
}
printf("result=%d\n", result);
return 0;
}
arg1=10, arg2=100, arg3=1000 result=1110
「sample10」は、main()関数内にあるインラインアセンブラのコードからfunc()関数を呼び出すプログラムです。__asmブロック内では、func()関数に渡すための引数をプッシュし、その後CALL命令を使ってfunc()関数を呼び出しています。
func()関数は、渡された3つの引数を出力し、引数をすべて加算した結果を戻り値として返します。CALL命令から制御が戻され__asmブロックに戻ると、func()関数に渡した引数をスタックからポップし、最後にfunc()関数の戻り値が格納されているEAXレジスタの値をresult変数にストアしています。実行結果を見れば、func()関数に適切に値が渡され、戻り値を受け取っていることが確認できます。
上記のようにC言語の関数を呼び出せば、アセンブリ言語を使ってC言語の標準関数を呼び出すこともできます。C言語の関数が、機械語の世界でどのように動作しているのか、上記のサンプルから感じ取ることができると思います。
最後に
レジスタを直接操作するという作業を除いて、本稿で解説してきたインラインアセンブラの機能はすべてC言語でも行うことができます。むしろ、C言語で記述した方がより安定していて、汎用性も高く、保守も容易です。特別な事情がない限り、実践の開発で積極的にインラインアセンブラを使う理由というものはないでしょう。
しかし、例えばIntelのCPUは、マルチメディア処理専用のレジスタや命令を用意しています。こうした局所的な命令を明示的に利用したい場合、C言語ではなくアセンブリ言語を使わなければなりません。高度な処理が要求されるゲームプログラミングなどでは、現在でもアセンブリ言語の活躍の場が存在することでしょう。また、実践の機会がなかったとしても、アセンブリ言語や機械語は、コンピュータの原理に直結しています。アセンブリ言語を学習することで、コンピュータの原理を正しく理解している技術者として、ステップアップすることができます。
