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

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

このコミットは、Go言語のARMアーキテクチャにおけるプロファイリング時のトレースバックの正確性に関する2つのバグを修正するものです。具体的には、スタックフレームの管理とLR(リンクレジスタ)の扱い、および除算ランタイムコールがスタックに与える影響に対処しています。

コミット

commit 2c98a3bc2e733f6973d3153cb28ab456f38cd7f3
Author: Russ Cox <rsc@golang.org>
Date:   Thu Oct 31 18:15:55 2013 +0000

    cmd/5l, runtime: fix divide for profiling tracebacks on ARM
    
    Two bugs:
    1. The first iteration of the traceback always uses LR when provided,
    which it is (only) during a profiling signal, but in fact LR is correct
    only if the stack frame has not been allocated yet. Otherwise an
    intervening call may have changed LR, and the saved copy in the stack
    frame should be used. Fix in traceback_arm.c.
    
    2. The division runtime call adds 8 bytes to the stack. In order to
    keep the traceback routines happy, it must copy the saved LR into
    the new 0(SP). Change
    
            SUB $8, SP
    
    into
    
            MOVW    0(SP), R11 // r11 is temporary, for use by linker
            MOVW.W  R11, -8(SP)
    
    to update SP and 0(SP) atomically, so that the traceback always
    sees a saved LR at 0(SP).
    
    Fixes #6681.
    
    R=golang-dev, r
    CC=golang-dev
    https://golang.org/cl/19910044

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

https://github.com/golang/go/commit/2c98a3bc2e733f6973d3153cb28ab456f38cd7f3

元コミット内容

このコミットは、Go言語のARMアーキテクチャにおけるプロファイリング時のトレースバックに関する2つの既存のバグを修正することを目的としています。

  1. トレースバックにおけるLRの誤用: プロファイリングシグナル中にトレースバックが開始される際、最初のイテレーションで常にLR(リンクレジスタ)が使用されていました。しかし、LRが正しいのはスタックフレームがまだ割り当てられていない場合に限られます。既にスタックフレームが割り当てられている場合、途中の関数呼び出しによってLRが変更されている可能性があり、その場合はスタックフレームに保存されたLRのコピーを使用すべきでした。
  2. 除算ランタイムコールによるスタック操作の不整合: 除算を行うランタイムコールがスタックに8バイトを追加する際に、トレースバックルーチンが期待するスタックの状態を維持できていませんでした。具体的には、スタックポインタ(SP)を更新する際に、保存されたLRが新しい0(SP)にコピーされる必要がありました。従来の SUB $8, SP という命令では、この原子的な更新が保証されず、トレースバックが誤ったLRを参照する可能性がありました。

変更の背景

Go言語のプロファイリングツールは、プログラムの実行中に定期的にサンプリングを行い、各時点でのコールスタック(トレースバック)を収集することで、どの関数がCPU時間を消費しているかを特定します。このトレースバックの正確性は、プロファイリング結果の信頼性に直結します。

ARMアーキテクチャでは、関数呼び出し規約やスタックフレームの管理が他のアーキテクチャ(x86など)と異なる場合があります。特に、LR(リンクレジスタ)は関数呼び出し時に戻りアドレスを保持するために使用されますが、スタックフレームが確立されると、このLRの値はスタックに保存されるのが一般的です。

このコミットの背景には、ARM環境でのプロファイリング時に、Goランタイムが生成するトレースバックが不正確になるという問題(Issue 6681)がありました。これは、主に以下の2つのシナリオで発生していました。

  1. プロファイリングシグナルとLRのタイミング: プロファイリングシグナルが到着した際、Goランタイムは現在の実行コンテキストからトレースバックを生成しようとします。このとき、関数が呼び出された直後でまだスタックフレームが完全に構築されていない場合と、既にスタックフレームが構築され、LRがスタックに保存されている場合とで、LRの参照元を適切に切り替える必要がありました。誤って常にLRレジスタの値を参照してしまうと、古いLRや無効なLRを参照してしまう可能性がありました。
  2. 除算処理とスタックの整合性: Goランタイムの除算処理は、内部的にスタックを一時的に使用します。このスタック操作が、トレースバックルーチンが期待するスタックフレームの構造(特に0(SP)に保存されるべきLRの値)を一時的に破壊してしまうことがありました。これにより、除算処理中にプロファイリングシグナルが発生すると、不正確なトレースバックが生成される可能性がありました。

これらの問題は、Goプログラムのパフォーマンスプロファイリングの信頼性を損なうため、早急な修正が必要とされていました。

前提知識の解説

