学校では教えてくれないこと

スタックってなあに?(1)

2006.12

「昨日、風邪引いて会社休んじゃったから、仕事がスタックしちゃって」というような会話を聞いたことがあるんじゃないかな。「スタック」は「積み重ねる」という意味の単語だから、わりとイメージしやすいよね。でも、組込み業界では以下のような使い方をするんだ。

「スタックがあふれて例外になっていたんですよ」「このタスクのスタックサイズはどれくらいにすればいいですか?」「スタックのトレースはとれないのか」

・・・なんだか雰囲気が違うよね。(^-^;これらはどういうことを意味しているんだろうか。

スタックのおさらい

大学などで情報工学を勉強したことのある人なら、「スタック」といえば「アルゴリズムとデータ構造」の授業を思い出すんじゃないかな?「スタック」は、入れる口と取り出す口が等しい袋のようなデータ構造なんだ。以下に、スタック操作の例を示してみよう。

スタック操作1

[ がスタックの底を示しているんだけど、右方向に積みあがっていく様子がわかるかな?スタックに要素を追加することをPush、一番上の要素を取り出すことをPopというんだ。また、最後に入った要素が最初に取り出されるので、スタックはLIFO(Last-In, First-Out)とも呼ばれる。

ちなみに、スタックと並んで教えてもらうデータ構造に「キュー」があり、これは、入れる口と取り出す口が異なる筒みたいなものだ。こちらは最初に入った要素が最初に取り出されるので、FIFO(First-In, First-Out)ということになる。

逆ポーランド記法

スタックの例として、本などでは、逆ポーランド記法がよく取り上げられる。逆ポーランド記法とはカッコがいらない計算表記法で、例えば、

5 3 +

と書くと、5+3の意味になるんだ。

((5+3)*2-4)/3=

というような加減乗除が混ざった式は、通常は演算順序を指定するためにカッコが必要だよね。でも、逆ポーランド記法を使うと、

5 3 + 2 * 4 - 3 / =

という風にカッコを使わずに表現できるんだ。演算子を後ろに書くのがポイントで「後置記法」とも呼ばれている。この計算方法をプログラムで実現するのに「スタック」の考え方が使えるんだ。つまり、

  • 数値だったらスタックにPushする
  • +、- 、× 、÷ の演算子がきたら2回Popする
  • 演算したら答えをスタックにPushする
  • =がきたら1回Popして答えとする

という手順(こういうのをアルゴリズムという)を繰り返すと、計算結果が得られるというものなんだ。わかるかな?上記の例((5+3)*2-4)/3=の場合を、順番に見てみよう。逆ポーランド記法では、5 3 + 2 * 4 - 3 / = となる。

スタック操作2

で、答え4が得られることになる。

では、これがプログラムの世界でどのように利用されているかを説明していこう。これもまだ一般的な話なんだけど、もうちょっと我慢してしてね。

関数とスタック

C言語のプログラムは、関数の集まりで構成されているのは知っているよね。例えば、以下のようなmain(), funcA(), funcB()で構成されるプログラムを考えてみよう。

プログラム1

例だから、各関数の内容自体はあまり気にしないでほしい。
実行結果は、以下のようになる。

プログラム2

当たり前じゃないか、と思うかもしれないけど、このプログラムを実行するのにスタックが重要な役割を果たしているんだ。

関数を呼ぶためには

このプログラムの関数呼び出しシーケンスは、以下のようになるんだ。どんどん入れ子になっていくよね。

プログラム3

CPUは、指定したプログラムコードがある場所(アドレス)にジャンプする命令を持っている。funcA()からfuncB()を呼び出すときには、funcB()のプログラムコードがあるアドレスにジャンプする命令を実行すればいいってわけだ。これはイメージしやすいよね。だけど、ただジャンプするだけでは問題が出てしまうんだ。

funcA()では、変数a1に100を代入してからfuncB()を呼び出す。funcB()での処理が終わって、funcA()に戻ってきたときにも、もちろんa1=100であってほしいわけだから、これはどこかに覚えておかないといけないよね。さらに、どこにジャンプすればfuncB()からfuncA()に戻れるのか?ということも覚えておかないといけないんだ。

ただし、funcB()を抜けたら、変数b1,b2の値はもう覚えておく必要はなくなる。これらが有効なのはfuncB()の中だけだから。これをスタック構造を使って解決していているんだ。実際にどうやっているのかを説明していこう!

スタックメモリ

通常、プログラムの実行単位(タスク、スレッド)ごとにスタックメモリという特別なメモリ領域が割り当てられているんだ。スタックメモリとは、そのタスクや関数内だけで使われる変数やアドレス情報なんかを置いておくためのメモリ領域だ。タスクを終了する、または関数から抜けると、スタックの内容も破棄される。

ちなみに、グローバル変数は、他のタスクや関数からもアクセスできるようにスタックメモリとは別の領域に配置されているんだ。この中にスタックを構築するんだけど、CPUは、その一番上のアドレスをスタックポインタ(SP)レジスタに保持している。pushしたり、popしたりすると、SPレジスタが移動するわけなんだ。

あと、多くのシステムではフレームポインタ(FP)レジスタというものが用意されているんだけど、SPレジスタやFPレジスタの具体的な使い方をみていこう。funcA()からfuncB()を呼び出すところに着目して説明するね。

関数を呼び出すとき

CPUは、funcA()の処理に入ってくると、変数a1があるから、これをスタックにpushする。厳密にはa1の値が格納されているメモリのアドレスになるんだけど、ここでは簡単にして、単にa1としておくね。

SP=1 FP=0 [a1

次に、funcB()を呼び出すわけだけど、ここでfuncB()から戻ってきたときのアドレス(リターンアドレスという)をスタックにpushする。リターンアドレスは、funcB()のすぐ後ろの行だと思ってもらえばいいかな。funcB()が戻ってきたら実行されるべきコードということになるよね。

プログラム4

ここでは仮にretBとしよう。

SP=2 FP=0 [a1 retB

次に、FPレジスタを更新する。まず、現在のFPレジスタの値をスタックにpushして、現在のSPレジスタの値をFPレジスタにコピーする。

SP=3 FP=3 [a1 retB 0

これでfuncB()を呼び出す準備ができたんだ。このようなスタックの状態で、funcB()のアドレスにジャンプする。funcB()では、さらに変数b1,b2が宣言されているから、これらもスタックに積まれる。

SP=5 FP=3 [a1 retB 0 b1 b2

呼び出し元の関数に戻るとき

funcB()でログ表示が終わると、ここから呼び出し元のfuncA()へ戻るんだ。 さて、どうやって戻ろうか?

そうだね。 スタックにpushしておいたretBを取り出してジャンプすればよさそうだね。だけど、ここで問題が出てくるんだ。 retBを取り出すためには何回スタックをpopすればいいんだろう? funcB()で宣言する変数の数は、いつも2個とは限らないから、retBを取り出すまでのpop回数は分からないということになるよね。

ここでFPレジスタの登場なんだ! このときのために、funcB()にジャンプする前にSPレジスタの値を保存しておいたんだよ。FPレジスタに保存しておいた値をSPレジスタに設定するとどうなるんだろう。

SP=3 FP=3 [a1 retB 0

スタックのトップがまき戻ったイメージだね。

この時点でb1, b2はスタックの管理外になってしまうことに注意しないといけない。ここでpopすると、0が取り出せる。 これはfuncA()におけるFPレジスタの値だったから、FPレジスタに書き戻そう。

SP=2 FP=0 [a1 retB

もう1回popするとやっとretBが得られるんだ。

SP=1 FP=0 [a1

これでfunA()を処理していたときの状態に戻せたよね。 あとはretAにジャンプすれば完了だ。

ちょっとややこしかったかな?ポイントは、C言語のような関数ベースのプログラムの動作には、スタック構造が重要な役割を果たしているということなんだ。ちなみに、ここでは関数内で宣言されたAuto変数だけをスタックに積んだけど、実際には関数に渡された引数も積まれることがあるんだ。次回は、スタックメモリに関して注意すべき点を説明するよ。

スタックってなあに?(2)に続く

さて、ここでちょっとブレイクタイム。

「アルゴリズムとデータ構造」の書籍を紹介してと言う、質問をもらったので、ビリーのオススメを紹介しよう。質問を送ってくれた人、どうもありがとう!

『Cによるアルゴリズムとデータ構造』
関西学院大学教授/工学博士 茨木俊秀 著・オーム社 発行

この本は、アルゴリズムの基本概念をしっかりと解説してくれている入門書だよ。詳しくは、オーム社ホームページを参照してね。

http://shop.ohmsha.co.jp/shopdetail/000000003920/01-02/page1/order/

ページのトップへ