.NET Framework で浮動小数点制御ワードを変更した場合に発生する可能性がある問題について

Last Update:
このエントリーをはてなブックマークに追加

こんにちは、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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace FpuControlWordRepro
{
internal static class Program
{
// https://learn.microsoft.com/ja-jp/cpp/c-runtime-library/reference/control87-controlfp-control87-2
[DllImport("ucrtbase.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern uint _controlfp(uint newControl, uint mask);

private static void Main()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X86)
{
Console.WriteLine("Run this sample on x86 architecture.");
return;
}

uint before = _controlfp(0, 0);
Console.WriteLine($"Original control word = 0x{before:X4}");
uint after = _controlfp(0, 0x00000010 /* _EM_INVALID */);
Console.WriteLine($"Modified control word = 0x{after:X4}");

DoSomethingWithFloatingPointValue(1.0);
}


[MethodImpl(MethodImplOptions.NoInlining)]
private static void DoSomethingWithFloatingPointValue(double value)
{
if (double.IsPositiveInfinity(value))
{
Console.WriteLine("Value is positive infinity.");
}
else if (double.IsNegativeInfinity(value))
{
Console.WriteLine("Value is negative infinity.");
}
else
{
Console.WriteLine($"Value is {value}");
}
Console.WriteLine("Did not reproduce the issue here.");
Console.WriteLine("Please note that reproducibility depends on multiple factors, such as the .NET runtime version, the CPU microarchitecture, and the specific code paths taken by the JIT compiler.");
Console.WriteLine("Not reproducing the issue does not mean changing the control word has no problems. It may still cause issues in certain scenarios or under specific conditions.");
}
}
}

このサンプル コードでは、制御ワードを変更した後、DoSomethingWithFloatingPointValue メソッドの中で double.IsPositiveInfinity および double.IsNegativeInfinity を呼び出して、浮動小数点値に対する単純な判定を行っています。

なお、上記のサンプル コードは現象を再現させやすくするための例であり、再現性は .NET ランタイムのバージョン、CPU のマイクロアーキテクチャー、JIT コンパイラーが生成するコード パスなど、複数の要素に依存します。お手元の環境で問題が再現しない場合であっても、制御ワードを変更したことに起因する問題が潜在していないことを意味するわけではない点にご注意ください。

3. 実行時に発生する事象

弊社で本サンプル コードを x86 構成 (32 ビット プロセス) でビルドし、.NET Framework 上で実行したところ、以下のように Process is terminated due to StackOverflowException. と表示されてアプリケーションが異常終了する挙動を確認しています。

1
2
3
4
Original control word = 0x9001F
Modified control word = 0x9000F

Process is terminated due to StackOverflowException.

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
2
3
4
5
6
7
8
9
10
11
障害が発生しているアプリケーション名: FpuControlWordRepro.exe、バージョン: 1.0.0.0、タイム スタンプ: 0xdc53a7fd
障害が発生したモジュール名: clr.dll、 バージョン: 4.8.9325.0、タイム スタンプ: 0x693b62aa
例外コード: 0xc00000fd
フォールト オフセット: 0x0045984a
フォールト プロセス ID: 0x30D0
アプリケーションのフォールトの開始時刻: 0x1DCE93290F356A1
Faulting アプリケーション パス: C:\Users\user1\Desktop\FpuControlWordRepro.exe
Faulting モジュール パス: C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll
Report Id: ece04306-5e7e-4af8-8425-7a50fbd33b8f
Faulting パッケージの完全名:
Faulting パッケージ相対アプリケーション ID:

