PR

 機器の操作中に突如リセットあるいはフリーズしてしまう――これは,しばしば発生するトラブルです。ここでは,その原因について解説します。

 このトラブルは,典型的なC言語のメモリの扱いに関するバグです。C言語で開発している人の間では,おそらくこれから述べる内容は初歩的で常識的なことのはずです。とりわけ,ソース・コードを書いているときに,実際のターゲットでメモリのデータがどうなっているかを想像できる人は,これから書いてあることを読む必要はありません。しかし,実際にバグは発生していますので,開発に携わっている人がこのC言語の基本中の基本を理解していないことも多いように感じます。このため,あえてこの機会に書くことにしました。

原因と対策

 リセットの原因はその操作を実行するプログラムを見て,すぐに分かりました。関数内のローカル変数に確保した配列に対して,確保した領域を超える書き込みを発生させていたのです。そのプログラムは,図5-1のようなものでした(説明しやすくするために,バグがすぐに分かる仮想的なプログラムにしています)。

図5-1 文字列をスタックに書き込むプログラム

 図5-1のようなソース・コードは,もちろん通常はあり得ません。3行目でaからsまでの文字列(19個)があるのに,9行目でローカル領域は16個分しか確保していないからです。しかし,プログラムが大規模になり修正がたび重なると,初心者だけでなくベテランでも見逃すことがあります。

 このミスは初歩的ですが,この例のようにC言語では一目でバグと分かるようなソース・コードでもコンパイル・エラーなしにプログラムができてしまい,実際の機器でも動いてしまいます。そして,実行時にトラブルを発生させてしまいます。

 実際に何が起こっているのか,詳しく見てみましょう。図5-1は,前述のようにaからsまでの文字列をスタックに書き込むプログラムです。これをコンパイルした結果が図5-2の右側です。プログラムの要素すべてがメモリ上に現れています。じっくりと見てみましょう。

 C言語で書いた文は.textセクションというプログラム格納領域にアセンブラ命令で展開されます。そしてグローバル変数は.dataセクションというデータ格納領域に割り当てられ,ローカル変数はスタック領域に割り当てられます。図5-2の右下のメモリ・マップで,スタック領域の一部に塗りつぶされていない所があります。ここは,プログラムには出現しないものの,実はC言語の実行において非常に重要なメモリになります。

図5-2 プログラムとメモリの対比

 C言語の関数呼び出しは,スタックと非常に密接な関係があります。スタックは,教科書などで「先入れ後出し(FILO:first-in last-out)のメモリ」と習います(図5-3)。しかし,C言語ではスタック中の途中のデータを頻繁に書き換えます。またスタックの中は,多くの関数のローカル変数や関数を呼び出すときの引数,今いる関数から戻る番地が混然一体となっています。それが重大な問題を起こします。

図5-3 一般的なスタックのとらえ方

 実際にfunc1( )関数の呼び出しとスタックの関係を図5-4に示したので,見てください。関数を呼び出すとき,その引数をスタック・トップにプッシュします。今回はgstrのアドレス(引数)になります(1)。そしてcall命令でfunc1にジャンプしますが,このcall命令は実は一つの命令で以下のような動作を行っています。
PUSH EIP+5
JMP アドレス

 最初に今のプログラム・カウンタ(EIP)の次の命令のアドレスをスタック・トップにプッシュしてから,呼び出し先にジャンプします(2)。ここまでの処理でスタックは図5-4左のように状態?になります。

 呼び出されたfunc1( )関数では,まずBP(ベース・ポインタ)レジスタの値をスタック・トップに保存します。これは,この直後のBPレジスタを利用し上書きするので,元の値を待避しておくためです(3)。そのBPレジスタに,今現在のスタック・ポインタを代入します(4)。実は,このBP(フレーム・ポインタと呼ぶことが多い)も重要ですので,後で詳しく説明します。

 その次に,SPから0x14(16進数の14,0xは16進数の意味)を引いています(5)。この0x14バイト(10進数では20バイト)が,このfunc1( )関数で使えるローカル変数の部分の大きさです。実際にfunc1()関数で宣言している変数は,文字(char)型で文字16個分の配列(16バイト)と1個のポインタ変数(4バイト)なので,20バイトになります。ここでスタックは状態(2)になります。

 そしてC言語は,このローカル変数を,先ほどのBP(フレーム・ポインタ)と一緒に利用します。今回のfunc1なら,
char buf[16]; BP-0x14
char *p; BP-0x4
となります。図5-2ではA(0012FF60番地)から,0012FF70番地(ローカル変数char *p)のところまで利用します。func1( )の引数*strもフレーム・ポインタ経由で利用します(0012FF7C番地)。
char *str BP+0x8

 これで関数func1( )の呼び出しと,そしてfunc1()のローカル変数が完成しました(中身ではなく,ローカル変数用のメモリが確保されたということです)。C言語の関数のローカル変数は,フレーム・ポインタ経由で利用できるようになっていて,このスタック上に確保されたローカル変数領域や戻り番地,そして引数などの構造を,スタック・フレームと呼びます。

 関数の呼び出しシーケンスと戻り方を分かりやすくするために,先にfunc1( )からmain( )に帰るところを説明します。帰るときは,まずフレーム・ポインタBPの値をSPに代入します(6)。これでスタックの先頭がfunc1( )用に確保したローカル変数の場所よりも下がったので,func1( )のローカル変数はここで命が尽きました(“ローカル変数”という呼び名になるのも分かりますね)。そして,func1( )用に使ったフレーム・ポインタ(BP)を,呼び出されたときの以前のBPに戻すために,スタック・トップからBPを取り出します(7)。これでfunc1( )としての後始末はすべて完了したので,あとはmain( )に戻るだけです。戻るためにret命令が使われていますが,このret命令は実際には,スタック・トップの値をプログラム・カウンタに代入するというように,多くの場合,実現されています。

 最後にmain( )に戻ってきたら,func1( )をcallする前に設定した引数用に積み上げたスタックを取り除き(8),無事に呼び出し処理を行う前と同じ状態になり,これで関数呼び出しに関する処理がすべて完了します。

 さて,ここで呼び出されたfunc1( )のローカル変数のあたりをよく見てください。図5-4のさっきのスタック・フレームのところです。スタックはFILOと言いましたが,C言語の関数におけるスタックの使い方はFILOとは限りません。この図にある通り,ローカル変数はスタックの中にあり,そのスタックの中のローカル変数を直接書き換えてしまうのです。

