C言語プログラムとの結合
「一つのプログラムは一つの言語で記述しなければならない」という決まりはない。 一部のみアセンブリ言語で,残りを高級言語で記述することもできる。 特にC言語は,「アセンブリ言語に近い高級言語」として設計されていて,アセンブリ言語プログラムとの結合が容易だ。
アセンブリ言語プログラムからC言語プログラムを呼び出す
下記は,C言語で記述した,階乗を計算する関数だ。
/* nの階乗を計算する */
int factorial(int n)
{
int i, f = 1;
for (i = 1; i <= n; i++) {
f *= i;
}
return f;
}
この関数をアセンブリ言語プログラムから呼び出してみよう。
C言語の関数は,コンパイルすると,機械語のサブルーチンに翻訳される。
関数名が,サブルーチンの開始位置を表すラベルとなる。
従って,call factorial
を実行すれば上記の関数を呼び出せる。
問題は,引数の渡し方と戻り値の受け取り方だ。
先に答を言うと,上記の関数 factorial を呼び出すアセンブリ言語プログラムは以下のようになる。
; C言語で記述された関数 factorial を呼び出す.
; int factorial(int n);
section .text
global _start
extern factorial, print_eax_int
_start:
push dword 10 ; 引数をスタックに積む
call factorial ; 関数呼び出し
add esp, 4 ; 自分が積んだ引数を除去
call print_eax_int ; factorialの戻り値を出力
mov eax, 1
mov ebx, 0
int 0x80 ; exit
上記のC言語プログラムを fact.c に,アセンブリ言語プログラムを test_fact.s に記述したとする。 ビルド手順は以下のようになる。
$ gcc -c -m32 fact.c -- fact.oを生成
$ nasm test_fact.s -- test_fact.oを生成
$ ld fact.o test_fact.o ../chap9/print_eax_int.o
$ ./a.out
3628800
gccの-c
オプションは,「コンパイルのみを行って .o ファイルを生成する。リンクは行わない(実行可能ファイルを生成しない)」という意味だ。
-m32
オプションは「i386用(32ビットモード)の機械語プログラムを出力せよ」という意味(-m32
を付けないと,x86-64用(64ビットモード)の機械語プログラムが出力され,Nasmが出力する機械語プログラムと結合できない)。
補足: 呼び出し規約
引数や戻り値の渡し方など,サブルーチンの呼び出し方に関する取り決めを呼び出し規約 (calling conventions) と言う。 同じ呼び出し規約に従っていれば,異なる言語で記述されたサブルーチンでも相互に呼び出しできる。 呼び出し規約は,CPUによっても,OSによっても,プログラミング言語やコンパイラによっても異なる場合がある。 以下で説明するのはi386 + Linux + GCCの場合の呼び出し規約だ。
- 引数の渡し方: 呼び出し元が,引数をスタックに格納 (push) してからcallを実行する。
- 複数の引数を渡す場合,右の引数から順にスタックにpushする(下記のadd2.sを参照)。
- サブルーチン終了後,スタック内の引数は,呼び出し元が片付ける。
- 戻り値の渡し方: サブルーチンが,EAXに戻り値を格納してからretを実行する。
1個の引数が何バイトかは,引数の型によって決まる。 i386 + GCC の場合,int型は32ビット (= 4バイト) だ。 呼び出し側とサブルーチン側とで「何型の引数を何個渡す」か合致していないと,意図したものと異なる値を引数と解釈してしまう。
C言語プログラムからアセンブリ言語プログラムを呼び出す
逆に,C言語プログラムからアセンブリ言語プログラムを呼び出すこともできる。
#include <stdio.h> /* printfのために必要 */
extern int add2(int a, int b); /* extern宣言 */
int main()
{
printf("%d\n", add2(32, 27));
return 0;
}
C言語の extern 宣言は,関数名(ラベル)だけでなく,引数の型や戻り値の型も記述する。
サブルーチン add2 の定義は以下のようになる。 引数はスタックに格納されて渡される。 戻り値はEAXに格納して返す。
section .text
global add2
; 2数の和を返す.
; int add2(int a, int b);
add2:
mov eax, [esp + 4] ; 第1引数 a を読み出す
add eax, [esp + 8] ; 第2引数 b を加える
ret
ESPが指す先(スタックトップ)には,CALL命令によって格納された戻り番地がある。 引数はCALL命令の実行前にpushされているので,引数の番地は ESP + 4 になる。 「右の引数から順にpush」されているので,スタックトップに近い方に第1引数,遠い方に第2引数がある(下図)。
ビルド手順は以下のようになる。 これまでとほぼ同じだが,ld コマンドの代わりに gcc を使うところが異なる。
$ gcc -c -m32 test_add2.c
$ nasm add2.s
$ gcc -m32 test_add2.o add2.o -- リンク (gccを使用)
$ ./a.out
59
ld の代わりに gcc を使うのは,test_add2.o と add2.o 以外に,C言語プログラムを実行可能にするために必要なファイルをリンクするためだ。
例えば printf の定義が格納されている /lib32/libc.so.6 や,必要な初期化を行って main を呼び出す /usr/lib32/crt1.o などをリンクしなければならない。
これらのファイルを手で指定すれば ld コマンドでも実行可能ファイルを作れるが,面倒なので,ここでは gcc にその仕事をさせることにする。
なお,gcc に -v
オプションを付けて実行すると,どのファイルをリンクしているか表示させることができる。
補足: call-savedレジスタ,call-clobberedレジスタ
問: C言語プログラムから呼び出されるアセンブリ言語サブルーチンは,レジスタの値を保存する必要があるか? 必要はないか?
EAXレジスタは戻り値を格納するために使うので,当然,呼び出し前の値は保存しなくてよい。
他のレジスタについては,呼び出し規約において以下のように決められている。
- EBX, ESI, EDI, EBP はcall-savedレジスタである。 すなわち,サブルーチンが,呼び出し前の値を保つ義務を負う。
- それ以外 (EAX, ECX, EDX) はcall-clobberedレジスタである。 サブルーチンは,これらのレジスタの値を保つ義務を負わない。
C言語プログラムから呼び出されるアセンブリ言語サブルーチンを記述する際は,上記のcall-savedレジスタの値を保存するように記述しなければならない。 さもないと,C言語側の動作に支障が生じる。
一方,C言語の関数を呼び出すアセンブリ言語プログラムを記述する際は,call-clobberedレジスタの値は保存されないと思って記述しなければならない(必要なら自分で退避・復元しなければならない)。
補足: レジスタの退避とベースポインタ (EBP)
上記のサブルーチン add2 を以下のように記述したとする。 レジスタ ESI と EDI を使っているが,これらはcall-savedレジスタなので,push/pop で退避と復元を行っている。
section .text
global add2
; 2数の和を返す.
; int add2(int a, int b);
add2:
push esi
push edi
mov esi, [esp + 12] ; 第1引数 a を読み出す
mov edi, [esp + 16] ; 第2引数 b を読み出す
mov eax, esi
add eax, edi
pop edi
pop esi
ret
push/pop を行うとスタックポインタ ESP が指す先が変化するので,ESPからの相対番地も変わる。 そのため上の例では,第1引数は ESP + 12 番地,第2引数は ESP + 16 番地となる(下図)。
- 参考: ESPからの相対番地を使うと push/pop を行うたびに変化してしまうので,代わりに以下の方法がよく用いられる (例えばC言語の関数のコンパイル結果を逆アセンブルすると,冒頭が必ず下記の形になっている)。
section .text
global _add2
; 2数の和を返す.
; int add2(int a, int b);
_add2:
push ebp ; ebpを退避
mov ebp, esp ; この時点のespをebpにコピー
push esi
push edi
mov esi, [ebp + 8] ; 第1引数 a を読み出す
mov edi, [ebp + 12] ; 第2引数 b を読み出す
mov eax, esi
add eax, edi
pop edi
pop esi
pop ebp
ret
つまり,サブルーチンの冒頭でスタックポインタ (ESP) の値をベースポインタ (EBP) にコピーし,EBPからの相対番地で各引数にアクセスする(「ベースポインタ」という名前は「スタック中の基準位置を指すレジスタ」という意味)。
mov ebp, esp
の後に何回 push してもEBPの値は変わらないので,EBPからの相対番地も変わらない(下図)。
ただし,EBPもcall-savedレジスタなので,mov ebp, esp
の前にEBPの値を退避しておく必要がある。