Skip to content

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,我们通常遵循以下习惯:

  1. 参数传递:函数需要的参数通常放在 RDI, RSI, RDX, RCX, R8, R9(Linux 规则)。
  2. 返回值:函数执行的结果,按照惯例必须放在 RAX 中。
  3. 保护现场:如果函数要用到 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)。