跳转至

函数调用

在汇编代码中,函数是通过 CALL 指令执行的隔离命令块。CALL 指令只接受函数地址作为操作数,把返回地址压入栈,并把 EIP 设为它的操作数值。下面的伪代码展示了 CALL 的实际效果,左边是十六进制内存地址:

0x1: CALL EAX
0x2: ...

执行 CALL EAX 时,下一个地址被压入栈,EIP 被设为 EAX,表明 CALL 本质上是 PUSH 加 JMP。下面的伪代码强调了这一点:

0x1: PUSH 3h
0x2: JMP EAX
0x3: ...

虽然 PUSH 指令和要执行的代码之间有一个额外地址,结果相同:在 EAX 处的代码块执行前,分支后面的代码地址被压入栈。这样做的原因是让被调用者(被调用的函数)在返回时知道跳回调用者(发起调用的函数)的哪里。

如果调用没有参数的函数,只需要 CALL 命令。但如果被调用者接受参数,参数必须首先按相反顺序压入栈。下面的伪代码展示了带三个参数的函数调用可能的样子:

PUSH 300h ; arg3
PUSH 200h ; arg2
CALL ECX ; 调用

被调用者执行时,栈顶包含指向调用后代码的返回地址。第一个参数 0x100 在栈上位于返回地址下方。第二个参数 0x200 在其下方,接着是第三个参数 0x300。被调用者建栈帧,用 EBP 的内存偏移量引用每个参数。

被调用者执行完后,它恢复调用者的栈帧,执行 RET,把返回地址弹出栈并跳转到它。

由于参数不是被调用者栈帧的一部分,它们在 RET 执行后仍留在栈上。如果调用者负责清理栈,它会在 CALL ECX 完成后立即给 ESP 加 12(3 个参数,每个 4 字节)。如果被调用者负责,它通过执行 RET 12 而不是 RET 来清理。这个责任由被调用者的调用约定决定。

函数的调用约定告诉编译器汇编代码应该如何传递参数、存储实例指针、传达返回值以及清理栈。不同编译器有不同调用约定,但表 4-12 中列出的四个是游戏黑客可能遇到的仅有的四个。

指令清理者说明调用者Visual Studio 中的默认约定。被调用者Win32 API 函数使用的约定。被调用者前两个 DWORD(或更小)参数在 ECX 和 EDX 中传递。被调用者用于成员函数。类实例指针在 ECX 中传递。

表 4-12 中的「指令」列给出调用约定的名称,「清理者」列告诉你给定指令下由调用者还是被调用者负责清理栈。就这四个调用约定而言,参数总是从右到左压入,返回值总是存储在 EAX 中。这是标准,但不是规则;其他调用约定可能不同。