GoSuda

Go 条件文(if)のAMD64アセンブリコード生成と分岐命令の分析

By Lee Yunjin views ...

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バイトから構成されています。

struct8 byte8 byte
stringmem addressstring length

視覚的に描くと上記のとおりで、前の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        if x < 10 {
58                println(s1)
59        } else {
60                println(s2)
61        }
62}
63EOF
64
65# 4. 同じディレクトリパスおよびファイル名の環境でそれぞれビルドを実行
66echo "[4/6] Compiling both sources inside 'test_dir'..."
67cp main.go test_dir/main.go
68cd test_dir && go build -o ../main_orig main.go && cd ..
69
70rm test_dir/main.go
71cp main_from_asm.go test_dir/main.go
72cd test_dir && go build -o ../main_asm main.go && cd ..
73
74# 5. go tool objdumpを使用して純粋なmain.mainアセンブリ関数を抽出
75echo "[5/6] Extracting main.main assembly sections..."
76go tool objdump -s "main\.main" main_orig > orig.asm
77go tool objdump -s "main\.main" main_asm > asm.asm
78
79# 仮想アドレス、オフセット、機械語バイトのテキストを削除し
80# CPUが実行する純粋な命令セット(Opcode & Operands)のフィールドのみをフィルタリング
81awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
82awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
83
84# 6. 二つの機械語命令構造のdiff検証
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
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文について見ていくことにします。