可以把栈想成内存里一段从高地址向低地址增长**的区域,用来存局部变量、函数返回地址、保存寄存器等。
x86-64 栈是向下生长的(从高地址往低地址延伸):
● rbp:栈帧基指针,指向当前函数栈帧的底部(高地址)
● rsp:栈指针,指向当前函数栈帧的顶部(低地址)
● 函数内的局部变量,都存在 rsp 和 rbp 之间的这段内存里,且往 rsp 方向(低地址)分布
形象的想 栈就像一堆摞的很高的盘子 盘子之间是想通的
1️⃣ 四个寄存器的身份
| 模式 | 栈顶指针 | 栈基指针(帧指针) |
|---|---|---|
| 64 位 | RSP(Stack Pointer) | RBP(Base Pointer) |
| 32 位 | ESP | EBP |
RSP/ESP:始终指向当前栈帧 最顶端(低地址),也就是最新一次 push 后的地址。 RBP/EBP:在函数调用时保存上一个栈帧的基址,方便通过固定偏移访问局部变量和参数文字描述
假设从 main 调用 foo,栈的关键步骤是
| 步骤 | 汇编动作 | RSP 变化 | RBP 变化 | 说明 |
|---|---|---|---|---|
| 进入 foo 前 | call foo | RSP 减 8,压入返回地址 | RBP 不变 | call 自动 push 返回地址 |
| 函数序言 | push rbp mov rbp, rsp | RSP 再减 8 出空间用来保存旧 RBP;RBP=当前 RSP | 建立新栈帧 | |
| 分配局部变量 | sub rsp, N | RSP 向下减 N | RBP 保持不变 | 预留栈空间 |
| 调用子函数/保存寄存器 | push rbx 等 | RSP 每 push 一次减 8 | RBP 仍不动 | |
| 函数结尾 | leave 即 mov rsp, rbp + pop rbp | RSP 还原到 栈基(rbp 所在处) 再弹出旧 RBP | 恢复上层 RBP | |
| 返回 | ret | 弹出返回地址到 RIP,RSP 加 8 | RBP = 上层 RBP | 栈帧销毁 |
(1)对于为什么 RSP 减 8:
这是因为在 x86-64 架构下,栈上的每个“槽”是按 8 字节(64 bit)对齐的,而 call、push 这些指令本质上就是:先让 RSP 减去一个 8 字节元素的大小,再把数据写入 [RSP] 所指的地址。
call 指令会把下一条要执行的指令地址也就是返回地址压入栈里。 在 64 位模式下,返回地址是 8 字节
push 的动作也是“栈顶指针先减去操作数宽度,再写值”。rbp 宽度是 64 bit → 8 字节,
(2)leave中的pop rbp是直接用原rbp覆盖了当时mov rbp,rsp的那个rbp
(3)ret=pop rip 取出最初存储的返回地址进 rip
且(rsp)=(rsp)+8 回去,销栈 附一张手写图
调用过程
这是函数调用的基础,函数调用的流程,简单的说就是
RSP永远指向 栈顶(更准确:指向当前栈帧里最低地址的已用位置)。
x86 栈通常 由高向低地址增长:压栈会让 RSP -= 8(64 位)
RBP(frame/base pointer):传统上的栈底。
进入函数把旧 RBP 保存起来,然后把 RBP 设为当前 RSP,这样可以用固定偏移访问局部变量和参数。
但注意 tips:现代编译器常常省略 frame pointer(-fomit-frame-pointer),此时 RBP 可能被当作普通寄存器用,或者根本不建立RBP 栈帧链。
一次典型调用:**caller → call → callee → ret **
x86-64 的前 6 个整型/指针参数用寄存器:
RDI, RSI, RDX, RCX, R8, R9
Caller 侧:调用函数
mov edi,7;a=7放到 RDI/EDI call foo;调用callfoo 做了 两件事:
第一步(压栈):把 返回地址(也就是 call 指令下一条指令的地址)压入栈中保存;
第二步(跳转):把 RIP 寄存器的值 直接设置为被调用函数(比如 foo)第一条指令的内存地址,CPU 从此开始执行该函数的代码。
栈变化为 :rsp-=8 为接下来入栈的返回地址腾空间
然后把返回地址压入 rsp 当前指向的新栈顶的位置
Callee 侧:函数序言
foo:push rbp mov rbp,rsp sub rsp,16...push rbp:包含 rsp-=8,把被调用函数的 rbp 地址保存在栈上
mov rbp , rsp:建立当前的栈帧基准,以当前的栈顶为栈底开始拓展栈
然后对 rsp 操作给被调用函数的局部变量和临时空间腾地方
Ret: 函数返回
mov rsp,rbp pop rbp (上面两条等价于leave) ret让 rsp=rbp,是直接丢弃了被调用函数在下面生长的新栈空间,
然后 pop rbp,先恢复 rbp 的值然后 rsp+=8
最后 ret 弹出返回地址:让 rip=[ rsp ], rsp+=8
特殊帧栈变化
push rbp mov rbp,rsp sub rsp,N...leave ret这是上面的 用 rbp 始终指向当前函数栈帧的基准点,
局部变量用 [rbp-…],参数/返回地址用 [rbp+…] 来表示和访问
但有的时候会省略掉 rbp 和这个过程
省略帧指针
省略之后 函数入口是
sub rsp,32...add rsp,32ret这个时候 rbp 变成了一个通用寄存器,编译器用 rsp 为基准或者把某些地址算出来放进寄存器
sub rsp,32mov DWORD PTR[rsp+12],edi;局部变量放在 rsp+offset——局部变量放在了 rsp,并用 rsp+来访问
sub rsp,32lea rax,[rsp+16];rax 当临时“frame base” mov[rax-4],edi—— 把 栈基准地址 算进另一个寄存器
这里把拓展出 rsp 的 32 字节一分为二,在正中间的[rsp+16] 作为栈基准存放进 rax,此时的 rax 和原本的 rbp 作用差不多
下面就用 [rax-4] 这样的 rax 基准来访问数据
这时的 rbp 就是一个普通的寄存器 用来干什么都行
不会出现大量 [rbp-xx] / [rbp+xx]
叶子函数(leaf function)
叶子函数是不再调用别的函数 没有 call,
并且局部变量和计算结果直接能放寄存器 不用放在栈里 自然用不到栈和 rbp
foo:lea eax,[rdi+1]ret这里 foo 没有 push rbp 和 mov rbp,rsp
这种函数只需计算 lea eax, [rdi+1] 用寄存器完成
栈对齐导致的 rsp 调整
通常要求在 call 前满足
于是经常有:sub rsp, 8 / sub rsp, 40
或者在 call 前临时 sub/add rsp, 8 做对齐这种操作
这不是在建拆栈 只是在做栈对齐
tail call 尾调用优化
尾调用就是函数的最后一条语句是调用另一个函数并直接返回其结果
正常调用是 f call g 建立帧栈,g 执行后 ret 回到 f,f 再 ret 回到调用者
这里面有两层帧栈
所以会把 call g 优化成 **jmp g **不压入 f 的返回地址也不在调用 g 的时候建栈
直接 jmp 跳转到 g 的入口执行 ,g 执行完 ret 回到 f
这样相当于 g 没有再建立帧栈 而是利用了 f 的栈帧