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

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

このコミットは、Go言語のランタイムにおけるdefer関数の呼び出し元行番号がスタックトレースに誤って表示されるバグを修正します。具体的には、defer関数が実行される際の呼び出し元のプログラムカウンタ(PC)が指す命令の直前に、適切な行番号を持つハードウェアNOP命令を挿入することで、スタックトレースの正確性を向上させています。これにより、デバッグ時にdefer関数の呼び出し元を正しく特定できるようになります。

コミット

commit 7e97d398795fd91e5ab9637572984291e19de4b9
Author: Russ Cox <rsc@golang.org>
Date:   Fri Jul 12 13:47:55 2013 -0400

    cmd/5g, cmd/6g, cmd/8g: fix line number of caller of deferred func
    
    Deferred functions are not run by a call instruction. They are run by
    the runtime editing registers to make the call start with a caller PC
    returning to a
            CALL deferreturn
    instruction.
    
    That instruction has always had the line number of the function's
    closing brace, but that instruction's line number is irrelevant.
    Stack traces show the line number of the instruction before the
    return PC, because normally that's what started the call. Not so here.
    The instruction before the CALL deferreturn could be almost anywhere
    in the function; it's unrelated and its line number is incorrect to show.
    
    Fix the line number by inserting a true hardware no-op with the right
    line number before the returned-to CALL instruction. That is, the deferred
    calls now appear to start with a caller PC returning to the second instruction
    in this sequence:
            NOP
            CALL deferreturn
    
    The traceback will show the line number of the NOP, which we've set
    to be the line number of the function's closing brace.
    
    The NOP here is not the usual pseudo-instruction, which would be
    elided by the linker. Instead it is the real hardware instruction:
    XCHG AX, AX on 386 and amd64, and AND.EQ R0, R0, R0 on ARM.
    
    Fixes #5856.
    
    R=ken2, ken
    CC=golang-dev
    https://golang.org/cl/11223043

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

https://github.com/golang/go/commit/7e97d398795fd91e5ab9637572984291e19de4b9

元コミット内容

このコミットは、Go言語のdefer関数が実行された際に、スタックトレースに表示される呼び出し元の行番号が不正確であるという問題に対処しています。通常の関数呼び出しとは異なり、defer関数はランタイムによってレジスタが操作され、特定のCALL deferreturn命令に戻るように呼び出し元のプログラムカウンタ(PC)が設定されます。このCALL deferreturn命令は常にその関数が定義されているブロックの閉じ括弧の行番号を持っていましたが、スタックトレースは通常、リターンPCの直前の命令の行番号を表示するため、defer関数の場合はこの行番号が実際の呼び出し元とは無関係な場所を指してしまうという問題がありました。

変更の背景

Go言語のdeferキーワードは、関数がリターンする直前(またはパニックが発生した場合)に実行される関数をスケジュールするために使用されます。これはリソースの解放(ファイルのクローズ、ロックの解除など)やエラーハンドリングにおいて非常に便利です。しかし、デバッグ時やエラー発生時にスタックトレースを解析する際、defer関数の呼び出し元が誤った行番号で表示されると、問題の根本原因を特定することが困難になります。

例えば、ある関数内でdeferされた関数がパニックを起こした場合、スタックトレースにはdefer関数が呼び出された場所ではなく、その関数が定義されているブロックの終了位置(閉じ括弧)の行番号が表示されていました。これは、deferの実行が通常のCALL命令ではなく、ランタイムによるPCの操作によって行われるという特殊なメカニズムに起因します。開発者は、スタックトレースを見て、実際にdeferがスケジュールされたコードの行を特定したいと考えるため、この不正確さはデバッグ体験を著しく損ねていました。このコミットは、このデバッグ上の課題を解決し、より正確なスタックトレース情報を提供することを目的としています。

前提知識の解説

Goのdeferメカニズム

Goのdeferステートメントは、現在の関数が実行を終了する直前に、指定された関数呼び出しをスケジュールします。これは、関数の正常終了時だけでなく、パニックが発生した場合にも実行されます。deferされた関数はLIFO(後入れ先出し)の順序で実行されます。

deferの内部的な動作は、通常の関数呼び出しとは異なります。コンパイラはdeferステートメントを検出すると、その関数呼び出しを特別なリスト(defer record)に追加します。関数が終了する際、ランタイムはこのリストを走査し、登録された関数を呼び出します。この呼び出しは、通常のCALL命令を介して行われるのではなく、ランタイムがスタックフレームとレジスタを操作して、defer関数が呼び出し元から戻ってきたかのように見せかけることで実現されます。具体的には、defer関数が戻るべきアドレス(リターンPC)を、defer処理を管理するランタイム内の特定の命令(deferreturn)に設定します。

スタックトレースとプログラムカウンタ(PC)