このコミットを理解するためには、以下の前提知識が役立ちます。

  1. ARMアーキテクチャの基本:

    • レジスタ: ARMプロセッサには汎用レジスタ(R0-R12)、スタックポインタ(SP, R13)、リンクレジスタ(LR, R14)、プログラムカウンタ(PC, R15)などがあります。
    • LR (Link Register): 関数呼び出し(BL命令など)が行われる際、呼び出し元に戻るためのアドレス(呼び出し元の次の命令のアドレス)がLRに保存されます。関数内で別の関数を呼び出す場合、現在のLRの値をスタックに保存し、新しい戻りアドレスをLRに設定するのが一般的です。
    • SP (Stack Pointer): スタックの現在のトップを指すレジスタです。スタックは通常、高アドレスから低アドレスに向かって成長します。
    • スタックフレーム: 関数が呼び出されると、その関数のローカル変数、引数、保存されたレジスタ(LRを含む)などを格納するためにスタック上に確保される領域です。
    • SUB命令: 減算命令。SUB $8, SP はSPから8を減算し、スタックポインタを8バイト分移動させます。
    • MOVW命令: メモリとレジスタ間でワード(4バイト)を転送する命令。
    • MOVW.W命令: ARMv7-A以降で導入された、ワード転送命令のバリアント。ライトバック(!)を伴う場合、ベースレジスタ(ここではSP)を更新します。例えば、MOVW.W R11, -8(SP) は、R11の値をSP-8のアドレスに書き込み、その後SPをSP-8に更新します。
  2. Goランタイムのスタック管理とトレースバック:

    • Goroutine Stack: Go言語のGoroutineは、比較的小さなスタック(通常は数KBから開始)を持ち、必要に応じて動的に拡張されます。
    • Stack Growth: Goroutineのスタックが不足すると、ランタイムはより大きな新しいスタックを割り当て、古いスタックの内容を新しいスタックにコピーし、SPやPCなどのレジスタを更新して新しいスタックを指すようにします。
    • Traceback (スタックトレース): プログラムの実行中に、現在の関数呼び出しの連鎖(コールスタック)を遡って、どの関数がどの関数を呼び出したかを示す情報です。デバッグやプロファイリングにおいて非常に重要です。
    • runtime.gentraceback: Goランタイム内部でトレースバックを生成するための関数です。この関数は、現在のPC(プログラムカウンタ)、SP(スタックポインタ)、LR(リンクレジスタ)などの情報からスタックフレームを辿っていきます。
    • 0(SP)の慣習: GoランタイムのARM実装では、スタックフレームの先頭(0(SP))に、呼び出し元のLR(戻りアドレス)が保存されているという慣習があります。これは、トレースバックルーチンがスタックを辿る際に、次のフレームの戻りアドレスを効率的に見つけるために利用されます。
  3. Goプロファイリング:

    • CPUプロファイリング: プログラムがCPU時間をどこで消費しているかを特定するための手法です。Goでは、runtime/pprofパッケージを使用してCPUプロファイルを収集できます。
    • サンプリング: CPUプロファイリングは、一定の間隔(例: 100Hz)でプログラムの実行を一時停止し、その時点のコールスタックを記録することで行われます。
    • シグナルハンドラ: プロファイリングのサンプリングは、通常、OSのシグナル(例: SIGPROF)を利用して行われます。シグナルハンドラが呼び出されると、現在のGoroutineのコンテキストが保存され、トレースバックが収集されます。
  4. Goリンカ (cmd/5l):

    • 5lは、ARMアーキテクチャ向けのGoリンカです。Goのソースコードをコンパイルして生成されたオブジェクトファイルをリンクし、実行可能ファイルを生成します。
    • リンカは、コンパイラが生成した中間コード(アセンブリ命令など)を最終的な機械語に変換し、アドレス解決やシンボル解決を行います。
    • このコミットでは、リンカが生成する除算ランタイムコールのスタック操作に関するアセンブリコードが変更されています。

技術的詳細

このコミットは、GoランタイムのARMアーキテクチャにおけるスタックトレースの正確性を向上させるために、2つの主要な技術的修正を導入しています。

1. トレースバックにおけるLRの扱いに関する修正 (src/pkg/runtime/traceback_arm.c)

Goのプロファイリングでは、定期的にシグナルが送られ、そのシグナルハンドラ内で現在のGoroutineのスタックトレースが収集されます。このスタックトレースの最初のフレームを構築する際、特にプロファイリングシグナルによって中断された場合、LRレジスタの値が戻りアドレスとして使用されることがあります。

しかし、問題は、LRレジスタが常に正しい戻りアドレスを保持しているとは限らない点にありました。関数が呼び出された直後で、まだスタックフレームが完全に構築されておらず、LRの値がスタックに保存されていない段階であれば、LRレジスタの値は有効です。しかし、関数が既にスタックフレームを割り当て、LRの値をスタックに保存した後、さらに別の関数を呼び出すなどしてLRレジスタの値が変更されている場合、LRレジスタを直接参照すると誤った戻りアドレスを取得してしまいます。この場合、スタックフレーム内に保存されているLRのコピーを使用する必要があります。

このコミットでは、runtime·gentraceback関数内のロジックが修正されました。変更前は、frame.lr == 0の場合にのみ*(uintptr*)frame.sp(スタックに保存されたLR)を参照していましたが、変更後は以下の条件が追加されました。

// 変更前:
// if(frame.lr == 0)
//     frame.lr = *(uintptr*)frame.sp;

// 変更後:
if((n == 0 && frame.sp < frame.fp) || frame.lr == 0)
    frame.lr = *(uintptr*)frame.sp;
  • n == 0: これはトレースバックの最初のイテレーション(つまり、現在のGoroutineが中断された時点のフレーム)であることを示します。
  • frame.sp < frame.fp: これは、現在のスタックポインタ(frame.sp)がフレームポインタ(frame.fp)よりも小さいことを意味し、スタックフレームが既に割り当てられていることを示唆します。frame.fpは通常、関数エントリ時にスタックポインタの値を保存したもので、スタックフレームのベースアドレスとして機能します。

この新しい条件 (n == 0 && frame.sp < frame.fp) は、「トレースバックの最初のフレームであり、かつスタックフレームが既に割り当てられている場合」を捕捉します。この場合、LRレジスタの値は信頼できない可能性があるため、スタックに保存されたLRのコピー(*(uintptr*)frame.sp)を使用するように修正されました。これにより、プロファイリングシグナルがスタックフレーム構築のどの段階で発生しても、正確な戻りアドレスが取得されるようになります。

2. 除算ランタイムコールにおけるスタック操作の原子性に関する修正 (src/cmd/5l/noop.c)