記事内ではこのスタック オーバーフローに至る以下の流れを、ダンプ ファイルのコール スタックを含めて詳しく見ていきます。

  1. _controlfp 呼び出しにより、_EM_INVALID 例外マスクが解除される。
  2. その後、JIT コンパイラー (clrjit.dll) や JIT が生成したコードの内部で実行される浮動小数点演算において、無効な演算に該当する値が処理された際に浮動小数点ハードウェア例外 (#IA、Invalid Arithmetic Operation) が発生する。
  3. この浮動小数点ハードウェア例外を CLR が処理する過程で、CLR のベクター化例外ハンドラー (clr!CLRVectoredExceptionHandler) が呼び出されるが、このハンドラー内部の処理にも浮動小数点演算が含まれており、例外マスク解除済みの状態でこれが実行されるため、再びハードウェア例外がスローされる。
  4. これが繰り返されることで例外処理が再帰的に呼び出され、最終的にスタックを使い切ってスタック オーバーフローによる異常終了に至る。

3.1. WinDbg でのコール スタック解析

異常終了時に取得したダンプ ファイルを WinDbg で開き、knL コマンドでコール スタックを表示すると、以下のように同じパターンのフレームが約 2,500 階層にわたって繰り返し積み上がっている状態が観測されました。下記はその全体のうち、最上部 (フレーム 00 〜 0d) と最下部 (フレーム a06 以降) のみを抜粋したものです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
0:000> knL 1000
# ChildEBP RetAddr
00 00ee3eb0 7410653b clr!DontCallDirectlyForceStackOverflow+0xf
01 00ee3ed8 73f4f351 clr!CLRVectoredExceptionHandler+0xb3
02 00ee3f24 7735382f clr!CLRVectoredExceptionHandlerShim+0xd6
03 00ee3f74 7734ec64 ntdll!RtlpCallVectoredHandlers+0xf8
04 (Inline) -------- ntdll!RtlCallVectoredExceptionHandlers+0xa
05 00ee400c 7735b5af ntdll!RtlDispatchException+0x67
06 00ee4b2c 73fe4c05 ntdll!KiUserExceptionDispatcher+0xf
07 00ee4b2c 73fe4cba clr!SOTolerantBoundaryFilter+0x66
08 00fdee38 73e32f32 clr!invokeCompileMethodHelper+0x15e
09 00fdee38 73e33fb0 VCRUNTIME140_CLR0400!_EH4_CallFilterFunc+0x12
0a 00ee4b74 73f4f469 VCRUNTIME140_CLR0400!_except_handler4_common+0x80
0b 00ee4b9c 77394822 clr!_except_handler4+0x29
0c 00ee4bc0 773947f4 ntdll!ExecuteHandler2+0x26
0d 00ee4c8c 7735b5af ntdll!ExecuteHandler+0x24

... (以下、フレーム 06 〜 0d とほぼ同じパターンが約 2,500 階層に渡って繰り返される) ...

a01 00fdee38 73e33fb0 VCRUNTIME140_CLR0400!_EH4_CallFilterFunc+0x12
a02 00fddef4 73f4f469 VCRUNTIME140_CLR0400!_except_handler4_common+0x80
a03 00fddf1c 77394822 clr!_except_handler4+0x29
a04 00fddf40 773947f4 ntdll!ExecuteHandler2+0x26
a05 00fde00c 7735b5af ntdll!ExecuteHandler+0x24
a06 00fdeb34 728f4755 ntdll!KiUserExceptionDispatcher+0xf
a07 00fdeb34 728b2fb5 clrjit!Compiler::gtHashValue+0x218
a08 00fdeb68 728a4232 clrjit!Compiler::optCSEindex+0x39
a09 00fdebc0 728a974a clrjit!Compiler::optOptimizeCSEs+0xc2
a0a 00fdebd8 728b5ed7 clrjit!Compiler::compCompile+0x344
a0b 00fdec18 728b2468 clrjit!Compiler::compCompileHelper+0x32e
a0c 00fdec90 728b25be clrjit!Compiler::compCompile+0x2b0
a0d 00fded90 728ba5ed clrjit!jitNativeCode+0x1fa
a0e 00fdeddc 73eb0b95 clrjit!CILJit::compileMethod+0x7d
a0f 00fdee38 73eb0c65 clr!invokeCompileMethodHelper+0x10b
a10 00fdee80 73eb0cba clr!invokeCompileMethod+0x3d
a11 00fdeeec 73eb0764 clr!CallCompileMethodWithSEHWrapper+0x3d
a12 00fdf2ac 73eb0355 clr!UnsafeJitFunction+0x34a
a13 00fdf3a8 73eafe41 clr!MethodDesc::MakeJitWorker+0x48c
a14 00fdf418 73e884a4 clr!MethodDesc::DoPrestub+0x596
a15 00fdf490 73e629ab clr!PreStubWorker+0xef
a16 00fdf4b4 0335092e clr!ThePreStub+0x11
a17 00fdf4e8 73e62526 FpuControlWordRepro!FpuControlWordRepro.Program.Main()+0xe6
a18 00fdf4f4 73e6e549 clr!CallDescrWorkerInternal+0x34
a19 00fdf548 73e6f217 clr!CallDescrWorkerWithHandler+0x6b
a1a 00fdf5b8 73fab190 clr!MethodDescCallSite::CallTargetWorker+0x170
a1b (Inline) -------- clr!MethodDescCallSite::Call+0xf
a1c 00fdf6dc 73fab291 clr!RunMain+0x1c6
a1d 00fdf948 73fab106 clr!Assembly::ExecuteMainMethod+0xf7
a1e 00fdfe2c 73fab514 clr!SystemDomain::ExecuteMainMethod+0x61c
a1f 00fdfe84 73fab45a clr!ExecuteEXE+0x4c
a20 00fdfec4 73edf24c clr!_CorExeMainInternal+0xd8
a21 00fdff00 7464a38e clr!_CorExeMain+0x4d
a22 00fdff3c 746dfbae mscoreei!_CorExeMain+0x100
a23 00fdff4c 746e5788 mscoree!ShellShim__CorExeMain+0x9e
a24 00fdff64 74fe5d49 mscoree!_CorExeMain_Exported+0x8
a25 00fdff64 7734d83b kernel32!BaseThreadInitThunk+0x19
a26 00fdffbc 7734d7c1 ntdll!__RtlUserThreadStart+0x2b
a27 00fdffcc 00000000 ntdll!_RtlUserThreadStart+0x1b

スタックの最下部 (フレーム a17 以降) から、FpuControlWordRepro.Program.Main() メソッドが呼び出されている本来の処理の流れを確認できます。Main メソッドからのメソッド呼び出しに対する初回 JIT コンパイル中であるため、プリスタブ (clr!ThePreStub) を経由して JIT コンパイラー (clrjit!Compiler::compCompile 等) が実行されています。

そして、フレーム a07 付近の JIT コンパイラー (clrjit.dll) の内部処理の最中で、最初の浮動小数点ハードウェア例外が発生しています。JIT コンパイル処理に含まれる浮動小数点演算が _EM_INVALID 例外マスク解除済みの状態で実行されたため、無効な演算による浮動小数点例外がスローされています。

その後、フレーム a05 から 06 にかけて、以下のような Windows / CLR の例外処理パスが実行されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
ntdll!KiUserExceptionDispatcher       ← 浮動小数点例外を受け取る
clr!SOTolerantBoundaryFilter
clr!invokeCompileMethodHelper ← JIT コンパイル中のフレーム
VCRUNTIME140_CLR0400!_EH4_CallFilterFunc
VCRUNTIME140_CLR0400!_except_handler4_common
clr!_except_handler4
ntdll!ExecuteHandler2
ntdll!ExecuteHandler
ntdll!RtlDispatchException
ntdll!RtlCallVectoredExceptionHandlers
ntdll!RtlpCallVectoredHandlers
clr!CLRVectoredExceptionHandlerShim
clr!CLRVectoredExceptionHandler ← この内部で再度浮動小数点演算

CLR のベクター化例外ハンドラー (clr!CLRVectoredExceptionHandler) は、例外発生時の状況を解析するために内部処理として浮動小数点演算を含んでおり、これ自身が例外マスク解除済みの状態で実行されるため、再び浮動小数点ハードウェア例外を発生させます。これによりベクター化例外ハンドラーが再帰的に呼び出され、フレーム 06 〜 0d のパターンが約 2,500 階層に渡って積み重なります。最終的にスタックを使い切ったところで、フレーム 00 の clr!DontCallDirectlyForceStackOverflow (CLR が STATUS_STACK_OVERFLOW を明示的に発生させてプロセスを終了するためのヘルパー関数) に到達し、プロセスが異常終了しています。

3.2. WinDbg での FPU 制御ワードの確認

WinDbg では rF コマンドで現在のスレッドの x87 FPU レジスターの状態を確認できます。これは「本当に制御ワードが変更されているのか」をダンプ ファイルから確認する際に有効なコマンドで、同種の問題を調査する際にご活用いただけます。

1
2
3
4
5
6
7
8
9
0:000> rF
fpcw=027F: rn 53 puozdi fpsw=0100: top=0 cc=0001 -------- fptw=FFFF
fopcode=0000 fpip=0000:73f4f1fa fpdp=0000:00000000
st0= 0.000000000000000000000e+0000 st1= 0.000000000000000000000e+0000
st2= 0.000000000000000000000e+0000 st3= 0.000000000000000000000e+0000
st4= 0.000000000000000000000e+0000 st5= 1.000000000000000000000e+0000
st6= 9.091796875000000000000e-0001 st7= 1.000000000000000000000e+0000
clr!DontCallDirectlyForceStackOverflow+0xf:
742a984a 832000 and dword ptr [eax],0 ds:002b:00ee2eac=00000000

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) と、上記 rFfpcw=027F が一致しない点にお気づきの方もいらっしゃるかもしれません。この不一致には以下の 2 つの要因が絡んでいます。