スタックトレースは、プログラムの実行中に発生した関数呼び出しのシーケンスを記録したものです。エラーやパニックが発生した際に、どの関数がどの順序で呼び出されたかを示すことで、問題の発生源を特定するのに役立ちます。

スタックトレースは、各スタックフレームに保存されているリターンアドレス(プログラムカウンタ、PC)を辿ることで生成されます。リターンアドレスは、関数が呼び出し元に戻るべき次の命令のアドレスを示します。通常、スタックトレースはリターンPCの直前の命令の行番号を表示します。これは、その命令が関数呼び出しを開始したと見なされるためです。

NOP命令

NOP(No Operation)命令は、CPUに対して何もしないことを指示する命令です。プログラムの実行フローには影響を与えませんが、命令ポインタは次の命令に進みます。NOP命令は、アライメントの調整、命令キャッシュの最適化、またはデバッグ目的で意図的に遅延を挿入するために使用されることがあります。

重要なのは、コンパイラやリンカが最適化の一環として、意味のないNOP命令を削除(elide)することがある点です。このコミットでは、リンカによって削除されない「真のハードウェアNOP」を使用している点がポイントです。

技術的詳細

このコミットが解決しようとしている問題は、Goのdefer関数がスタックトレースに表示される際の行番号の不正確さです。

  1. 問題の根源:

    • defer関数は、通常のCALL命令によって呼び出されるわけではありません。
    • 代わりに、Goランタイムがレジスタを操作し、defer関数が終了した際に、特定のランタイム命令であるCALL deferreturnに戻るように呼び出し元のPCを設定します。
    • このCALL deferreturn命令自体は、常にその関数が定義されているブロックの閉じ括弧の行番号に関連付けられていました。
    • しかし、スタックトレースは通常、リターンPCの「直前の命令」の行番号を表示します。
    • deferの場合、CALL deferreturnの直前の命令は、関数内のほぼどこにでも存在しうる無関係な命令であり、その行番号はdeferがスケジュールされた実際の場所とは全く関係ありませんでした。
  2. 解決策:

    • このコミットは、CALL deferreturn命令の直前に、適切な行番号を持つ「真のハードウェアNOP」命令を挿入することで問題を解決します。
    • これにより、defer関数が戻るべきPCは、NOP命令の次の命令(つまりCALL deferreturn)を指すことになります。
    • スタックトレースはリターンPCの直前の命令(この場合は新しく挿入されたNOP)の行番号を表示するため、このNOPdeferがスケジュールされた関数の閉じ括弧の行番号を設定することで、スタックトレースの正確性を確保します。
    • このNOPは、リンカによって削除されないように、通常の擬似命令ではなく、各アーキテクチャ固有の実際のハードウェアNOP命令が使用されます。
      • 386およびamd64アーキテクチャ: XCHG AX, AX (0x90) が使用されます。これはレジスタAXの内容をAXと交換する命令で、実質的に何もしませんが、CPUサイクルを消費し、命令として存在します。
      • ARMアーキテクチャ: AND.EQ R0, R0, R0 (0x00000000) が使用されます。これはR0レジスタの内容をR0とAND演算し、結果をR0に格納する命令で、条件コードがEQの場合に実行されます。これも実質的に何もしませんが、命令として存在します。

この変更により、defer関数がパニックを起こしたり、デバッグ中にスタックトレースを調べたりする際に、より意味のある、関数が終了する場所を示す行番号が提供されるようになります。

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

このコミットでは、主にGoコンパイラのバックエンド部分、特にコード生成を担当するファイルが変更されています。

  • src/cmd/5g/ggen.c: ARMアーキテクチャ (5gはARMコンパイラ) 向けのコード生成ロジック。
  • src/cmd/6g/ggen.c: AMD64アーキテクチャ (6gはAMD64コンパイラ) 向けのコード生成ロジック。
  • src/cmd/8g/ggen.c: 386アーキテクチャ (8gは386コンパイラ) 向けのコード生成ロジック。
  • test/fixedbugs/issue5856.go: このバグが修正されたことを検証するための新しいテストケース。

これらのggen.cファイル内で、deferreturn関数への呼び出しを生成する箇所に、アーキテクチャ固有のハードウェアNOP命令を挿入するロジックが追加されています。

コアとなるコードの解説

変更は、各アーキテクチャのggen.cファイル内のginscall関数に集中しています。この関数は、GoのAST(抽象構文木)ノードからアセンブリ命令を生成する役割を担っています。

src/cmd/5g/ggen.c (ARM) の変更点

