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

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

このコミットは、Goコンパイラの一部であるcmd/6g(AMD64アーキテクチャ向けのGoコンパイラ)におけるスタックゼロイングのバグ修正に関するものです。具体的には、amd64p32環境(64ビットアーキテクチャ上で動作する32ビットABI)において、スタックのゼロクリア処理が正しく行われない問題を修正しています。

コミット

commit 741244e4336f7056c733f68d6aef07bc27349e9d
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date:   Thu Mar 13 08:12:38 2014 +0100

    cmd/6g: fix stack zeroing preamble on amd64p32.
    
    It was using a REP STOSQ but putting in CX the number of 32-bit
    words to clear.
    
    LGTM=dave
    R=rsc, dave
    CC=golang-codereviews
    https://golang.org/cl/75240043

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

https://github.com/golang/go/commit/741244e4336f7056c733f68d6aef07bc27349e9d

元コミット内容

cmd/6g: fix stack zeroing preamble on amd64p32.

It was using a REP STOSQ but putting in CX the number of 32-bit words to clear.

変更の背景

Go言語のランタイムは、セキュリティとガベージコレクションの正確性を保証するために、新しく割り当てられたスタックフレームのメモリ領域をゼロクリアする(ゼロで埋める)処理を行います。これは「スタックゼロイング」と呼ばれます。このコミットは、amd64p32という特定の環境において、このスタックゼロイング処理にバグが存在したために行われました。

amd64p32は、64ビットのAMD64アーキテクチャ上で動作する32ビットのポインタを使用するABI(Application Binary Interface)です。これは、メモリ使用量を抑えたり、既存の32ビットコードとの互換性を保つために使用されることがあります。

問題は、スタックゼロイングの際に使用されるアセンブリ命令REP STOSQと、その繰り返し回数を指定するCXレジスタの値の不一致にありました。REP STOSQは8バイト(quadword)単位でメモリをゼロクリアする命令ですが、CXレジスタには32ビットワード(4バイト)の数、つまりクリアすべきバイト数の半分が設定されていました。このため、意図したメモリ領域の半分しかゼロクリアされないというバグが発生していました。これは、未初期化のメモリが使用される可能性や、ガベージコレクションが誤ったポインタを検出する可能性につながる重大な問題です。

前提知識の解説

1. Goコンパイラ (cmd/6g)

Go言語のコンパイラは、ソースコードを機械語に変換するツールです。cmd/6gは、Go 1.x系の時代にAMD64(x86-64)アーキテクチャ向けのGoプログラムをコンパイルするために使用されていたコンパイラです。Goのツールチェーンは、各アーキテクチャとOSの組み合わせに対応するコンパイラを持っており、6gはその一つでした(現在はgo tool compileに統合されています)。

2. スタックゼロイング (Stack Zeroing)

Go言語では、関数呼び出し時に新しいスタックフレームが割り当てられます。このスタックフレームのメモリ領域は、セキュリティ上の理由(以前の関数の機密データが残るのを防ぐ)と、ガベージコレクションの正確性(古いポインタが残っているとGCが誤動作する可能性がある)を保証するために、すべてゼロで初期化されます。この処理をスタックゼロイングと呼びます。

3. amd64p32 ABI

amd64p32は、64ビットのAMD64プロセッサ上で動作するが、ポインタサイズが32ビットであるという特殊なABIです。通常のAMD64 ABIではポインタは64ビットですが、amd64p32はメモリフットプリントを削減したり、特定のレガシーシステムとの互換性を保つために使用されることがあります。このABIでは、ポインタのサイズが32ビット(4バイト)であるため、メモリ操作の単位を考慮する際に注意が必要です。

