Go语言中的If语句
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 word)数据类型,其词源是 CoMPareQuadword,缩写为 CMPQ。
观察 0x47a84e 内存地址,可以看到 CMPQ os.Args+8(SB), $0x2 语句。在这种情况下,程序会将接收到的参数数量与十六进制 0x2(即 2)进行比较。
随后,通过 JL 指令判断参数是否小于 2(即程序自身是否为唯一参数)并进行跳转。也就是说,这是 Jump, Less than 的缩写,即 JL。如果前面的比较运算显示参数小于 2,则跳转至 0x47a8b0 地址,此处存在 JGE 指令。然而,由于该语句使用的是 AX 寄存器,因此必须明确寄存器中所存值的具体含义。
MOVQ 指令
接下来,我们需要了解程序是如何利用 CX 寄存器存储数据起始地址,以及在读取地址后如何提取实际数据。
观察 0x47858-0x47863 范围,可以看到该运算是分步执行的。
首先,通过 MOVQ os.Args(SB), CX 指令将参数数组的起始地址存入 CX 寄存器。此时必须理解 Go 的字符串类型。
Go 中的 string 是一个结构体,该结构体由两个 8 字节的数据组成,共 16 字节。
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
如上图所示,前 8 字节存储字符串的起始地址,后 8 字节存储字符串的长度。
因此,字符串的地址被存入 AX 寄存器,字符串的长度被存入 BX 寄存器。
CALL
在之前的文章中,查看 runtime 相关函数时也曾出现过 CALL 指令。该指令出现在 Go 使用的函数之前,字面意思就是 调用(CALL)某个函数。随后,利用 CALL 函数将字符串转换为整数,此时 函数内部抽象隐藏了整数存储的具体位置。
CMPQ 指令 & JGE 指令
回到之前的地址 0x47a86c,该指令正在 比较字符串的地址与数字 0xa(十进制 10)!
这意味着,由于程序内不再使用该参数,编译器直接 覆盖了字符串的位置,并将其作为整数变量 x 的存储空间。
这就是 Go 语言等所进行的激进优化的本质。
随后出现 JGE 指令,这是 Jump, Greater or Equals 的缩写。因此,该语句询问的是与比较对象相比是否“大于或等于”。
由此可见,并非直接对应 x < 10 语句,而是 比较方向与 x < 10 相反!
这是因为在机器语言中,当条件不满足时先行跳转,比在执行一次条件满足的比较后再确认是否不满足要 更直观,且能节省一条指令。
这种优化非常经典,与上述 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 语句。