Go言語の条件分岐(if)におけるAMD64アセンブリコードの生成と分岐命令の分析

Go言語におけるif文
まず、私たちがGoを選んだのは、Goが現代的な言語の中で最もアセンブリが「美しく」、古典的な言語と比較しても、その構文の効率性がむしろ圧倒的である場合があるからです。
では、前回の講義で簡単なGoプログラムの動作方式について理解したので、さっそくGoとAssemblyを命令行ごとに比較してみましょう。
ソースコード
まず、Goに限らず、GCCを含む現代のコンパイラは、意味のない分岐文を自動的に最適化します。GCCやClangのようなC言語コンパイラも、業界標準である-O2で非常に積極的な最適化を行うため、プログラマーがコンパイラを全面的に信頼しにくい時代は、ついに20世紀後半から完成してしまったと言えるでしょう。
したがって、コンパイラの立場から予測して別の構文に置き換えにくい条件を与えてこそ、少なくとも意味を持ちます。
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // もし、これをx = 10のように予測可能で、分岐を消せるものでごまかすと
10 // コンパイラが最適化して分岐を消してしまう。
11 // そのため、これを直接アセンブリで観察するには、C言語では-O0などを指定するか、
12 // そもそもコンパイラが予測できない外部の値を使わせるのだが、
13 // このセクションは現代のプログラミングを扱うので、Goのバイナリ最適化を
14 // 無効にする方法は使用しない。
15 if len(os.Args) < 2 {
16 return
17 }
18 x, _ := strconv.Atoi(os.Args[1])
19
20 if x < 10 {
21 println("X is smaller than 10")
22 } else {
23 println("X is larger or same as 10")
24 }
25}
この場合、入力をコンパイラが予測できないため、分岐文はそのまま機械語に翻訳されます。
アセンブリ語
1TEXT main.main(SB) /home/yjlee/introduction-to-golang/learn-golang/if-and-switch/golang-if/main.go
2 main.go:8 0x47a840 493b6610 CMPQ SP, 0x10(R14)
3 main.go:8 0x47a844 7670 JBE 0x47a8b6
4 main.go:8 0x47a846 55 PUSHQ BP
5 main.go:8 0x47a847 4889e5 MOVQ SP, BP
6 main.go:8 0x47a84a 4883ec10 SUBQ $0x10, SP
7 main.go:15 0x47a84e 48833d12fb0a0002 CMPQ os.Args+8(SB), $0x2
8 main.go:15 0x47a856 7c58 JL 0x47a8b0
9 main.go:15 0x47a858 488b0d01fb0a00 MOVQ os.Args(SB), CX
10 main.go:18 0x47a85f 488b4110 MOVQ 0x10(CX), AX
11 main.go:18 0x47a863 488b5918 MOVQ 0x18(CX), BX
12 main.go:18 0x47a867 e834e8ffff CALL strconv.Atoi(SB)
13 main.go:20 0x47a86c 4883f80a CMPQ AX, $0xa
14 main.go:20 0x47a870 7d1d JGE 0x47a88f
15 main.go:21 0x47a872 e809befbff CALL runtime.printlock(SB)
16 main.go:21 0x47a877 488d0519f50100 LEAQ 0x1f519(IP), AX
17 main.go:21 0x47a87e bb15000000 MOVL $0x15, BX
18 main.go:21 0x47a883 e878c6fbff CALL runtime.printstring(SB)
19 main.go:21 0x47a888 e853befbff CALL runtime.printunlock(SB)
20 main.go:21 0x47a88d eb1b JMP 0x47a8aa
21 main.go:23 0x47a88f e8ecbdfbff CALL runtime.printlock(SB)
22 main.go:23 0x47a894 488d05c8040200 LEAQ 0x204c8(IP), AX
23 main.go:23 0x47a89b bb1a000000 MOVL $0x1a, BX
24 main.go:23 0x47a8a0 e85bc6fbff CALL runtime.printstring(SB)
25 main.go:23 0x47a8a5 e836befbff CALL runtime.printunlock(SB)
26 main.go:25 0x47a8aa 4883c410 ADDQ $0x10, SP
27 main.go:25 0x47a8ae 5d POPQ BP
28 main.go:25 0x47a8af c3 RET
29 main.go:16 0x47a8b0 4883c410 ADDQ $0x10, SP
30 main.go:16 0x47a8b4 5d POPQ BP
31 main.go:16 0x47a8b5 c3 RET
32 main.go:8 0x47a8b6 e845f0feff CALL runtime.morestack_noctxt.abi0(SB)
33 main.go:8 0x47a8bb eb83 JMP main.main(SB)
go toolを使うと親切にも、どの構文とどのアセンブリが対応しているかを一つひとつ教えてくれます。
今回学ぶのは比較文とif分岐文なので、いくつかの行に注目すればよいです。
CMPQ命令 & JL命令
CMPQ命令は4バイト(4ワード)のデータ型を比較するための命令で、語源は CoMPareQuadword から CMPQ と略したものです。
0x47a84e番地を見ると、CMPQ os.Args+8(SB), $0x2という構文が入っています。この場合、プログラムが受け取った引数の数と、16進数の0x2(つまり単に2)を比較します。
その後、引数が2より小さいか(つまりプログラム自身だけが引数である場合)を判定し、JLによって比較後のジャンプを行います。つまりこれは Jump, Less than を略して JL となります。直前の比較演算で引数が2より小さかった場合は0x47a8b0番地にジャンプしますが、そこにはJGEがあります。しかし、この構文で使われているのはAXレジスタなので、レジスタに格納された値の正体を知る必要があります。
MOVQ命令
この次に、実際に'CX'レジスタを使ってデータの開始アドレスを格納し、アドレスを読んだ後に実際のデータをどのように取り出そうとしているかを知る必要があります。
0x47858-0x47863の範囲を見ると、段階的にこの演算を行っています。
まず、引数配列の開始アドレスをMOVQ os.Args(SB), CX命令でCXレジスタに格納します。この時、Goの文字列型を理解しておく必要があります。
Goのstringは構造体であり、この構造体は8バイトのデータ2つで、合計16バイトから構成されています。
| struct | 8 byte | 8 byte |
|---|---|---|
| string | メモリアドレス | 文字列の長さ |
視覚的に描くと上記のようになり、前の8バイトは文字列の開始アドレス、後ろの8バイトは文字列の長さが格納されています。
したがって、文字列のアドレスをAXレジスタに、文字列の長さをBXレジスタに格納します。
CALL
前の投稿でもruntime系の関数を見たとき、CALLという命令が付いていました。これはGoで使用される関数の前に付いており、ある関数を呼び出すという意味で、文字通りCALLです。その後、CALL関数を使って文字列を整数に変換しますが、この時、整数をどこに格納するかは_関数に抽象化されて見えません。_
CMPQ命令 & JGE命令
再び先ほどのアドレス0x47a86cに戻ると、命令は文字列のアドレスと、数値である0xa(10進数で10)を比較しています!
これは、プログラム内でもはや該当の引数を使わないため、文字列の位置に上書きして整数型変数xの場所を作った、という意味です。
これがGo言語などで行われる積極的な最適化の正体です。
その後、JGEという命令が登場しますが、これは Jump, Greater or Equals の略です。したがってこの構文は、比較対象と照合したときに大きいか等しいかを問うています。
よって、x < 10構文そのままではなく、x < 10で構文の比較方向が逆転しています!
これは機械語では、条件が一致しないときに先に飛び越す方が、条件が一致するときの比較を1回行った後に、さらに一致しないかを確認するより直感的であり、命令を1つ節約できるからです。
このような最適化は非常に古典的であるため、上で見たstrconv.Atoiの例と異なり、最適化がかなり低水準のコンパイラでも頻繁に現れるパターンです。知っておくと良いでしょう。
したがって、このような点を応用すると、ソースは異なるものの、アセンブリ単位で100%一致するソースを得ることができます。
鏡像コードの例
以下のスクリプトを使うと、bashスクリプトは100%鏡像の二つのソースを作成し、その都度変わるメタデータを除外してmainだけを見たとき、正確に同一のアセンブリが得られることを検証できます。
1#!/usr/bin/env bash
2
3# 1. 既存の残存ファイルおよびディレクトリを完全に初期化
4echo "[1/6] Cleaning up old artifacts..."
5rm -rf test_dir main_orig main_asm orig.asm asm.asm orig_pure.asm asm_pure.asm
6mkdir -p test_dir
7
8# 2. 元のバージョンのソースコード(main.go)を作成
9echo "[2/6] Generating main.go..."
10cat << 'EOF' > main.go
11package main
12
13import (
14 "os"
15 "strconv"
16)
17
18func main() {
19 if len(os.Args) < 2 {
20 return
21 }
22 x, _ := strconv.Atoi(os.Args[1])
23
24 s1 := "X is smaller than 10"
25 s2 := "X is larger or same as 10"
26
27 if x < 10 {
28 println(s1)
29 } else {
30 println(s2)
31 }
32}
33EOF
34
35# 3. 鏡像バージョンのソースコード(main_from_asm.go)を作成
36# コンパイラが最適化テンプレート(JGE)をそのまま使うように、演算子構造を完全に対称に同期
37echo "[3/6] Generating main_from_asm.go..."
38cat << 'EOF' > main_from_asm.go
39package main
40
41import (
42 "os"
43 "strconv"
44)
45
46func main() {
47 if len(os.Args) < 2 {
48 return
49 }
50 x, _ := strconv.Atoi(os.Args[1])
51
52 s1 := "X is smaller than 10"
53 s2 := "X is larger or same as 10"
54
55 // 10を基準にしてx < 10の構造を維持すれば、コンパイラは
56 // main.goとまったく同じJGEの仕組みおよびブロック配置を採用します。
57 switch {
58 case x < 10:
59 println(s1)
60 default:
61 println(s2)
62 }
63}
64EOF
65
66# 4. 同じディレクトリパスおよびファイル名の環境で、それぞれビルドを実行
67echo "[4/6] Compiling both sources inside 'test_dir'..."
68cp main.go test_dir/main.go
69cd test_dir && go build -o ../main_orig main.go && cd ..
70rm test_dir/main.go
71
72cp main_from_asm.go test_dir/main.go
73cd test_dir && go build -o ../main_asm main.go && cd ..
74
75# 5. go tool objdumpを使って、純粋なmain.mainアセンブリ関数を抽出
76echo "[5/6] Extracting main.main assembly sections..."
77go tool objdump -s "main\.main" main_orig > orig.asm
78go tool objdump -s "main\.main" main_asm > asm.asm
79
80# 仮想アドレス、オフセット、機械語バイトデータのテキストを除去し、
81# CPUが実行する純粋な命令セット(Opcode & Operands)フィールドのみをフィルタリング
82awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
83awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
84
85# 6. 両機械語命令構造のdiff検証
86echo "[6/6] Verifying assembly structural integrity via diff..."
87echo "------------------------------------------------------------"
88if diff orig_pure.asm asm_pure.asm > /dev/null; then
89 echo "===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <==="
90 echo "컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다."
91else
92 echo "===> [실패] 어셈블리 명령어 구조에 차이점이 발견되었습니다. <==="
93 diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"
実際にソースを実行してみると、以下のような情報が得られます。
1[1/6] Cleaning up old artifacts...
2[2/6] Generating main.go...
3[3/6] Generating main_from_asm.go...
4[4/6] Compiling both sources inside 'test_dir'...
5[5/6] Extracting main.main assembly sections...
6[6/6] Verifying assembly structural integrity via diff...
7------------------------------------------------------------
8===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <===
9컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다.
10------------------------------------------------------------
結論
プログラミング言語は多くの抽象化を提供しますが、抽象化の裏には非常に面白く、積極的な最適化が隠れていることが分かりました。また、この点を逆手に取ることで、ソースは異なるもののアセンブリは同一の鏡像のコードも作ることができました。低水準に関心があり、Goで書かれたプロプライエタリなソフトウェアに出会った場合は、直接アセンブリを分解して分析し、ソースを復元することも不可能ではないと思われます。
次の講義
次回は、If文とまた別の面白さがあるselect-case文について見ていくことにします。