剖析 go 语言的函数调用

krystollia · · 2590 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

让我们来看几个 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语言中文网 荣誉推出


有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

2590 次点击  
加入收藏 微博
被以下专栏收入,发现更多相似内容
3 回复  |  直到 2018-07-11 19:52:10
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传