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    if len(os.Args) < 2 {
15        return
16    }
17    x, _ := strconv.Atoi(os.Args[1])
18
19    if x < 10 {
20        println("X is smaller than 10")
21    } else {
22        println("X is larger or same as 10")
23    }
24}

在这种情况下,由于编译器无法预测输入,分支语句会被原样翻译为机器码。

汇编语言

 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 语句。在这种情况下,程序会比较输入参数的数量与十六进制 0x2(即十进制的 2)。

随后,若参数个数小于 2(即程序自身是唯一参数),则通过 JL 进行比较后跳转。JL 是 Jump, Less than 的缩写,即 JL。对于上一条比较指令,若参数个数小于 2,则跳转到地址 0x47a8b0,该处包含 JGE 指令。然而,此语句使用的是 AX 寄存器,因此必须弄清楚寄存器中所存储值的含义。

MOVQ 指令

接下来,需要了解如何实际使用 CX 寄存器来保存数据的起始地址,并且在读取该地址之后,如何提取实际数据。

观察 0x47858-0x47863 范围,可以看到此操作是分步完成的。

首先,通过 MOVQ os.Args(SB), CX 指令将参数数组的起始地址放入 CX 寄存器。此时,需要理解 Go 的字符串类型。

Go 的 string 是一个结构体,该结构体由两个 8 字节数据组成,共 16 字节。

struct8 byte8 byte
stringmem addressstring length

直观地表示如上所示:前 8 字节存储字符串的起始地址,后 8 字节存储字符串的长度。

因此,字符串的地址被存入 AX 寄存器,字符串的长度被存入 BX 寄存器。

CALL

在之前的文章中,观察 runtime 相关函数时,也能看到 CALL 这一指令。它附加在 Go 所使用的函数之前,表示调用某个函数,字面意思正是 CALL。随后,通过调用函数将字符串转换为整数,而此时整数被存放在何处,在函数中被抽象化了,无法看到。

CMPQ 指令与 JGE 指令

回到之前的地址 0x47a86c,该指令正在比较字符串的地址与数字 0xa(十进制的 10)!

这意味着,由于程序之后不再使用该参数,编译器将字符串所在的位置覆盖,从而腾出了整型变量 x 的位置。

这正是 Go 等语言中所进行的激进优化的本质。

随后出现的 JGE 指令,是 Jump, Greater or Equals 的缩写。因此,该语句询问的是与比较对象相比是否大于或等于。

所以,它并不是 x < 10 原样的语句,而是比较方向被颠倒了! 这是因为在机器码中,当条件不成立时提前跳过,比起先执行一次条件成立时的比较、再确认其是否不成立,更为直观,并且可以节省一条指令。

由于这种优化非常经典,因此它在优化程度相当低的编译器中也频繁出现,值得熟记。

因此,将这一点加以应用,就可以得到语法不同但在汇编层面 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 语句。