@@ -84,6 +84,20 @@ ginscall(Node *f, int proc)
  	case 0:	// normal call
  	case -1:	// normal call but no return
  	\tif(f->op == ONAME && f->class == PFUNC) {
+\t\t\tif(f == deferreturn) {
+\t\t\t\t// Deferred calls will appear to be returning to
+\t\t\t\t// the BL deferreturn(SB) that we are about to emit.
+\t\t\t\t// However, the stack trace code will show the line
+\t\t\t\t// of the instruction before that return PC. 
+\t\t\t\t// To avoid that instruction being an unrelated instruction,
+\t\t\t\t// insert a NOP so that we will have the right line number.
+\t\t\t\t// ARM NOP 0x00000000 is really AND.EQ R0, R0, R0.
+\t\t\t\t// Use the latter form because the NOP pseudo-instruction
+\t\t\t\t// would be removed by the linker.
+\t\t\t\tnodreg(&r, types[TINT], 0);
+\t\t\t\tp = gins(AAND, &r, &r);
+\t\t\t\tp->scond = C_SCOND_EQ;
+\t\t\t}
  		\tp = gins(ABL, N, f);
  		\tafunclit(&p->to, f);
  		\tif(proc == -1 || noreturn(p))
  • if(f == deferreturn): 生成される呼び出しがdeferreturn関数へのものであるかをチェックします。
  • nodreg(&r, types[TINT], 0);: レジスタR0を表すノードを作成します。
  • p = gins(AAND, &r, &r);: AND命令を生成します。AANDはARMのAND命令に対応します。&r, &rはR0とR0のAND演算を意味し、結果はR0に格納されます。
  • p->scond = C_SCOND_EQ;: 命令の条件コードをEQ(Equal)に設定します。これにより、AND.EQ R0, R0, R0という命令が生成されます。これはARMアーキテクチャにおけるハードウェアNOPとして機能します。

src/cmd/6g/ggen.c (AMD64) および src/cmd/8g/ggen.c (386) の変更点

@@ -82,6 +82,19 @@ ginscall(Node *f, int proc)
  	case 0:	// normal call
  	case -1:	// normal call but no return
  	\tif(f->op == ONAME && f->class == PFUNC) {
+\t\t\tif(f == deferreturn) {
+\t\t\t\t// Deferred calls will appear to be returning to
+\t\t\t\t// the CALL deferreturn(SB) that we are about to emit.
+\t\t\t\t// However, the stack trace code will show the line
+\t\t\t\t// of the instruction byte before the return PC. 
+\t\t\t\t// To avoid that being an unrelated instruction,
+\t\t\t\t// insert an x86 NOP that we will have the right line number.
+\t\t\t\t// x86 NOP 0x90 is really XCHG AX, AX; use that description
+\t\t\t\t// because the NOP pseudo-instruction would be removed by
+\t\t\t\t// the linker.
+\t\t\t\tnodreg(&reg, types[TINT], D_AX);
+\t\t\t\tgins(AXCHGL, &reg, &reg);
+\t\t\t}
  		\tp = gins(ACALL, N, f);
  		\tafunclit(&p->to, f);
  		\tif(proc == -1 || noreturn(p))
  • if(f == deferreturn): 同様に、deferreturn関数への呼び出しであるかをチェックします。
  • nodreg(&reg, types[TINT], D_AX);: AXレジスタを表すノードを作成します。
  • gins(AXCHGL, &reg, &reg);: XCHG命令を生成します。AXCHGLはx86のXCHG命令に対応します。&reg, &regはAXレジスタとAXレジスタの内容を交換することを意味し、実質的に何もしませんが、x86アーキテクチャにおけるハードウェアNOP(オペコード0x90)として機能します。

test/fixedbugs/issue5856.go の変更点

このファイルは、修正が正しく機能することを確認するための新しいテストケースです。

// run

// Copyright 2013 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.

package main

import (
	"fmt"
	"os"
	"runtime"
	"strings"
)

func main() {
	f()
	panic("deferred function not run")
}

var x = 1

func f() {
	if x == 0 {
		return
	}
	defer g() // defer g() is called here
	panic("panic")
} // The closing brace of f() is on line 28

func g() {
	_, file, line, _ := runtime.Caller(2) // Caller(2) gets the caller of the caller (i.e., f()'s closing brace)
	if !strings.HasSuffix(file, "issue5856.go") || line != 28 {
		fmt.Printf("BUG: defer called from %s:%d, want issue5856.go:28\n", file, line)
		os.Exit(1)
	}
	os.Exit(0)
}

このテストは、f()関数内でg()deferし、その後panicを発生させます。g()関数内ではruntime.Caller(2)を使用して、g()を呼び出した関数の呼び出し元(つまりf()の終了位置)のファイル名と行番号を取得します。修正が適用されていれば、この行番号はf()の閉じ括弧の行(この場合は28行目)と一致するはずです。一致しない場合はテストが失敗し、バグが再発したことを示します。

関連リンク

参考にした情報源リンク

  • コミットメッセージと差分情報: /home/orange/Project/comemo/commit_data/16753.txt
  • Go言語のdeferに関する公式ドキュメントやブログ記事(一般的な知識として参照)
  • x86およびARMアーキテクチャの命令セットに関する情報(一般的な知識として参照)