开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第22天,点击查看活动详情
函数调用基本概念
1)调用者caller与被调用者callee
如果一个函数调用另外一个函数,那么该函数被称为调用者函数,也叫做caller,而被调用的函数称为被调用者函数,也叫做callee。比如函数main中调用sum函数,那么main就是caller,而sum函数就是callee。
2)函数栈和函数栈帧
函数执行时需要有足够的内存空间,供它存放局部变量、参数等数据,这段空间对应到虚拟地址空间的栈,也即函数栈。在现代主流机器架构上(例如x86)中,栈都是向下生长的。栈的增长方向是从高位地址到地位地址向下进行增长。
分配给一个个函数的栈空间被称为“函数栈帧”。Go语言中函数栈帧布局是这样的:先是调用者caller栈基地址,然后是调用者函数caller的局部变量、接着是被调用函数callee的返回值和参数。然后是被调用者callee的栈帧。
注意,栈和栈帧是不一样的。在一个函数调用链中,比如函数A调用B,B调用C,则在函数栈上,A的栈帧在上面,下面依次是B、C的函数栈帧。Go1.17以前的版本,函数栈空间布局如下:
通过在centos8上安装gvm,可以方便切换多个Go版本测试不同版本的特性。
gvm地址:github.com/moovweb/gvm
执行:
gvm list
显示gvm安装的go版本列表:
gvm gos (installed) go1.14.2 go1.15.14 go1.15.7 go1.16.1 go1.16.13 go1.17.1 go1.18 go1.18.1 system
1)Go15版本函数调用分析
执行
gvm use go1.15.14
切换到 go1.15.14版本,我们定义一个函数调用:
package mainfunc main() { var r1, r2, r3, r4, r5, r6, r7 int64 = 1, 2, 3, 4, 5, 6, 7 A(r1, r2, r3, r4, r5, r6, r7)}func A(p1, p2, p3, p4, p5, p6, p7 int64) int64 { return p1 + p2 + p3 + p4 + p5 + p6 + p7}
使用命令打印出main.go汇编:
GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go
从汇编代码的注释中,我们可以清楚的看到,main函数调用A函数的局部变量、入参在栈中的存储位置。main函数通过 ADDQ $-128, SP 指令,一共在栈上分配了128字节的内存空间:
SP+64SP+112 指向的56个栈空间,存储的是r1r7这7个main函数的局部变量;SP+56 该地址接收函数A的返回值;SP~SP+48 指向的56个字节空间,用来存放A函数的 7 个入参。
综上,在Go1.15.14版本的函数调用中:参数完全通过栈传递;参数列表从右至左依次压栈。当程序准备好函数的入参之后,会调用汇编指令CALL "".A(SB),这个指令首先会将 main 的返回地址 (8 bytes) 存入栈中,然后改变当前的栈指针 SP 并执行 A 函数的汇编指令。栈空间变为:
在A函数栈中,我们可以看到:程序先把r1r9参数分别从寄存器赋值到main栈帧的入参地址部分,即当前的SP+48SP+112位。**其实这跟GO1.15.14的函数调用参数传递过程差不多,只不过一个是在caller中做参数从寄存器拷贝到栈上,一个是在callee中做参数从寄存器拷贝到栈上。**而且前者只使用了AX一个寄存器,后者使用了9个不同的寄存器。
于为什么Go1.17.1函数调用的参数传递开始基于寄存器进行传递,原因无外乎。
第一,**CPU访问寄存器比访问栈要快的多。**函数调用通过寄存器传参比栈传参,性能要高5%。
第二,早期Go版本为了降低实现的复杂度,统一使用栈传递参数和返回值,不惜牺牲函数调用的性能。
第三,Go从1.17.1版本,开始支持多ABI(application binary interface 应用程序二进制接口,规定了程序在机器层面的操作规范,主要包括调用规约calling convention),主要是两个ABI:一个是老版本Go采用的平台通用ABI0,一个是Go独特的ABIInternal,前者遵循平台通用的函数调用约定,实现简单,不用担心底层cpu架构寄存器的差异;后者可以指定特定的函数调用规范,可以针对特定性能瓶颈进行优化,在多个Go版本之间可以迭代,灵活性强,支持寄存器传参提升性能。
所谓“调用规约(calling convention)”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。