スタックと割り込み ―― プログラムが動く仕組みを知ろう
● 関数呼び出しが入れ子になっている場合
関数呼び出しが入れ子になっているプログラムを実行した場合のスタックの様子を図7に示します.関数FuncAの実行開始時の状態は図6と同じとします.この状態から,関数リターンを実行せずに連続して関数FuncB,FuncC,FuncDが呼ばれています.連続した関数呼び出しを行うと,(2),(3),(4)のようにスタックに戻り先アドレスをプッシュし続けます.そして,(5)のように関数リターンが実行されるたびにスタックから一つずつ戻り先アドレスをポップし,呼び出し元に戻ります.(6),(7)の関数リターンも同じようにスタックから戻り先アドレスを一つポップして呼び出し元に戻ります.
図7 関数呼び出しが入れ子になっているプログラムにおけるスタックの様子
関数呼び出しが入れ子になっていても,LIFOの性質により,プッシュ・ポップのみでFuncAに戻ることができる.
このように複雑な関数呼び出しや関数リターンの場合も,LIFOの性質を利用することによって,煩雑な手続きなしにスタック・ポインタを利用できます.
● 戻り先アドレスのほかに何をスタックしているのか
C言語の関数呼び出しがどう実現されているのかを考えてみると,「戻り先アドレス」以外にも,仕組みがよく分からないものがありませんか? 例えば,引き数はどのように渡すのでしょうか? あらかじめ決まったアドレスに引き数の入れ物を用意する方法では,関数の入れ子が深くなる場合などに管理が複雑になってしまいます.また,関数終了後は不要となるローカル変数は,どうやってメモリ上に領域を確保し,また解放しているのでしょうか?
実は,これらを格納する「作業領域」としてもスタックを使っているのです.以下に,作業領域が必要になるものを四つ挙げます注4.
注4;戻り値用の領域も必要なのだが,レジスタを利用することが多いので除いている.
- 関数リターン時の戻り先アドレス
- 引き数
- ローカル変数
- テンポラリ変数(計算の途中結果を保存するためにコンパイラが生成する変数)
これらの領域に必要なメモリ・サイズは,宣言された型や式で決まるので,コンパイル時に分かります.関数呼び出し時は,スタック領域の先頭から(1)~(4)の合計のメモリ・サイズの領域を確保します注5.逆に関数リターン時は,領域を解放します.スタック領域の確保と解放は,スタック・ポインタの値の加算/減算だけでそれぞれ行うことができます.
注5;実際には,消費メモリ量と実行速度の両方の観点からスタックにすべての領域を割り付けることはせず,一部を高速アクセスが可能なレジスタに割り付けることが多い.重要なのは,どこかに領域を確保する必要があるということである.
● 関数フレーム,またはスタック・フレーム
このように確保した領域は関数ごとに固有で,関数フレーム(またはスタック・フレーム)と呼ばれます.関数フレームに割り付けたデータはスタック・ポインタからの相対アドレスでアクセスします.関数フレームにどの順番でデータを割り付けるかは呼び出し規約で決まります.呼び出し規約とは,引き数が複数個ある場合にソース上で右の引き数から順々に割り付けるなど,関数フレーム内における変数マッピングのための規約です.これはコンパイラの処理仕様で決まっており,C言語レベルでは規定されていません注6.
注6;そのため,通常は,スタックの構造に依存するようなトリッキなプログラムを書いてはいけない.
図8に関数フレームの例を示します.引き数としてchar型の整数aとbを取る関数FuncAをmain関数から呼び出したとき,メモリ上のスタック領域の先頭は0xff10から関数FuncAの関数フレーム分として0xff08まで伸長します.実引き数1と2は引き数aとb用に確保したアドレス0xff0c,0xff0eにそれぞれ格納します.関数FuncA終了時はスタック・ポインタを関数フレーム分増加させ,関数フレームを解放します.
図8 関数フレームの例
引き数としてchar型の変数aとbを持ち,char型の変数resultを返す関数FuncAの関数フレームは,例えばこのように確保される.
補足ですが,関数呼び出しが入れ子になる場合は,関数フレームが関数呼び出しの順にスタックに確保されます.そして,関数リターンごとに一つずつ関数フレームが削除されます.図7で示した戻り先アドレスの例と同じように,関数フレームもLIFOの性質で上手に処理できます.