4. x86アセンブリ命令

  • REPプレフィックス: REPは、後続の文字列操作命令(STOS, MOVS, CMPSなど)をCXレジスタに指定された回数だけ繰り返すためのプレフィックスです。
  • STOS命令: STOS(Store String)命令は、AX/EAX/RAXレジスタの内容をDIレジスタが指すメモリ位置に格納し、DIレジスタをインクリメント(またはデクリメント)します。
    • STOSB: 1バイト単位で格納。
    • STOSW: 2バイト(word)単位で格納。
    • STOSD: 4バイト(doubleword)単位で格納。
    • STOSQ: 8バイト(quadword)単位で格納。
  • レジスタ:
    • AX/EAX/RAX: 汎用レジスタ。STOS命令では、このレジスタの内容がメモリに書き込まれます。
    • CX/ECX/RCX: カウンタレジスタ。REPプレフィックスと組み合わせて、繰り返し回数を指定します。
    • DI/EDI/RDI: 汎用レジスタ。STOS命令では、メモリへの書き込み先のポインタとして使用されます。
    • SP/ESP/RSP: スタックポインタレジスタ。現在のスタックのトップを指します。
  • アドレッシングモード:
    • D_CONST: 定数。
    • D_AX, D_CX, D_DI: それぞれAX, CX, DIレジスタを指します。
    • D_SP+D_INDIR: SPレジスタが指すアドレスを基準とした間接アドレッシング。frame-stkzerosizeはオフセットを示します。
    • AMOVQ: 64ビットの移動命令(Move Quadword)。
    • ALEAQ: 64ビットのアドレス計算命令(Load Effective Address Quadword)。
    • AREP: REPプレフィックスに対応するGoコンパイラ内部の命令表現。
    • ASTOSQ: STOSQ命令に対応するGoコンパイラ内部の命令表現。

5. widthptrstkzerosize

  • widthptr: ポインタのサイズ(バイト単位)。amd64p32環境では4バイト、通常のAMD64環境では8バイトになります。
  • stkzerosize: ゼロクリアする必要があるスタック領域の合計サイズ(バイト単位)。

技術的詳細

このコミットの核心は、src/cmd/6g/ggen.cファイル内のdefframe関数にあります。この関数は、Goコンパイラが関数プロローグ(関数の開始時に実行されるコード)を生成する際に、スタックフレームの初期化(ゼロイングを含む)を行う部分です。

元のコードでは、スタックゼロイングのために以下の命令シーケンスを生成していました。

  1. AMOVQ, D_CONST, 0, D_AX, 0: AXレジスタに0をロードします。これはSTOS命令でメモリに書き込む値です。
  2. AMOVQ, D_CONST, stkzerosize/widthptr, D_CX, 0: CXレジスタに、ゼロクリアすべきスタック領域のサイズをwidthptr(ポインタのサイズ)で割った値をロードします。これはREP命令の繰り返し回数になります。
  3. ALEAQ, D_SP+D_INDIR, frame-stkzerosize, D_DI, 0: DIレジスタに、ゼロクリアを開始するスタック上のアドレスをロードします。
  4. AREP, D_NONE, 0, D_NONE, 0: REPプレフィックスを生成します。
  5. ASTOSQ, D_NONE, 0, D_NONE, 0: STOSQ命令を生成します。

問題は2行目と5行目の組み合わせにありました。ASTOSQは8バイト単位でメモリを操作するSTOSQ命令に対応しています。しかし、CXレジスタにロードされる値はstkzerosize/widthptrでした。

  • 通常のAMD64環境ではwidthptrは8バイトなので、stkzerosize/8となり、STOSQの繰り返し回数として正しく機能します。
  • しかし、amd64p32環境ではwidthptrは4バイトです。この場合、CXにはstkzerosize/4がロードされます。これは、STOSQが期待する8バイト単位の繰り返し回数の2倍の値になります。結果として、REP STOSQstkzerosizeの半分の領域しかゼロクリアしませんでした。例えば、16バイトをゼロクリアしたい場合、stkzerosizeは16、widthptrは4なので、CX16/4 = 4になります。REP STOSQは4回繰り返され、合計4 * 8 = 32バイトをゼロクリアするように見えますが、実際にはDIレジスタのインクリメントが8バイト単位で行われるため、stkzerosizeの半分の領域しかゼロクリアされません。

