この記事は Chapter I: A Primer on Go Assembly を翻訳、加筆したものです。
この記事では以下のような人を想定しています。
- Go言語の文法を理解している
- サブルーチンコール時の一般的なスタックの挙動を理解している
環境
$ go version
go version go1.10 linux/amd64
擬似アセンブリ
Goコンパイラが出力するアセンブリは、抽象化されたものであり、実際のハードウェアにマッピングされていません。 Goアセンブラはこの擬似アセンブリを対象のハードウェアに沿った機械語に変換します。
Javaのバイトコードのようなものを想像するとわかりやすいかもしれません。
このような中間層を設けることの最大の利点は新しいアーキテクチャに対応するのが楽になることです。
詳細を知りたい場合は、Rob Pike氏著の The Design of the Go Assemblerを見てください。
Goアセンブリを知るためにもっとも重要なことは、Goアセンブリが対象のハードウェアと直接対応していないという事実です。 一部分はハードウェアと直接結びついていますが、そうでないものもあります。これによってコンパイラはパイプライン上でアセンブラPassを必要とすることがなくなり、代わりに、コンパイラはこのハードウェアを抽象化した擬似アセンブリを扱えばよく、Instruction selection(この場合は、Goアセンブリから実際のアセンブリへの変換を指す)はcode generation(コンパイラによるGoアセンブリの生成のこと)の後で部分的に行われるようになっています。擬似アセンブリの例として、GOアセンブリのMOV命令は
clear
やload
命令などに変換されることもあればアーキテクチャによっては(名前は変わるかもしれないが)そのままのこともあります。メモリのデータ移動やサブルーチンのコールやリターンなどのアーキテクチャに共通する概念が抽象化される一方で、ハードウェアに特有な命令はそのまま表されることが多いです。
Goアセンブラはこの擬似アセンブリをパースして、リンカの入力となるよう命令に変換するプログラムです。
単純なプログラムを用いた例
次のコードを考えて見ましょう。
//go:noinline
func add(a, b int32) (int32, bool) { return a + b, true }
func main() { add(10, 32) }
(//go:noinline
インライン展開をしないようにするためのディレクティブ)
このコードをアセンブリにコンパイルしてみましょう。
$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
0x0013 RET
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
0x002b PCDATA $0, $0
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
Dissecting add
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
-
0x0000
: 命令のオフセット 関数の先頭からの相対位置で表されます。 -
TEXT "".add
:TEXT
ディレクティブは"".add
シンボルが.text
セクションに含まれること、続く命令がこの関数の中身であることを示しています。
空文字列""
はリンク時に現在のパッケージ名に置換されます。今回はmain.add
となります。 -
(SB)
:SB
はGoアセンブリで仮想的に定義されたレジスタで、"Static-Base"ポインタのことです。プログラムのアドレス空間の先頭を表しています。
"".add(SB)
は"".add
シンボルがアドレス空間の先頭からリンカによって計算される一定のオフセットにあることを示しています。言い換えるとアドレスが決まっているグローバルスコープな関数ということです。
objdump
を使うとこのことがよくわかります。
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
objdump補足
-
-j .text
textセクションのみ表示 -
-t
シンボルテーブルを表示 -
000000000044d980 g F .text 000000000000000f main.add
アドレス0x44d980
にmain.add
という名前のグローバルな関数シンボルが存在
ユーザーによって定義されたシンボルは全て 擬似レジスタ FP(ローカル)とSB(グローバル) からのオフセットとして記述されます。
擬似レジスタSBはメモリの起点として考えることができるため、foo(SB)シンボルはfooのアドレスを表すシンボルとして考えられます
-
NOSPLIT
: 現在のスタックを拡張する必要があるか確認する stack-split preambleを挿入しないようにコンパイラに指示しています。
add
関数はローカル変数をもたずスタックフレームを必要としないので現在のスタックを拡張する必要はないため、関数呼び出しのたびにスタック拡張の確認を行うことはCPUリソースの無駄遣いです。コンパイラはこのことを自動的に把握して、自動でこのNOSPLIT
フラグをセットします。スタック拡張については、この後のGoroutineの節で触れています。 -
$0-16
:$0
はこの関数に割り当てられるスタックフレームのバイト数を、16
はcallerによって渡される引数(+返り値)のサイズを表しています。(int32×3 + bool(4byteでalign)で16バイト)
一般的なケースでは、スタックフレームのサイズの後には、マイナス記号で区切られた引数のサイズが続きます。(このマイナス記号は引き算を表しているわけではない) $24-8は、関数に24バイトのスタックフレームがあり、呼び出し元のスタックフレームに存在する8バイトの引数で呼び出されることを示しています。TEXTにNOSPLITが指定されていないときは、引数のサイズは必ず明示する必要があります。Goプロトタイプを使用したアセンブリ関数の場合、go vet は引数のサイズが正しいかのチェックを行います。
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA と PCDATA ディレクティブはGCが利用するための情報を含んでいます。
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
Goの呼び出し規約では、すべての引数はcallerのスタックフレームで事前に確保されたスペースを使ってスタックを通して渡されるようになっています。
よってcalleeに引数を渡し、calleeの返り値がcallerの下に戻ってくるように適切にスタックサイズを管理することはcallerの責任になっています。
Goコンパイラは、 PUSH/POP命令を生成することはありません。代わりに、スタックの先頭をさす擬似レジスタであるSPを足したり引いたりすることでスタックを伸び縮みさせています。
[UPDATE: We've discussed about this matter in issue #21: about SP register.]
擬似レシスタSPはローカル変数や引数を参照するのに利用されます。SPはスタックフレームの先頭を指すので、[−framesize, 0)の範囲の負のオフセットを使って参照を行います。e.g. x-8(SP), y-4(SP)
公式のドキュメントには、ユーザーが定義したシンボルは、FPレジスタからのオフセットで表されると書いてありますが、これは自動的に生成されたコードには当てはまりません。
最近のGoコンパイラは、引数やローカル変数を常にスタックポインタからのオフセットで参照するようになっています。これによってFPは、x86などのレジスタの数が少ないプラットフォームで追加の汎用レジスタとして使うことができるようになりました。
詳細を知りたい方は Stack frame layout on x86-64 を見てください。
[UPDATE: We've discussed about this matter in issue #2: Frame pointer.]
"".b+12(SP)
と "".a+8(SP)
はそれぞれ、スタックの上から12バイトと8バイトのアドレスを参照しています。(スタックは上位アドレスから下位アドレスに伸びていくことに注意)
.a
と .b
は参照場所に与えられた任意のエイリアスです。名前は処理内容には影響しませんが、仮想レジスタ上で間接アドレッシングを利用するのに必須です。
擬似フレームポインタであるFPについてのドキュメントには以下のように書かれています。
FPは関数の引数を参照するための仮想のフレームポインタです。コンパイラはこのレジスタの内容を保持し、このレジスタに基づいたオフセットとしてスタック上の関数の引数を参照します。つまり64bitアーキテクチャの場合、 0(FP) は関数の最初の引数を、8(FP)は2番目の引数を指します。しかし、この方法で引数にアクセスするには、first_arg+0(FP)やsecond_arg+8(FP)のように先頭に名前をつけて指定する必要があります。(FPからのオフセットは、シンボルからのオフセットを意味しているSBの場合と異なります) アセンブラは0(FP) と 8(FP)のような名前のない書き方は受け付けずこの名前指定を強制します。実際の名前は処理内容には無関係ですが引数名をドキュメント化するために用いられます。
最後に2点程重要なことがあります。
- 最初の引数
a
は0(SP)
ではなく8(SP)
に配置されます。 これは caller がCALL
命令のときにリターン先のアドレスを0(SP)
に格納しているからです。 - 引数は後ろのものからスタックにPUSHされていきます。
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
ADDL
は 2つの Long-words(4バイト長の値) の加算を行いその結果をAX
に格納します。ここでは AX
と CX
の加算を行いその結果を AX
に格納しています。
そしてその結果は、callerが返り値を受け取ることを目的に、事前に確保しておいたスタック上の "".~r2+16(SP)
に格納されます。ここでも"".~r2
には処理内容的には意味は持ちません。
Goは複数の返り値をサポートしているので、この例では定数のtrue
も返り値をして返すようにしています。
最初の返り値と同様に、オフセットは異なりますが "".~r3+20(SP)
に結果を格納します。
0x0013 RET
最後の擬似命令 RET
はGoアセンブラにターゲットとしているハードウェアでサブルーチンから戻るための適切な命令を挿入するように指示するためのものです。
ほとんどの場合、0(SP)
に格納したリターン先のアドレスをPOPしてそこにジャンプします。
TEXTブロックの最後の命令は、何かしらのjump命令である必要があります(大抵はRETを用いる)
もしjump命令がない場合、リンカはTEXTブロックを超えて命令を実行しないように自分にjumpする命令を追加します。
文法や解説が大量に出てきたので簡単なまとめを記しておきます。
;; グローバル関数シンボル "".add を宣言(リンク時に main.add)
;; stack-split preambleを挿入しない
;; スタックフレームは0バイト、16バイトの引数が渡される
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
;; ...omitted FUNCDATA stuff...
0x0000 MOVL "".b+12(SP), AX ;; 呼び出し側のスタックフレームからAXに2番目の引数(b)を移動
0x0004 MOVL "".a+8(SP), CX ;; 呼び出し側のスタックフレームからCXに最初の引数(a)を移動
0x0008 ADDL CX, AX ;; AX=CX+AX
0x000a MOVL AX, "".~r2+16(SP) ;; AX に格納された加算結果を呼び出し側のスタックフレームに移動
0x000e MOVB $1, "".~r3+20(SP) ;; 定数`true`を呼び出し側のスタックフレームに移動
0x0013 RET ;; 0(SP) に格納されたリターン先のアドレスにジャンプ
main.add
の処理が終了した時のスタックの内容を可視化すると次のようになっています。
| +-------------------------+ <-- 32(SP)
| | |
G | | |
R | | |
O | | main.main's saved |
W | | frame-pointer (BP) |
S | |-------------------------| <-- 24(SP)
| | [alignment] |
D | | "".~r3 (bool) = 1/true | <-- 21(SP)
O | |-------------------------| <-- 20(SP)
W | | |
N | | "".~r2 (int32) = 42 |
W | |-------------------------| <-- 16(SP)
A | | |
R | | "".b (int32) = 32 |
D | |-------------------------| <-- 12(SP)
S | | |
| | "".a (int32) = 10 |
| |-------------------------| <-- 8(SP)
| | |
| | |
| | |
\ | / | return address to |
\|/ | main.main + 0x30 |
- +-------------------------+ <-- 0(SP) (TOP OF STACK)
(diagram made with https://textik.com)
Dissecting main
main
関数の内容をもう一度おさらいしておきましょう。
func main() { add(10, 32) }
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
0x0000 TEXT "".main(SB), $24-0
add
関数のときと同様です。今回はスタックフレームに24バイト確保し、引数も受け取らず返り値も特に何も返さないようにしています。
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
もう一度述べますが、Goの呼び出し規約では関数の引数はすべてスタックを通して渡されます。
SPを$24バイト引くことでmain
は自身のスタックフレームを24バイト確保しています。(スタックが下方向に伸びることに注意してください)
この確保した$24バイトは以下のように使います。
- 8 bytes (
16(SP)
-24(SP)
) は フレームポインタBP の現在の値を保存するのに使われます。これによってスタックの巻き戻し(呼び出し下の関数を辿ること)が可能になりデバッグ時に便利です。 (MOVQ BP, 16(SP)
) - 1+3 bytes (
12(SP)
-16(SP)
) はadd
関数 の2番目の返り値を受け取るために確保されています (bool
は1バイトだがamd64
アーキテクチャのアラインメントのため+3バイト) - 4 bytes (
8(SP)
-12(SP)
) はadd
関数 の1番目の返り値を受け取るために確保されています (int32
) - 4 bytes (
4(SP)
-8(SP)
) はadd
関数の 引数の値のために確保されていますb (int32)
- 4 bytes (
0(SP)
-4(SP)
) はadd
関数の 引数の値のために確保されていますa (int32)
最後に、スタックの確保に続いて、 LEAQ
はフレームポインタの新しいアドレスを計算し、それをBP
に格納します。(x86のlea命令と同様に BP = 16(SP))
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
caller は callee のための引数を8byteのQuad-wordとしてスタックの先頭に配置しています。
配置している値は一見すると意味のない値に見えますが、137438953482
は 4バイトの10
と 32
をひとまとめにして表しています。
$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
32 10
137438953482 の上位32-63bitが 100000(32)
、下位0-31bitが 00000000000000000000000000001010(10)
を表しています。
0x002b CALL "".add(SB)
SBからの相対オフセットとして add
関数 を CALL
命令 で呼び出します。
CALL
はリターン先のアドレスとして8byteのアドレスをスタックの先頭に配置するので、`add`関数 内で参照している SP
はすべて8byte下にシフトされることに注意しましょう。
例えば、"".a
は 0(SP)
でなく 8(SP)
と表されます。
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
最後に、
- フレームレジスタを巻き戻す
MOVQ 16(SP), BP
- 確保したスタックフレームを解放する
ADDQ $24, SP
- リターン
して main関数の実行を終えます
add
とmain
を通して、やっていることは一般的なサブルーチンコールだと思います。
Goroutineのスタック管理
Goroutineに関するアセンブリを見ていけば、スタック管理に関する命令に一気に馴染めるでしょう。
これらのパターンをなるべく早く理解できるように、ここでは、何をしているのか、なぜこのようなことをしているのかを理解しましょう。
Stacks
Goプログラム中に現れるGoroutineの数は状況によって異なります。実用的なプログラムでは数百万に及ぶこともあるかもしれません。Goのランタイムは、メモリを使い尽くしてしまわないように、Goroutineのスタックの確保には保守的な方法をとっています。
どんなGoroutineも最初は2KBのスタック空間がランタイムによって割り当てられます。(スタックは実際にはバックグラウンドでヒープに割り当てられます)
Goroutineが実行される時、場合によっては最初に割り当てられた2KBより多くのメモリが必要になるかもしれません。その場合、スタックを破壊して他のメモリ領域に侵入してしまうかもしれません。
このようなスタックオーバーフローを防ぐために、ランタイムはGoroutineがスタックを超えそうなときに、従来の2倍の大きさのスタックを確保して、そこにスタックの内容をコピーします。
このプロセスは stack-split と呼ばれており、Goroutineのスタックサイズを効率よく動的に扱えるようにしています。
Splits
stack-splitが機能するように、コンパイラはスタックオーバーフローが起きるかもしれない各関数の最初と最後にいくつかの命令を挿入してスタックオーバーフローが起きたことをチェックできるようにしています。
最初の方で見たようにスタックオーバーフローが起こり得ない関数ではこの処理は無駄なので、NOSPLIT
によってコンパイラにチェックする命令の挿入の必要がないことを伝えることができます。
上のmain
関数では stack-split に関するコードの説明は省きましたが、今度は省略せずに見てみましょう。
0x0000 TEXT "".main(SB), $24-0
;; stack-split prologue
0x0000 MOVQ (TLS), CX
0x0009 CMPQ SP, 16(CX)
0x000d JLS 58
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; stack-split epilogue
0x003a NOP
;; ...omitted PCDATA stuff...
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
- 関数の最初(prologue)でGoroutineがスタックを使い切っているかチェックし、その場合は関数の最後(epilogue)にジャンプしています。
- 関数の最後(epilogue)では、スタック拡張の処理をトリガーして、それが終わった後、関数の最初(prologue)に戻ります。
このprologueとepilogueはスタックサイズが十分な大きさになるまでループし続けることに注意してください。
Prologue
0x0000 MOVQ (TLS), CX ;; store current *g in CX
0x0009 CMPQ SP, 16(CX) ;; compare SP and g.stackguard0
0x000d JLS 58 ;; jumps to 0x3a if SP <= g.stackguard0
TLS
はランタイムによって管理される仮想のレジスタで現在のg
を指すポインタを持っています。これは、Goroutineの全ての状態をトレースするデータ構造です。
ランタイムのソースコードから g
の定義を確認してみましょう。
type g struct {
stack stack // 16 bytes
// stackguard0 は Prologueで比較対象となるスタックポインタ
// 通常、stackgurad0は stack.lo+StackGuard となるが、プリエンプションをトリガーするために StackPreempt になる可能性もある
// プリエンプション: マルチタスクのコンピュータシステムが実行中のタスクを一時的に中断する動作
stackguard0 uintptr
stackguard1 uintptr
// ...omitted dozens of fields...
}
g.stack
が16バイトなので、16(CX)
は g.stackguard0
となります。これはランタイムによって管理されているスタックの閾値で、スタックポインタと比較するとGoroutineがスタック空間を使い切ってしまったかを確認できます。
スタックは下位アドレス方向に伸びていくので、 SP <= stackguard0
ならばスタック空間を使い切っていることになります。その場合、prologueは epilogueにジャンプします。
Epilogue
0x003a NOP
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
epilogueの処理は簡単で、ランタイムのスタック拡張関数をコールしてスタックを拡張し、prologueのコードに戻るだけです。
CALL
の前の NOP
は prologueのコードが CALL
に直接ジャンプしないようにするために存在しています。プラットフォームによってはかなり深い部分まで解説する必要が出てくるので説明は省きますが、CALL命令の前にNOP命令を置いてそこにジャンプすることは共通のプラクティスとなっています。
[UPDATE: We've discussed about this matter in issue #4: Clarify "nop before call" paragraph.]
今回説明したのは氷山の一角にすぎません。
スタック拡張のメカニズムはここでは説明しきれないほど細かく複雑なので機会があればそれ専用の章を設けたいと思っています。
まとめ
今回はGo Assemblyを単純な例を用いて解説してみました。
残りの章でGoの内部実装をさらに掘り進めていきます。