図5-4 関数呼び出しとスタック

 図5-5で,メモリ上にいろいろなデータが並んでいるのをあらためて確認してください。アドレスの昇順に,
buf[16],*p,以前のBP,戻り番地
がすき間なく,なんの区別もなく並んでいます。ここでbufに17個以上の文字列が入ると,右の「char *p」の領域にあふれ,動作がおかしくなります(今回のプログラムでどうおかしくなるかは,話がそれますので詳しく説明しません)。

図5-5 危険なローカル変数

 また,例えば配列の大きさをbuf[16]と宣言しているにもかかわらず,buf[25]と25番目の要素にアクセスするようにプログラムに書いても,コンパイル・エラーにもならなければ,実行時のエラーにもなりません。ところがこのbuf[25]というのは,呼び出した関数から戻るためのアドレスを保存しているエリアです。これを見れば分かるように,1バイトでも間違って書き換えてしまうと,正しく関数が実行継続できなくなるという危険性があります。

 このように配列の大きさなんて,乱暴な言い方をしてしまえば,コンパイル時に領域を確保するだけのものであって,実行時には何も役に立ちません。もちろん,ポインタを使っても同じです。もし,誤ってこの戻り番地を完全に0クリアしてしまえば,関数から戻るときに簡単に0番地にジャンプしてしまい,場合によってはリセットしてしまったりします。また0にしなくても,想定外のアドレスにプログラム・カウンタが設定されれば正常には実行できなくなり,いとも簡単に暴走したりフリーズしたりしてしまいます。

 もちろん配列のサイズを正しく意識したソース・コードを書けば,問題は発生しません。しかし,この手の問題が発生する場合,実はエンジニアがローカル変数上の配列の領域外アクセスを行ったときに何が起こるのかを知らないことが,問題の根底にあることが多いといえます。誰でもこのようなミスを犯すことはあります。ただ,理解していないエンジニアが書いたプログラムでは,ミスを見つけることも難しくなります。

 なお,Java言語では配列の書き込みがあふれた場合,例外(IndexOutOfBoundsException)が発生します。しかし,C言語では何も守ってくれません。この手のバグは,lintツールや最近の各種静的解析ツール,「Purify」などの動的解析ツールを使えば,自動検出しやすくなりました。もっと積極的にこれらのツールを使うべきでしょう。

技術者必修の基本

 ここで言いたいのは,「だから配列は確保したサイズしか利用しないよう,気を付けること」ではありません。図5-6のように,C言語で記述されたプログラムというのは,メモリ上にローカル変数,命令,グローバル変数,C言語が動作する制御エリアなどが何の壁もなく並んでいるだけです。C言語は,基本的にバグを防いでくれない言語であり,ミスがあってもメモリ上のどこでも自由に書き換えることができてしまいます(高機能なOSを用いた場合には,OSとCPUのメモリ管理ユニット(MMU)で保護をしてくれる機能がありますが,保護される範囲は限定的です)。プログラムの実行時の安全性もこんなにもろいのです。それを理解して安全なソース・コードを書くためには,ソース・コードとメモリの対比表のようなものが,おおよそでも頭に想像できるようになることがベストです。

図5-6 C言語で作成したプログラムを実行したときのメモリ・イメージ

 マイコンが変わっても,このメモリやスタックにべったり依存した基本構造はそんなに変わりません。自分が普段よく使うマイコンどれか一つで動作を確認しながら理解できれば,他のマイコンで開発するときにも大丈夫でしょう。

 普段から,このようなことはやってはいけないことと言われているはずです。そういうときは,なぜそうなのか,しっかりと理解することが必要です。

 もともとC言語は,OS(アセンブラで書いたPDP11用のOSを書き換えたUNIXカーネル)を記述するために作られた言語です。UNIXに限らず,OSの実装をするようなよく分かっている人は,こんなC言語でも何も困らず,むしろ自分で制御ができることを最大限利用します。実際に,意図的に戻り番地を書き換える,なんてことも珍しい技ではありません。おそらくC言語を開発・設計したKen ThompsonもBrian KernighanもDennis Ritchieも,このようなことを理解していない人がC言語を使う,なんてことは想定しなかったのではないかと思われます。

 しかし現実には,C言語は一般アプリケーションの開発に用いられ,安全性が重視される組み込みの分野で最も多く使われる,というのが現実です。エンジニア一人ひとりが,C言語とコンピュータのアーキテクチャを理解して正しく使うべきでしょうが,業界としても次の二つを今まで以上に取り組む必要があると思います。

  • C言語に代わる安全な言語(プラットフォーム)の普及
  • C言語を安全に使うためのツールの整備
  • 個人のミスを防ぐための開発プロセスの導入

 エンジニアの教育やC言語を利用する上での規約なども必要ですが,それだけでは結局は個人のレベル(教育レベルや規約の守り方,規約の抜けなど)に依存するため,100%の解決は難しいといえます。エンジニアがミスをしても,システムの支障が最小限になるような仕組みをテクノロジーとして育てて,開発していくことが重要だと思われます。