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

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

2007.3

前回は、スタックメモリを使った関数コールの仕組みまでを説明したけど、覚えてるかな?

今回は、スタックメモリに関する注意点から説明をはじめよう。やっと組込み向けの内容になるんだ!

スタックオーバーフロー

マルチタスク環境では、複数のタスクが同時並行的に動作したよね。これはつまり、それぞれのタスクは別々の関数を独立して実行しているということなんだ。

このことを実現するために、スタックメモリはタスクごとに別々に割り当てられるんだ。ここで、ひとつ関数を考えてみよう。特定のファイルから配列のバッファにデータを読み出すというソースコードだよ。

コード例

C言語の書式としては正しいし、コンパイルもエラーにならない。きっと、実行してもちゃんと想定通りの動作結果になるはず。だけど、この関数にはシステム異常を引き起こす可能性が潜んでいるんだ。どこが問題かわかるかな?

そう、答は buf[ ] という配列の大きさなんだ。

char型で要素数が4096個だから、buf[4096]は4096バイト(4KB)のメモリを占有することになる。この関数を「スタックメモリを1KBしか持っていない」タスクAがコールしたら どうなるだろう。前回の内容を思い出してほしい。read_file()関数に入ったときに「Auto変数がスタックメモリに積まれる」んだったよね。

CPUはスタックのトップをスタックポインタ(SP)というレジスタに保持するだけで、スタックメモリがどれくらい確保されているのかは全く関知していない。buf[ ]のサイズ分を積んでしまうと、SPが指すアドレスは、当然、タスクAに割り当てられたスタックメモリの範囲をはるかに超えたところになってしまう。

説明図

さらに、変数sizeやopen(), read()等の関数で宣言されているAuto変数は、その後ろに積まれていくから、どんどんはみ出していくんだ。

ここで、buf[]にデータを読み込んだり、sizeに値を代入したりすると、「別の目的で使用されているメモリ」を壊してしまうことになってしまう。その結果、システムが正しく動作しなくなったり、最悪はプログラム例外を起こしてシステムがハングアップすることが予想される。この現象を「スタックオーバーフロー」というんだ。

スタックオーバーフローを起こさないためには

スタックオーバーフローが起こるかどうかは「タスクのスタックサイズ」に依存する。つまり、同じ関数でも「大きなスタックメモリを持つタスクB」で実行すれば表面化しないんだ。これも問題をやっかいにしている一因だね。

WindowsやLinuxなどの汎用OSでは、プロセス(スレッド)に割り当てられる標準のスタックサイズが大きい(1MBなど)から、実はスタックオーバーフローの問題はほとんど起こらないんだ。だけど、限られたメモリ資源で動作する組込みシステムでは、大きなスタックメモリを潤沢に割り当てることはできない。場合によっては、タスクの関数コールシーケンスを詳細に解析して、スタックメモリをギリギリまでチューニングしなければならないケースもあるくらいだよ。

だから、この問題は、WindowsやLinuxのシステムで動作していた プログラムを組込みシステムに移植したときに、表面化しやすいと言えるね。

スタックオーバーフローによって引き起こされる現象は、メモリのどこが壊されるかによって、単なる表示の文字化け程度から、例外発生によるシステム全体のハングアップまで、実に様々だ。

現象を見ただけでは、なかなかスタックオーバーフローが原因とは突き止められないものだよ。そういう意味でも、非常にやっかいな設計上のバグの一つといえるんだ。スタックオーバーフローを起こさないためには、プログラミング上の注意点があるから、それを説明しよう。

プログラミング上の注意

スタックオーバーフローを起こさないソースコードを書くときの注意点をあげてみよう。

  1. 大きなデータをAuto変数として宣言しない
  2. 再帰呼び出しを使わない

(1)大きなデータをAuto変数として宣言しない

前半で例にあげたように「大きな配列をAuto変数で宣言する」のは、直接的にスタックオーバーフローを引き起こす原因になるから、避けるべきなんだ。だけど、配列の要素数が少なければよいのかというとそうでもない。