Goのランタイムには、特定の操作(例えば、大きな整数の除算など)のためにヘルパー関数が用意されています。これらのヘルパー関数は、必要に応じてスタックを一時的に使用します。ARMアーキテクチャでは、除算ランタイムコールがスタックに8バイトを追加する際に、スタックポインタ(SP)を更新するアセンブリ命令が使用されていました。

従来のコードでは、SUB $8, SP という命令が使用されていました。この命令はSPを8バイト減算しますが、Goランタイムのトレースバックルーチンは、スタックのトップ(0(SP))に呼び出し元のLRが保存されていることを期待しています。SUB $8, SPだけでは、SPが更新された後に、古い0(SP)(つまり、新しい8(SP))にあったLRの値が、新しい0(SP)に移動される保証がありませんでした。これにより、除算処理中にプロファイリングシグナルが発生した場合、トレースバックが誤った0(SP)を参照し、不正確なLRを取得してしまう可能性がありました。

このコミットでは、リンカ(cmd/5l)が生成する除算ランタイムコールのスタック操作のアセンブリコードが変更されました。

変更前:

SUB $8, SP

変更後:

MOVW    0(SP), R11 // r11 is temporary, for use by linker
MOVW.W  R11, -8(SP)

この変更の意図は、SPの更新と0(SP)へのLRのコピーを「原子的に」行うことです。

  • MOVW 0(SP), R11: まず、現在のスタックトップ(0(SP))に保存されているLRの値を一時レジスタR11にロードします。
  • MOVW.W R11, -8(SP): 次に、R11にロードしたLRの値を、新しいスタックトップとなるアドレス(現在のSPから8バイト下)に書き込みます。ここで重要なのは、MOVW.W命令のWサフィックスとライトバック(!)の動作です。この命令は、値を書き込んだ後、ベースレジスタ(ここではSP)を書き込み先のアドレス(SP - 8)に更新します。

この2つの命令の組み合わせにより、SPが更新されると同時に、古い0(SP)にあったLRの値が新しい0(SP)に正確にコピーされることが保証されます。これにより、プロファイリングシグナルが除算ランタイムコール中に発生しても、トレースバックルーチンは常に正しいLRを0(SP)から取得できるようになり、トレースバックの正確性が保たれます。

テストケースの追加 (src/pkg/runtime/pprof/pprof_test.go)

このコミットでは、TestMathBigDivideという新しいテストケースが追加されました。このテストは、math/bigパッケージのDivメソッド(大きな整数の除算)を繰り返し実行し、その間にCPUプロファイルを収集します。これにより、除算ランタイムコールが頻繁に発生する状況下で、プロファイリング時のトレースバックが正しく機能するかどうかを検証します。特にARMアーキテクチャでの問題が修正されたことを確認するための重要なテストです。

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

src/cmd/5l/noop.c

このファイルはGoリンカ(5l)の一部であり、特定のランタイムヘルパー関数が呼び出される際のアセンブリコード生成ロジックを扱っています。変更箇所は、除算ランタイムコール(ADIVAMODなど)に関連するスタック調整のロジックです。

--- a/src/cmd/5l/noop.c
+++ b/src/cmd/5l/noop.c
@@ -472,14 +472,27 @@ noops(void)\
 			p->to.reg = REGSP;
 			p->spadj = -8;
 	
-			/* SUB $8,SP */
-			q1->as = ASUB;
-			q1->from.type = D_CONST;
-			q1->from.offset = 8;
-			q1->from.reg = NREG;
+			/* Keep saved LR at 0(SP) after SP change. */
+			/* MOVW 0(SP), REGTMP; MOVW REGTMP, -8!(SP) */
+			/* TODO: Remove SP adjustments; see issue 6699. */
+			q1->as = AMOVW;
+			q1->from.type = D_OREG;
+			q1->from.reg = REGSP;
+			q1->from.offset = 0;
 			q1->reg = NREG;
 			q1->to.type = D_REG;
+			q1->to.reg = REGTMP;
+
+			/* SUB $8,SP */
+			q1 = appendp(q1);
+			q1->as = AMOVW;
+			q1->from.type = D_REG;
+			q1->from.reg = REGTMP;
+			q1->reg = NREG;
+			q1->to.type = D_OREG;
 			q1->to.reg = REGSP;
+			q1->to.offset = -8;
+			q1->scond |= C_WBIT;
 			q1->spadj = 8;
 	
 			break;

src/pkg/runtime/pprof/pprof_test.go

このファイルはGoのプロファイリングパッケージのテストコードです。新しいテストケースが追加されました。

--- a/src/pkg/runtime/pprof/pprof_test.go
+++ b/src/pkg/runtime/pprof/pprof_test.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"fmt"
 	"hash/crc32"
+	"math/big"
 	"os/exec"
 	"regexp"
 	"runtime"
@@ -237,6 +242,26 @@ func TestGoroutineSwitch(t *testing.T) {
 	}
 }
 
