[インデックス 18752] ファイルの概要
このコミットは、GoランタイムにおけるWindows/386アーキテクチャでの特定のパニック処理に関するバグ修正です。具体的には、「GoがCを呼び出し、そのCがさらにGoを呼び出し、その内部のGoコードがパニックを起こし、外部のGoコードがそのパニックを回復する」という複雑なシナリオにおいて、Windowsの構造化例外処理(SEH)チェーンが正しく復元されない問題に対処しています。
コミット
commit 1249d3a518169213535f92a0ab23b494013a55a8
Author: Russ Cox <rsc@golang.org>
Date: Wed Mar 5 11:10:40 2014 -0500
runtime: handle Go calls C calls Go panic correctly on windows/386
32-bit Windows uses "structured exception handling" (SEH) to
handle hardware faults: that there is a per-thread linked list
of fault handlers maintained in user space instead of
something like Unix's signal handlers. The structures in the
linked list are required to live on the OS stack, and the
usual discipline is that the function that pushes a record
(allocated from the current stack frame) onto the list pops
that record before returning. Not to pop the entry before
returning creates a dangling pointer error: the list head
points to a stack frame that no longer exists.
Go pushes an SEH record in the top frame of every OS thread,
and that record suffices for all Go execution on that thread,
at least until cgo gets involved.
If we call into C using cgo, that called C code may push its
own SEH records, but by the convention it must pop them before
returning back to the Go code. We assume it does, and that's
fine.
If the C code calls back into Go, we want the Go SEH handler
to become active again, not whatever C has set up. So
runtime.callbackasm1, which handles a call from C back into
Go, pushes a new SEH record before calling the Go code and
pops it when the Go code returns. That's also fine.
It can happen that when Go calls C calls Go like this, the
inner Go code panics. We allow a defer in the outer Go to
recover the panic, effectively wiping not only the inner Go
frames but also the C calls. This sequence was not popping the
SEH stack up to what it was before the cgo calls, so it was
creating the dangling pointer warned about above. When
eventually the m stack was used enough to overwrite the
dangling SEH records, the SEH chain was lost, and any future
panic would not end up in Go's handler.
The bug in TestCallbackPanic and friends was thus creating a
situation where TestSetPanicOnFault - which causes a hardware
fault - would not find the Go fault handler and instead crash
the binary.
Add checks to TestCallbackPanicLocked to diagnose the mistake
in that test instead of leaving a bad state for another test
case to stumble over.
Fix bug by restoring SEH chain during deferred "endcgo"
cleanup.
This bug is likely present in Go 1.2.1, but since it depends
on Go calling C calling Go, with the inner Go panicking and
the outer Go recovering the panic, it seems not important
enough to bother fixing before Go 1.3. Certainly no one has
complained.
Fixes #7470.
LGTM=alex.brainman
R=golang-codereviews, alex.brainman
CC=golang-codereviews, iant, khr
https://golang.org/cl/71440043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/1249d3a518169213535f92a0ab23b494013a55a8
元コミット内容
このコミットは、GoランタイムがWindows/386環境で、GoコードがCコードを呼び出し、そのCコードが再びGoコードを呼び出す(Go -> C -> Go)という状況で、内部のGoコードがパニックを起こし、外部のGoコードがそのパニックを回復する際に発生する、構造化例外処理(SEH)チェーンの破損を修正します。
具体的には、パニック回復時にSEHスタックが適切に巻き戻されず、SEHレコードへのダングリングポインタが発生していました。これにより、最終的にSEHチェーンが失われ、その後のパニックがGoのハンドラによって捕捉されずにバイナリがクラッシュする可能性がありました。
この修正は、endcgo
クリーンアップ処理中にSEHチェーンを復元することで、このバグを解決します。
変更の背景
Windowsの32ビット環境(windows/386)では、ハードウェア障害を処理するために「構造化例外処理(SEH)」というメカニズムを使用します。これは、Unixのシグナルハンドラとは異なり、ユーザー空間でスレッドごとの例外ハンドラがリンクリストとして管理されるものです。このリンクリスト内の構造体はOSスタック上に存在する必要があり、通常、レコードをプッシュした関数は、リターンする前にそのレコードをポップするという規律があります。これを怠ると、リストの先頭がもはや存在しないスタックフレームを指す「ダングリングポインタ」エラーが発生します。
Goランタイムは、各OSスレッドの最上位フレームにSEHレコードをプッシュし、これはcgoが関与しない限り、そのスレッド上のすべてのGo実行に十分です。
問題は、Goがcgoを介してCコードを呼び出し、そのCコードがさらにGoコードを呼び出す(Go -> C -> Go)というシナリオで発生しました。
- GoからCへの呼び出し: Goがcgoを使ってCコードを呼び出す場合、呼び出されたCコードは独自のSEHレコードをプッシュする可能性がありますが、Goコードに戻る前にそれらをポップするという規約があります。Goランタイムはこの規約が守られることを前提としています。
- CからGoへのコールバック: CコードがGoにコールバックする場合、GoのSEHハンドラが再びアクティブになることが望まれます。そのため、CからGoへの呼び出しを処理する
runtime.callbackasm1
は、Goコードを呼び出す前に新しいSEHレコードをプッシュし、Goコードが戻るときにそれをポップします。
この一連の呼び出しの中で、内部のGoコードがパニックを起こし、外部のGoコードがdefer
を使ってそのパニックを回復する場合があります。この回復処理は、内部のGoフレームだけでなく、C呼び出しも効果的に消去します。しかし、このシーケンスでは、cgo呼び出し前の状態にSEHスタックが適切にポップされず、前述のダングリングポインタが作成されていました。
このダングリングポインタは、最終的にmスタックが十分に利用されてダングリングSEHレコードが上書きされると、SEHチェーンが失われる原因となりました。その結果、将来のパニックがGoのハンドラに到達せず、バイナリがクラッシュするという深刻な問題を引き起こしました。
TestCallbackPanic
などのテストが、このSEHチェーンの破損を引き起こし、その後に実行されるTestSetPanicOnFault
(ハードウェア障害を引き起こすテスト)がGoの障害ハンドラを見つけられずにクラッシュするという状況を作り出していました。
このバグはGo 1.2.1にも存在していた可能性が高いですが、Go -> C -> Goの呼び出し、内部Goのパニック、外部Goの回復という特定のシナリオに依存するため、Go 1.3より前に修正するほど重要ではないと判断されていました。しかし、根本的な問題として修正が必要でした。
前提知識の解説
構造化例外処理 (Structured Exception Handling, SEH)
SEHは、Microsoft Windowsオペレーティングシステムが提供する、プログラム実行中の例外(エラー)を処理するためのメカニズムです。ハードウェア例外(ゼロ除算、無効なメモリアクセスなど)とソフトウェア例外(アプリケーションが意図的に発生させる例外)の両方を捕捉できます。
SEHの主要な特徴は以下の通りです。
- フレームベース: 例外ハンドラは、関数のスタックフレームに関連付けられます。関数が実行されると、その関数に関連する例外ハンドラ情報がスタック上にプッシュされます。
- リンクリスト: 各スレッドは、現在アクティブな例外ハンドラのリンクリストを保持しています。新しいハンドラが設定されると、リストの先頭に追加されます。例外が発生すると、システムはこのリストを逆順に辿り、適切なハンドラを見つけます。
__try
,__except
,__finally
: C/C++言語では、Microsoft固有の拡張としてこれらのキーワードが提供され、SEHブロックを定義するために使用されます。__try
ブロック: 例外が発生する可能性のあるコードを含みます。__except
ブロック:__try
ブロック内で例外が発生した場合に実行されるハンドラコードを含みます。__finally
ブロック:__try
ブロックの終了方法(正常終了か例外発生か)に関わらず、常に実行されるクリーンアップコードを含みます。
- スタック上のレコード: SEHハンドラ情報は、通常、OSスタック上に配置されます。関数がハンドラを登録する際にスタックフレームからメモリを割り当て、関数がリターンする前にそのメモリを解放(ポップ)することが期待されます。これを怠ると、スタックが巻き戻された後に、リンクリストが解放済みのメモリを指す「ダングリングポインタ」となり、未定義の動作やクラッシュを引き起こす可能性があります。
cgo
cgoは、GoプログラムからC言語のコードを呼び出すためのGoの機能です。また、CコードからGoコードを呼び出す「コールバック」もサポートしています。cgoを使用することで、既存のCライブラリを利用したり、パフォーマンスが重要な部分をCで記述したりすることが可能になります。
cgoの仕組みは複雑で、GoランタイムとCランタイムの間でスタック、レジスタ、例外処理などのコンテキストを切り替える必要があります。特に、異なる言語の例外処理メカニズムが混在する場合、その相互作用は慎重に管理される必要があります。
Goのパニックと回復 (panic/recover)
Goには、プログラムの異常終了を処理するためのpanic
とrecover
という組み込み関数があります。
panic
: 現在のGoルーチンの通常の実行フローを停止させ、遅延関数(defer
で登録された関数)を順に実行しながらスタックを巻き戻します。recover
:defer
関数内で呼び出された場合、パニックを捕捉し、パニックの引数を返します。これにより、パニックによるプログラムのクラッシュを防ぎ、通常の実行フローを再開できます。recover
がdefer
関数以外で呼び出された場合、またはアクティブなパニックがない場合はnil
を返します。
このコミットのバグは、Goのpanic/recover
メカニズムが、WindowsのSEHとcgoの相互作用という特定の複雑な状況下で、期待通りにSEHチェーンを管理できなかったことに起因します。
技術的詳細
このバグは、GoのランタイムがWindows/386環境でcgoを介してCコードとGoコードの間でコンテキストを切り替える際に、SEHチェーンの管理に不備があったことに起因します。
- SEHレコードの管理: Windows/386では、SEHレコードはスレッドのOSスタック上にリンクリストとして存在します。各SEHレコードは、前のレコードへのポインタと、例外ハンドラの関数ポインタを含みます。
- GoのSEH設定: Goランタイムは、OSスレッドの最上位フレームに独自のSEHレコードをプッシュします。これは、Goコード内で発生するパニックやハードウェア例外を捕捉するために使用されます。
- cgo呼び出し時のSEH:
- Go -> C: GoがCを呼び出す際、Cコードは独自のSEHレコードをプッシュする可能性があります。規約上、CコードはGoに戻る前にこれらのレコードをポップする必要があります。
- C -> Go (コールバック): CコードがGoにコールバックする場合、
runtime.callbackasm1
というアセンブリルーチンが仲介します。このルーチンは、Goコードを呼び出す前に新しいSEHレコードをプッシュし、Goコードが戻るときにそれをポップします。これにより、Goコード実行中はGoのSEHハンドラがアクティブになります。
- バグの発生シナリオ:
- GoコードがCコードを呼び出す (
runtime.cgocall
)。 - CコードがGoコードにコールバックする (
runtime.callbackasm1
を介して)。 - コールバックされた内部のGoコードが
panic
を起こす。 - 最初のGoコード(Cを呼び出した側)が
defer
とrecover
を使ってこのパニックを捕捉し、回復する。 - この回復プロセス中に、SEHスタックがcgo呼び出し前の状態に完全に巻き戻されない。
- 結果として、SEHリンクリスト内に、もはや有効でないスタックフレームを指す「ダングリングポインタ」が残る。
- GoコードがCコードを呼び出す (
- バグの影響:
- ダングリングポインタが存在する状態でOSスタックがさらに使用されると、そのダングリングポインタが指すメモリ領域が上書きされる可能性があります。
- これにより、SEHリンクリストが破損し、システムが例外ハンドラチェーンを正しく辿れなくなります。
- その結果、その後のパニックやハードウェア障害が発生しても、Goの例外ハンドラが捕捉できなくなり、プログラムがクラッシュします。
TestSetPanicOnFault
のようなハードウェア障害を意図的に発生させるテストが、Goのハンドラを見つけられずにクラッシュする原因となっていました。
修正アプローチ
この修正は、runtime.cgocall
の終了処理であるendcgo
関数において、SEHチェーンを明示的に復元することで問題を解決します。
SEHUnwind
構造体の導入:runtime.h
に新しい構造体SEHUnwind
が導入されました。これは、現在のSEHレコード(seh
)と、その前のSEHUnwind
レコードへのリンク(link
)を保持します。m->sehunwind
の追加:M
構造体(GoのOSスレッドを表す構造体)にsehunwind
フィールドが追加され、SEHUnwind
リンクリストの先頭を指すようになりました。runtime.cgocall
でのSEH保存:runtime.cgocall
関数内で、Cコードを呼び出す前に現在のSEHチェーンの先頭をsehunwind
構造体に保存し、m->sehunwind
リンクリストにプッシュします。これは、パニックが発生してCコードがSEHをクリーンアップする機会がない場合に特に重要です。endcgo
でのSEH復元:endcgo
関数(cgo呼び出しの終了時に実行されるクリーンアップ処理)において、保存しておいたSEHチェーンの先頭をruntime.setseh
を使って復元し、m->sehunwind
リンクリストからポップします。これにより、cgo呼び出し前のSEH状態が確実に復元されます。- ヘルパー関数の追加:
runtime.getseh
とruntime.setseh
というアセンブリ関数がsys_windows_386.s
に追加され、それぞれ現在のSEHチェーンの先頭を取得・設定する役割を担います。これらはWindows/386固有の機能です。 - テストの改善:
TestCallbackPanicLocked
テストに、パニック回復後にSEHが正しく復元されているかをチェックするアサーションが追加されました。これにより、将来同様のバグが導入された場合に早期に検出できるようになります。
この修正により、Go -> C -> Goのシナリオで内部Goがパニックを起こし、外部Goが回復した場合でも、SEHチェーンが常に正しい状態に保たれるようになり、ダングリングポインタやSEHチェーンの破損が防止されます。
コアとなるコードの変更箇所
このコミットにおける主要なコード変更は以下のファイルに集中しています。
src/pkg/runtime/cgocall.c
:runtime·cgocall
関数にSEHUnwind sehunwind;
が追加され、SEHアンワインド情報を管理するためのローカル変数として使用されます。- Cコードを呼び出す前に、現在のSEHチェーンの先頭を
sehunwind
に記録し、m->sehunwind
リンクリストにプッシュするコードが追加されました。 endcgo
関数(cgo呼び出しの終了処理)において、m->sehunwind
リンクリストからSEH情報を取得し、runtime·setseh
を使ってSEHチェーンを復元するコードが追加されました。
src/pkg/runtime/runtime.h
:- 新しい構造体
SEHUnwind
が定義されました。これは、link
(次のSEHUnwind
へのポインタ)とseh
(SEHレコードへのポインタ)を持ちます。 M
構造体(OSスレッドを表す)にSEHUnwind* sehunwind;
フィールドが追加され、SEHアンワインド情報のリンクリストの先頭を指すようになりました。GOOSARCH_windows_386
というマクロが定義され、Windows/386固有のコードブロックを条件付きでコンパイルするために使用されます。runtime·getseh
とruntime·setseh
関数のプロトタイプが追加されました。これらはWindows/386でのみ有効で、他のプラットフォームでは空のマクロに置き換えられます。
- 新しい構造体
src/pkg/runtime/sys_windows_386.s
:TEXT runtime·getseh(SB)
アセンブリ関数が追加されました。これは、FSレジスタの0オフセットにある値を(現在のSEHチェーンの先頭を指すポインタ)をAXレジスタにロードして返します。TEXT runtime·setseh(SB)
アセンブリ関数が追加されました。これは、引数として渡された値をAXレジスタにロードし、それをFSレジスタの0オフセットに書き込みます。これにより、SEHチェーンの先頭が設定されます。
src/pkg/runtime/syscall_windows_test.go
:TestCallbackPanicLocked
テストのロジックが変更され、パニック回復後にSEHが正しく復元されているかをruntime.GetSEH()
を使って検証するアサーションが追加されました。- テストの順序に関するコメントが追加され、
TestCallbackPanicLocked
が他の関連テストの前に実行されるべき理由が説明されています。
これらの変更により、cgo呼び出しのライフサイクル全体でSEHチェーンの整合性が維持されるようになります。
コアとなるコードの解説
src/pkg/runtime/cgocall.c
の変更
void
runtime·cgocall(void (*fn)(void*), void *arg)
{
Defer d;
+ SEHUnwind sehunwind; // 新しく追加されたSEHUnwind構造体
if(m->racecall) {
runtime·asmcgocall(fn, arg);
@@ -130,6 +131,14 @@ runtime·cgocall(void (*fn)(void*), void *arg)
d.argp = (void*)-1; // unused because unlockm never recovers
d.special = true;
g->defer = &d;
+
+ // Record current SEH for restoration during endcgo.
+ // This matters most when the execution stops due to panic
+ // and the called C code isn't given a chance to clean up
+ // the SEHs it has pushed.
+ sehunwind.seh = runtime·getseh(); // 現在のSEHチェーンの先頭を取得
+ sehunwind.link = m->sehunwind; // 既存のSEHUnwindリストの先頭をリンクに設定
+ m->sehunwind = &sehunwind; // 新しいsehunwindをリストの先頭に設定
m->ncgo++;
@@ -166,6 +175,9 @@ endcgo(void)
m->cgomal = nil;
}
+ runtime·setseh(m->sehunwind->seh); // 保存しておいたSEHチェーンの先頭を復元
+ m->sehunwind = m->sehunwind->link; // m->sehunwindをリストの次の要素に進める(ポップ)
+
if(raceenabled)
runtime·raceacquire(&cgosync);
}
runtime·cgocall
はGoからCへの呼び出しを処理する関数です。Cコードを呼び出す前に、現在のSEHチェーンの先頭をruntime·getseh()
で取得し、それをsehunwind
という新しいSEHUnwind
構造体に保存します。このsehunwind
構造体は、m->sehunwind
リンクリストの先頭にプッシュされます。これにより、cgo呼び出し前のSEH状態が記録されます。
endcgo
関数は、cgo呼び出しが終了した後に実行されるクリーンアップ処理です。ここで、m->sehunwind
リンクリストの先頭から保存しておいたSEH情報を取得し、runtime·setseh()
を使ってSEHチェーンを元の状態に復元します。その後、m->sehunwind
をリストの次の要素に進めることで、現在のsehunwind
レコードをポップします。このメカニズムにより、Go -> C -> Goのシナリオで内部Goがパニックを起こし、外部Goが回復した場合でも、SEHチェーンが常に正しい状態に保たれることが保証されます。
src/pkg/runtime/runtime.h
の変更
typedef struct SEH SEH;
+typedef struct SEHUnwind SEHUnwind; // 新しい構造体の定義
// ...
struct SEHUnwind // SEHUnwind構造体の定義
{
SEHUnwind*\tlink; // 次のSEHUnwindレコードへのポインタ
SEH*\tseh; // SEHレコードへのポインタ
};
// ...
struct M // M構造体(OSスレッドを表す)
{
// ...
SEH*\tseh;
+ SEHUnwind*\tsehunwind; // 新しく追加されたフィールド
uintptr\tend[];
};
// ...
// On Windows 386, we have functions for saving and restoring
// the SEH values; elsewhere #define them away.
#ifdef GOOSARCH_windows_386 // Windows/386でのみ有効な関数プロトタイプ
SEH*\truntime·getseh(void);
void\truntime·setseh(SEH*);
#else // その他のプラットフォームでは空のマクロに定義
#define runtime·getseh() nil
#define runtime·setseh(x) do{}while(0)
#endif
SEHUnwind
構造体は、SEHチェーンのアンワインドに必要な情報をカプセル化します。M
構造体にsehunwind
フィールドが追加されたことで、各OSスレッドが自身のSEHアンワインド情報のリンクリストを管理できるようになりました。runtime·getseh
とruntime·setseh
は、Windows/386固有のSEH操作を抽象化するための関数プロトタイプです。
src/pkg/runtime/sys_windows_386.s
の変更
TEXT runtime·getseh(SB),NOSPLIT,$0
MOVL 0(FS), AX // FSレジスタの0オフセットにある値をAXにロード
RET // AXの値を返す(現在のSEHチェーンの先頭)
TEXT runtime·setseh(SB),NOSPLIT,$0
MOVL seh+0(FP), AX // 引数sehの値をAXにロード
MOVL AX, 0(FS) // AXの値をFSレジスタの0オフセットに書き込む
RET // SEHチェーンの先頭を設定
これらのアセンブリ関数は、Windows/386のSEHメカニズムに直接アクセスします。FSレジスタは、スレッド情報ブロック(TEB)の先頭を指し、その0オフセットには現在のSEHチェーンの先頭を指すポインタが格納されています。runtime·getseh
はこのポインタを読み取り、runtime·setseh
はこのポインタを書き換えることで、SEHチェーンの先頭を操作します。
src/pkg/runtime/syscall_windows_test.go
の変更
func TestCallbackPanicLocked(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if !runtime.LockedOSThread() {
t.Fatal("runtime.LockOSThread didn't")
}
oldSEH := runtime.GetSEH() // パニック前のSEHを取得
defer func() {
s := recover()
if s == nil {
t.Fatal("panic did not recover")
}
if s.(string) != "callback panic" {
t.Fatal("wrong panic:", s)
}
if !runtime.LockedOSThread() {
t.Fatal("lost lock on OS thread after panic")
}
if newSEH := runtime.GetSEH(); oldSEH != newSEH { // パニック回復後のSEHをチェック
t.Fatalf("SEH not restored after panic: %#x became %#x", oldSEH, newSEH)
}
}()
nestedCall(t, func() { panic("callback panic") })
panic("nestedCall returned")
}
TestCallbackPanicLocked
テストは、OSスレッドをロックした状態でGo -> C -> Goのパニックシナリオをシミュレートします。重要な変更は、パニック発生前にruntime.GetSEH()
で現在のSEHチェーンの先頭を記録し、recover
ブロック内でパニック回復後に再度runtime.GetSEH()
を呼び出し、元のSEHと一致するかどうかを検証するアサーションが追加された点です。これにより、SEHチェーンが正しく復元されていることがテストによって保証されます。
関連リンク
- Go Issue #7470: https://github.com/golang/go/issues/7470
- Go CL 71440043: https://golang.org/cl/71440043
参考にした情報源リンク
- Structured Exception Handling (Windows): https://learn.microsoft.com/en-us/windows/win32/debug/structured-exception-handling
- Go cgo documentation: https://pkg.go.dev/cmd/cgo
- A Tour of Go (Panic and Recover): https://go.dev/tour/concurrency/12
- The Go Programming Language Specification (Defer statements): https://go.dev/ref/spec#Defer_statements
- Go runtime source code (relevant files):
src/runtime/cgocall.go
(Go 1.2.1時点ではsrc/pkg/runtime/cgocall.c
に相当)src/runtime/proc.go
(Go 1.2.1時点ではsrc/pkg/runtime/proc.c
に相当)src/runtime/runtime.go
(Go 1.2.1時点ではsrc/pkg/runtime/runtime.h
に相当)src/runtime/sys_windows_386.s
(Go 1.2.1時点ではsrc/pkg/runtime/sys_windows_386.s
に相当)src/runtime/syscall_windows_test.go
(Go 1.2.1時点ではsrc/pkg/runtime/syscall_windows_test.go
に相当)
- Go 1.2.1 Release Notes (for context on Go version): https://go.dev/doc/go1.2.1
- Go 1.3 Release Notes (for context on Go version): https://go.dev/doc/go1.3
- FS Register (x86): https://en.wikipedia.org/wiki/X86_architecture#Registers (TEBへのポインタとして使用されることが多い)
- Thread Environment Block (TEB): https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-teb (SEHフレームのポインタを含む)