(1) _controlfp の戻り値はハードウェア レジスターの生値ではない

前述のとおり、_controlfp が戻す値は float.h で定義される抽象表現であり、ハードウェアの FPCW レジスターとはビットレイアウトが異なります。両者を同じ状態で並べると以下のようになります。

項目 _controlfp の戻り値 FPCW レジスター
全 6 例外マスク + 53 ビット精度 + 最近接丸め (既定) 0x9001F 0x027F
_EM_INVALID をアンマスクした状態 0x9000F 0x027E

このように、0x9001F0x027F は実際には同じ 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
0:000> .frame /r a02
a02 00fddef4 73f4f469 VCRUNTIME140_CLR0400!_except_handler4_common+0x80

0:000> dv
CookiePointer = 0x74594000
CookieCheckFunction = 0x73e62b80
ExceptionRecord = 0x00fde024
EstablisherFrame = 0x00fdee28
ContextRecord = 0x00fde074
DispatcherContext = 0x00fddfac
Revalidate = 0x00 ''
TryLevel = 0
ScopeTable = 0x73eb0c00
Disposition = ExceptionContinueSearch (0n1)
ScopeTableRecord = 0x73eb0c10
FilterResult = 0n20254208
ExceptionPointers = struct _EXCEPTION_POINTERS
FilterFunc = <value unavailable>
FramePointer = 0x00000000 ""
EnclosingLevel = 0

