CALL & RET
在汇编语言中,CALL 和 RET 是实现函数(子程序)调用的核心指令。
你可以把它们想象成一对“传送门”:CALL 送你过去,并留下回家的路标;RET 则顺着路标带你回来。
一、 CALL 指令:出发与记路
CALL 指令的作用是跳转到指定的函数地址去执行,但在跳转之前,它必须做一件极其重要的事情:记录返回地址。
1. 执行 CALL 时,CPU 做了什么?
当你执行 CALL some_function 时,CPU 内部偷偷完成了两个动作:
1. 压栈返回地址:将 CALL 指令的下一条指令的地址(RIP)压入栈中(相当于 PUSH RIP)。
2. 跳转:将 RIP 修改为目标函数的首地址(相当于 JMP some_function)。
2. 为什么非要压栈地址?
如果没有这个动作,函数执行完了就不知道该回到主程序的哪里继续运行了。栈就像是一个“面包屑”路径,记录了你从哪里跳出来的。
二、 RET 指令:回家
RET 指令通常出现在函数的最后一行。
1. 执行 RET 时,CPU 做了什么?
它的动作非常简单,正好是 CALL 的逆过程:
1. 出栈地址:从栈顶弹出一个值(相当于 POP)。
2. 跳回:直接将这个值赋给 RIP,CPU 就会从这个地址开始执行。
三、 完整过程图解
假设我们有如下代码片段:
0x100: call my_func ; 指令在地址 0x100
0x105: mov rbx, rax ; 这是 CALL 的下一条指令,地址 0x105
...
my_func: ; 函数在地址 0x500
0x500: add rax, 10
0x505: ret
执行流程:
1. 运行到 0x100 (call):
* CPU 把 0x105 压入栈。
* RIP 变为 0x500。
2. 运行子程序内容:执行 add rax, 10。
3. 运行到 0x505 (ret):
* CPU 从栈顶弹出 0x105。
* RIP 变为 0x105。
4. 回到主程序:从 mov rbx, rax 继续执行。
四、 核心警告:栈平衡 (Stack Balance)
这是初学者最容易导致“程序崩溃”的地方。RET 永远只认“当前栈顶”的值。
如果在函数内部,你 PUSH 了一些东西但忘了 POP 出来,栈顶就被你弄乱了:
my_func:
push rbx ; 备份 rbx
add rax, rbx
; 哎呀!忘了 pop rbx
ret ; 此时栈顶是刚才 push 进去的 rbx 的值,而不是返回地址!
; CPU 会尝试跳转到 rbx 存储的那个数字代表的地址,通常会直接崩溃。
金科玉律:函数体内 PUSH 的次数必须等于 POP 的次数。
五、 函数调用的“潜规则”(调用约定)
在 64 位环境下(Linux/Windows),虽然 CALL 只管跳,但为了配合 CALL,我们通常遵循以下习惯:
- 参数传递:函数需要的参数通常放在
RDI,RSI,RDX,RCX,R8,R9(Linux 规则)。 - 返回值:函数执行的结果,按照惯例必须放在
RAX中。 - 保护现场:如果函数要用到
RBX,RBP,R12-R15,必须先PUSH它们,并在RET前POP回去。
六、 总结对比
| 特性 | CALL | RET |
|---|---|---|
| 本质动作 | PUSH (返回地址) + JMP | POP (返回地址) + JMP |
| 操作数 | 必须提供目标地址(Label) | 通常不需要操作数 |
| 栈的影响 | 栈顶指针 (RSP) 减少 8 | 栈顶指针 (RSP) 增加 8 |
| 出现位置 | 调用方(主程序) | 被调用方(函数末尾) |
七、 拓展:RET n 指令
在某些古老的 32 位代码或特定的系统调用中,你会看到 ret 4 或 ret 8。
* 这表示:在弹出返回地址后,再把 ESP 额外增加 \(n\) 个字节。
* 用途:这通常是为了在退出函数的同时,顺便清理掉通过栈传递的参数。
思考题:
如果我在一个函数内部写了两次 CALL 自己(递归),栈会发生什么变化?如果递归没有出口(死循环),会发生什么后果?(提示:这就叫“栈溢出” Stack Overflow)。