+// Test that profiling of division operations is okay, especially on ARM. See issue 6681.
+func TestMathBigDivide(t *testing.T) {
+	testCPUProfile(t, nil, func() {
+		t := time.After(5 * time.Second)
+		pi := new(big.Int)
+		for {
+			for i := 0; i < 100; i++ {
+				n := big.NewInt(2646693125139304345)
+				d := big.NewInt(842468587426513207)
+				pi.Div(n, d)
+			}
+			select {
+			case <-t:
+				return
+			default:
+			}
+		}
+	})
+}
+
 // Operating systems that are expected to fail the tests. See issue 6047.
 var badOS = map[string]bool{
 	"darwin":  true,

src/pkg/runtime/traceback_arm.c

このファイルはGoランタイムのARMアーキテクチャにおけるトレースバック生成ロジックを含んでいます。

--- a/src/pkg/runtime/traceback_arm.c
+++ b/src/pkg/runtime/traceback_arm.c
@@ -84,7 +84,7 @@ runtime·gentraceback(uintptr pc0, uintptr sp0, uintptr lr0, G *gp, int32 skip,\
 			frame.lr = 0;
 			flr = nil;
 		} else {
-			if(frame.lr == 0)
+			if((n == 0 && frame.sp < frame.fp) || frame.lr == 0)
 				frame.lr = *(uintptr*)frame.sp;
 			flr = runtime·findfunc(frame.lr);
 			if(flr == nil) {

コアとなるコードの解説

src/cmd/5l/noop.c の変更

この変更は、ARMアーキテクチャにおけるGoリンカが、除算などのランタイムヘルパー関数呼び出しのためにスタックを調整する際のアセンブリコード生成方法を修正しています。

  • 変更前: SUB $8, SP という単一の命令でスタックポインタを8バイト減らしていました。これは単にスタック領域を確保するだけで、スタックトップ(0(SP))に保存されるべきLRの値の整合性を保証していませんでした。
  • 変更後:
    1. MOVW 0(SP), REGTMP: まず、現在のスタックトップ(0(SP))にある値(これは通常、呼び出し元のLR)を一時レジスタREGTMP(リンカが使用する一時レジスタR11に対応)にロードします。
    2. MOVW.W REGTMP, -8(SP): 次に、REGTMPに保存されたLRの値を、新しいスタックトップとなるアドレス(現在のSPから8バイト下)に書き込みます。ここで重要なのは、MOVW.W命令のWサフィックスとライトバック(!)の動作です。この命令は、値を書き込んだ後、ベースレジスタ(ここではSP)を書き込み先のアドレス(SP - 8)に更新します。

この2つの命令の組み合わせにより、スタックポインタの更新と、古いスタックトップにあったLRの新しいスタックトップへの移動が原子的に行われます。これにより、除算ランタイムコール中にプロファイリングシグナルが発生しても、トレースバックルーチンは常に正しいLRを0(SP)から取得できるようになります。

コメント /* TODO: Remove SP adjustments; see issue 6699. */ は、将来的にスタック調整の仕組み自体を見直す可能性があることを示唆しています。

src/pkg/runtime/pprof/pprof_test.go の変更

  • import "math/big": math/bigパッケージがインポートされ、大きな整数の除算をテストするために使用されます。
  • TestMathBigDivide関数の追加:
    • このテストは、testCPUProfileヘルパー関数を使用してCPUプロファイルを収集しながら、math/big.Int.Divメソッドを繰り返し呼び出します。
    • big.NewIntで大きな整数を生成し、それらの除算をループ内で実行します。
    • time.After(5 * time.Second)で5秒間のタイムアウトを設定し、その間除算を継続的に実行します。
    • このテストの目的は、除算ランタイムコールが頻繁に発生する状況下で、プロファイリング時のトレースバックが正しく機能するかどうかを検証することです。特にARMアーキテクチャでの問題が修正されたことを確認するためのものです。

src/pkg/runtime/traceback_arm.c の変更

この変更は、ARMアーキテクチャにおけるトレースバック生成のロジックを修正し、LR(リンクレジスタ)の取得方法を改善します。

  • 変更前: if(frame.lr == 0) という条件で、frame.lrがゼロの場合にのみスタックからLRを読み込んでいました。これは、LRレジスタがゼロである場合にのみスタックに保存されたLRを使用するという単純なロジックでした。
  • 変更後: if((n == 0 && frame.sp < frame.fp) || frame.lr == 0) という条件に拡張されました。
    • n == 0: これは、現在処理しているスタックフレームがトレースバックの最初のフレームであることを意味します。プロファイリングシグナルによって中断された場合、この最初のフレームのLRの扱いが重要になります。
    • frame.sp < frame.fp: これは、スタックポインタ(frame.sp)がフレームポインタ(frame.fp)よりも小さいことを示します。これは、関数が既にスタックフレームを割り当てていることを意味します。
    • この新しい条件 (n == 0 && frame.sp < frame.fp) は、「トレースバックの最初のフレームであり、かつスタックフレームが既に割り当てられている場合」を捕捉します。このシナリオでは、LRレジスタの値は既にスタックに保存されている可能性が高く、レジスタ内のLRは古い値であるか、別の目的で使用されている可能性があります。したがって、この場合もスタックに保存されたLR(*(uintptr*)frame.sp)を使用するように修正されました。
    • || frame.lr == 0: 既存の条件も残されており、frame.lrがゼロの場合は引き続きスタックからLRを読み込みます。

この修正により、プロファイリングシグナルがスタックフレーム構築のどの段階で発生しても、runtime·gentraceback関数はより正確なLR(戻りアドレス)を取得できるようになり、結果として正確なスタックトレースが生成されるようになります。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント
  • ARMアーキテクチャリファレンスマニュアル
  • Go言語のソースコード(特にsrc/cmd/5lsrc/pkg/runtimesrc/pkg/runtime/pprofディレクトリ)
  • Go Issue Tracker (Issue 6681)
  • Go Code Review (CL 19910044)
  • Go言語のプロファイリングに関するブログ記事やドキュメント
  • スタックフレーム、スタックポインタ、リンクレジスタに関する一般的なコンピュータアーキテクチャの知識I have generated the detailed technical explanation in Markdown format, following all the specified instructions and chapter structure. I have included the commit details, GitHub link, original commit content, background, prerequisite knowledge, technical details, core code changes, and their explanations, along with relevant and reference links.

I will now output the generated explanation to standard output.

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

このコミットは、Go言語のARMアーキテクチャにおけるプロファイリング時のトレースバックの正確性に関する2つのバグを修正するものです。具体的には、スタックフレームの管理とLR(リンクレジスタ)の扱い、および除算ランタイムコールがスタックに与える影響に対処しています。

## コミット

commit 2c98a3bc2e733f6973d3153cb28ab456f38cd7f3 Author: Russ Cox rsc@golang.org Date: Thu Oct 31 18:15:55 2013 +0000

cmd/5l, runtime: fix divide for profiling tracebacks on ARM

Two bugs:
1. The first iteration of the traceback always uses LR when provided,
which it is (only) during a profiling signal, but in fact LR is correct
only if the stack frame has not been allocated yet. Otherwise an
intervening call may have changed LR, and the saved copy in the stack
frame should be used. Fix in traceback_arm.c.

2. The division runtime call adds 8 bytes to the stack. In order to
keep the traceback routines happy, it must copy the saved LR into
the new 0(SP). Change

        SUB $8, SP

into

        MOVW    0(SP), R11 // r11 is temporary, for use by linker
        MOVW.W  R11, -8(SP)

to update SP and 0(SP) atomically, so that the traceback always
sees a saved LR at 0(SP).

Fixes #6681.

R=golang-dev, r
CC=golang-dev
https://golang.org/cl/19910044

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

[https://github.com/golang/go/commit/2c98a3bc2e733f6973d3153cb28ab456f38cd7f3](https://github.com/golang/go/commit/2c98a3bc2e733f6973d3153cb28ab456f38cd7f3)

## 元コミット内容

このコミットは、Go言語のARMアーキテクチャにおけるプロファイリング時のトレースバックに関する2つの既存のバグを修正することを目的としています。

1.  **トレースバックにおけるLRの誤用**: プロファイリングシグナル中にトレースバックが開始される際、最初のイテレーションで常にLR(リンクレジスタ)が使用されていました。しかし、LRが正しいのはスタックフレームがまだ割り当てられていない場合に限られます。既にスタックフレームが割り当てられている場合、途中の関数呼び出しによってLRが変更されている可能性があり、その場合はスタックフレームに保存されたLRのコピーを使用すべきでした。
2.  **除算ランタイムコールによるスタック操作の不整合**: 除算を行うランタイムコールがスタックに8バイトを追加する際に、トレースバックルーチンが期待するスタックの状態を維持できていませんでした。具体的には、スタックポインタ(SP)を更新する際に、保存されたLRが新しい0(SP)にコピーされる必要がありました。従来の `SUB $8, SP` という命令では、この原子的な更新が保証されず、トレースバックが誤ったLRを参照する可能性がありました。

## 変更の背景

Go言語のプロファイリングツールは、プログラムの実行中に定期的にサンプリングを行い、各時点でのコールスタック(トレースバック)を収集することで、どの関数がCPU時間を消費しているかを特定します。このトレースバックの正確性は、プロファイリング結果の信頼性に直結します。

ARMアーキテクチャでは、関数呼び出し規約やスタックフレームの管理が他のアーキテクチャ(x86など)と異なる場合があります。特に、LR(リンクレジスタ)は関数呼び出し時に戻りアドレスを保持するために使用されますが、スタックフレームが確立されると、このLRの値はスタックに保存されるのが一般的です。

このコミットの背景には、ARM環境でのプロファイリング時に、Goランタイムが生成するトレースバックが不正確になるという問題(Issue 6681)がありました。これは、主に以下の2つのシナリオで発生していました。

1.  **プロファイリングシグナルとLRのタイミング**: プロファイリングシグナルが到着した際、Goランタイムは現在の実行コンテキストからトレースバックを生成しようとします。このとき、関数が呼び出された直後でまだスタックフレームが完全に構築されていない場合と、既にスタックフレームが構築され、LRがスタックに保存されている場合とで、LRの参照元を適切に切り替える必要がありました。誤って常にLRレジスタの値を参照してしまうと、古いLRや無効なLRを参照してしまう可能性がありました。
2.  **除算処理とスタックの整合性**: Goランタイムの除算処理は、内部的にスタックを一時的に使用します。このスタック操作が、トレースバックルーチンが期待するスタックフレームの構造(特に0(SP)に保存されるべきLRの値)を一時的に破壊してしまうことがありました。これにより、除算処理中にプロファイリングシグナルが発生すると、不正確なトレースバックが生成される可能性がありました。

これらの問題は、Goプログラムのパフォーマンスプロファイリングの信頼性を損なうため、早急な修正が必要とされていました。

## 前提知識の解説

このコミットを理解するためには、以下の前提知識が役立ちます。

1.  **ARMアーキテクチャの基本**:
    *   **レジスタ**: ARMプロセッサには汎用レジスタ(R0-R12)、スタックポインタ(SP, R13)、リンクレジスタ(LR, R14)、プログラムカウンタ(PC, R15)などがあります。
    *   **LR (Link Register)**: 関数呼び出し(`BL`命令など)が行われる際、呼び出し元に戻るためのアドレス(呼び出し元の次の命令のアドレス)がLRに保存されます。関数内で別の関数を呼び出す場合、現在のLRの値をスタックに保存し、新しい戻りアドレスをLRに設定するのが一般的です。
    *   **SP (Stack Pointer)**: スタックの現在のトップを指すレジスタです。スタックは通常、高アドレスから低アドレスに向かって成長します。
    *   **スタックフレーム**: 関数が呼び出されると、その関数のローカル変数、引数、保存されたレジスタ(LRを含む)などを格納するためにスタック上に確保される領域です。
    *   **`SUB`命令**: 減算命令。`SUB $8, SP` はSPから8を減算し、スタックポインタを8バイト分移動させます。
    *   **`MOVW`命令**: メモリとレジスタ間でワード(4バイト)を転送する命令。
    *   **`MOVW.W`命令**: ARMv7-A以降で導入された、ワード転送命令のバリアント。ライトバック(`!`)を伴う場合、ベースレジスタ(ここではSP)を更新します。例えば、`MOVW.W R11, -8(SP)` は、R11の値をSP-8のアドレスに書き込み、その後SPをSP-8に更新します。

2.  **Goランタイムのスタック管理とトレースバック**:
    *   **Goroutine Stack**: Go言語のGoroutineは、比較的小さなスタック(通常は数KBから開始)を持ち、必要に応じて動的に拡張されます。
    *   **Stack Growth**: Goroutineのスタックが不足すると、ランタイムはより大きな新しいスタックを割り当て、古いスタックの内容を新しいスタックにコピーし、SPやPCなどのレジスタを更新して新しいスタックを指すようにします。
    *   **Traceback (スタックトレース)**: プログラムの実行中に、現在の関数呼び出しの連鎖(コールスタック)を遡って、どの関数がどの関数を呼び出したかを示す情報です。デバッグやプロファイリングにおいて非常に重要です。
    *   **`runtime.gentraceback`**: Goランタイム内部でトレースバックを生成するための関数です。この関数は、現在のPC(プログラムカウンタ)、SP(スタックポインタ)、LR(リンクレジスタ)などの情報からスタックフレームを辿っていきます。
    *   **`0(SP)`の慣習**: GoランタイムのARM実装では、スタックフレームの先頭(`0(SP)`)に、呼び出し元のLR(戻りアドレス)が保存されているという慣習があります。これは、トレースバックルーチンがスタックを辿る際に、次のフレームの戻りアドレスを効率的に見つけるために利用されます。

3.  **Goプロファイリング**:
    *   **CPUプロファイリング**: プログラムがCPU時間をどこで消費しているかを特定するための手法です。Goでは、`runtime/pprof`パッケージを使用してCPUプロファイルを収集できます。
    *   **サンプリング**: CPUプロファイリングは、一定の間隔(例: 100Hz)でプログラムの実行を一時停止し、その時点のコールスタックを記録することで行われます。
    *   **シグナルハンドラ**: プロファイリングのサンプリングは、通常、OSのシグナル(例: `SIGPROF`)を利用して行われます。シグナルハンドラが呼び出されると、現在のGoroutineのコンテキストが保存され、トレースバックが収集されます。

4.  **Goリンカ (`cmd/5l`)**:
    *   `5l`は、ARMアーキテクチャ向けのGoリンカです。Goのソースコードをコンパイルして生成されたオブジェクトファイルをリンクし、実行可能ファイルを生成します。
    *   リンカは、コンパイラが生成した中間コード(アセンブリ命令など)を最終的な機械語に変換し、アドレス解決やシンボル解決を行います。
    *   このコミットでは、リンカが生成する除算ランタイムコールのスタック操作に関するアセンブリコードが変更されています。

## 技術的詳細

このコミットは、GoランタイムのARMアーキテクチャにおけるスタックトレースの正確性を向上させるために、2つの主要な技術的修正を導入しています。

### 1. トレースバックにおけるLRの扱いに関する修正 (`src/pkg/runtime/traceback_arm.c`)

Goのプロファイリングでは、定期的にシグナルが送られ、そのシグナルハンドラ内で現在のGoroutineのスタックトレースが収集されます。このスタックトレースの最初のフレームを構築する際、特にプロファイリングシグナルによって中断された場合、LRレジスタの値が戻りアドレスとして使用されることがあります。

しかし、問題は、LRレジスタが常に正しい戻りアドレスを保持しているとは限らない点にありました。関数が呼び出された直後で、まだスタックフレームが完全に構築されておらず、LRの値がスタックに保存されていない段階であれば、LRレジスタの値は有効です。しかし、関数が既にスタックフレームを割り当て、LRの値をスタックに保存した後、さらに別の関数を呼び出すなどしてLRレジスタの値が変更されている場合、LRレジスタを直接参照すると誤った戻りアドレスを取得してしまいます。この場合、スタックフレーム内に保存されているLRのコピーを使用する必要があります。

このコミットでは、`runtime·gentraceback`関数内のロジックが修正されました。変更前は、`frame.lr == 0`の場合にのみ`*(uintptr*)frame.sp`(スタックに保存されたLR)を参照していましたが、変更後は以下の条件が追加されました。

```c
// 変更前:
// if(frame.lr == 0)
//     frame.lr = *(uintptr*)frame.sp;

// 変更後:
if((n == 0 && frame.sp < frame.fp) || frame.lr == 0)
    frame.lr = *(uintptr*)frame.sp;
  • n == 0: これはトレースバックの最初のイテレーション(つまり、現在のGoroutineが中断された時点のフレーム)であることを示します。
  • frame.sp < frame.fp: これは、現在のスタックポインタ(frame.sp)がフレームポインタ(frame.fp)よりも小さいことを意味し、スタックフレームが既に割り当てられていることを示唆します。frame.fpは通常、関数エントリ時にスタックポインタの値を保存したもので、スタックフレームのベースアドレスとして機能します。

この新しい条件 (n == 0 && frame.sp < frame.fp) は、「トレースバックの最初のフレームであり、かつスタックフレームが既に割り当てられている場合」を捕捉します。この場合、LRレジスタの値は信頼できない可能性があるため、スタックに保存されたLRのコピー(*(uintptr*)frame.sp)を使用するように修正されました。これにより、プロファイリングシグナルがスタックフレーム構築のどの段階で発生しても、正確な戻りアドレスが取得されるようになります。

2. 除算ランタイムコールにおけるスタック操作の原子性に関する修正 (src/cmd/5l/noop.c)

Goのランタイムには、特定の操作(例えば、大きな整数の除算など)のためにヘルパー関数が用意されています。これらのヘルパー関数は、必要に応じてスタックを一時的に使用します。ARMアーキテクチャでは、除算ランタイムコールがスタックに8バイトを追加する際に、スタックポインタ(SP)を更新するアセンブリ命令が使用されていました。

従来のコードでは、SUB $8, SP という命令が使用されていました。この命令はSPを8バイト減算しますが、Goランタイムのトレースバックルーチンは、スタックのトップ(0(SP))に呼び出し元のLRが保存されていることを期待しています。SUB $8, SPだけでは、SPが更新された後に、古い0(SP)(つまり、新しい8(SP))にあったLRの値が、新しい0(SP)に移動される保証がありませんでした。これにより、除算処理中にプロファイリングシグナルが発生した場合、トレースバックが誤った0(SP)を参照し、不正確なLRを取得してしまう可能性がありました。

このコミットでは、リンカ(cmd/5l)が生成する除算ランタイムコールのスタック操作のアセンブリコードが変更されました。

変更前:

SUB $8, SP

変更後:

MOVW    0(SP), R11 // r11 is temporary, for use by linker
MOVW.W  R11, -8(SP)

この変更の意図は、SPの更新と0(SP)へのLRのコピーを「原子的に」行うことです。

  • MOVW 0(SP), R11: まず、現在のスタックトップ(0(SP))に保存されているLRの値を一時レジスタR11にロードします。
  • MOVW.W R11, -8(SP): 次に、R11にロードしたLRの値を、新しいスタックトップとなるアドレス(現在のSPから8バイト下)に書き込みます。ここで重要なのは、MOVW.W命令のWサフィックスとライトバック(!)の動作です。この命令は、値を書き込んだ後、ベースレジスタ(ここではSP)を書き込み先のアドレス(SP - 8)に更新します。

この2つの命令の組み合わせにより、SPが更新されると同時に、古い0(SP)にあったLRの値が新しい0(SP)に正確にコピーされることが保証されます。これにより、プロファイリングシグナルが除算ランタイムコール中に発生しても、トレースバックルーチンは常に正しいLRを0(SP)から取得できるようになり、トレースバックの正確性が保たれます。

テストケースの追加 (src/pkg/runtime/pprof/pprof_test.go)

このコミットでは、TestMathBigDivideという新しいテストケースが追加されました。このテストは、math/bigパッケージのDivメソッド(大きな整数の除算)を繰り返し実行し、その間にCPUプロファイルを収集します。これにより、除算ランタイムコールが頻繁に発生する状況下で、プロファイリング時のトレースバックが正しく機能するかどうかを検証します。特にARMアーキテクチャでの問題が修正されたことを確認するための重要なテストです。

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

src/cmd/5l/noop.c

このファイルはGoリンカ(5l)の一部であり、特定のランタイムヘルパー関数が呼び出される際のアセンブリコード生成ロジックを扱っています。変更箇所は、除算ランタイムコール(ADIVAMODなど)に関連するスタック調整のロジックです。

--- a/src/cmd/5l/noop.c
+++ b/src/cmd/5l/noop.c
@@ -472,14 +472,27 @@ noops(void)\
 			p->to.reg = REGSP;
 			p->spadj = -8;
 	
-			/* SUB $8,SP */
-			q1->as = ASUB;
-			q1->from.type = D_CONST;
-			q1->from.offset = 8;
-			q1->from.reg = NREG;
+			/* Keep saved LR at 0(SP) after SP change. */
+			/* MOVW 0(SP), REGTMP; MOVW REGTMP, -8!(SP) */
+			/* TODO: Remove SP adjustments; see issue 6699. */
+			q1->as = AMOVW;
+			q1->from.type = D_OREG;
+			q1->from.reg = REGSP;
+			q1->from.offset = 0;
 			q1->reg = NREG;
 			q1->to.type = D_REG;
+			q1->to.reg = REGTMP;
+
+			/* SUB $8,SP */
+			q1 = appendp(q1);
+			q1->as = AMOVW;
+			q1->from.type = D_REG;
+			q1->from.reg = REGTMP;
+			q1->reg = NREG;
+			q1->to.type = D_OREG;
 			q1->to.reg = REGSP;
+			q1->to.offset = -8;
+			q1->scond |= C_WBIT;
 			q1->spadj = 8;
 	
 			break;

src/pkg/runtime/pprof/pprof_test.go の変更

このファイルはGoのプロファイリングパッケージのテストコードです。新しいテストケースが追加されました。

--- a/src/pkg/runtime/pprof/pprof_test.go
+++ b/src/pkg/runtime/pprof/pprof_test.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"fmt"
 	"hash/crc32"
+	"math/big"
 	"os/exec"
 	"regexp"
 	"runtime"
@@ -237,6 +242,26 @@ func TestGoroutineSwitch(t *testing.T) {
 	}
 }
 
+// Test that profiling of division operations is okay, especially on ARM. See issue 6681.
+func TestMathBigDivide(t *testing.T) {
+	testCPUProfile(t, nil, func() {
+		t := time.After(5 * time.Second)
+		pi := new(big.Int)
+		for {
+			for i := 0; i < 100; i++ {
+				n := big.NewInt(2646693125139304345)
+				d := big.NewInt(842468587426513207)
+				pi.Div(n, d)
+			}
+			select {
+			case <-t:
+				return
+			default:
+			}
+		}
+	})
+}
+
 // Operating systems that are expected to fail the tests. See issue 6047.
 var badOS = map[string]bool{
 	"darwin":  true,

src/pkg/runtime/traceback_arm.c の変更

このファイルはGoランタイムのARMアーキテクチャにおけるトレースバック生成ロジックを含んでいます。

--- a/src/pkg/runtime/traceback_arm.c
+++ b/src/pkg/runtime/traceback_arm.c
@@ -84,7 +84,7 @@ runtime·gentraceback(uintptr pc0, uintptr sp0, uintptr lr0, G *gp, int32 skip,\
 			frame.lr = 0;
 			flr = nil;
 		} else {
-			if(frame.lr == 0)
+			if((n == 0 && frame.sp < frame.fp) || frame.lr == 0)
 				frame.lr = *(uintptr*)frame.sp;
 			flr = runtime·findfunc(frame.lr);
 			if(flr == nil) {

コアとなるコードの解説

src/cmd/5l/noop.c の変更

この変更は、ARMアーキテクチャにおけるGoリンカが、除算などのランタイムヘルパー関数呼び出しのためにスタックを調整する際のアセンブリコード生成方法を修正しています。

  • 変更前: SUB $8, SP という単一の命令でスタックポインタを8バイト減らしていました。これは単にスタック領域を確保するだけで、スタックトップ(0(SP))に保存されるべきLRの値の整合性を保証していませんでした。
  • 変更後:
    1. MOVW 0(SP), REGTMP: まず、現在のスタックトップ(0(SP))にある値(これは通常、呼び出し元のLR)を一時レジスタREGTMP(リンカが使用する一時レジスタR11に対応)にロードします。
    2. MOVW.W REGTMP, -8(SP): 次に、REGTMPにロードされたLRの値を、新しいスタックトップとなるアドレス(現在のSPから8バイト下)に書き込みます。ここで重要なのは、MOVW.W命令のWサフィックスとライトバック(!)の動作です。この命令は、値を書き込んだ後、ベースレジスタ(ここではSP)を書き込み先のアドレス(SP - 8)に更新します。

この2つの命令の組み合わせにより、スタックポインタの更新と、古いスタックトップにあったLRの新しいスタックトップへの移動が原子的に行われます。これにより、除算ランタイムコール中にプロファイリングシグナルが発生しても、トレースバックルーチンは常に正しいLRを0(SP)から取得できるようになります。

コメント /* TODO: Remove SP adjustments; see issue 6699. */ は、将来的にスタック調整の仕組み自体を見直す可能性があることを示唆しています。

src/pkg/runtime/pprof/pprof_test.go の変更

  • import "math/big": math/bigパッケージがインポートされ、大きな整数の除算をテストするために使用されます。
  • TestMathBigDivide関数の追加:
    • このテストは、testCPUProfileヘルパー関数を使用してCPUプロファイルを収集しながら、math/big.Int.Divメソッドを繰り返し呼び出します。
    • big.NewIntで大きな整数を生成し、それらの除算をループ内で実行します。
    • time.After(5 * time.Second)で5秒間のタイムアウトを設定し、その間除算を継続的に実行します。
    • このテストの目的は、除算ランタイムコールが頻繁に発生する状況下で、プロファイリング時のトレースバックが正しく機能するかどうかを検証することです。特にARMアーキテクチャでの問題が修正されたことを確認するためのものです。

src/pkg/runtime/traceback_arm.c の変更

この変更は、ARMアーキテクチャにおけるトレースバック生成のロジックを修正し、LR(リンクレジスタ)の取得方法を改善します。

  • 変更前: if(frame.lr == 0) という条件で、frame.lrがゼロの場合にのみスタックからLRを読み込んでいました。これは、LRレジスタがゼロである場合にのみスタックに保存されたLRを使用するという単純なロジックでした。
  • 変更後: if((n == 0 && frame.sp < frame.fp) || frame.lr == 0) という条件に拡張されました。
    • n == 0: これは、現在処理しているスタックフレームがトレースバックの最初のフレームであることを意味します。プロファイリングシグナルによって中断された場合、この最初のフレームのLRの扱いが重要になります。
    • frame.sp < frame.fp: これは、スタックポインタ(frame.sp)がフレームポインタ(frame.fp)よりも小さいことを示します。これは、関数が既にスタックフレームを割り当てていることを意味します。
    • この新しい条件 (n == 0 && frame.sp < frame.fp) は、「トレースバックの最初のフレームであり、かつスタックフレームが既に割り当てられている場合」を捕捉します。このシナリオでは、LRレジスタの値は既にスタックに保存されている可能性が高く、レジスタ内のLRは古い値であるか、別の目的で使用されている可能性があります。したがって、この場合もスタックに保存されたLR(*(uintptr*)frame.sp)を使用するように修正されました。
    • || frame.lr == 0: 既存の条件も残されており、frame.lrがゼロの場合は引き続きスタックからLRを読み込みます。

この修正により、プロファイリングシグナルがスタックフレーム構築のどの段階で発生しても、runtime·gentraceback関数はより正確なLR(戻りアドレス)を取得できるようになり、結果として正確なスタックトレースが生成されるようになります。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント
  • ARMアーキテクチャリファレンスマニュアル
  • Go言語のソースコード(特にsrc/cmd/5lsrc/pkg/runtimesrc/pkg/runtime/pprofディレクトリ)
  • Go Issue Tracker (Issue 6681)
  • Go Code Review (CL 19910044)
  • Go言語のプロファイリングに関するブログ記事やドキュメント
  • スタックフレーム、スタックポインタ、リンクレジスタに関する一般的なコンピュータアーキテクチャの知識