让我们来看几个 go 函数调用的简单例子。通过研究 go 编译器为这些函数生成的汇编代码,我们来看看函数调用是如何工作的。这个课题对于一篇小小的文章来讲有点费劲,但是别担心,汇编语言是非常简单的,连 CPU 都能理解它。
![](https://raw.githubusercontent.com/studygolang/gctt-images/master/anatomy-of-a-function/1_CKK4XrLm3ylzsQzNbOaroQ.png)
*作者:Rob Baines https://github.com/telecoda/inktober-2016*
来看看我们的第一个函数,对,我们简单的将两个数相加。
```go
func add(a, b int) int {
return a + b
}
```
通过 `go build -gcflags '-N -l'`,我们禁用了编译优化,以使生成的汇编代码更加容易读懂。然后我们就可以用 go 工具 `objdump -s main.add func` (func是我们用的包名,也是 go build 生成的可执行文件的名称),将这个函数对应的汇编代码导出来。
如果你以前从来没有接触过汇编语言,那么恭喜,现在它对你来说是个新的东西。我在 mac 电脑上做的试验,所以汇编代码是英特尔 64位 的。
```
main.go:20 0x22c0 48c744241800000000 MOVQ $0x0, 0x18(SP)
main.go:21 0x22c9 488b442408 MOVQ 0x8(SP), AX
main.go:21 0x22ce 488b4c2410 MOVQ 0x10(SP), CX
main.go:21 0x22d3 4801c8 ADDQ CX, AX
main.go:21 0x22d6 4889442418 MOVQ AX, 0x18(SP)
main.go:21 0x22db c3 RET
```
在这里我们应该看什么呢?每一行都分成如下四个部分:
- 源文件名和行号 (main.go:15)。源文件的这一行的代码被翻译成带行号的汇编指令。Go 的一行有可能被翻译成多行汇编。
- 在目标文件中的偏移量(如 0x22C0)。
- 机器码(如 48c744241800000000)。这是 CPU 真正执行的二进制机器码。我们不会去看这部分,基本上也没人会去看。
- 机器码的汇编语言表达形式。这部分是我们希望去理解的。
让我们聚焦于汇编代码这部分。
- MOVQ, ADDQ 以及 RET 是指令。它们告诉 CPU 要做什么操作。跟在指令后面的是参数,告诉 CPU 要对谁进行操作。
- SP, AX 及 CX 是 CPU 的寄存器,是 CPU 存储工作用到的变量的地方。除了这几个,CPU 还会用到其它的一些寄存器。
- SP 是个特殊的寄存器,它用于存储当前的栈指针。栈是用于存储局部变量、函数的参数及函数返回地址的内存区域。每个 goroutine 对应一个栈。当一个函数调用另一个函数,被调用函数再继续调用别的函数,每个函数都会在栈上得到一个内存区域。函数调用时,SP 的值会减去被调用函数所需栈空间大小,这样就得到了一块供被调用函数使用的内存区域。
- 0x8(SP) 指向比 SP 所指内存位置往后8个字节的位置。
所以,几个要素包括:内存位置、CPU 寄存器、在内存和寄存器之间移动数据的指令,以及对寄存器的操作。这些差不多就是 CPU 所做的全部。
现在让我们详细的来看一下这些汇编代码,从第一条指令开始。还记得我们有两个参数 `a` 和 `b`,需要从内存加载,相加,然后返回。
1. `MOVQ $0x0, 0x18(SP)` 在内存地址 SP+0x18 处放入 0。这好像有点玄妙。
2. `MOVQ 0x8(SP), AX` 将内存地址 SP+0x8 处的内容放入 CPU 的 AX 寄存器中。也许这就是从内存中加载我们的一个参数?
3. `MOVQ 0x10(SP), CX` 将内存地址 SP+0x10 处的内容放入 CPU 的 CX 寄存器中。这就是我们的另一个参数。
4. `ADDQ CX, AX` 将 CX 与 AX 相加,结果留在 AX 中。好了,这就确确实实的将两个参数加起来了。
5. `MOVQ AX, 0x18(SP)` 将存储在 AX 中的内容存入内存地址 SP+0x18。这就是存储相加结果的过程。
6. `RET` 返回到调用函数。
还记得我们的函数有两个参数 `a` 和 `b`,它计算 `a+b` 并且返回结果。`MOVQ 0x8(SP), AX` 是将参数 `a` 移动到 AX。`a` 通过栈的 SP+0x8 位置传进函数。`MOVQ 0x10(SP), CX` 将参数 `b` 移动到 CX。`b` 通过栈的 SP+0x10 位置传进函数。`ADDQ CX, AX` 将 `a` 和 `b` 相加。`MOVQ AX, 0x18(SP)` 将结果存到内存地址 SP+0x18。运算结果通过放在栈的 SP+0x18 处传出给调用函数。当被调用函数返回,调用函数将从栈上读取返回值。
[这里我假定 `a` 就是第一个参数,`b` 就是第二个。我不确定这是正确的。我们可能需要更多的试验才能找到正确答案,不过这篇文章已经够长了。]
那么有点玄妙的第一行是做什么的呢?`MOVQ $0x0, 0x18(SP)` 将 0 存入内存地址 SP+0x18,注意到 SP+0x18 正是存储返回值的地址。我们可以猜测这是因为 go 对于未初始化变量会赋值为 0。即便不是必要,编译器也会这么做,因为我们禁用了编译优化。
来看看我们学到了什么。
- 看起来参数被存储在栈上,第一个参数位于 SP+0x8,另一个位于紧接着的更高地址的位置。
- 返回值看起来也是通过栈存储的,在比参数更高地址的位置。
现在我们来看另一个函数。这个函数有一个局部变量,但我们还是让它尽量保持简单。
```go
func add3(a int) int {
b := 3
return a + b
}
```
用同样的方式我们得到了以下的汇编代码。
```
TEXT main.add3(SB) /Users/phil/go/src/github.com/philpearl/func/main.go
main.go:15 0x2280 4883ec10 SUBQ $0x10, SP
main.go:15 0x2284 48896c2408 MOVQ BP, 0x8(SP)
main.go:15 0x2289 488d6c2408 LEAQ 0x8(SP), BP
main.go:15 0x228e 48c744242000000000 MOVQ $0x0, 0x20(SP)
main.go:16 0x2297 48c7042403000000 MOVQ $0x3, 0(SP)
main.go:17 0x229f 488b442418 MOVQ 0x18(SP), AX
main.go:17 0x22a4 4883c003 ADDQ $0x3, AX
main.go:17 0x22a8 4889442420 MOVQ AX, 0x20(SP)
main.go:17 0x22ad 488b6c2408 MOVQ 0x8(SP), BP
main.go:17 0x22b2 4883c410 ADDQ $0x10, SP
main.go:17 0x22b6 c3 RET
```
啊,看起来比上一个要复杂一些。让我们试着理解它。
前四条指令对应的是第 15 行的源代码。这行是:
```go
func add3(a int) int {
```
这行看起来并没有做太多。所以这可能是函数的某种"序言"。让我们来分解一下。
- `SUBQ $0x10, SP` 将 SP 的值减去 0x10 即 16。这样栈空间增加了 16 字节。
- `MOVQ BP, 0x8(SP)` 将寄存器 BP 中的值存储在 SP+8 的位置,`LEAQ 0x8(SP), BP` 将 SP+8 所对应的地址存储在 BP 中。这帮助我们建立了栈空间(栈帧) 的链。这有点玄妙,但恐怕这篇文章不会对此做解释了。
- 这一段的最后是 `MOVQ $0x0, 0x20(SP)`。这和我们刚讨论的上一个函数很类似,是将返回值初始化为 0。
汇编的下一行对应于源码的 `b := 3`。这个命令 `MOVQ $0x3, 0(SP)` 将 3 放入内存 SP+0 处。这个解决了我们的疑问。当我们把 SP 的值减去 0x10=16,我们空出了能容纳 2 个 8字节 变量的空间:局部变量 `b` 存储于 SP+0,而 BP 的值存储于 SP+0x8。
后面的 6 行对应于 `return a + b`。这包括从内存加载 `a` 和 `b`, 将它们相加,以及返回计算结果。让我们按顺序来看每一行。
- `MOVQ 0x18(SP), AX` 将存储于 SP+0x18 处的参数 `a` 移动到寄存器 AX。
- `ADDQ $0x3, AX` 将 AX 的值加 3(尽管我们关闭了优化选项, 但由于某种原因这里还是没有用到存储于 SP+0 的局部变量 `b`)。
- `MOVQ AX, 0x20(SP)` 将 `a+b` 的结果存储于 SP+0x20,这里即是我们的返回值存储的位置。
- 接下来是 `MOVQ 0x8(SP), BP` 和 `ADDQ $0x10, SP`。首先恢复 BP 的值,然后将 SP 的值增加 0x10,这样就恢复到了函数刚开始时 SP 的值。
- 最后是 `RET`,返回到调用函数。
那么我们学到了什么?
- 调用者函数为返回值和参数在栈上申请空间。返回值在栈上的地址高于参数。
- 如果被调用函数有局部变量,它将通过减小栈指针 SP 的值来申请空间。这与寄存器 BP 也有着一些奇妙的关系。
- 当函数返回时,一切对 SP 和 BP 的操作都会被回退。
让我们来绘制出 add3() 是如何使用栈的:
```
SP+0x20: 返回值
SP+0x18: 参数 a
SP+0x10: ??
SP+0x08: BP 原来的值
SP+0x0: 局部变量 b
```
我们并没看到哪里有提及 SP+0x10,所以我们不知道它有什么用。不过我可以告诉你,这里存储了函数返回的地址。这样 `RET` 命令才知道应该返回到哪里。
好了,对这篇文章来讲上述内容已经够了。如果你以前不知道这些东西如何工作,那么希望你现在明白了一点点。如果你曾因汇编而胆怯,也希望现在它对你来说不再那么晦涩难懂。如果你希望了解更多的细节,可以写下评论,我会考虑以后再写一篇更加详细的文章。
如果你喜欢这篇文章,或者从中学到了东西,请点赞,这样其他人也能看到它。
via: https://syslog.ravelin.com/anatomy-of-a-function-call-in-go-f6fc81b80ecc
作者:Phil Pearl 译者:krystollia 校对:rxcai
本文由 GCTT 原创翻译,Go语言中文网 首发。也想加入译者行列,为开源做一些自己的贡献么?欢迎加入 GCTT!
翻译工作和译文发表仅用于学习和交流目的,翻译工作遵照 CC-BY-NC-SA 协议规定,如果我们的工作有侵犯到您的权益,请及时联系我们。
欢迎遵照 CC-BY-NC-SA 协议规定 转载,敬请在正文中标注并保留原文/译文链接和作者/译者等信息。
文章仅代表作者的知识和看法,如有不同观点,请楼下排队吐槽