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

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

このコミットは、Goコンパイラ(cmd/5gcmd/6gcmd/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.fieldptr.fieldのように記述します。
  • ODOTODOTPTR: Goコンパイラの内部表現(ASTノード)で、それぞれ構造体のフィールドアクセス(struct.field)とポインタを介した構造体のフィールドアクセス(*ptr.field または ptr->field)を表します。
  • agenigen: コンパイラのコード生成フェーズにおける関数で、それぞれアドレス生成(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の場合や、より複雑なポインタチェーンを介したアクセスの場合に、このチェックが不十分である可能性がありました。

このコミットでは、以下の点が改善されています。

  1. ODOTへのnilチェックの追加: ODOTstruct.field)の場合でも、構造体自体がunmappedzero以上のサイズであれば、明示的なnilチェックが追加されました。これは、構造体変数がスタックやヒープに割り当てられている場合でも、その構造体へのポインタがnilである可能性を考慮に入れたものです。
  2. ODOTPTRのnilチェックの移動と強化: ODOTPTR*ptr.field)の場合、nilチェックのロジックがn->xoffset != 0の条件ブロックの外に移動されました。これにより、フィールドのオフセットに関わらず、ポインタが指す構造体のサイズがunmappedzero以上であれば、常に明示的なnilチェックが実行されるようになりました。
  3. 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はネストされた構造体とポインタを介したフィールドアクセス、p12ADDR(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.cODOT ケース):

--- 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.cODOTPTR ケース):

--- 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)を走査し、対応する機械語命令を生成します。このコミットで変更されたagenigen関数は、それぞれアドレスの計算と間接参照のコード生成を担当しています。

  • 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に追加されたp11p12は、この新しいチェックがカバーすべき具体的なシナリオを表現しています。

  • p11は、埋め込み構造体とポインタの組み合わせによる複雑なフィールドアクセスパスをテストします。
  • p12は、&((*p).i)のように、nilポインタを逆参照した結果のフィールドのアドレスを取るという、さらに複雑なケースをテストします。これらのテストケースは、コンパイラがこれらのエッジケースでも適切にnilチェックを挿入し、パニックを発生させることを保証します。

関連リンク

参考にした情報源リンク

  • Goのコミット履歴: https://github.com/golang/go/commits/master
  • Goのコードレビューシステム (Gerrit): https://go-review.googlesource.com/ (コミットメッセージに記載されているgolang.org/cl/8925043はGerritのチェンジリストIDです)
  • Goコンパイラの内部構造に関する資料(一般的な情報源、特定のコミットに直接関連するものではないが背景理解に役立つ)
  • オペレーティングシステムのメモリ管理(ゼロページマッピングなどに関する一般的な情報)
    • Linuxのメモリ管理に関するドキュメントなど