[戻る]
シリアルハンドルによる不正メモリアクセス回避注)2000/02/13 時点の情報に基づいた内容です。現状とは合致しない可能性があります。C/C++ における、 破棄されたメモリブロックに対する不正アクセス問題と、 それを解消するシリアルハンドルと呼ばれる手法について記述します。 問題提起
C/C++ におけるメモリ確保解放は、プログラマの責任で管理しなければなりません。 そのため、メモリ管理周りでバグが発生しやすいです。 ありがちなのが、以下のような解放済みメモリに対する不正アクセスです。 int *p = (int *)malloc( 256 ); : free( p ); : *p = 0 ; /* 不正なメモリアクセス */この例は非常に単純ですが、 実際の例はもっと複雑で、原因特定が難しいバグを引き起こします。 ゲーム内オブジェクトのワークメモリを、 動的に割り当て開放するというスタイルの実装は、 ゲームプログラムでは広く採用されています。 ゲーム内オブジェクトの生成・破棄は、頻繁に発生します。 いつ破棄されるか予想が難しいワークメモリが、 互いにポインタ参照しあうというシチュエーションが発生します。 そのため、破棄されたメモリに対する不正アクセスは、 ゲームプログラムでありがちな厄介なバグとなります。 ポインタが指す先のメモリブロックが、存在しているか or すでに破棄されているかを、 そのポインタを通して知ることは困難です。 ポインタだけでは足りず、何らかの補助的な仕組みが必要です。 後述するシリアルハンドルは、 この問題を解決するシンプルかつ効果的な手段です。 シリアルハンドルとは
ポインタとシリアルハンドルは、相互に変換できるとします。 ただし、そのシリアルハンドルが破棄済みの場合、 ポインタへの変換結果は NULL になるとします。 シリアルハンドルは、 過去に生成したものと同一値が生じることのないハンドルです。 具体的に言うと、 ポインタ → シリアルハンドル の変換を行い得られるシリアルハンドルは、 過去に生成したどのシリアルハンドルとも異なる値です。 いつだれが開放するかわからないメモリブロックを指すポインタは、 ポインタのまま利用せず、シリアルハンドルに変換しておきます。 メモリブロックを破棄する時は、 そのメモリブロックを指すシリアルハンドルも破棄します。 メモリブロックにアクセスする際は、 その都度シリアルハンドルをポインタに変換して、 有効なポインタ(非 NULL)が得られたことを確認した上でアクセスします。 シリアルハンドルを扱う、以下のような API があるとします。 これらの API を利用して、 先のコードは以下のように記述できます。 int sh = p2sh( malloc( 256 ) ); : int *p = (int *)sh2p( sh ); if( p != NULL ){ free( p ); dispose_sh( sh ); } : int *p = (int *)sh2p( sh ); if( p != NULL ){ *p = 0 ;} /* 不正なメモリアクセスは回避される */ちょっと冗長になりますが、やむをえないところです。 C++ と template を駆使して、 定型的な関数呼び出しを自動化するというアプローチも考えられますが、 ここでは割愛します。 シリアルハンドルの実装仕組みはとても単純です。 まず、シリアルハンドルの同時生成数の上限を決めます。 ここでは 65536 個までとします。シリアルハンドルは、以下のような 32 bit 値とします。
struct { int serial ; void *p ; } table[ 65536 ];ハンドル値は、先のテーブルのインデクス値です。 シリアル値は、 同じハンドル値のシリアルハンドルが生成される度にインクリメントされるカウンタで、 毎回ユニークなシリアルハンドルを生成させる効果を持っています。 もし、同一ハンドル値が 65536 回生成されると、 シリアル値がラップアラウンドしてしまい、 過去に生成されたシリアルハンドルと値が衝突しますが、 その可能性は極めて低いだろうということで、考慮しないことにします (衝突の危険を回避したい場合は、シリアル値のビット数を多くすれば良い。 せいぜいプレイ時間数時間のゲームの場合、16 bit で十分すぎるぐらい)。 ポインタ → シリアルハンドル変換関数は、 遊休状態のハンドルを一つ選んで、 シリアル値を更新して、シリアルハンドルを生成します。 そのハンドルに適用されたシリアル値とポインタ値は、 テーブルに記録しておきます。 シリアルハンドル → ポインタ変換関数は、 ハンドル値をインデクスとしてテーブルを参照して、 シリアル値が一致しているなら有効なシリアルハンドルであるとみなして、 ポインタを取得します。無効なら NULL を返却します。 シリアルハンドル破棄関数は、 ハンドル値をインデクスとしてテーブルを参照して、 シリアル値が一致しているなら有効なシリアルハンドルであるとみなして、 テーブル上のポインタ値を無効化します。 シリアルハンドルの応用
例えば、貴方が動的にメモリ確保して生成したオブジェクトを、 行儀の悪いプログラマに渡さないといけないというケースを想定します。 その行儀の悪いプログラマは、貴方が作成したメモリブロックを、 想定外の方法で破棄しようとし、 また破棄したメモリブロックに容赦なくアクセスを試みようとします。 このようなプログラマに対して、 動的に確保したメモリを渡すのは、 とてもリスキーなことです。 不正なメモリアクセスに伴い発生するバグは潜在化することも多く、 彼が引き起こした難解なバグに、貴方は振り回される可能性があります。 このようなケースを確実に回避する方法として、 シリアルハンドルはとても強力な手段となり得ます。 シリアルハンドルを利用すると、 先の想定シチュエーションの問題は解消します。 具体的に言うと、 何らかのワークメモリを確保して返却する API を作る際、 そのポインタを返却するのではなく、シリアルハンドルに変換した上で返却します。 int CreateObject(){ Object *p = (Object *)malloc( sizeof( *p ) ); return( p2sh( p ) ); };そのワークメモリに対してアクセスを行うあらゆる API は、 ワークメモリのポインタを直接受け取るのではなく、 シリアルハンドルを受け取るようにします。 bool SetObjectState( int sh , int state ){ Object *p = (Object *)sh2p( sh ); if( p == NULL ) return( false ); p->state = state ; return( true ); };そして、シリアルハンドル → ポインタ 変換 API は非公開とします。 以上です。 どんなに行儀の悪いプログラマでも、 シリアルハンドルから、ワークメモリの位置を特定することはできないため、 不正メモリアクセスが発生することはなくなります。 このように、頑丈なプログラムモジュールを作成したい場合、 シリアルハンドルは非常に有効な手段となります。 [戻る] |