ContextRecord = 0x00fde074 が、この階層で発生した例外のコンテキスト レコードのアドレスです。dx コマンドで _CONTEXT 構造体としてデコードして表示します。

1
2
3
4
5
6
7
8
9
10
11
12
0:000> dx -r1 ((VCRUNTIME140_CLR0400!_CONTEXT *)0xfde074)
((VCRUNTIME140_CLR0400!_CONTEXT *)0xfde074) : 0xfde074 [Type: _CONTEXT *]
[+0x000] ContextFlags : 0x1007f [Type: unsigned long]
[+0x004] Dr0 : 0x0 [Type: unsigned long]
[+0x008] Dr1 : 0x0 [Type: unsigned long]
[+0x00c] Dr2 : 0x0 [Type: unsigned long]
[+0x010] Dr3 : 0x0 [Type: unsigned long]
[+0x014] Dr6 : 0x0 [Type: unsigned long]
[+0x018] Dr7 : 0x0 [Type: unsigned long]
[+0x01c] FloatSave [Type: _FLOATING_SAVE_AREA]
[+0x08c] SegGs : 0x2b [Type: unsigned long]
...

_CONTEXT 構造体のオフセット +0x01cFloatSave メンバーがあり、これが FPU 状態を保持する _FLOATING_SAVE_AREA 構造体です。今回のコンテキスト レコード 0xfde074 における FloatSave のアドレスは 0xfde074 + 0x1c = 0xfde090 です。これを _FLOATING_SAVE_AREA 構造体としてデコードします。

