函数调用时的流程,参数如何传入以及寄存器、栈的变化
函数调用前的准备 (Caller)
在调用函数前,调用方(caller)会进行以下准备:
- 参数传递:
- 寄存器优先:对于前几个参数(通常是前4个,具体数量取决于调用约定),它们会被放入特定的通用寄存器中。在 x64
fastcall
约定下,参数会依次放入 RCX, RDX, R8, R9 寄存器。这种方式非常快,因为它避免了昂贵的内存操作 - 栈传递:如果参数数量超过了寄存器的限制,剩下的参数就会被从右到左(或从左到右,取决于具体约定)压入栈中
- 寄存器优先:对于前几个参数(通常是前4个,具体数量取决于调用约定),它们会被放入特定的通用寄存器中。在 x64
- 栈帧对齐:为了保证性能,特别是对于一些高级指令集(如 SSE、AVX),栈帧需要对齐到特定的字节边界(通常是16字节)。调用方会确保在调用
call
指令前,栈指针 RSP 是对齐的 - 调用指令:最后,调用方会执行
call
指令。call
指令有两个主要作用:- 将下一条指令的地址(即函数的返回地址)压入栈中
- 跳转到被调用函数(callee)的入口地址
函数执行过程中的变化 (Callee)
一旦 call
指令将控制权转移给被调用函数(callee),它会做以下几件事:
- 保存旧栈帧:函数的第一条指令通常是
push rbp
。这将调用方函数的基址寄存器 RBP 的值压入栈中,保存了调用方的栈帧信息 - 建立新栈帧:接下来,函数会执行
mov rbp, rsp
。这会将栈指针 RSP 的当前值复制到 RBP,从而建立起当前函数的栈帧。从现在开始,所有局部变量和参数都可以通过 RBP 加上或减去一个偏移量来访问 - 局部变量分配:如果函数有局部变量,它会通过
sub rsp, [size]
指令在栈上分配空间。这个操作会使栈指针 RSP 向低地址方向移动,为局部变量腾出空间 - 保存非易失性寄存器:
- 易失性寄存器(Volatile / Caller-saved):
RAX
,RCX
,RDX
,R8
-R11
等。这些寄存器被认为是临时的,调用方不指望它们在函数返回后保持原值 - 非易失性寄存器(Non-volatile / Callee-saved):
RBX
,RBP
,RDI
,RSI
,R12
-R15
等。这些寄存器被认为需要保持其值不变。如果被调用函数需要使用它们,就必须在使用前将它们的值压入栈中,并在返回前恢复
- 易失性寄存器(Volatile / Caller-saved):
- 执行函数主体:现在,函数开始执行其核心逻辑。它可以使用传递进来的参数(通过寄存器或栈),也可以使用自己栈上的局部变量
函数返回时的清理 (Callee & Caller)
当函数执行完毕,准备返回时,会进行以下清理工作:
- 恢复栈指针:函数会执行
mov rsp, rbp
。这个指令会将 RSP 的值恢复到进入函数时的状态,从而释放所有局部变量所占用的栈空间 - 恢复旧栈帧:接着,函数会执行
pop rbp
。这会将调用方保存的 RBP 值从栈中弹出并恢复到 RBP 寄存器中,从而恢复到调用方的栈帧 - 返回指令:最后,函数执行
ret
指令。ret
指令的作用是:- 从栈中弹出返回地址
- 将 RIP(指令指针寄存器)的值设置为弹出的返回地址,从而将程序的控制权交还给调用方的下一条指令
- 参数清理:在某些调用约定(如
cdecl
)中,调用方需要负责清理栈上用于参数传递的空间。然而在fastcall
等现代约定中,由于参数主要通过寄存器传递,这个步骤变得简化。如果参数是通过栈传递的,ret
指令后面通常会带一个立即数,告诉 CPU 在返回前额外弹出多少字节的栈空间