このバグは、amd64p32環境でのみ顕在化し、スタック上の未初期化データが残る原因となっていました。

修正は、AMOVQALEAQASTOSQといった固定の64ビット命令の生成を、ポインタサイズに依存するmovptrleaptrstosptrという抽象化された命令に置き換えることでした。

  • movptr: ポインタサイズに応じたMOV命令(32ビット環境ではMOVL、64ビット環境ではMOVQ)を生成します。
  • leaptr: ポインタサイズに応じたLEA命令(32ビット環境ではLEAL、64ビット環境ではLEAQ)を生成します。
  • stosptr: ポインタサイズに応じたSTOS命令(32ビット環境ではSTOSD、64ビット環境ではSTOSQ)を生成します。

これにより、amd64p32環境ではstosptrSTOSD(4バイト単位のゼロクリア)を生成し、CXレジスタの値stkzerosize/widthptrstkzerosize/4)と一致するようになります。結果として、スタックのゼロクリアが正しく行われるようになりました。

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

src/cmd/6g/ggen.cファイルのdefframe関数内の以下の行が変更されました。

--- a/src/cmd/6g/ggen.c
+++ b/src/cmd/6g/ggen.c
@@ -30,11 +30,11 @@ defframe(Prog *ptxt)
 	// when it looks for pointers.
 	p = ptxt;
 	if(stkzerosize > 0) {
-		p = appendpp(p, AMOVQ, D_CONST, 0, D_AX, 0);	
-		p = appendpp(p, AMOVQ, D_CONST, stkzerosize/widthptr, D_CX, 0);	
-		p = appendpp(p, ALEAQ, D_SP+D_INDIR, frame-stkzerosize, D_DI, 0);	
+		p = appendpp(p, movptr, D_CONST, 0, D_AX, 0);	
+		p = appendpp(p, movptr, D_CONST, stkzerosize/widthptr, D_CX, 0);	
+		p = appendpp(p, leaptr, D_SP+D_INDIR, frame-stkzerosize, D_DI, 0);	
 		p = appendpp(p, AREP, D_NONE, 0, D_NONE, 0);	
-		appendpp(p, ASTOSQ, D_NONE, 0, D_NONE, 0);	
+		appendpp(p, stosptr, D_NONE, 0, D_NONE, 0);	
 	}
 }

コアとなるコードの解説

変更された行は、スタックゼロイングのためのアセンブリ命令を生成する部分です。

  • - p = appendpp(p, AMOVQ, D_CONST, 0, D_AX, 0);
    • AMOVQmovptrに置き換えました。これにより、AXレジスタに0をロードする命令が、ポインタサイズに応じてMOVLまたはMOVQとして生成されるようになります。
  • - p = appendpp(p, AMOVQ, D_CONST, stkzerosize/widthptr, D_CX, 0);
    • AMOVQmovptrに置き換えました。これにより、CXレジスタに繰り返し回数をロードする命令が、ポインタサイズに応じてMOVLまたはMOVQとして生成されるようになります。
  • - p = appendpp(p, ALEAQ, D_SP+D_INDIR, frame-stkzerosize, D_DI, 0);
    • ALEAQleaptrに置き換えました。これにより、DIレジスタにスタックアドレスをロードする命令が、ポインタサイズに応じてLEALまたはLEAQとして生成されるようになります。
  • - appendpp(p, ASTOSQ, D_NONE, 0, D_NONE, 0);
    • ASTOSQstosptrに置き換えました。これが最も重要な変更点です。stosptrは、ポインタサイズに応じてSTOSD(32ビットポインタの場合)またはSTOSQ(64ビットポインタの場合)を生成します。amd64p32環境ではwidthptrが4バイトであるため、stosptrSTOSDを生成し、CXレジスタに設定された32ビットワードの数とSTOSDの4バイト単位の操作が一致するようになります。

これらの変更により、Goコンパイラはamd64p32環境においても、スタックゼロイングを正しく、かつ効率的に行うための適切なアセンブリ命令を生成できるようになりました。

関連リンク

参考にした情報源リンク