こんにちは、Japan Developer Support Core チームの松井です。 今回は、.NET Framework アプリケーションから x87 浮動小数点ユニット (FPU) の制御ワードを変更した場合に発生し得る、予期しない問題についてご案内します。
はじめに
Visual C++ ランタイムの _controlfp や _control87 といった関数を用いると、x86 アーキテクチャー上で動作するプロセスの x87 FPU 制御ワード (浮動小数点制御ワード) を変更できます。これにより、浮動小数点演算の精度・丸めモード・例外マスクなどを制御できます。
しかしながら、 .NET Framework アプリケーションから FPU の制御ワードを変更することはサポートされていません 。.NET Framework は既定の浮動小数点制御ワードの値を前提として動作しており、これを変更するとアプリケーション内で予期しない問題が発生する場合があります。実際に弊社で確認している事例では、JIT コンパイラーや CLR の内部処理に影響して、スタック オーバーフロー例外やアクセス違反などにより異常終了するケースが報告されています。
本記事では、簡単なサンプル コードを用いてこの問題を再現する例を示しつつ、推奨される対処方法についてご案内します。
1. 背景
公式ドキュメント _control87, _controlfp, __control87_2 では、/clr (共通言語ランタイムのコンパイル) オプションを使ってコンパイルした場合についての説明として、次のように記載されています。
これらの関数は、コンパイルに /clr (共通言語ランタイムのコンパイル) を使用する場合は無視されます。共通言語ランタイム (CLR) では、既定の浮動小数点精度のみをサポートします。
これは C++/CLI からの呼び出しに関する記載ですが、CLR 自体が「既定の浮動小数点精度のみをサポートする」という前提で設計されている点が重要です。この前提は、C++/CLI から呼び出した場合に限らず、純粋な .NET (C# 等) のアプリケーションから P/Invoke を経由して制御ワードを変更した場合にも該当します。CLR や JIT コンパイラーは制御ワードが既定値であることを前提に各種の処理を行うため、これを変更すると内部処理の挙動が破綻し、ランタイム自体が異常な状態に陥る可能性があります。
また、ゲーム開発者向けの公式ドキュメント Top Issues for Windows Titles - Manipulation of the Floating-Point Control Word においても、浮動小数点制御ワードの操作について次のように案内されています。
As a debugging aid, some developers have been enabling exceptions on the floating-point unit (FPU) via manipulations of the floating-point control word. Doing this is highly problematic and will likely cause the process to crash. (浮動小数点制御ワードを操作して FPU で例外を有効化する手法をデバッグ目的で利用する開発者がいますが、これは非常に問題が起きやすく、プロセスのクラッシュを招く可能性が高いです。)
上記はゲーム開発における留意事項として案内されているものですが、同ドキュメントでは「FPU 制御ワードの操作はプロセスのクラッシュを招きやすい」という点や、「ライブラリー側で異なる丸めルールや例外動作が必要な場合は、ライブラリー内部で制御ワードを保存・復元するべきであり、外部の安全性が確認されていないコード (システム API を含む) を呼び出してはならない」という点について述べられており、これらは .NET Framework アプリケーションを設計・実装する際にも参考になる考え方です。
2. サンプル コード
以下は x86 ターゲットで動作する .NET Framework のコンソール アプリケーションの例です。問題を再現させるためのコードを簡潔に示す目的で、P/Invoke を介して ucrtbase.dll の _controlfp を直接呼び出し、_EM_INVALID 例外マスクを解除しています。これにより、無効な浮動小数点演算が発生した際にハードウェア例外がスローされるようになります。
なお、本サンプル コードでは説明を簡潔にする目的で .NET アプリケーション側のコードから直接 _controlfp を呼び出していますが、実際のお問い合わせ事例では、アプリケーション コードに _controlfp 等の直接的な呼び出しがないにもかかわらず、同等の問題が発生するケースもございます。代表的な原因としては、P/Invoke 経由で呼び出すネイティブ DLL、ActiveX コントロールや COM コンポーネント、サードパーティー製フレームワークなどが、内部処理として FPU 制御ワードを変更している場合があります。アプリケーション コードを見直しても問題の原因が特定できない場合は、利用しているネイティブ コンポーネントやライブラリーが制御ワードを変更していないかについてもご確認いただくことをお勧めいたします。
1 | using System; |
このサンプル コードでは、制御ワードを変更した後、DoSomethingWithFloatingPointValue メソッドの中で double.IsPositiveInfinity および double.IsNegativeInfinity を呼び出して、浮動小数点値に対する単純な判定を行っています。
なお、上記のサンプル コードは現象を再現させやすくするための例であり、再現性は .NET ランタイムのバージョン、CPU のマイクロアーキテクチャー、JIT コンパイラーが生成するコード パスなど、複数の要素に依存します。お手元の環境で問題が再現しない場合であっても、制御ワードを変更したことに起因する問題が潜在していないことを意味するわけではない点にご注意ください。
3. 実行時に発生する事象
弊社で本サンプル コードを x86 構成 (32 ビット プロセス) でビルドし、.NET Framework 上で実行したところ、以下のように Process is terminated due to StackOverflowException. と表示されてアプリケーションが異常終了する挙動を確認しています。
1 | Original control word = 0x9001F |
Original control word = 0x9001F は _controlfp(0, 0) の戻り値、Modified control word = 0x9000F は _controlfp(0, 0x10) で _EM_INVALID マスクを解除した後の戻り値です。この戻り値はハードウェアレジスターの生値ではなく、_controlfp がやり取りするための移植可能な抽象表現で、float.h で定義される定数のビットパターンに従います。0x9001F は _EM_DENORMAL (0x80000) _PC_53 (0x10000) _EM_INVALID (0x10) _EM_ZERODIVIDE (0x08) _EM_OVERFLOW (0x04) _EM_UNDERFLOW (0x02) _EM_INEXACT (0x01) の組み合わせと読み取れ、「精度 53 ビット、全 6 種類の例外マスクすべて設定」という既定の状態を表します。_EM_INVALID (0x10) をクリアした結果として 0x9000F になり、#IA (無効な演算例外) のマスクが解除されたことが確認できます。
Process is terminated due to StackOverflowException. は .NET Framework ランタイムが StackOverflowException 発生時に標準エラー出力に表示するメッセージで、本サンプルではハードウェア例外の再帰的な起因により最終的にスタックを使い切ったことを示しています。
引き続いて、Windows の Application イベント ログには以下のような Application Error (ソース: Application、イベント ID 1000) のログが記録されます。例外コードが 0xc00000fd (STATUS_STACK_OVERFLOW) となっており、また障害が発生したモジュールが CLR ランタイム本体である clr.dll であることが確認できます。
1 | 障害が発生しているアプリケーション名: FpuControlWordRepro.exe、バージョン: 1.0.0.0、タイム スタンプ: 0xdc53a7fd |
記事内ではこのスタック オーバーフローに至る以下の流れを、ダンプ ファイルのコール スタックを含めて詳しく見ていきます。
_controlfp呼び出しにより、_EM_INVALID例外マスクが解除される。- その後、JIT コンパイラー (
clrjit.dll) や JIT が生成したコードの内部で実行される浮動小数点演算において、無効な演算に該当する値が処理された際に浮動小数点ハードウェア例外 (#IA、Invalid Arithmetic Operation) が発生する。 - この浮動小数点ハードウェア例外を CLR が処理する過程で、CLR のベクター化例外ハンドラー (
clr!CLRVectoredExceptionHandler) が呼び出されるが、このハンドラー内部の処理にも浮動小数点演算が含まれており、例外マスク解除済みの状態でこれが実行されるため、再びハードウェア例外がスローされる。 - これが繰り返されることで例外処理が再帰的に呼び出され、最終的にスタックを使い切ってスタック オーバーフローによる異常終了に至る。
3.1. WinDbg でのコール スタック解析
異常終了時に取得したダンプ ファイルを WinDbg で開き、knL コマンドでコール スタックを表示すると、以下のように同じパターンのフレームが約 2,500 階層にわたって繰り返し積み上がっている状態が観測されました。下記はその全体のうち、最上部 (フレーム 00 〜 0d) と最下部 (フレーム a06 以降) のみを抜粋したものです。
1 | 0:000> knL 1000 |
スタックの最下部 (フレーム a17 以降) から、FpuControlWordRepro.Program.Main() メソッドが呼び出されている本来の処理の流れを確認できます。Main メソッドからのメソッド呼び出しに対する初回 JIT コンパイル中であるため、プリスタブ (clr!ThePreStub) を経由して JIT コンパイラー (clrjit!Compiler::compCompile 等) が実行されています。
そして、フレーム a07 付近の JIT コンパイラー (clrjit.dll) の内部処理の最中で、最初の浮動小数点ハードウェア例外が発生しています。JIT コンパイル処理に含まれる浮動小数点演算が _EM_INVALID 例外マスク解除済みの状態で実行されたため、無効な演算による浮動小数点例外がスローされています。
その後、フレーム a05 から 06 にかけて、以下のような Windows / CLR の例外処理パスが実行されます。
1 | ntdll!KiUserExceptionDispatcher ← 浮動小数点例外を受け取る |
CLR のベクター化例外ハンドラー (clr!CLRVectoredExceptionHandler) は、例外発生時の状況を解析するために内部処理として浮動小数点演算を含んでおり、これ自身が例外マスク解除済みの状態で実行されるため、再び浮動小数点ハードウェア例外を発生させます。これによりベクター化例外ハンドラーが再帰的に呼び出され、フレーム 06 〜 0d のパターンが約 2,500 階層に渡って積み重なります。最終的にスタックを使い切ったところで、フレーム 00 の clr!DontCallDirectlyForceStackOverflow (CLR が STATUS_STACK_OVERFLOW を明示的に発生させてプロセスを終了するためのヘルパー関数) に到達し、プロセスが異常終了しています。
3.2. WinDbg での FPU 制御ワードの確認
WinDbg では rF コマンドで現在のスレッドの x87 FPU レジスターの状態を確認できます。これは「本当に制御ワードが変更されているのか」をダンプ ファイルから確認する際に有効なコマンドで、同種の問題を調査する際にご活用いただけます。
1 | 0:000> rF |
fpcw が x87 FPU の制御ワード (FPCW レジスター) 、fpsw がステータス ワード、fptw がタグ ワードです。WinDbg は fpcw の値に加えて、そのビット表現を rn 53 puozdi のようにデコードして表示します。rn は丸めモード (round to nearest)、53 は精度 53 ビット、続く小文字 6 つはマスクされている例外を表します (P=Precision/Inexact、U=Underflow、O=Overflow、Z=ZeroDivide、D=Denormal、I=Invalid)。例外がアンマスク (有効) になっている場合は、該当の文字が大文字 (I など) で表示されるため、一望で識別できます。
_controlfp の戻り値と FPCW レジスターの値が一致しないことについて
ここで、アプリケーションが入力として出力した _controlfp の戻り値 (0x9001F および 0x9000F) と、上記 rF の fpcw=027F が一致しない点にお気づきの方もいらっしゃるかもしれません。この不一致には以下の 2 つの要因が絡んでいます。
(1) _controlfp の戻り値はハードウェア レジスターの生値ではない
前述のとおり、_controlfp が戻す値は float.h で定義される抽象表現であり、ハードウェアの FPCW レジスターとはビットレイアウトが異なります。両者を同じ状態で並べると以下のようになります。
| 項目 | _controlfp の戻り値 |
FPCW レジスター |
|---|---|---|
| 全 6 例外マスク + 53 ビット精度 + 最近接丸め (既定) | 0x9001F |
0x027F |
_EM_INVALID をアンマスクした状態 |
0x9000F |
0x027E |
このように、0x9001F と 0x027F は実際には同じ FPU 状態を表しており、表現形式が異なるだけです。
(2) ダンプ取得時点の制御ワードが既定値に戻っている
アプリケーション コードでは _EM_INVALID をアンマスクしたため、その直後のハードウェア FPCW は本来 0x027E となっていたはずです。しかし上記 rF の出力ではダンプ取得時に fpcw=027F (全例外マスク、つまり _EM_INVALID もマスク済み) となっており、アンマスクしたたずの _EM_INVALID がマスクされた状態に戻っています。
この現象は、例外ディスパッチの過程で FPU 状態が退避・復元されることに起因します。Windows では x86 例外発生時に CONTEXT レコードを介して FPU 状態も含めたスレッド コンテキストが保存・復元されます。今回のようにベクター化例外ハンドラーが再帰的に呼び出されるケースでは、その途中で OS や CLR の内部処理が例外処理用のデフォルト状態を設定しているため、最終的にスタック オーバーフローに至る頂点 (clr!DontCallDirectlyForceStackOverflow) では FPCW が既定値に戻った状態となっているものと考えられます。
そのため、「ダンプ取得時の FPCW が既定値だからといって、それまでの処理でも制御ワードが変更されていなかった」とは限りません。同種の問題を調査する際には、ダンプ取得時の FPCW の値だけではなく、コード上で _controlfp 等の呼び出しが行われていないか、例外発生時のコンテキスト レコード内の FPCW はどうなっていたか、などもあわせてご確認いただくことをお勧めいたします。
例外発生時のコンテキスト レコードから元の FPCW を確認する
ベクター化例外ハンドラーが再帰的に呼び出される本ケースでは、最初の #IA 例外を引き起こした時点の FPCW を確認したい場合、再帰の最内側 (clrjit.dll の浮動小数点演算が例外を引き起こした地点) で発生した例外のコンテキスト レコードを参照する必要があります。コンテキスト レコードのアドレスは、コール スタック上の例外処理関連のフレームに渡された引数から取得できます。
ここでは、本記事執筆時点の .NET Framework 4.x ランタイムに含まれる VC ランタイム (VCRUNTIME140_CLR0400) の _except_handler4_common (C:\Program Files\Microsoft Visual Studio\<vs version>\<edition>\VC\Tools\MSVC\<msvc version>\crt\src\i386\chandler4.c) がコンテキスト レコードを引数として受け取っていることを利用する例を示します。なお、このモジュール名や関数のシグネチャは将来のバージョンで変更される可能性があり、必ずしも常にそのまま適用できるとは限りません。実際の調査時には、お使いの環境のシンボル情報をもとに、コンテキスト レコードのアドレスを取得できるフレームをコール スタックから選んでいただく必要がある点にご留意ください。考え方の一例として参考にしていただければと思います。
以下のように、再帰の最内側付近のフレーム a02 (VCRUNTIME140_CLR0400!_except_handler4_common) に切り替え、dv コマンドで引数の ContextRecord を確認します。
1 | 0:000> .frame /r a02 |
ContextRecord = 0x00fde074 が、この階層で発生した例外のコンテキスト レコードのアドレスです。dx コマンドで _CONTEXT 構造体としてデコードして表示します。
1 | 0:000> dx -r1 ((VCRUNTIME140_CLR0400!_CONTEXT *)0xfde074) |
_CONTEXT 構造体のオフセット +0x01c に FloatSave メンバーがあり、これが FPU 状態を保持する _FLOATING_SAVE_AREA 構造体です。今回のコンテキスト レコード 0xfde074 における FloatSave のアドレスは 0xfde074 + 0x1c = 0xfde090 です。これを _FLOATING_SAVE_AREA 構造体としてデコードします。
1 | 0:000> dx -r1 (*((VCRUNTIME140_CLR0400!_FLOATING_SAVE_AREA *)0xfde090)) |
ControlWord が 0x27e となっており、既定値の 0x27F と比較すると最下位ビット (_EM_INVALID を表す 0x01 のビット) がクリアされていることが確認できます。これは、アプリケーション コードで _controlfp(0, _EM_INVALID) を呼び出して _EM_INVALID 例外マスクを解除した状態のままで、clrjit.dll の浮動小数点演算が実行され、#IA 例外が発生したことを示しています。ErrorOffset の 0x728c4f77 は例外を発生させた命令のアドレスを表しており、ln コマンドで確認すると、これがコール スタックのフレーム a07 付近の clrjit.dll 内のアドレスに該当することを確認できます。
このように、例外発生時のコンテキスト レコードを参照することで、ダンプ取得時のスレッドの FPU 状態だけではなく、原因の最も近い状態を確認できます。
この事象は、CLR や JIT が「浮動小数点制御ワードは既定値である」という前提で動作しているために発生します。例外マスクの解除といった一見軽微な変更であっても、CLR の内部処理が依存している前提を崩すため、結果としてランタイム全体の動作が破綻します。
例外マスクの解除以外にも、精度の変更や丸めモードの変更を行った場合に、浮動小数点演算の結果が想定値と一致しなくなる、特定のメソッドの呼び出しで意図しない値が返却される、といった事象が発生し得ます。これらは必ずしも例外として顕在化せず、不可解な計算結果として現れる場合もあるため、一見すると原因の特定が非常に困難な不具合となります。
4. 推奨される対処方法
.NET Framework のアプリケーション コードや、そこから呼び出される P/Invoke、C++/CLI、ネイティブ DLL のいずれにおいても、浮動小数点制御ワードを既定値から変更しないことを推奨いたします。
4.1. アプリケーション コードから制御ワードを変更している場合
_controlfp、_control87、_controlfp_s、_set_controlfp、インライン アセンブリーによる fldcw 命令、fesetexceptflag、fesetround などの呼び出しを取り除き、制御ワードを既定値のまま使用してください。
特定の演算区間のみで丸めや例外動作の変更が必要な場合は、その区間に入る直前に元の制御ワードを保存し、区間の処理終了後 (例外発生時の経路も含む) に必ず元の値へ復元する必要があります。
4.2. サードパーティー ライブラリーが制御ワードを変更している場合
P/Invoke 経由で呼び出すネイティブ ライブラリーが内部的に制御ワードを変更している場合があります。代表的な例として、DirectX を初期化する際、D3DCREATE_FPU_PRESERVE フラグが指定されていないと Direct3D ランタイムは FPU を単精度・最近接丸めに設定します (Top Issues for Windows Titles - Manipulation of the Floating-Point Control Word)。
ネイティブ ライブラリーをラップして .NET から利用される場合は、ライブラリー側で D3DCREATE_FPU_PRESERVE のような制御ワードを保持するオプションが提供されていればそれを利用するか、ライブラリーの呼び出し前後で制御ワードを保存・復元する仕組みを実装することをご検討ください。
4.3. デバッグ目的で例外を有効化したい場合
「無効な浮動小数点演算が発生した箇所をすぐに検知したい」という目的で制御ワードの例外マスクを解除している場合がありますが、.NET アプリケーションにおいては、Visual Studio のデバッガーの設定で NaN や無限大の生成箇所をブレークポイントで停止させたり、コードのレビューで double.IsNaN、double.IsInfinity 等を用いたチェックを追加していただく方法をご検討ください。FPU 制御ワードを変更する手法は前述の通り CLR の前提と矛盾するため、デバッグ目的であってもご利用は推奨されません。
まとめ
.NET Framework アプリケーションにおいて x87 FPU の浮動小数点制御ワードを変更することはサポートされておらず、CLR は制御ワードが既定値であることを前提として動作します。例外マスクの解除や精度・丸めモードの変更を行うと、スタック オーバーフローやアクセス違反による異常終了、計算結果の不整合といった予期しない問題を引き起こす場合があります。アプリケーション コードおよびそこから呼び出されるネイティブ コードのいずれにおいても、.NET Framework アプリケーションでは既定の浮動小数点制御ワードを維持することをお勧めします。
本記事が .NET Framework アプリケーションのトラブルシューティングや設計の参考になれば幸いです。
本ブログの内容は弊社の公式見解として保証されるものではなく、開発・運用時の参考情報としてご活用いただくことを目的としています。もし公式な見解が必要な場合は、弊社ドキュメント (https://learn.microsoft.com や https://support.microsoft.com) をご参照いただくか、もしくは私共サポートまでお問い合わせください。