Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 19493] ファイルの概要

このコミットは、Goランタイムにおける型アサーションのバグを修正するものです。具体的には、x.(T) の形式でインターフェース値 x をゼロサイズ型 T(例えば struct{})に型アサーションする際に発生していた、スタック上の不適切な1バイト書き込みの問題に対処しています。この問題は、スタック上の他の変数をサイレントに破損させる可能性がありました。

コミット

commit d646040fd13b79f811c85bc7280a71c3493419ec
Author: Russ Cox <rsc@golang.org>
Date:   Mon Jun 2 21:06:30 2014 -0400

    runtime: fix 1-byte return during x.(T) for 0-byte T
    
    The 1-byte write was silently clearing a byte on the stack.
    If there was another function call with more arguments
    in the same stack frame, no harm done.
    Otherwise, if the variable at that location was already zero,
    no harm done.
    Otherwise, problems.
    
    Fixes #8139.
    
    LGTM=dsymonds
    R=golang-codereviews, dsymonds
    CC=golang-codereviews, iant, r
    https://golang.org/cl/100940043
---
 src/pkg/runtime/iface.goc   | 23 +++++++++++++++++----\n test/fixedbugs/issue8139.go | 50 +++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 4 deletions(-)\n
diff --git a/src/pkg/runtime/iface.goc b/src/pkg/runtime/iface.goc
index 96bb8b8aa4..c0a17e3034 100644
--- a/src/pkg/runtime/iface.goc
+++ b/src/pkg/runtime/iface.goc
@@ -209,9 +209,19 @@ func convT2E(t *Type, elem *byte) (ret Eface) {\n 
 static void assertI2Tret(Type *t, Iface i, byte *ret);\n 
+/*
+ * NOTE: Cannot use 'func' here, because we have to declare
+ * a return value, the only types we have are at least 1 byte large,
+ * goc2c will zero the return value, and the actual return value
+ * might have size 0 bytes, in which case the zeroing of the
+ * 1 or more bytes would be wrong.
+ * Using C lets us control (avoid) the initial zeroing.
+ */
 #pragma textflag NOSPLIT
-func assertI2T(t *Type, i Iface) (ret byte, ...) {\n-\tassertI2Tret(t, i, &ret);\n+void\n+runtime·assertI2T(Type *t, Iface i, GoOutput retbase)\n+{\n+\tassertI2Tret(t, i, (byte*)&retbase);\n }\n 
 static void\n@@ -260,9 +270,14 @@ func assertI2TOK(t *Type, i Iface) (ok bool) {\n 
 static void assertE2Tret(Type *t, Eface e, byte *ret);\n 
+/*
+ * NOTE: Cannot use 'func' here. See assertI2T above.
+ */
 #pragma textflag NOSPLIT
-func assertE2T(t *Type, e Eface) (ret byte, ...) {\n-\tassertE2Tret(t, e, &ret);\n+void\n+runtime·assertE2T(Type *t, Eface e, GoOutput retbase)\n+{\n+\tassertE2Tret(t, e, (byte*)&retbase);\n }\n 
 static void\ndiff --git a/test/fixedbugs/issue8139.go b/test/fixedbugs/issue8139.go
new file mode 100644
index 0000000000..821c9ff656
--- /dev/null
+++ b/test/fixedbugs/issue8139.go
@@ -0,0 +1,50 @@
+// run
+\n// Copyright 2014 The Go Authors.  All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+\n// Issue 8139. The x.(T) assertions used to write 1 (unexpected)
+// return byte for the 0-byte return value T.
+\npackage main
+\nimport "fmt"
+\ntype T struct{}
+\nfunc (T) M() {}
+\ntype M interface {
+\tM()
+}\n\nvar e interface{} = T{}
+var i M = T{}
+var b bool
+\nfunc f1() int {
+\tif b {
+\t\treturn f1() // convince inliner not to inline
+\t}
+\tz := 0x11223344
+\t_ = e.(T)
+\treturn z
+}\n\nfunc f2() int {
+\tif b {
+\t\treturn f1() // convince inliner not to inline
+\t}
+\tz := 0x11223344
+\t_ = i.(T)
+\treturn z
+}\n\nfunc main() {\n+\tx := f1()\n+\ty := f2()\n+\tif x != 0x11223344 || y != 0x11223344 {\n+\t\tfmt.Printf(\"BUG: x=%#x y=%#x, want 0x11223344 for both\\n\", x, y)\n+\t}\n+}\n

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/d646040fd13b79f811c85bc7280a71c3493419ec

元コミット内容

このコミットは、Goランタイムにおいて、ゼロバイト(ゼロサイズ)の型 T に対する x.(T) 型アサーション中に発生していた1バイトの戻り値に関する問題を修正します。

以前は、この1バイトの書き込みがスタック上のバイトをサイレントにクリアしていました。 もし同じスタックフレーム内により多くの引数を持つ別の関数呼び出しがあれば、害はありませんでした。 そうでなければ、もしその場所の変数が既にゼロであれば、害はありませんでした。 それ以外の場合、問題が発生していました。

このコミットは Issue #8139 を修正します。

変更の背景

Go言語では、インターフェース値 x が特定の型 T を保持しているかを確認し、その型 T の値を取り出すために x.(T) という型アサーション構文を使用します。このコミットが修正する問題は、特に T がゼロサイズ型(例えば struct{} のようにメモリを全く消費しない型)である場合に発生していました。

問題の核心は、Goランタイムが型アサーションの内部処理で、ゼロサイズ型 T の戻り値を処理する際に、誤って1バイトのデータをスタックに書き込んでいたことにあります。本来、ゼロサイズ型はメモリを消費しないため、戻り値としてスタック上に領域を確保したり、そこにデータを書き込んだりする必要はありません。しかし、当時のGoコンパイラ(特に goc2c というGoコードをCコードに変換するツール)が、Goの func 宣言された関数が戻り値を持つ場合、その戻り値の領域をゼロ初期化するという挙動を持っていました。ゼロサイズ型であっても、Goの関数として宣言されていると、このゼロ初期化の対象となり、結果としてスタック上の意図しない領域に1バイトのゼロが書き込まれてしまっていたのです。

この「サイレントな1バイトのクリア」は、以下のような状況で問題を引き起こす可能性がありました:

  1. スタック上のデータ破損: 型アサーションが行われたスタックフレーム内の、その1バイトの書き込み位置にたまたま別の有効なデータ(例えばローカル変数)が存在した場合、そのデータが意図せずゼロに上書きされてしまう。
  2. 検出の困難さ: 問題が顕在化するのは、上書きされたデータがたまたまゼロでなかった場合や、その後のプログラムのロジックがその破損したデータに依存していた場合に限られました。また、同じスタックフレーム内で他の関数呼び出しがあり、その呼び出しがスタックを適切に再配置していれば、問題が隠蔽されることもありました。

このバグは Issue #8139 として報告され、このコミットによって修正されました。

前提知識の解説

このコミットの変更内容を理解するためには、以下のGo言語およびランタイムに関する前提知識が必要です。

  • Goの型アサーション (x.(T)): Goにおいて、インターフェース型は異なる具象型の値を抽象的に扱うための強力な機能です。interface{} (空インターフェース) は任意の型の値を保持できます。x.(T) という構文は「型アサーション」と呼ばれ、インターフェース値 x が実際に型 T の値を保持しているかどうかを検査し、もし保持していればその値を型 T として取り出すために使用されます。 例えば、var i interface{} = "hello" と宣言された i に対して、s := i.(string) とすると、s には "hello" が代入されます。もし istring 以外の型を保持していた場合、パニックが発生します。安全な型アサーションとして s, ok := i.(string) の形式もあり、この場合 oktrue または false を返します。

  • Goにおけるゼロサイズ型 (Zero-sized types): Goには、メモリを全く消費しない「ゼロサイズ型」という概念があります。最も代表的なゼロサイズ型は、フィールドを一つも持たない空の構造体 struct{} です。他にも、要素を持たない配列型や、全ての要素がゼロサイズ型である配列型もゼロサイズ型となり得ます。 ゼロサイズ型は、メモリ効率が非常に重要な場面や、値そのものには意味がなく、その存在やイベントの発生をシグナルとして伝えたい場合(例: チャネルでの同期、セットの実装 map[T]struct{})に利用されます。これらはメモリを消費しないため、スタックやヒープに割り当てられる際に特別な考慮が必要です。

  • Goランタイムのインターフェース実装: Goのインターフェース値は、内部的には通常2つのポインタで表現されます。一つは具象型の型情報(_type 構造体へのポインタ)を指し、もう一つは具象値のデータ(値そのものまたは値へのポインタ)を指します。型アサーションは、これらの内部ポインタを検査し、必要に応じて値を変換または抽出するランタイムの低レベルな操作です。src/pkg/runtime/iface.goc は、これらのインターフェース操作に関するC言語で書かれたランタイムコードを含んでいます。

  • スタックフレームとメモリレイアウト: 関数が呼び出されると、その関数専用のメモリ領域がスタック上に確保されます。これを「スタックフレーム」と呼びます。スタックフレームには、関数の引数、ローカル変数、戻り値のアドレス、呼び出し元の情報などが格納されます。Goランタイムは、これらのスタックフレームを効率的に管理し、関数の呼び出しと戻り値の処理を行います。

  • goc2c: Goの初期のコンパイルツールチェーンでは、GoのソースコードをC言語のソースコードに変換する goc2c というツールが使用されていました。このCコードがその後Cコンパイラによってコンパイルされ、最終的な実行可能ファイルが生成されていました。この変換プロセスにおいて、Goの言語仕様とC言語の挙動の間に差異が生じることがあり、それが今回のようなバグの原因となることがありました。特に、Goの関数が戻り値を持つ場合、goc2c はその戻り値の領域をゼロ初期化するコードを生成する傾向がありました。

  • #pragma textflag NOSPLIT: これはGoコンパイラに対するディレクティブ(指示子)の一つです。NOSPLIT は、その関数がスタックを分割しない(つまり、スタックの成長を伴う新しいスタックフレームを割り当てない)ことを意味します。これは、非常に低レベルでパフォーマンスが重要なランタイム関数でよく使用され、スタックのオーバーヘッドを避けるために用いられます。

技術的詳細

このコミットが修正した問題は、Goランタイムの src/pkg/runtime/iface.goc ファイル内の assertI2T および assertE2T という関数に起因していました。これらの関数は、それぞれ Iface (インターフェース) から T (型) へ、Eface (空インターフェース) から T (型) への型アサーションを行うランタイム内部関数です。

問題の根源: Goの初期のコンパイルパイプラインでは、Goの関数がC言語に変換される際に、戻り値の領域が自動的にゼロ初期化されるという挙動がありました。assertI2TassertE2T は、Goのコード上では func assertI2T(...) (ret byte, ...) のように、byte 型の戻り値 ret を持つ関数として宣言されていました。 しかし、型アサーションの対象が struct{} のようなゼロサイズ型 T であった場合、本来 ret には何もデータが返されるべきではありません(メモリを消費しないため)。それにもかかわらず、goc2cbyte 型の戻り値 ret のために1バイトの領域をスタック上に確保し、それをゼロ初期化していました。この1バイトのゼロ初期化が、スタック上の意図しない領域(例えば、その関数を呼び出した側のスタックフレームにある別のローカル変数)を上書きしてしまい、データ破損を引き起こす可能性があったのです。

コミットメッセージにある「If there was another function call with more arguments in the same stack frame, no harm done. Otherwise, if the variable at that location was already zero, no harm done. Otherwise, problems.」という記述は、この問題の発生条件を説明しています。

  • 「より多くの引数を持つ別の関数呼び出しがあれば、害はない」: これは、その後の関数呼び出しがスタックフレームを再配置し、問題の1バイト書き込みが影響を与える領域を上書きしてしまうため、結果的に問題が隠蔽されることを示唆しています。
  • 「その場所の変数が既にゼロであれば、害はない」: これは、たまたま上書きされるスタック上の変数の値が既にゼロであった場合、ゼロで上書きされても値が変わらないため、問題が顕在化しないことを意味します。
  • 「それ以外の場合、問題が発生する」: 上記の条件に当てはまらない場合、つまりスタック上の有効なデータが意図せずゼロに上書きされ、それが後続の処理に影響を与える場合に、バグとして顕在化します。

修正アプローチ: この問題を解決するために、assertI2TassertE2T の実装が根本的に変更されました。

  1. Goの func 宣言からC言語の void 関数へ: Goの func キーワードを使って関数を宣言する代わりに、これらの関数をC言語の void 関数として直接定義するように変更されました。これにより、goc2c による自動的な戻り値のゼロ初期化の挙動を回避し、ランタイムがメモリの割り当てと初期化をより細かく制御できるようになりました。
  2. 汎用的な戻り値ポインタ GoOutput retbase の導入: 以前は (ret byte, ...) のように byte 型の戻り値を宣言していましたが、これを GoOutput retbase という汎用的なポインタ型に変更しました。GoOutput は、Goの出力引数(戻り値)を扱うための内部的な型です。これにより、ゼロサイズ型の場合でも、戻り値が占めるべきメモリ領域(実際にはゼロバイト)を正しく参照できるようになりました。
  3. assertI2Tret および assertE2Tret への引数変更: 内部的に戻り値を処理する assertI2Tret および assertE2Tret 関数に渡す引数が、以前の &ret から (byte*)&retbase に変更されました。これは、retbase が指すアドレスを byte ポインタにキャストすることで、ゼロサイズ型の場合でも正しいメモリ位置(実際には何も書き込まれない)を指すようにし、不適切な1バイト書き込みを防ぐためのものです。

テストケース test/fixedbugs/issue8139.go: このコミットには、バグを再現し修正を検証するための新しいテストファイル test/fixedbugs/issue8139.go が追加されています。このテストは、以下のようなロジックでバグを検出します。

  • f1()f2() 関数内で、z := 0x11223344 のように特定の非ゼロ値を持つローカル変数をスタックに配置します。
  • その直後に、_ = e.(T)_ = i.(T) のように、ゼロサイズ型 T{} への型アサーションを実行します。
  • もしバグが修正されていなければ、この型アサーションの際に z の値が意図せずゼロに上書きされてしまう可能性があります。
  • main() 関数では f1()f2() の戻り値(z の値)をチェックし、もし 0x11223344 以外になっていれば、バグがまだ存在することを示します。このテストは、スタック上のデータ破損という具体的な影響を捉えることで、修正が正しく行われたことを保証します。

コアとなるコードの変更箇所

diff --git a/src/pkg/runtime/iface.goc b/src/pkg/runtime/iface.goc
index 96bb8b8aa4..c0a17e3034 100644
--- a/src/pkg/runtime/iface.goc
+++ b/src/pkg/runtime/iface.goc
@@ -209,9 +209,19 @@ func convT2E(t *Type, elem *byte) (ret Eface) {
 
  static void assertI2Tret(Type *t, Iface i, byte *ret);
 
+/*
+ * NOTE: Cannot use 'func' here, because we have to declare
+ * a return value, the only types we have are at least 1 byte large,
+ * goc2c will zero the return value, and the actual return value
+ * might have size 0 bytes, in which case the zeroing of the
+ * 1 or more bytes would be wrong.
+ * Using C lets us control (avoid) the initial zeroing.
+ */
 #pragma textflag NOSPLIT
 -func assertI2T(t *Type, i Iface) (ret byte, ...) {
 --	assertI2Tret(t, i, &ret);
 +void
 +runtime·assertI2T(Type *t, Iface i, GoOutput retbase)
 +{
 +\tassertI2Tret(t, i, (byte*)&retbase);
  }
 
  static void
@@ -260,9 +270,14 @@ func assertI2TOK(t *Type, i Iface) (ok bool) {
 
  static void assertE2Tret(Type *t, Eface e, byte *ret);
 
+/*
+ * NOTE: Cannot use 'func' here. See assertI2T above.
+ */
 #pragma textflag NOSPLIT
 -func assertE2T(t *Type, e Eface) (ret byte, ...) {
 --	assertE2Tret(t, e, &ret);
 +void
 +runtime·assertE2T(Type *t, Eface e, GoOutput retbase)
 +{
 +\tassertE2Tret(t, e, (byte*)&retbase);
  }
 
  static void

コアとなるコードの解説

src/pkg/runtime/iface.goc の変更は、Goランタイムがインターフェースの型アサーションを処理する方法の根本的な修正を示しています。

  1. assertI2T 関数の変更:

    • 変更前:
      func assertI2T(t *Type, i Iface) (ret byte, ...) {
          assertI2Tret(t, i, &ret);
      }
      
      このGoの func 宣言では、ret byte という1バイトの戻り値が明示的に宣言されていました。前述の通り、goc2c はこの ret の領域をゼロ初期化していました。ゼロサイズ型 T の場合、この ret は不要であり、そのゼロ初期化がスタック上の他のデータを上書きする原因となっていました。
    • 変更後:
      void
      runtime·assertI2T(Type *t, Iface i, GoOutput retbase)
      {
          assertI2Tret(t, i, (byte*)&retbase);
      }
      
      関数がGoの func からC言語の void 関数として再定義されました。これにより、goc2c による自動的な戻り値のゼロ初期化の挙動を回避しています。戻り値は GoOutput retbase という引数として渡されるようになりました。GoOutput はGoランタイム内部で出力引数を扱うための型です。 assertI2Tret に渡すアドレスも &ret から (byte*)&retbase に変更されています。これは、retbase が指すアドレスを byte ポインタにキャストすることで、ゼロサイズ型の場合でも正しいメモリ位置(実際には何も書き込まれない)を指すようにし、不適切な1バイト書き込みを防ぐためのものです。
  2. assertI2T の上のコメント:

    /*
     * NOTE: Cannot use 'func' here, because we have to declare
     * a return value, the only types we have are at least 1 byte large,
     * goc2c will zero the return value, and the actual return value
     * might have size 0 bytes, in which case the zeroing of the
     * 1 or more bytes would be wrong.
     * Using C lets us control (avoid) the initial zeroing.
     */
    

    この重要なコメントは、なぜGoの func を使用できないのかを明確に説明しています。Goの型システムでは、最小でも1バイトのサイズを持つ型しか宣言できません。そのため、goc2c はGoの func で宣言された戻り値をゼロ初期化する際に、少なくとも1バイトの領域をゼロにします。しかし、実際の戻り値がゼロサイズ型(0バイト)である場合、この1バイト以上のゼロ初期化は誤りであり、スタック上の意図しない領域を上書きしてしまいます。C言語で直接実装することで、この初期化を制御(回避)できるため、このアプローチが取られました。

  3. assertE2T 関数の変更: assertE2T 関数も assertI2T と同様の理由と方法で修正されています。Eface (空インターフェース) から型 T へのアサーションを行う関数であり、同じくゼロサイズ型 T の場合に問題が発生していました。変更内容は assertI2T と全く同じパターンです。

これらの変更により、Goランタイムはゼロサイズ型への型アサーションを正しく処理し、スタック上のデータ破損という潜在的なバグが解消されました。

関連リンク

  • GitHubコミットページ: https://github.com/golang/go/commit/d646040fd13b79f811c85bc7280a71c3493419ec
  • Gerrit Change-ID: https://golang.org/cl/100940043 (GoプロジェクトがGoogle CodeからGitHubへ移行したため、この古いGerrit CL番号から直接GitHubのIssueページへのリンクは見つかりませんでした。しかし、このコミットがIssue #8139を修正したものであることはコミットメッセージに明記されています。)

参考にした情報源リンク