调用约定是一组必须由调用方(调用过程的代码)和被调用方(被调用的过程)执行的步骤。高级语言处理所有复杂的调用约定,人们可以简单地在函数之间传递参数,而不用关心参数是如何传递的。当在程序集中编程时,被调用方需要知道调用方在哪里或者如何传递函数的参数,并且调用方需要知道被调用方将如何返回答案。在程序集级别,调用约定完全不受限制,程序员可以自由定义自己的约定。Visual Studio 附带的 C++ 编译器使用 C 调用约定,因此在对程序集例程进行编程时采用这种约定通常是有利的,尤其是如果例程是从 C++ 调用的,或者它们本身调用用 C++ 编写的过程。
栈是内存的一部分,用作将参数传递给函数的半自动后进先出数据结构。它允许函数调用是递归的,处理参数传递,返回地址,并用于临时保存寄存器或其他值。使用PUSH
和CALL
指令将值添加到栈中,并使用POP
和RET
指令以相反的顺序将其从栈中移除。栈用于将地址保存在。函数调用方的代码段,这样当子程序完成时,返回地址可以从栈中弹出(使用RET
指令),控制可以从调用方在代码中的位置恢复。
栈由一个特殊的指针RSP
(栈指针)指向。指令PUSH
和POP
都将MOV
数据指向RSP
所指向的点,并且递减(PUSH
)或递增(POP
)栈指针,使得下一个要推送的值将在栈段中的下一个地址完成。
过去,传递参数和保存返回地址完全是栈的任务,但是在 x64 中,一些参数是通过寄存器传递的。通常避免使用PUSH
和POP
指令,而是手动增加和减少栈指针,并使用MOV
指令。手动操作栈在 x64 中很常见,因为PUSH
和POP
指令不允许任何大小的操作数。使用ADD
和SUB
设置RSP
的位置,使用MOV
往往更快,而不是重复调用PUSH
。栈只是内存中另一个标记为读/写的段。栈和程序中任何其他段的唯一区别是栈指针(RSP
)恰好指向它。
在 Visual Studio 使用的 C 调用约定中,一些寄存器应该在函数调用中保持相同的值。函数不应在返回前不恢复原始值的情况下更改代码中这些寄存器的值。这些寄存器称为非暂存寄存器。
表 7:寄存器的暂存/非暂存状态
注册 | 刮擦/非刮擦 |
---|---|
RAX | 擦 |
RBX | 无划痕 |
RCX | 擦 |
旋风炸药 | 擦 |
重复性劳损 | 无划痕 |
推荐的日摄入量 | 无划痕 |
RBP | 无划痕 |
RSP | 无划痕 |
R8 至 R11 | 擦 |
R12 至 R15 | 无划痕 |
XMM0 至 XMM5 | 擦 |
XMM6 至 XMM15 | 无划痕 |
ST(0)至 ST(7) | 擦 |
MM0 至 MM7 | 擦 |
YMM0 至 YMM5 | 擦 |
YMM6 至 YMM15 | 无划痕 |
一些寄存器可以由子过程或函数随意修改,并且调用者不期望子过程会保持任何特定的值。这些寄存器称为暂存寄存器。
在代码中使用非暂存寄存器没有错。以下示例使用 RBX 和 RSI 将 100 到 1 的值相加(RBX 和 RSI 都是非刮擦的)。需要注意的重要一点是,非暂存寄存器在过程开始时被推入栈,并在返回之前弹出。
Sum100 proc
push rbx ; Save RBX
push rsi ; Save RSI
xor rsi, rsi
mov rbx, 100
MyLoop:
add rsi, rbx
dec rbx
jnz MyLoop
mov rax, rsi
pop rsi ; Restore RSI
pop rbx ; Restore RBX
ret
Sum100 endp
push 指令将寄存器的值保存到栈中,pop 指令将其再次弹出到寄存器中。当子过程返回时,所有非暂存寄存器将具有与调用子过程时完全相同的值。
使用暂存寄存器通常比推送和弹出非暂存寄存器更好。推送和弹出需要读写内存,这总是比使用寄存器慢。
当我们在 x64 应用程序中使用 C 调用约定指定一个过程时,微软的 C++ 编译器使用 fastcall,这意味着一些参数是通过寄存器传递的,而不是使用栈。只有前四个参数通过寄存器传递。任何附加参数都通过栈传递。
表 8:整数和浮点参数
参数号 | If 整数 | 如果浮动 |
---|---|---|
one | RCX | XMM0 |
Two | 旋风炸药 | XMM1 |
three | R8 | XMM2 |
four | R9 | XMM3 |
整数参数在 RCX、RDX、R8 和 R9 中传递,而浮点参数使用前四个 SSE 寄存器(XMM0 到 XMM3)。使用适当大小的寄存器,这样如果您传递整数(32 位值),那么将使用 ECX、EDX、R8D 和 R9D。如果您正在传递字节,那么将使用 CL、DL、R8B 和 R9B。同样,如果浮点参数是 32 位(C++ 中的浮点),它将占用相应 SSE 寄存器的最低 32 位,如果它是 64 位(C++ 双精度),那么它将占用 SSE 寄存器的最低 64 位。
| | 注意:第一个参数总是在 RCX 或 XMM0 中传递;第二个总是在 RDX 或 XMM2 中传递。如果第一个参数是整数,第二个参数是浮点数,那么第二个参数将被传入 XMM1,XMM0 将不被使用。如果第一个参数是浮点值,第二个参数是整数,那么第二个参数将在 RDX 中传递,RCX 将不会被使用。 |
例如,考虑以下 C++ 函数原型:
int SomeProc(int a, int b, float c, int d);
这个过程需要四个参数,它们是浮点或整数值,所以它们都将通过寄存器传递(只有第 5 个和后续参数需要栈)。
以下是 C++ 编译器将如何传递参数,或者如果您从程序集调用 C++ 过程,它将如何期望您传递参数:
- ECX 将通过一项法案
- b 将在 EDX 通过
- c 将在 XMM2 的最低 dword 中传递
- d 将在 R9D 中通过
RAX 总是返回整数值,XMM0 总是返回浮点值。指针或引用也总是在 RAX 返回。
以下示例从调用方获取两个整数参数,并将它们相加,在 RAX 返回结果:
; First parameter is passed in ECX, second is passed in
EDX
; The prototype would be something like: int AddInts(int
a, int b);
AddInts proc
add ecx, edx ; Add the second parameter's value to
the first
mov eax, ecx ; Place this result into EAX for
return
ret ; Caller will read EAX for the return
value
AddInts endp
过去,所有参数都是通过栈传递给过程的。在 C 语言的调用约定中,调用方仍然必须分配空白的栈空间,就像在栈上传递参数一样,即使值是在寄存器中传递的。调用函数或子过程时,在栈上创建的代替传递参数的空间称为影子空间。如果参数没有被放入寄存器中,它就是传递参数的空间。
不管传递的参数数量是多少,影子空间的大小应该不少于 32 字节。即使传递一个字节,也要在栈上保留 32 个字节。
| | 注意:这种对栈的浪费可能是因为 C++ 编译器更容易编程。这种编程水平的许多东西几乎没有清晰的文档或解释。微软 C 调用约定使用阴影空间的确切原因尚不清楚。 |
要调用具有以下原型的函数,请使用以下命令:
void Uppercase(char a);
C++ 编译器将使用如下内容:
sub rsp, 20h ; Make 32 bytes of shadow space
mov cl, 'a' ; Move parameter in to cl
call Uppercase ; Call the function
add rsp, 20h ; Deallocate the shadow space from the stack
要调用具有六个参数的函数,请使用以下命令:
void Sum(int a, int b, int c, int d, int e, int f);
某些参数必须在栈上传递;只有前四个将使用寄存器传递。
sub rsp 20h ; Allocate 32 bytes of shadow space
mov ecx, a ; Move the four register parameters into
their registers
mov edx, b
mov r8d, c
mov r9d, d
push f ; Push the remaining parameters onto the stack
push e
call Sum ; Call the function
add rsp, 28h; Delete shadow space and the parameters we
passed via the stack
| | 注意:当子程序返回时,通过栈传递的参数实际上不会从内存中移除。栈指针简单地递增,使得新推送的参数将覆盖旧值。 |
要从外部程序集文件调用用 C++ 编写的函数,C++ 和程序集都必须有一个extern
关键字来表示该函数在外部可用。
// C++ File:
#include <iostream>
using namespace std;
extern "C" void
SubProc();
extern "C" int
SumIntegers(int a, int
b, int c, int
d, int e, int
f)
{
return a + b + c +
d + e + f;
}
int main()
{
SubProc();
return 0;
}
; Assembly file in the same project
extern SumIntegers: proc
.code
SubProc proc
push 60 ; Push two params that don't
push 50 ; fit int regs. Opposite
order!
sub rsp, 20h ; Allocate shadow space
mov ecx, 10 ; Move the first four params
mov edx, 20 ; into their regs in any order
mov r8d, 30
mov r9d, 40
call SumIntegers
add rsp, 30h ; Deallocate shadow space
; and space from params
; this is 6x8=48 bytes.
ret
SubProc endp
End
当参数被推送到栈上时,栈会减少。参数从右向左推送(与函数的 C++ 原型的顺序相反)。
字节和数据字不能被推到栈上,因为PUSH
指令只取一个字或 qword 作为其操作数。由于这个原因,栈指针可以递减到它在分配影子空间的指令中的最终位置(这是操作数的数量乘以 8)。然后,可以用MOV
指令代替推送,将参数移动到栈段中相应的位置。
第一个参数移动到 RCX,然后第二个参数移动到 RDX,第三个参数移动到 R8,第四个参数移动到 R9。随后的参数从 RSP+20h 开始移动到内存中,然后是 RSP+28h、RSP+30h,以此类推,为栈上的每个参数留下 8 字节的空间,无论它们是 qwords 还是 bytes。每个附加参数是 RSP+xxh,其中 xx 是 8 乘以参数索引。
| | 注意:作为十六进制的替代,使用八进制可能更自然。在八进制中,第四个参数在 RSP+40o 传递,第五个是 RSP+50o,第六个是 RSP+60o。这种模式一直持续到 RSP+100o,这是第 8 个参数。 |
; Assembly file alternate version without PUSH
extern SumIntegers: proc
.code
SubProc proc
sub rsp, 30h ; Sub enough for 6 parameters
from RSP
mov ecx, 10 ; Move the first four params
mov edx, 20 ; into their regs in any order
mov r8d, 30
mov r9d, 40
; And we can use MOV to move dwords
; bytes or whatever we need to the stack
; as if we'd pushed them!
mov dword ptr [rsp+20h], 50
mov dword ptr [rsp+28h], 60
call SumIntegers
add rsp, 30h ; Deallocate shadow space
; and space from params
; this is 6x8=48 bytes.
ret
SubProc endp
End