マネージド言語のランタイム構造とGoバイナリのAMD64アセンブリ解析

マネージド言語とは何か?
マネージド言語とは、アンマネージド言語、すなわちプログラマーが書いたロジックからほとんど逸脱せず実行するだけの言語とは異なり、GC、ランタイム最適化、グリーンスレッド、並行性処理などをランタイムで実行し、ユーザーが危険な低水準の管理を行う必要をなくしてくれる言語である。このような言語の場合、ビジネスロジックだけに集中して開発に没頭できるという利点があるが、一方で、プログラマーの直感と実際のプログラムの動作が異なりうるため、精密なランタイムチューニングが必要な場合もある。まず、マネージド言語の中で最もミニマリスト的な哲学に忠実であり、アセンブリが正直な Go 言語を見ていくことにする。
Go 言語のバイナリ構造
| .text | .data | .gopclntab、.typelink など |
|---|---|---|
| 実行される機械語コード | 保存されるデータ | 言語ランタイムセクション |
Go 言語はユーザーが入力したとおりに1:1で機械語へ変換するわけではないため、.text セクションのロジックは言語ランタイムセクションとも密接に関連している。また、ユーザーが別途記述していない runtime.printnl() のような関数が .text セクションのアセンブリに追加される。このような自動的なコード挿入を通じて、Go 言語は開発者を手動管理から解放する手助けをしている。
Go で main 関数の部分だけを見る
まず、簡単な例のソース main.go を作成し、main から AMD64 マシン上で 見てみよう。
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
その後、次のようにビルドする。
1go build main.go
2
Go は簡単な低水準デバッグのために go tool をサポートしている。 go tool でメインパッケージのメイン関数の分だけのアセンブリを見るために、この構文を入力する。
1go tool objdump -s "main\.main" ./main
2
アセンブリ
1TEXT main.main(SB) /home/yjlee/compare-assembly/go/main.go
2 main.go:7 0x468f60 493b6610 CMPQ SP, 0x10(R14)
3 main.go:7 0x468f64 762f JBE 0x468f95
4 main.go:7 0x468f66 55 PUSHQ BP
5 main.go:7 0x468f67 4889e5 MOVQ SP, BP
6 main.go:7 0x468f6a 4883ec10 SUBQ $0x10, SP
7 main.go:8 0x468f6e 90 NOPL
8 main.go:4 0x468f6f e8cca3fcff CALL runtime.printlock(SB)
9 main.go:4 0x468f74 488d05da290100 LEAQ 0x129da(IP), AX
10 main.go:4 0x468f7b bb0b000000 MOVL $0xb, BX
11 main.go:4 0x468f80 e83bacfcff CALL runtime.printstring(SB)
12 main.go:4 0x468f85 e8f6a5fcff CALL runtime.printnl(SB)
13 main.go:4 0x468f8a e811a4fcff CALL runtime.printunlock(SB)
14 main.go:9 0x468f8f 4883c410 ADDQ $0x10, SP
15 main.go:9 0x468f93 5d POPQ BP
16 main.go:9 0x468f94 c3 RET
17 main.go:7 0x468f95 e8e6afffff CALL runtime.morestack_noctxt.abi0(SB)
18 main.go:7 0x468f9a ebc4 JMP main.main(SB)
19
- 現在のゴルーチンのスタックフレーム領域が十分かどうかを、ゴルーチン制御ブロックのレジスタ(R14)内部のスタックガード値と現在のスタックポインタ(SP)をCMPQで比較して確認し、不足していればスタック拡張のためのエントリポイントである 0x468f95 アドレスへジャンプ(JBE)する。
- 以前のベースポインタを保存するために PUSHQ BP でスタックに挿入する。
- ベースポインタ(BP)レジスタに現在のスタックポインタ(SP)をコピーし、関数開始時のスタック基準点を固定する。
- その後、16バイトのローカル変数用スタック領域を確保し(SUBQ $0x10, SP)、NOPL を用いて仮想命令で埋めることでCPUキャッシュのアライメントを行う。
- Go ランタイムで内部文字列の標準出力の同期のために runtime.printlock(SB) を呼び出してロックをかける。
- LEAQ 命令を用いて、定数として割り当てられた文字列("Hello World")の開始アドレスを、Go ABI 規格に従い第1引数として使われる汎用レジスタ AX に格納する。
- 次に、文字列の長さを表す値を第2引数レジスタである BX に格納する。(MOVL $0xb, BX、すなわち10進数で11)
- runtime.printstring(SB) を呼び出し、渡された AX(データアドレス)と BX(長さ)の情報に基づいてコンソールに出力する。
- 改行処理のために runtime.printnl(SB) を呼び出す。
- 出力が完了したので、runtime.printunlock(SB) によってロックを解除する。
- ADDQ $0x10, SP で確保していた16バイトのスタックメモリを復元する。
- POPQ BP で既存のベースポインタを復元する。
- RET によって関数を呼び出した箇所へ制御権を返す。
- 最初のスタック検査で領域が不足していた場合は、0x468f95 アドレスの runtime.morestack_noctxt.abi0(SB) を呼び出し、マネージド言語らしくスタックランタイムを動的に拡張する。
- スタック拡張が完了すると、再び main.main(SB) のエントリポイントへ復帰(JMP)する。見てのとおり、ビジネスロジックのアセンブリはかなり明確であり、薄いランタイム管理だけが付け加えられた形である。
最適化がない場合
上記の形は、Go コンパイラが分かれている2つの関数を自動的にインライン化して最適化した結果である。しかし、学習のため、この場合は sayHello をインライン化しないようにする。これを行うために、次のフラグでソースをコンパイルする。
1go build -gcflags="-l" main.go
2
シェルで結果を出力してみると、重複するアセンブリが見つかる。
1yjlee@elegant:~/compare-assembly/go$ go build -gcflags="-l" main.go
2
3go tool objdump -s "main\.sayHello" ./main
4TEXT main.sayHello(SB) /home/yjlee/compare-assembly/go/main.go
5 main.go:3 0x468f60 493b6610 CMPQ SP, 0x10(R14)
6 main.go:3 0x468f64 7636 JBE 0x468f9c
7 main.go:3 0x468f66 55 PUSHQ BP
8 main.go:3 0x468f67 4889e5 MOVQ SP, BP
9 main.go:3 0x468f6a 4883ec10 SUBQ $0x10, SP
10 main.go:5 0x468f6e 4889442420 MOVQ AX, 0x20(SP)
11 main.go:5 0x468f73 48895c2428 MOVQ BX, 0x28(SP)
12 main.go:4 0x468f78 e8c3a3fcff CALL runtime.printlock(SB)
13 main.go:4 0x468f7d 488b442420 MOVQ 0x20(SP), AX
14 main.go:4 0x468f82 488b5c2428 MOVQ 0x28(SP), BX
15 main.go:4 0x468f87 e834acfcff CALL runtime.printstring(SB)
16 main.go:4 0x468f8c e8efa5fcff CALL runtime.printnl(SB)
17 main.go:4 0x468f91 e80aa4fcff CALL runtime.printunlock(SB)
18 main.go:5 0x468f96 4883c410 ADDQ $0x10, SP
19 main.go:5 0x468f9a 5d POPQ BP
20 main.go:5 0x468f9b c3 RET
21 main.go:3 0x468f9c 4889442408 MOVQ AX, 0x8(SP)
22 main.go:3 0x468fa1 48895c2410 MOVQ BX, 0x10(SP)
23 main.go:3 0x468fa6 e8d5afffff CALL runtime.morestack_noctxt.abi0(SB)
24 main.go:3 0x468fab 488b442408 MOVQ 0x8(SP), AX
25 main.go:3 0x468fb0 488b5c2410 MOVQ 0x10(SP), BX
26 main.go:3 0x468fb5 eba9 JMP main.sayHello(SB)
27
インライン化を解除すると、関数呼び出し規格に合わせて引数(AX、BX)を保存するため、スタックポインタのオフセットである 0x20(SP) などに値を再びロードする MOVQ 演算が挿入される。つまり、コンパイラが最適化しているのは、このような不要なメモリ移動演算および呼び出しオーバーヘッドの対象であることが確認された。
次回
次回は Go 言語における if 文、switch 文を扱う予定である。今後時間が取れれば、Go ランタイムセクションも分析する予定である。