函数和栈
在程序运行的过程中,会涉及到对函数的调用,调用时IP寄存器会指向被调用函数的地址,函数返回后继续执行本函数剩下的代码
程序执行单元(线程或者协程)在执行过程中需要记录程序上下文的数据结构,包括局部变量,BP,参数,返回值等。程序用栈帧这种结构来记录这些信息
要使得程序正常运行,需要时刻知道两个信息:
- 当前栈帧在哪?
- 当前在执行什么指令?
这两个信息存在使用BP,SP和IP中:
-
BP:存储函数调用堆栈基址指针的寄存器,即当前函数栈帧是从哪里开始的
-
SP:存储当前函数栈顶的位置,也就是当前栈的内存分配到了哪里
- BP和SP寄存器表示一个函数栈的开始和结束
-
IP:指向下一条指令地址
栈帧
go的栈帧结构如下:
栈帧由以下5个部分组成:
- Ret address:当前函数执行结束后,跳转到哪个位置继续执行
- Caller bp:调用者bp寄存器的值
- Local var:本地变量
- Callee ret:被调用方的返回值
- Callee param:被调用方需要的参数
每个函数的汇编中,都有如下的样板代码:
TEXT "".funXXX(SB), ABIInternal, $stackFrameSize-X
SUBQ $stackFrameSize, SP // 将SP下移stackFrameSize大小,即开辟栈帧
MOVQ BP, stackFrameSize - 8(SP) // 将callerbp的值保存到栈帧开头的8个字节处
LEAQ stackFrameSize - 8(SP), BP // 将当前函数的bp值放入bp寄存器
...
CALL "".funcXXXX(SB) // 调用其他函数
...
MOVQ stackFrameSize - 8(SP), BP // 将callerbp恢复到bp寄存器
ADDQ $stackFrameSize, SP // 将sp上移stackFrameSize大小,即释放栈帧
RET // 返回,将SP位置的值放入ip寄存器,并且sp上移8位
下面以一个简单的go代码来分析具体的函数调用过程
汇编
假设有如下go代码,主函数main,调用函数cal,代码比较简单
package main
func main() {
cal(1, 2)
}
func cal(a, b int) int {
c := 1
return a + b + c
}
go版本:1.14.10
执行命令:go tool compile -S -N -l main.go生成如下的汇编代码,其中:
- -S:输出汇编代码
- -N:禁止优化
- -l:禁止内联
如果不加上 -N -l 的参数,编译器会对汇编代码进行优化,编译结果会跟这里的差别很大
go中几个常见的汇编指令:
- CALL funa:将下一条指令的地址压栈,跳转到函数funa的起始地址
- RET:将栈顶元素弹出到IP寄存器,即SP = SP + 8,跳转到之前压入的下一条指令位置
Cal
TEXT "".main(SB), ABIInternal, $32-0
SUBQ $32, SP
MOVQ BP, 24(SP)
LEAQ 24(SP), BP
MOVQ $1, (SP)
MOVQ $2, 8(SP)
CALL "".cal(SB)
MOVQ 24(SP), BP
ADDQ $32, SP
RET
-
开辟栈空间,保存/设置BP,SP指针
SUBQ $32, SP
:SP寄存器的值减32,为main方法分配32字节的空间MOVQ BP, 24(SP)
:将BP寄存器的值,即上一个栈的bp保存到SP+24位置的栈空间上LEAQ 24(SP), BP
:将SP+24的地址保存到BP寄存器,设置当前栈的BP
-
准备参数,调用cal方法:
MOVQ $1, (SP)
:将立即数1放到栈顶位置MOVQ $2, 8(SP)
:将立即数2放到栈顶+8的位置CALL "".cal(SB)
:调用cal方法
-
销毁栈帧`:
MOVQ 24(SP), BP
:恢复上一个栈的BP,将SP+24位置的值放到BP寄存器ADDQ $32, SP:SP+=32
:"销毁"当前栈帧RET
:方法返回
在main函数中一共分配了40字节的栈空间:
- 头8个字节存放caller BP
- 接下来8个字节用于接收cal方法的返回值
- 剩下16分字节用于设置调用cal方法的两个参数
doCal
go代码:
func cal(a, b int) int {
c := 1
return a + b + c
}
对应的汇编:
TEXT "".cal(SB), NOSPLIT|ABIInternal, $16-24
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
MOVQ $0, "".~r2+40(SP)
MOVQ $1, "".c(SP)
MOVQ "".a+24(SP), AX
ADDQ "".b+32(SP), AX
INCQ AX
MOVQ AX, "".~r2+40(SP)
MOVQ 8(SP), BP
ADDQ $16, SP
RET
-
第一行
- $16:为cal方法分配了16自己的栈帧
- 24:参数和返回加起来有24字节
-
2,3,4行:开辟16字节的栈空间,保存caller bp
-
MOVQ $0, "".~r2+40(SP)
:清空返回位置 -
MOVQ $1, "".c(SP)
:将局部变量c放到SP位置 -
MOVQ "".a+24(SP), AX
:将SP+8位置的数放到AX -
ADDQ "".b+32(SP), AX
:将AX的值加上SP+32位置的数,再放到AX,此时AX = a + b -
INCQ AX
:AX = AX + 1,此时AX = a + b + c- 编译器做了优化,用INCQ指令实现 + c
-
MOVQ AX, "".~r2+40(SP)
:将AX的值放到SP+40位置 -
最后3行:恢复caller bp,销毁栈空间,返回到caller
cal栈帧如下:
可以看出:
- go中传递参数和返回值都用的栈,这样能方便传递多个返回值
- 函数的入参和返回值的空间都在caller的栈帧中分配