1
2
3
4
5
6
7
8
9
10
11
0:000> dx -r1 (*((VCRUNTIME140_CLR0400!_FLOATING_SAVE_AREA *)0xfde090))
(*((VCRUNTIME140_CLR0400!_FLOATING_SAVE_AREA *)0xfde090)) [Type: _FLOATING_SAVE_AREA]
[+0x000] ControlWord : 0x27e [Type: unsigned long]
[+0x004] StatusWord : 0xb9a1 [Type: unsigned long]
[+0x008] TagWord : 0xbfff [Type: unsigned long]
[+0x00c] ErrorOffset : 0x728c4f77 [Type: unsigned long]
[+0x010] ErrorSelector : 0x30c0000 [Type: unsigned long]
[+0x014] DataOffset : 0xfdeb14 [Type: unsigned long]
[+0x018] DataSelector : 0x0 [Type: unsigned long]
[+0x01c] RegisterArea [Type: unsigned char [80]]
[+0x06c] Spare0 : 0x0 [Type: unsigned long]

ControlWord0x27e となっており、既定値の 0x27F と比較すると最下位ビット (_EM_INVALID を表す 0x01 のビット) がクリアされていることが確認できます。これは、アプリケーション コードで _controlfp(0, _EM_INVALID) を呼び出して _EM_INVALID 例外マスクを解除した状態のままで、clrjit.dll の浮動小数点演算が実行され、#IA 例外が発生したことを示しています。ErrorOffset0x728c4f77 は例外を発生させた命令のアドレスを表しており、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 命令、fesetexceptflagfesetround などの呼び出しを取り除き、制御ワードを既定値のまま使用してください。

特定の演算区間のみで丸めや例外動作の変更が必要な場合は、その区間に入る直前に元の制御ワードを保存し、区間の処理終了後 (例外発生時の経路も含む) に必ず元の値へ復元する必要があります。

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.IsNaNdouble.IsInfinity 等を用いたチェックを追加していただく方法をご検討ください。FPU 制御ワードを変更する手法は前述の通り CLR の前提と矛盾するため、デバッグ目的であってもご利用は推奨されません。

まとめ

.NET Framework アプリケーションにおいて x87 FPU の浮動小数点制御ワードを変更することはサポートされておらず、CLR は制御ワードが既定値であることを前提として動作します。例外マスクの解除や精度・丸めモードの変更を行うと、スタック オーバーフローやアクセス違反による異常終了、計算結果の不整合といった予期しない問題を引き起こす場合があります。アプリケーション コードおよびそこから呼び出されるネイティブ コードのいずれにおいても、.NET Framework アプリケーションでは既定の浮動小数点制御ワードを維持することをお勧めします。

本記事が .NET Framework アプリケーションのトラブルシューティングや設計の参考になれば幸いです。


本ブログの内容は弊社の公式見解として保証されるものではなく、開発・運用時の参考情報としてご活用いただくことを目的としています。もし公式な見解が必要な場合は、弊社ドキュメント (https://learn.microsoft.comhttps://support.microsoft.com) をご参照いただくか、もしくは私共サポートまでお問い合わせください。