例えば、long型配列の場合はchar型配列の4倍のメモリが必要になる。配列がどれくらいのメモリを占有するかを意識する必要があるということだね。このような「大きな配列」については、ソースコードを見れば一目瞭然だから、コードレビューを実施することで、事前に潰すことができるんだ。じゃあ、こんな場合はどうだろうか。

コード例1

MY_DATAという構造体にパラメータを設定して送信するというイメージだよ。ここで、MY_PACKET構造体が以下の形なら、特に問題はないだろうね。

コード例

だけど、例えば下のようなUnicode文字列を含む構造体だったとしたらどうだろう?一般的なUnicodeは1文字が2バイトで表現されるから、unsigned shortの配列としてもっているよね。

コード例

結果として、この構造体のサイズは1KBを超えることになるんだ。このように構造体/共用体のサイズは、一見しただけではわからない。構造体/共用体をAuto変数として宣言する場合は、どれくらいのメモリを占有するのか内部構造も含めて頭に入れておく必要があるんだ。

一般に、大きなデータを扱う場合は、次のような方法でスタックメモリ以外の領域に実体が置かれるように設計する。

  • malloc()等を使ってヒープメモリから確保する
  • グローバル変数として宣言する
  • Auto変数宣言にstatic修飾子をつける

グローバル変数や static修飾された変数の領域はあらかじめスタックとは別の場所に確保されるから、スタックオーバーフロー問題を起こすことはないんだ。

「どこに確保されるのか?」については、また別の回で話すつもりだから、待っててね。コード例1をグローバル変数を使って修正したものは下のようになる。Auto変数は、それを指すポインタだけになるんだ。 ポインタ変数は32-bit CPUでは4バイトだから、スタックメモリを圧迫しない。

コード例2

コード例2では実体であるg_pktが無くなることはないけど、実体を動的に確保した場合には、「ポインタが指している先のメモリがちゃんと存在するか?」などのチェックをする必要が出てくるんだ。

(2)再帰呼び出しを使わない

再帰呼び出しとは、関数の中から自分自身の関数を呼び出す構造をいう。これが、なぜ問題となるかは・・・もうわかるよね?

関数コールの再帰レベルが深くなるに従って、Auto変数がどんどんスタックに積み上がっていくためだね。Auto変数が8バイト分しか宣言されていない関数でも、再帰的に100回コールされたら、 少なくとも800バイト以上のスタックメモリを消費してしまうことになるんだ(極端な例だけど…)。

また、再帰呼び出しは、スタックの問題だけではなく、終了条件を一歩間違えるといつまでも関数を抜けてこない「無限ループ」に陥りやすいという性質もあるから、一般に組込みシステムでは「ご法度」とされているんだ。アルゴリズムの関係上、どうしても仕方ない場合を除いて、有限回数のループ処理として実装するべきだろうね。

スタックトレース

スタックメモリの情報は、デバッグ時にとても役に立つんだよ。スタックの中を解析すれば「関数コールのシーケンス」がわかるからなんだ。あるタスクが不具合で止まってしまったときに、

  • どの関数のどのあたりを実行していたのか?
  • どのようなパスを通ってその関数がコールされたのか?
  • 引数の値は?

などが分かったら問題個所を特定しやすいよね。

組込み開発で実際のターゲットボード上のデバッグを行う「デバッガ」と呼ばれる機器には、プログラムの実行を止めたときに、タスクのスタックメモリの内容を関数名で表示させる機能を持ったものがあるんだ。このような機能は「スタックトレース」と呼ばれていて、特に不具合の解析時には大変有効なツールだよ。

『スタックってなあに?』はこれで終るけど、最初に話していた、「スタックがあふれて例外になっていたんですよ」「このタスクのスタックサイズはどれくらいにすればいいですか?」「スタックのトレースはとれないのか」・・・・・って話にもついていけるようになったよね!

次回は、ビリーへ質問してくれた内容に答えて、メモリのオーバーフローとアンダーフローについての説明をするね!!

ページのトップへ