2007年になりました.今年もよろしくお願いいたします.
だいぶ前の授業のフォローなのですが,「関数の戻り値に構造体を書くことができるのはなぜか」をうまく説明できないか,ちょっと検討したり,手を動かしてみました.
私が学生のとき,コンパイラに関する授業で学んだのは,「戻り値もスタックに置く」という方法でした.
以下,機械語(アセンブリ言語)での挙動をかいつまんで説明します.メモリ上にスタック領域があって,値を乗せたり(push),最上段の値を取り出したり(pop)できるものとします.
- 関数呼び出しをするにあたり,最初に,戻り値を格納する領域を確保するためのpushをします.次に関数呼び出しの戻り先アドレス,そして仮引数の領域をpushします.
- それから,関数の最初のアドレスに制御を移します.
- 関数の中では,ローカル変数の領域をpushして,あとは何らかの処理をします.
- 関数が呼び出された時点で,戻り値を格納するアドレスが計算できますので,戻り値をそこに格納します.
- 関数の処理を終えるときは,ローカル変数の領域,仮引数の領域をpopします.そして関数呼び出しの戻り先アドレスもpopして,その値に制御を移します.それにより,関数の戻り値が,スタックの最上段に乗っていることになります.
当時,授業やコンパイラ作成演習でのソース言語はPascalのサブセット*1,ターゲット言語はCASLでした.
Cでも同じ振る舞いをするのか,少し調べてみました.コンパイラはGCCです.コンパイル時のオプションに「-S」をつけて,例えば「gcc -S program.c」を実行すれば,「program.s」という名前のファイルが生成されます.これが,アセンブリ言語で書かれた(狭義の)コンパイルの結果です.
いろいろコードを書いて見ましたが,上の方法とは異なっていると言ってよさそうです.戻り値となる構造体の領域を確保しているのは,呼び出す側です.関数の中で,そのアドレスに値を格納しています.
単純な構造体であれば,レジスタのみで値を受け渡ししています.構造体が複雑になれば,ライブラリ関数memcpyを使って値をコピーしています.関数の定義が同じでも,元となる構造体が異なれば,受け渡しの方法が異なるため,アセンブリコードが違ってきます.
以下は,動作確認用のソースファイルです.
#include <stdio.h> struct point1 { int x, y; }; struct point2 { int x, y; int z[20]; }; struct point1 returnstruct1(void) { struct point1 p = {1, 2}; return p; } struct point2 returnstruct2(void) { struct point2 p = {1, 2}; return p; } int main(void) { struct point1 p1; struct point2 p2; p1 = returnstruct1(); printf("p1(%d,%d)\n", p1.x, p1.y); p2 = returnstruct2(); printf("p2(%d,%d)\n", p2.x, p2.y); return 0; }
*1:ここでの「サブセット」(subset,部分集合)は,仕様が,本来のものから一部欠けているという意味です.