デバッグするとバグが出る?! : DLLにまつわる問題
Monday, April 10th, 2006※ このアーティクルはWindows/NT環境下でMicrosoft Visual C++ 6.0 SP3 による開発の最中に出くわした’厄介’なバグとその顛末です。
debug-modeに切り換えたとたんバグが消えてしまうという症状に悩まされるのはよくあることですが、逆にdebug-modeでしか発生しないバグという何とも奇妙な…
何が起こったのか
ここに掲載するアーティクルのため、小さなお試しコードを書いていました。XMLのチュートリアルXMLを用いた状態遷移で、IBM alphaWorksの XML4C(XML Parser for C++)を使ってXMLファイルをパースする試みの最中でした。
「こんなのどーってことないや」とタカをくくって鼻歌混じりにコードをこさえ、コンパイルも実行もいたって正常に完了しました。
僕は普段はもっぱらrelease-modeでコンパイル/実行を繰り返しながら開発を進めていきます。debug-modeは、たまに不可解なバグに遭遇するときにお世話になる程度なんです。
で、何の気なしにdebug-modeでコンパイルし、実行してみると突如として

なんてダイアログが現われたのです。release-modeでは何の問題もなかったというのに…
Visual Studioのデバッガは以下のコードがヘンだと主張していました:
DOMString x("hello");
char* tmp = x.transcode();
cout << tmp << endl;
delete[] tmp; // ここでassert
この最後の delete[] tmp;で"不正なヒープ領域を解放した!"と怒っているようです。
XML4Cが提供するクラスDOMStringはその名のとおりDOM(Document Object Model)内で使われているUNICODE文字列です。DOMStringのメソッドtranscode()は内包するUNICODE文字列をnativeなコード、すなわちShift_JISに変換し、その先頭ポインタを返してくれます。XML4Cのマニュアルには、
DOMString::transcode() で得られたポインタはきちんと解放するように
と書かれていたので、ぼくはその注意を忠実に守っただけですよ…
その原因は?
いくらrelease-modeでおっけーだったからといって、debug-modeでこんなシビアなassert吐くようでは納得いきません。コード生成オプションをさんざんいじくりまわしてダメ!と言われる組み合わせを模索し、ようやくその原因を突き止めました…
XML4CはDLLとリンクライブラリ、そしてヘッダファイルで構成されています。「もしや…」と思ってDLLが使っているDLLを調べたところ、MSVCRT.DLLが使われていたんです。
MSVCRT.DLLとは、Visual C++のruntime-libraryをDLL化したものです。runtime-libraryをDLL化し、それを呼び出すことにすれば、それぞれの実行モジュールにruntime-libraryがリンクされませんから、実行モジュール(ここではDLL)はそれだけコンパクトになります。
不具合の原因はここにありました。
XML4Cは変換結果を格納する領域をXML4C自身が確保します。このとき operator new が使われています。これは最終的には runtime-library が提供するヒープ領域確保関数が呼び出されることになります。つまりこれはMSVCRT.DLLにある確保関数です。
一方、XML4Cを利用するアプリケーションをdebug-modeで構築すると、マルチスレッド/rutime-DLL(オプション:-MDd)のとき、使われるライブラリはMSVCRTD.DLLです。
すると、DLLとアプリケーションとで、使われるruntime-libraryが異なることになります。
この状況においても通常はほとんど問題は生じません。が、僕が遭遇した状況では、(DLLが使っている)release版runtimeが確保した領域を(アプリケーションが使っている)debug版runtimeが解放することになります。
debug版runtimeのヒープ領域確保関数はデバッグのための小さな領域を余分に確保し、解放関数でそこを検証することでヒープ領域の不正なアクセスを検出します。
release版runtimeが確保したヒープ領域には、そのデバッグ用のエリアが存在しません。にもかかわらずdebug版の解放関数が検証を行ない、結果的に異常と判断されたのです。
どうやって解決したか
ほとんどの場合、DLLがどんなコンパイル・オプションで構築されたかなんて気にする必要はありません(し、実際気にしないでしょう)。その意味で今回の例は非常に特殊といえます。DLLのコンパイル・オプションまで調査するハメになったのは、DLLが確保した領域をアプリケーションが解放するという特殊な場合だからです。
このDLLを作ったのが僕ならば、確保した領域を解放する関数もついでに提供するでしょう。そうすれば領域の解放はDLL内で行われるため、領域確保時との整合性が保てます。
しかしながらXML4Cは天下のIBM製、ソースコードが公開されているとはいえ、勝手にいぢくるわけにもまいりません。使う側で何らかの手段を講じることにしました。
まず、DLLとアプリケーションとのコンパイルオプションを一致させること。XML4Cを使うときは:
- マルチスレッド
- ランタイムDLL
- release-mode
でなくてはなりません(コンパイル・オプション -MD)。
最初の2つについてはコードの冒頭に以下のような’オマジナイ’を書いておくことで適合しないコンパイル・オプションを抑止しました:
/* マルチスレッド / runtime-DLL でないならコンパイル停止 */
#if !defined(_MT) || !defined(_DLL)
# error sorry, multithread & runtime-DLL only.
#endif
まことに厳しいのが3番目の制約です。これはすなわちXML4Cを使うアプリケーションをdebug-mode(コンパイル・オプション-MDd)で構築できないことを意味します。
XML4CのAPIドキュメントを丹念に読み返し、幸いにもライブラリ側で領域を取るんじゃなくて、アプリケーション側で用意した領域に出力するUNICODE->native文字列の変換関数 XMLString::transcode() を見つけました。debug-modeではこれを使うようにコードの修正を行ないました。
#ifndef _DEBUG
// release-mode
std::string transocode(const DOMString& x) {
char* tmp = x.transcode();
std::string ret(tmp);
delete[] tmp;
return ret;
}
#else
// debug-mode 変換結果を格納する領域はアプリケーション側で確保する
std::string transcode(const DOMString& x) {
const wchar_t* buf = x.rawBuffer(); // UNICODEバッファと
unsigned len = x.length(); // その長さ
wchar_t* wcs = new wchar_t[len+1]; // 領域確保
char* mbs = reinterpret_cast<char*>(wcs);
*std::copy(buf, buf+len, wcs) = L'\0'; // UNICODEバッファからコピー
XMLString::transcode(wcs, mbs, len*2+1); // nativeコードに変換
std::string ret(mbs); // std::stringを生成
delete[] wcs; // 領域解放
return ret;
}
#endif
DOMString x("Hello");
cout << transcode(x) << endl;
ま、こんなやりかたで急場をしのいだわけですが、このwork-aroundはできれば避けたいですね。だってrelase時とdebug時では異なるコードがコンパイルされるわけで、それでは何の為のdebugだかわかりませんからね。
教訓 : DLLが領域確保する関数を作ったら、その領域を解放する関数も公開すべし!