[インデックス 16225] ファイルの概要
このコミットは、Goコンパイラ(cmd/5g
、cmd/6g
、cmd/8g
)における、大きな構造体へのnilポインタアクセスに対するチェックを強化するものです。具体的には、構造体のフィールドにアクセスする際に、基底ポインタがnilである場合に確実にパニックを発生させるための明示的なチェックが追加されています。これにより、nilポインタの逆参照がより予測可能で堅牢になります。
コミット
commit 578dc3a96ce6649b021ee437e089af3a205dff82
Author: Ian Lance Taylor <iant@golang.org>
Date: Wed Apr 24 08:13:01 2013 -0700
cmd/5g, cmd/6g, cmd/8g: more nil ptr to large struct checks
R=r, ken, khr, daniel.morsing
CC=dsymonds, golang-dev, rickyz
https://golang.org/cl/8925043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/578dc3a96ce6649b021ee437e089af3a205dff82
元コミット内容
cmd/5g, cmd/6g, cmd/8g: more nil ptr to large struct checks
R=r, ken, khr, daniel.morsing
CC=dsymonds, golang-dev, rickyz
https://golang.org/cl/8925043
変更の背景
Go言語では、nilポインタを逆参照しようとするとランタイムパニックが発生し、プログラムが異常終了することが期待されます。これは、C/C++のような言語で発生しうるセグメンテーション違反などの未定義動作を防ぎ、プログラムの安全性を高めるための重要な設計原則です。
しかし、特定の条件下、特に大きな構造体のフィールドにアクセスする場合、コンパイラが生成するコードによっては、nilポインタの逆参照が即座にパニックを引き起こさない可能性がありました。例えば、nilポインタ(アドレス0)を基点として、大きなオフセットを持つフィールドにアクセスしようとした場合、そのアクセスが実際に不正なメモリアドレスに触れることでOSレベルのセグメンテーション違反を引き起こし、Goランタイムがそれを捕捉してパニックに変換する、という流れになります。しかし、もしアクセスしようとしているフィールドが構造体の先頭(オフセット0)にあり、かつOSがアドレス0のページを何らかの理由でマップしているような稀なケースでは、即座にクラッシュしない可能性もゼロではありませんでした。
このコミットは、このような潜在的なエッジケースを排除し、nilポインタの逆参照が常に予測可能かつ一貫してパニックを引き起こすように、コンパイラレベルでのチェックを強化することを目的としています。特に、unmappedzero
という閾値以上のサイズの構造体に対して、フィールドアクセス時に明示的なnilチェックを挿入することで、この問題を解決しています。
前提知識の解説
このコミットを理解するためには、以下の概念が役立ちます。
- Goコンパイラ (
cmd/5g
,cmd/6g
,cmd/8g
): Go言語のソースコードを機械語に変換するツール群です。5g
はARMアーキテクチャ、6g
はx86-64アーキテクチャ、8g
はx86アーキテクチャ向けのコンパイラを指します。これらはGoの初期のコンパイラであり、現在はgo tool compile
コマンドの内部で利用されています。 - nilポインタ: どのメモリアドレスも指していないポインタです。Goでは、初期化されていないポインタ変数はデフォルトでnil値を取ります。
- ポインタの逆参照 (Dereferencing): ポインタが指すメモリアドレスに格納されている値にアクセスする操作です。Goでは
*p
のように記述します。 - 構造体 (Struct): 異なる型のフィールドをまとめた複合データ型です。
- フィールドアクセス: 構造体のインスタンスから特定のフィールドの値にアクセスする操作です。Goでは
s.field
やptr.field
のように記述します。 ODOT
とODOTPTR
: Goコンパイラの内部表現(ASTノード)で、それぞれ構造体のフィールドアクセス(struct.field
)とポインタを介した構造体のフィールドアクセス(*ptr.field
またはptr->field
)を表します。agen
とigen
: コンパイラのコード生成フェーズにおける関数で、それぞれアドレス生成(agen
)と間接参照コード生成(igen
)を担当します。unmappedzero
: Goランタイムがnilポインタの逆参照を検出するために使用する内部的な閾値です。通常、オペレーティングシステムはアドレス0のメモリページをマップしていません。そのため、アドレス0へのアクセスはページフォルト(セグメンテーション違反)を引き起こし、Goランタイムがこれを捕捉してパニックに変換します。unmappedzero
は、このゼロページがマップされていないことを前提とした最適化やチェックの基準となる値です。この値以上のサイズの構造体は、nilポインタからのアクセス時に明示的なチェックが必要と判断されます。- レジスタ割り当て (
regalloc
) とデータ移動 (gmove
): コンパイラが機械語を生成する際に、CPUのレジスタを割り当て、データをレジスタ間やメモリ間で移動させるための内部操作です。 OINDREG
: コンパイラ内部で、レジスタによる間接参照(レジスタが指すメモリアドレスへのアクセス)を表すオペレーションコードです。ATESTB
: 特定のアーキテクチャ(この場合はx86/x86-64)で、メモリのバイトをテストする(読み込む)ための命令を生成するコンパイラ内部の関数です。アドレス0からの読み込みは、nilポインタチェックの一般的な手法です。
技術的詳細
このコミットの主要な変更は、Goコンパイラのコード生成部分、特にsrc/cmd/{5g,6g,8g}/cgen.c
ファイルに集中しています。これらのファイルは、Goの抽象構文木(AST)を各アーキテクチャの機械語命令に変換する役割を担っています。
変更の核心は、ODOT
(構造体フィールドアクセス)とODOTPTR
(ポインタを介した構造体フィールドアクセス)の処理ロジックに、明示的なnilポインタチェックを追加した点です。
以前のコードでは、ODOTPTR
の場合、nilチェックはn->xoffset != 0
(フィールドのオフセットが0でない場合)の条件ブロック内に配置されていました。これは、オフセットが0でない場合、nilポインタからのアクセスは通常、不正なメモリアドレスへのアクセスとなり、OSによって検出されるという前提に基づいています。しかし、オフセットが0の場合や、より複雑なポインタチェーンを介したアクセスの場合に、このチェックが不十分である可能性がありました。
このコミットでは、以下の点が改善されています。
ODOT
へのnilチェックの追加:ODOT
(struct.field
)の場合でも、構造体自体がunmappedzero
以上のサイズであれば、明示的なnilチェックが追加されました。これは、構造体変数がスタックやヒープに割り当てられている場合でも、その構造体へのポインタがnilである可能性を考慮に入れたものです。ODOTPTR
のnilチェックの移動と強化:ODOTPTR
(*ptr.field
)の場合、nilチェックのロジックがn->xoffset != 0
の条件ブロックの外に移動されました。これにより、フィールドのオフセットに関わらず、ポインタが指す構造体のサイズがunmappedzero
以上であれば、常に明示的なnilチェックが実行されるようになりました。igen
関数におけるnilチェックの移動と強化:igen
関数(間接参照コード生成)においても、ODOTPTR
のケースで同様にnilチェックのロジックがn->xoffset != 0
の条件ブロックの外に移動され、常に実行されるようになりました。
nilチェックのメカニズム: 追加されたnilチェックのコードは、以下のような処理を行います。
if(nl->type->width >= unmappedzero) { // または nl->type->type->width >= unmappedzero
regalloc(&n1, types[tptr], N); // ポインタ値を格納するためのレジスタを割り当て
gmove(res, &n1); // 結果レジスタ(ポインタ値)をn1に移動
regalloc(&n2, types[TUINT8], &n1); // 1バイト読み込み用のレジスタを割り当て
n1.op = OINDREG; // n1を間接参照オペレーションに設定
n1.type = types[TUINT8]; // 1バイト型に設定
n1.xoffset = 0; // オフセットを0に設定
gmove(&n1, &n2); // または gins(ATESTB, nodintconst(0), &n1);
// アドレス0から1バイトを読み込もうとする
regfree(&n1); // レジスタを解放
regfree(&n2); // レジスタを解放
}
このコードは、ポインタが指すアドレス(res
レジスタに格納されている)をn1
レジスタにコピーし、そのアドレスのオフセット0から1バイトを読み込もうとします。もしポインタがnil
(アドレス0)であれば、この読み込み操作は通常、オペレーティングシステムによって保護されたメモリ領域へのアクセスとなり、ページフォルト(セグメンテーション違反)を引き起こします。Goランタイムはこのフォルトを捕捉し、panic: runtime error: invalid memory address or nil pointer dereference
というメッセージでパニックを発生させます。
unmappedzero
という閾値は、Goランタイムがゼロページをマップしていないという仮定に基づいています。この閾値以上のサイズの構造体に対してこのチェックを適用することで、コンパイラはnilポインタの逆参照をより確実に検出できるようになります。
test/nilptr.go
の変更は、この新しいチェックが正しく機能することを確認するためのテストケースを追加しています。特に、p11
はネストされた構造体とポインタを介したフィールドアクセス、p12
はADDR(DOT(IND(p)))
という複雑なポインタ操作を伴うnil逆参照をテストしており、これらのシナリオでもパニックが適切に発生することを確認しています。
コアとなるコードの変更箇所
変更は主に以下のファイルにわたります。
src/cmd/5g/cgen.c
src/cmd/6g/cgen.c
src/cmd/8g/cgen.c
test/nilptr.go
各cgen.c
ファイルでは、agen
関数とigen
関数内のODOT
およびODOTPTR
ケースで、nilポインタチェックのロジックが追加または移動されています。
例 (src/cmd/5g/cgen.c
の ODOT
ケース):
--- a/src/cmd/5g/cgen.c
+++ b/src/cmd/5g/cgen.c
@@ -679,6 +679,19 @@ agen(Node *n, Node *res)\n
case ODOT:
agen(nl, res);
+ // explicit check for nil if struct is large enough
+ // that we might derive too big a pointer.
+ if(nl->type->width >= unmappedzero) {
+ regalloc(&n1, types[tptr], N);
+ gmove(res, &n1);
+ regalloc(&n2, types[TUINT8], &n1);
+ n1.op = OINDREG;
+ n1.type = types[TUINT8];
+ n1.xoffset = 0;
+ gmove(&n1, &n2);
+ regfree(&n1);
+ regfree(&n2);
+ }
if(n->xoffset != 0) {
nodconst(&n1, types[TINT32], n->xoffset);
regalloc(&n2, n1.type, N);
例 (src/cmd/5g/cgen.c
の ODOTPTR
ケース):
--- a/src/cmd/5g/cgen.c
+++ b/src/cmd/5g/cgen.c
@@ -694,20 +707,20 @@ agen(Node *n, Node *res)\n
case ODOTPTR:
cgen(nl, res);
+ // explicit check for nil if struct is large enough
+ // that we might derive too big a pointer.
+ if(nl->type->type->width >= unmappedzero) {
+ regalloc(&n1, types[tptr], N);
+ gmove(res, &n1);
+ regalloc(&n2, types[TUINT8], &n1);
+ n1.op = OINDREG;
+ n1.type = types[TUINT8];
+ n1.xoffset = 0;
+ gmove(&n1, &n2);
+ regfree(&n1);
+ regfree(&n2);
+ }
if(n->xoffset != 0) {
-\t\t\t// explicit check for nil if struct is large enough
-\t\t\t// that we might derive too big a pointer.\n-\t\t\tif(nl->type->type->width >= unmappedzero) {\n-\t\t\t\tregalloc(&n1, types[tptr], N);\n-\t\t\t\tgmove(res, &n1);\n-\t\t\t\tregalloc(&n2, types[TUINT8], &n1);\n-\t\t\t\tn1.op = OINDREG;\n-\t\t\t\tn1.type = types[TUINT8];\n-\t\t\t\tn1.xoffset = 0;\n-\t\t\t\tgmove(&n1, &n2);\n-\t\t\t\tregfree(&n1);\n-\t\t\t\tregfree(&n2);\n-\t\t\t}\n nodconst(&n1, types[TINT32], n->xoffset);
regalloc(&n2, n1.type, N);
regalloc(&n3, types[tptr], N);
test/nilptr.go
には、以下のテストケースが追加されています。
func p11() {
t := &T2{}
p := &t.i
println(*p)
}
// ADDR(DOT(IND(p))) needs a check also
func p12() {
var p *T = nil
println(*(&((*p).i)))
}
コアとなるコードの解説
Goコンパイラのコード生成器は、Goプログラムの抽象構文木(AST)を走査し、対応する機械語命令を生成します。このコミットで変更されたagen
とigen
関数は、それぞれアドレスの計算と間接参照のコード生成を担当しています。
agen(Node *n, Node *res)
: この関数は、ノードn
のアドレスを計算し、その結果をres
ノードに格納するためのコードを生成します。ODOT
ケース:struct.field
のようなフィールドアクセスを処理します。nl
は構造体自体を表すノードです。if(nl->type->width >= unmappedzero)
: ここで、構造体nl
のサイズ(width
)がunmappedzero
以上であるかをチェックします。もしそうであれば、明示的なnilチェックが必要と判断されます。- 内部の
regalloc
,gmove
,OINDREG
の設定、そしてgmove(&n1, &n2)
(またはgins(ATESTB, ...)
)は、res
に格納されているポインタ値(構造体のアドレス)のオフセット0から1バイトを読み込もうとする命令を生成します。これにより、ポインタがnilであれば、OSがゼロページをマップしていないため、ページフォルトが発生し、Goランタイムがこれを捕捉してパニックに変換します。
ODOTPTR
ケース:*ptr.field
のようなポインタを介したフィールドアクセスを処理します。nl
はポインタ自体を表すノードです。if(nl->type->type->width >= unmappedzero)
: ここで、ポインタnl
が指す型(構造体)のサイズ(width
)がunmappedzero
以上であるかをチェックします。- 以前は、このnilチェックは
if(n->xoffset != 0)
の内部にありました。つまり、フィールドのオフセットが0でない場合にのみチェックが行われていました。このコミットでは、このチェックがif(n->xoffset != 0)
の外に移動され、フィールドのオフセットに関わらず、ポインタが指す構造体が大きい場合に常にnilチェックが実行されるようになりました。
igen(Node *n, Node *a, Node *res)
: この関数は、ノードn
が指す値(間接参照)を計算し、その結果をres
ノードに格納するためのコードを生成します。a
はアドレスを保持するノードです。ODOTPTR
ケース:agen
関数と同様に、ポインタが指す構造体のサイズがunmappedzero
以上であれば、明示的なnilチェックが実行されるようにロジックが変更されています。
これらの変更により、Goコンパイラは、大きな構造体へのポインタを介したフィールドアクセスにおいて、nilポインタの逆参照をより早期かつ確実に検出できるようになり、ランタイムパニックの一貫性が向上しました。
test/nilptr.go
に追加されたp11
とp12
は、この新しいチェックがカバーすべき具体的なシナリオを表現しています。
p11
は、埋め込み構造体とポインタの組み合わせによる複雑なフィールドアクセスパスをテストします。p12
は、&((*p).i)
のように、nilポインタを逆参照した結果のフィールドのアドレスを取るという、さらに複雑なケースをテストします。これらのテストケースは、コンパイラがこれらのエッジケースでも適切にnilチェックを挿入し、パニックを発生させることを保証します。
関連リンク
- Go言語の公式ドキュメント: https://golang.org/doc/
- Goコンパイラのソースコード: https://github.com/golang/go/tree/master/src/cmd/compile
- Goのnilポインタに関する議論(一般的な情報): https://go.dev/blog/go-pointers
参考にした情報源リンク
- Goのコミット履歴: https://github.com/golang/go/commits/master
- Goのコードレビューシステム (Gerrit): https://go-review.googlesource.com/ (コミットメッセージに記載されている
golang.org/cl/8925043
はGerritのチェンジリストIDです) - Goコンパイラの内部構造に関する資料(一般的な情報源、特定のコミットに直接関連するものではないが背景理解に役立つ)
- "Go's Execution Tracer": https://go.dev/blog/go-execution-tracer (ランタイムの動作に関する一般的な理解に役立つ)
- "The Go Programming Language Specification": https://go.dev/ref/spec (Go言語のセマンティクスに関する公式な定義)
- オペレーティングシステムのメモリ管理(ゼロページマッピングなどに関する一般的な情報)
- Linuxのメモリ管理に関するドキュメントなど