プログラミング

【C言語組み込み】スタックって何?

今回は、そもそもスタックとは何?ということを解説します。
組み込み初心者にとってはなかなか理解しにくいところかもしれませんが、組み込みエンジニアとしては絶対に理解しておくべきことなので、しっかり理解してください。

スタック(コンピュータ用語)

コンピュータ用語としての一般的なスタックは、以下の図の構造を持つものです。

  • 複数のデータを格納できる入れ物(バケツのようなイメージ)
  • データを格納するときは一番上に積む(push)
  • データを取り出すときは、一番上に積まれているものを取り出す(pop)

データの構造からLIFO(Last In First Out)とも表現されます。名前の通り、最後に入れたものを最初に取り出す、という意味です。
先に入れたものを取り出すためには、上に積まれたものを先にすべて取り出す必要があります。

組み込みでのスタック

それでは、組み込みでのスタックがどういったものかを見ていきましょう。基本はすでに説明したものと同じですが、もう少し具体的な例でみていきます。

組み込みの場合、スタックのサイズをあらかじめ指定します。たいていの場合、RTOSでスタック領域が管理され、タスク1つに対して1つのスタック領域を確保します。

例として、アドレス0x2000から256byteのサイズ分、スタック領域を確保したとします。このとき、一番最初にデータを入れる場所は、0x2000ではなく0x20FFです。次にデータを入れるアドレスをスタックポインタと呼び、今回の場合初期値は0x20FFです。

ここに4byteのデータをpushすると以下の図のようになり、スタックポインタは0x20FBと変わります。

ここからさらに状況が進んで50byteのデータが入っている状態が以下の図です。ここからデータを4byte分pushするとデータが4byte増えてスタックポインタが0x20C9に変わり、4byte分popするとデータが4byte減ってスタックポインタが0x20D1と変わります。

スタックにpushされるもの

次に、スタック領域にpushされるデータにはどういったものがあるのか、を見ていきます。

スタック領域に入るデータは主に以下です。

  • 一時変数(ローカル変数)
  • 関数コール時に一時保持すべきデータ
  • ディスパッチ時に一時保持すべきデータ

一時変数(ローカル変数)についてはイメージがしやすいと思います。関数内で定義されたstaticでない変数は、関数内でのみ保持されるものなので、スタックを使って一時的にデータを保持します。

残りの2つについてはこれだけではわからないと思いますので、もう少し詳細を見てきましょう。

ただし、注意点として、上記のものが必ずスタック領域に保持されるとは限らないことに留意してください。実際に何かしらの処理をするときには、レジスタという領域にデータを格納する必要がありますが、レジスタの領域が足りなくなった場合にデータをスタックに退避するというイメージになります。

また、コンパイラの最適化によって、表面的には同じ動作をする、より最適な内部動作へと変更されている可能性もあります。


今回の説明では、簡略化のためにレジスタや最適化は考慮せずに説明します。

関数コール時にスタック領域に格納されるデータ

関数コール時にスタックに格納されるデータは、主に以下の通りです。

  • 関数コール前に実行していたプログラムの実行アドレス
  • 引数
  • 返り値

詳細な動作を見ていきましょう。(わかりやすくするために簡略化された説明となります)

実行アドレスの格納

例えば、アドレス0x400に配置されているプログラムがあったとします。
CPUは分岐や関数コールがない限りはアドレスに配置されている順序で1命令ずつ処理がされていくので、0x400、0x401、0x402・・・と順番に処理されていきます。

CPUがアドレス0x410に到達した時点で関数コールがあり、0x500に配置されている関数のプログラムを実行しろ、という命令があると、0x500にジャンプしてここから次の処理をしていきます。
0x500にある関数の処理が終わったとき、0x410の位置に戻ってくる必要がありますが、0x500の関数ではどの位置に戻るか、という情報を持っていません。なぜならば、関数はいろいろなところから呼ばれる可能性があり、関数終了時に戻るアドレスは毎回同じではないからです。

ですので、関数コールで0x500にジャンプする前に戻るアドレスをスタックにpushし、関数が終了したらpushしたアドレスをpopして元のアドレスにジャンプする、といった処理になります。

引数の格納

同じように0x500への関数へジャンプするケースで考えます。

0x500の関数はどこからコールされるかわからないので、どこからコールされても同じ動作をする必要があります。よって引数を渡す時も、関数コールをする直前に引数の値をスタックにコピーしてpushし、0x500の関数へジャンプしてからpopすることによってデータの受け渡しを実施します。引数をスタックに入れる順序をルール化しておけば、正しくデータの受け渡しが可能となります。

こうやって見ると、関数内で引数の値を変更しても関数コール前の値が変更されない理由がよくわかりますね。関数内で引数の値を変更しても、スタック内のデータが更新されるだけとなります。このデータは関数内でpopされて終わりとなので、関数コール前の値には影響しないことがわかります。

返り値の格納

返り値についても引数と考え方は同じになります。

0x500の関数から0x400の関数に値を受け渡すために、元のアドレスにジャンプする前に返り値をコピーしてスタックに保持し、元のアドレスに戻ってからpopすることで0x400の関数が返り値を得る、ということですね。

関数コール全体でのpush/popの流れ

以上、関数コールでの3つのスタックに保持されるものを見てきましたが、Last In First Outの原理を考慮して処理の順序を考えると、以下の通りになります。

  1. 0x410に到達する
  2. 実行アドレスをスタックにpushする (スタック1のpush)
  3. 引数をスタックにpushする (スタック2のpush)
  4. 0x500の関数にジャンプする
  5. 引数をスタックからpopする (スタック2のpop)
  6. 関数内の処理をする
  7. 関数が完了したら元の実行アドレスをpopする (スタック1のpop)
  8. 返り値をスタックにpushする (スタック3のpush)
  9. popした実行アドレスへジャンプする
  10. 元の関数で返り値をpopする (スタック3のpop)

上記の順番であれば最後に入れたものを最初に取り出すという原理が守れていることがわかります。

注意点

関数コールについてみてきましたが、ここまででわかる注意点は、「引数や返り値に大きなデータを入れると、その分スタック領域を使用する」ということです。

構造体を引数で渡すときにはポインタ渡しにする、というのはよく言われることですが、それはこういった理由があることがわかりますね。

ディスパッチ時にスタック領域に格納されるデータ

ディスパッチとは、CPUが1つで複数のタスクが存在するときに、あるタスクから別のタスクにプログラムの実行権を渡すことです。

一例として、Aのタスクが実行中にAよりも優先度の高いBが動作し始めるとき、Aのタスクの処理を一時的に止めてBの処理を先に実行する、といったことが発生します。

Aのタスクの処理が再開するときに、前回どこまで実行されていたかを覚えておく必要があります。これを実現するために、次に実行するプログラムのアドレスをスタック領域に保持します。同様に、Aのタスクで使用していたスタックポインタも覚えておく必要があるので、こちらもスタック領域に保持します。

まとめ

以上、組み込みにおけるスタックの説明とスタックに格納されるものについて解説しました。

初めて知ったよ、という人は、これを機にしっかり理解していただいて、今後実装するときに意識していただきたいと思います。

次回は、用意したスタック領域以上のデータを格納してしまったとき、つまりスタックオーバーフローについて説明したいと思います。

COMMENT

メールアドレスが公開されることはありません。

CAPTCHA