函数的调用过程主要要点在于借助寄存器和内存帧栈传递参数和返回值。虽然同为编译型语言,Go 相较 C 对寄存器和栈的使用有一些差别,同时,Go 语言自带协程并引入 defer 等语句,在调用过程上显得更加复杂。 理解Go函数调用在CPU指令层的过程有助于编写高效的代码,在性能优化、Bug排查的时候,能更迅速的确定要点。本文以简短的示例代码和对应的汇编代码演示了Go的调用过程,展示了不同数据类型的参数的实际传递过程,同时分析了匿名函数、闭包作为参数或者返回值传递时,在内存上的实际数据结构。对于协程对栈的使用和实现细节,本文不展开。
阅读本文需要掌握计算机体系结构基础知识(至少了解程序内存布局、栈、寄存器)、Go 基础语法。参考文档提供了这些主题更详细的知识。
以下:
术语
- 栈:每个进程/线程/goroutine有自己的调用栈,参数和返回值传递、函数的局部变量存放通常通过栈进行。和数据结构中的栈一样,内存栈也是后进先出,地址是从高地址向低地址生长。
- 栈帧:(stack frame)又常被称为帧(frame)。一个栈是由很多帧构成的,它描述了函数之间的调用关系。每一帧就对应了一次尚未返回的函数调用,帧本身也是以栈的形式存放数据的。
- caller 调用者
- callee 被调用者,如在 函数 A 里 调用 函数 B,A 是 caller,B 是 callee
寄存器(X86)
- ESP:栈指针寄存器(extended stack pointer),存放着一个指针,该指针指向栈最上面一个栈帧(即当前执行的函数的栈)的栈顶。注意:
- ESP指向的是已经存储了内容的内存地址,而不是一个空闲的地址。例如从 0xC0000000 到 0xC00000FF是已经使用的栈空间,ESP指向0xC00000FF
- EBP:基址指针寄存器(extended base pointer),也叫帧指针,存放着一个指针,该指针指向栈最上面一个栈帧的底部。
- EIP:寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。
注意:16位寄存器没有前缀(SP、BP、IP),32位前缀是E(ESP、EBP、EIP),64位前缀是R(RSP、RBP、RIP)
汇编指令
- PUSH:进栈指令,PUSH指令执行时会先将ESP减4,接着将内容写入ESP指向的栈内存。
- POP :出栈指令,POP指令执行时先将ESP指向的栈内存的一个字长的内容读出,接着将ESP加4。注意:
- 用PUSH指令和POP指令时只能按字访问栈,不能按字节访问栈。
- CALL:调用函数指令,将返回地址(call指令的下一条指令)压栈,接着跳转到函数入口。
- RET:返回指令,将栈顶返回地址弹出到EIP,接着根据EIP继续执行。
- LEAVE:等价于 mov esp,ebp; pop ebp;
- MOVL:在内存与寄存器、寄存器与寄存器之间转移值
- LEAL:用来将一个内存地址直接赋给目的操作数
注意:8位指令后缀是B、16位是S、32位是L、64位是Q
调用惯例
调用惯例(calling convention)是指程序里调用函数时关于如何传参如何分配和清理栈等的方案。一个调用惯例的内容包括:
-
参数是通过寄存器传递还是栈传递或者二者混合
-
通过栈传递时参数是从左至右压栈还是从右至左压栈
-
函数结果是通过寄存器传递还是通过栈传递
-
调用者(caller)还是被调用者(callee)清理栈空间
-
被调用者应该为调用者保存哪些寄存器
例如,C 的调用惯例(cdecl, C declaration)是:
-
函数实参在线程栈上按照从右至左的顺序依次压栈。
-
函数结果保存在寄存器EAX/AX/AL中
-
浮点型结果存放在寄存器ST0中
-
编译后的函数名前缀以一个下划线字符
-
调用者负责从线程栈中弹出实参(即清栈)
-
8比特或者16比特长的整形实参提升为32比特长。
-
受到函数调用影响的寄存器(volatile registers):EAX, ECX, EDX, ST0 - ST7, ES, GS
-
不受函数调用影响的寄存器: EBX, EBP, ESP, EDI, ESI, CS, DS
-
RET指令从函数被调用者返回到调用者(实质上是读取寄存器EBP所指的线程栈之处保存的函数返回地址并加载到IP寄存器)
cdecl 将函数返回值保存在寄存器中,所以 C 语言不支持多个返回值。另外,cdecl 是调用者负责清栈,因而可以实现可变参数的函数。如果是被调用者负责清理的话,无法实现可变参数的函数,但是编译代码的效率会高一点,因为清理栈的代码不用在每次调用的时候(编译器计算)生成一遍。(x86的ret指令允许一个可选的16位参数说明栈字节数,用来在返回给调用者之前解堆栈。代码类似ret 12
这样,如果遇到这样的汇编代码,说明是被调用者清栈。)
注意,虽然 C 语言 里都是借助寄存器传递返回值,但是返回值大小不同时有不同的处理情形。若小于4字节,返回值存入eax寄存器,由函数调用方读取eax。若返回值5到8字节,采用eax和edx联合返回。若大于8个字节,首先在栈上额外开辟一部分空间temp,将temp对象的地址做为隐藏参数入栈。函数返回时将数据拷贝给temp对象,并将temp对象的地址用寄存器eax传出。调用方从eax指向的temp对象拷贝内容。
可以看到,设计一个编程语言的特性时,需要为其选择合适调用惯例才能在底层实现这些特性。(调用惯例是编程语言的编译器选择的,同样的语言不同的编译器可能会选择实现不同的调用惯例)
一次典型的 C 函数调用过程
在caller里:
- 将实参从右至左压栈(X86-64下是:将实参写入寄存器,如果实参超过 6 个,超出的从右至左压栈)
- 执行 call 指令(会将返回地址压栈,并跳转到 callee 入口)
进入callee里:
- push ebp; mov ebp,esp; 此时EBP和ESP已经分别表示callee的栈底和栈顶了。之后 EBP 的值会保持固定。此后局部变量和临时存储都可以通过基准指针EBP加偏移量找到了。
- sub xxx, esp; 栈顶下移,为callee分配空间,用于存放局部变量等。分配的内存单元可以通过 EBP - K 或者 ESP + K 得到地址访问。
- 将某些寄存器的值压栈(可能)
- callee执行
- 将某些寄存器值弹出栈(可能)
- mov esp,ebp; pop ebp; (这两条指令也可以用 leave 指令替代)此时 EBP 和 ESP 回到了进入callee之前的状态,即分别表示caller的栈底和栈顶状态。
- 执行 ret 指令
回到了caller里的代码
int add(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7, int arg8) {
return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8;
}
int main() {
int i = add(1, 2, 3 , 4, 5, 6, 7, 8);
}
x86版汇编
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 10, 14 sdk_version 10, 14
.globl _add ## -- Begin function add
.p2align 4, 0x90
_add: ## @add
.cfi_startproc
## %bb.0:
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset %ebp, -8
movl %esp, %ebp
.cfi_def_cfa_register %ebp
pushl %ebx
pushl %edi
pushl %esi
subl $32, %esp
.cfi_offset %esi, -20
.cfi_offset %edi, -16
.cfi_offset %ebx, -12
movl 36(%ebp), %eax
movl 32(%ebp), %ecx
movl 28(%ebp), %edx
movl 24(%ebp), %esi
movl 20(%ebp), %edi
movl 16(%ebp), %ebx
movl %eax, -16(%ebp) ## 4-byte Spill
movl 12(%ebp), %eax
movl %eax, -20(%ebp) ## 4-byte Spill
movl 8(%ebp), %eax
movl %eax, -24(%ebp) ## 4-byte Spill
movl 8(%ebp), %eax
addl 12(%ebp), %eax
addl 16(%ebp), %eax
addl 20(%ebp), %eax
addl 24(%ebp), %eax
addl 28(%ebp), %eax
addl 32(%ebp), %eax
addl 36(%ebp), %eax
movl %ebx, -28(%ebp) ## 4-byte Spill
movl %ecx, -32(%ebp) ## 4-byte Spill
movl %edx, -36(%ebp) ## 4-byte Spill
movl %esi, -40(%ebp) ## 4-byte Spill
movl %edi, -44(%ebp) ## 4-byte Spill
addl $32, %esp
popl %esi
popl %edi
popl %ebx
popl %ebp
retl
.cfi_endproc
## -- End function
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset %ebp, -8
movl %esp, %ebp
.cfi_def_cfa_register %ebp
subl $40, %esp
movl $1, (%esp)
movl $2, 4(%esp)
movl $3, 8(%esp)
movl $4, 12(%esp)
movl $5, 16(%esp)
movl $6, 20(%esp)
movl $7, 24(%esp)
movl $8, 28(%esp)
calll _add
xorl %ecx, %ecx
movl %eax, -4(%ebp)
movl %ecx, %eax
addl $40, %esp
popl %ebp
retl
.cfi_endproc
## -- End function
.subsections_via_symbols
x86-64版汇编
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 10, 14 sdk_version 10, 14
.globl _add ## -- Begin function add
.p2align 4, 0x90
_add: ## @add
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl 24(%rbp), %eax
movl 16(%rbp), %r10d
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl %edx, -12(%rbp)
movl %ecx, -16(%rbp)
movl %r8d, -20(%rbp)
movl %r9d, -24(%rbp)
movl -4(%rbp), %ecx
addl -8(%rbp), %ecx
addl -12(%rbp), %ecx
addl -16(%rbp), %ecx
addl -20(%rbp), %ecx
addl -24(%rbp), %ecx
addl 16(%rbp), %ecx
addl 24(%rbp), %ecx
movl %eax, -28(%rbp) ## 4-byte Spill
movl %ecx, %eax
movl %r10d, -32(%rbp) ## 4-byte Spill
popq %rbp
retq
.cfi_endproc
## -- End function
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $32, %rsp
movl $1, %edi
movl $2, %esi
movl $3, %edx
movl $4, %ecx
movl $5, %r8d
movl $6, %r9d
movl $7, (%rsp)
movl $8, 8(%rsp)
callq _add
xorl %ecx, %ecx
movl %eax, -4(%rbp)
movl %ecx, %eax
addq $32, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
可以看到 Clang 编译出的X86目标代码并不使用寄存器传递参数,而X86-64目标代码里,使用寄存器传递前六个参数。
一次典型的Go函数调用过程
Go 选择的调用惯例是:
- 参数完全通过栈传递,从参数列表的右至左压栈
- 返回值通过栈传递,返回值的栈空间在参数之前,即返回值在更接近caller栈底的位置
- caller负责清理栈
package main
func main() {
add(1,2)
}
//go:noinline
func add(a , b int) int {
c := 3
d := a + b + c
return d
}
TEXT main.main(SB) /Users/user/go/src/test/main.go
main.go:4 0x104ea20 65488b0c2530000000 MOVQ GS:0x30, CX
main.go:4 0x104ea29 483b6110 CMPQ 0x10(CX), SP
main.go:4 0x104ea2d 762e JBE 0x104ea5d
main.go:4 0x104ea2f 4883ec20 SUBQ $0x20, SP ; 增加 32 bytes 的栈空间(四个 qword,8个bytes 为一个 qword)
main.go:4 0x104ea33 48896c2418 MOVQ BP, 0x18(SP) ; 将 BP 的值写入到刚分配的栈空间的第一个qword
main.go:4 0x104ea38 488d6c2418 LEAQ 0x18(SP), BP ; 将刚分配的栈空间的第一个字的地址赋值给BP(即BP此时指向了刚才存放旧BP值的地址)
main.go:5 0x104ea3d 48c7042401000000 MOVQ $0x1, 0(SP); 将给add函数的第一个实参值1 写入到刚分配栈空间的最后一个qword
main.go:5 0x104ea45 48c744240802000000 MOVQ $0x2, 0x8(SP); 将给add函数的第二个实参值2 写入到刚分配栈空间的第三个qword。第二个 qword 没有用到,实际上是给callee用来存放返回值的。
main.go:5 0x104ea4e e81d000000 CALL main.add(SB); 调用 add 函数
main.go:6 0x104ea53 488b6c2418 MOVQ 0x18(SP), BP; 将从栈里第四个qword将旧的BP值取回赋值到BP
main.go:6 0x104ea58 4883c420 ADDQ $0x20, SP; 增加SP的值,栈收缩,收回 32 bytes的栈空间
main.go:6 0x104ea5c c3 RET
TEXT main.add(SB) /Users/user/go/src/test/main.go
main.go:11 0x104ea70 4883ec18 SUBQ $0x18, SP; 分配 24 bytes 的栈空间(3 个 qword)。
main.go:11 0x104ea74 48896c2410 MOVQ BP, 0x10(SP); 将 BP值 写入第一个qword
main.go:11 0x104ea79 488d6c2410 LEAQ 0x10(SP), BP; 将刚分配的24 bytes 栈空间的第一个字的地址赋值给BP(即BP此时指向了刚才存放旧BP值的地址)
main.go:11 0x104ea7e 48c744243000000000 MOVQ $0x0, 0x30(SP);将存放返回值的地址清零,0x30(SP) 对应的内存位置是上一段 main.main 里分配的栈空间的第二个qword。
main.go:12 0x104ea87 48c744240803000000 MOVQ $0x3, 0x8(SP); 对应 c := 3 这行代码。局部变量 c 对应的是栈上内存。3 被写入到刚分配的 24 bytes 空间的第二个qword。
main.go:13 0x104ea90 488b442420 MOVQ 0x20(SP), AX; 将add的实参 1 写入到AX 寄存器。
main.go:13 0x104ea95 4803442428 ADDQ 0x28(SP), AX; 将add的实参 2 增加到 AX 寄存器。
main.go:13 0x104ea9a 4883c003 ADDQ $0x3, AX; 将局部变量值 3 增加到 AX 寄存器
main.go:13 0x104ea9e 48890424 MOVQ AX, 0(SP); 将 AX 的值(计算结果) 写入到刚分配的 24 bytes 空间的第三个qword。(对应代码 d := a + b + c)
main.go:14 0x104eaa2 4889442430 MOVQ AX, 0x30(SP); 将 AX 的值写入到main里为返回值留的栈空间(main里分配的32 bytes 中的第二个 qword)
main.go:14 0x104eaa7 488b6c2410 MOVQ 0x10(SP), BP; 恢复BP的值为函数入口处保存的旧BP的值。
main.go:14 0x104eaac 4883c418 ADDQ $0x18, SP; 将 SP 增加三个字,收回add入口处分配的栈空间。
main.go:14 0x104eab0 c3 RET
函数调用过程中,栈的变化情况如图:
初始状态:
call add执行前栈状态:
进入add里之后栈状态:
add里ret执行前栈状态:
main里ret执行前栈状态:
可以看到 Go 的调用过程和 C 类似,区别在于 Go 的参数完全通过栈传递,Go 的返回值也是通过栈传递。对于每种数据类型在作为参数传递时的表现,可以测试一下:
不同数据类型作为参数时的传递方式
Go 基础数据类型的参数传递
package main
import (
"fmt"
"runtime/debug"
)
func main() {
str := "hello"
int8 := int8(8)
int64 := int64(64)
boolValue := true
ExampleStr(str)
ExampleBool(boolValue)
ExampleInt8(int8)