调用惯例的历史
作者:Raymond Chen
在x86平台上关于调用惯例的好处是有如此多的选择!
在16位的世界中,调用惯例的部分是由指令集固定的:BP寄存器缺省为SS选择子,而其他寄存器缺省为DS选择子。因此BP寄存器是访问基于栈的参数必须的寄存器。
用于返回值的寄存器也是由指令集自动选择的。AX寄存器作为累加器,因此是传递返回值的显然选择。8086指令集还有把DA:AX对处理为单个32位值的特殊指令,因此使用这个寄存器对返回32位值是显而易见的选择。
剩下SI,DI,BX及CX。
(注:在函数调用中不需要保留的寄存器通常被称为“草稿寄存器(scratch)”。)
当一个调用惯例决定哪些寄存器应该被保留时,你需要平衡调用者与被调用者的所需。调用者更倾向于保留所有的寄存器,因为这样调用者不需要在调用中费神地保存/恢复值。被调用者则更倾向于不保留寄存器,因为这样就不需要在进入是保存值,在退出时恢复它。
如果你要求保留的寄存器太少,那么调用者将充斥寄存器保存/恢复代码。但如果你要求保留的寄存器太多,那么被调用者将被迫保存及恢复调用者可能并不关系的寄存器。对叶子函数(不调用其他任何函数的函数)这尤其重要。
不一致的x86指令集也是一个因素。CX寄存器不能用于访问内存,因此你希望CX以外的某个寄存器作为草稿寄存器,这样一个叶子函数至少可以无需保留任何寄存器而访问内存。这样BX被选作草稿寄存器,留下SI与DI作为保留寄存器。
下面是16位调用惯例的纲要:
All
16位世界的所有调用惯例都保留寄存器BP,SI,DI(其他是草稿寄存器),返回值根据大小放入DX:AX或AX中。
C (__cdecl)
带有可变数目参数的函数在相当大的程度上限制了C调用惯例。它基本上就是要求调用者清理栈,并且参数从右至左压栈,因此第一个参数有相对于栈顶固定的位置。传统的(pre-prototype)C语言允许你调用函数,无需告诉编译器该函数要求什么参数,如果你“知道”该函数不介意,向它传递错误数目的参数是惯常的做法。(这的一个经典例子,参考open。如果第二个参数没有指明要创建一个文件,第三个参数是可选的)。
总之:调用者清理栈,参数从右向左压栈。
函数名修饰包含一个前置下划线。我的猜测是该前置下划线避免了函数名与汇编器的保留字冲突(例如,想象一下,如果你有一个名为call的函数)。
Pascal (__pascal)
Pascal不支持带有可变数目参数的函数,因此它可以使用被调用者清理栈的惯例。参数从左至右压栈,因为这看上去更自然。函数名修饰包含到大写字母的转换。这是必要的,因为Pascal是不区分大小写的语言。
几乎所有的Win16函数都被导出为Pascal调用惯例。被调用者清栈惯例在每个调用点节省3个字节,在每个函数固定的2个字节开销。因此如果一个函数被调用10次,在调用点你节省了3*10 = 30字节,而在函数本身增加2字节,净节省28字节。它还稍快一些。在Win16上,节省几百字节以及几个时钟周期是了不得的。
Fortran (__fortran)
Fortran调用惯例与Pascal调用惯例相同。给予它不同的名字,可能是因为Fortran有奇特的按引用传递的行为。
Fastcall (__fastcall)
Fastcall调用惯例在DX寄存器里传递第一个参数,在CX寄存器里传递第二个参数(我认为)。这是否实际更快,取决你调用的使用。通常它会更快,因为在寄存器中传递的参数无需溅出到栈,然后由被调用者重新载入。另一方面,如果在计算第一与第二参数之间出现不可忽略的计算,调用者不得不执行溅出。雪上加霜的是,被调用函数通常将寄存器溅出到内存,因为为别的事它需要备用寄存器,这在“前两个参数间存在不可忽略计算”的情形里,意味着你得到了两次溅出。噢!
结果,__fastcall通常仅对短的叶子函数更快一些,即使这样也不一定。
好了,这些都是我所记得的16位调用惯例。第二部分将讨论32位调用惯例,如果我找到时间写它。
铺垫:这个信息实际上在将来的讨论中有用。虽然不是很好的细节,但你可能会注意到有一些解释……嗯……这很难描述。等一下。
很好奇,只有8086与x86平台有多个调用惯例。所有其他都只有一个。
现在我们将进入没有人记得或甚至关心的琐事:你不会再看的32位调用惯例。
All
这里列出的所有处理器都是RISC类型的,这意味着除了硬连线为0的0寄存器,还有许多寄存器。(结果0是一个非常便利获取的数字)。附加给寄存器的任何含义都是由调用惯例强加的。
作为旧处理器的一个倒退,call指令将返回地址保存在一个寄存器,而不是压入栈。也是好事,因为处理器无需正式知道“栈”;它成为调用惯例的一种解释。
如常,被调用函数可以将用于传递参数的寄存器或栈空间用作草稿寄存器,返回值寄存器也可以。
你可能注意到RISC调用惯例基本相同。再次的,证明8086/x86是古怪之物。广受欢迎的怪物,提醒你。
The Alpha AXP
Alpha AXP(AXP也是一个官方不代表任何东西的人造首字母缩写)有32个整形寄存器,其中一个硬连线为0。按照惯例,其中一个寄存器是“栈指针”,一个是“返回地址”寄存器;另外两个有与参数传递无关的特殊含义。
前6个参数通过寄存器传递,余下参数在栈上传递、如果函数是可变参数的,参数可以溅出到栈上,这样它们可以作为一个数组来访问。
另外7个寄存器在调用间保留,一个是返回值,而剩下的13都是草稿寄存器。1零寄存器+1栈指针+1返回地址+2特殊+6参数+7保留+1返回值+13草稿 = 总共32个整形寄存器。
Alpha AXP上的函数名是完全不修饰的。
The MIPS R4000
前4个参数在a0,a1,a2及a3中传递;余下的溅出到栈上。另外,栈上有4个“死空间”,如果在栈上传递了四个寄存器参数,那里是它们“应该待的地方”。这些由被调用者用来在需要时溅出寄存器参数。(对可变参数函数特别方便)。
MIPS上的函数名完全不修饰。
The PowerPC
前八个参数在寄存器中传递(r3到r10),返回地址手动管理。
我忘了对第九个参数及以后会发生什么……
PowerPC上的函数名通过前置两个圆点来修饰。
免责声明:我个人没有使用MPIS或PPC处理器的经验,因此我对这些处理器的讨论可能少许幼稚,不过我认为基本思想是合理的。
好了,让我们继续:32位x86调用惯例。
(顺便说一下,以防人们不了解:我只会在进行Windows编程时你可能遇到的,或由Microsoft编译器使用的,调用惯例的上下文中进行讨论。我不准备讨论其他操作系统或特定于某个语言或编译器供应商的调用惯例)。
记住:如果一个调用惯例用于一个C++成员函数,对该函数存在一个隐藏的“this”参数作为隐含的第一个参数。
All
所有32位x86调用惯例都保留EDI,ESI,EBP及EBX寄存器,返回值使用EDX:EAX对。
C (__cdecl)
就像16位的世界,对32位世界也有相同的限制。参数从右至左压栈(因此第一个参数最接近栈顶),调用者清理参数。使用前置下划线修饰函数名。
__stdcall
这是Win32使用的调用惯例,除了可变参数函数(必须使用__cdecl)以及极少数使用__fastcall的函数。参数从右至左压栈,被调用者清栈。函数名由前置下划线以及带有该函数参数字节数的后接@符号修饰。
__fastcall
前两个参数在ECX及EDX中传递,其余就像__stdcall那样通过栈传递。同样,由被调用者清栈。函数名由前置下划线以及带有该函数参数字节数的后接@符号修饰(包括寄存器参数)。
thiscall
第一个参数(它是“this”参数)在ECX中传递,其余就像__stdcall那样通过栈传递。再次的,由被调用者清栈。函数名由C++编译器以一个异常复杂的,还编码进了每个参数类型的机制来修饰。这是必须的,因为C++允许函数重载,因此必须使用一个复杂的修饰方案使得各个重载有不同的修饰名。
记住调用惯例是调用者与被调用者之间的一个协议。对于你们那些疯狂到以汇编写程序的人,这意味着你的回调函数需要保留由调用惯例托管的寄存器,因为调用者(操作系统)依赖于它。比如你破坏了调用中的EBX寄存器,程序崩溃也就没什么令人惊讶了。将来进一步讨论它。
Ia-64架构(Itanium)与AMD64架构(AMD64)是相对新的,因此你们中的许多人不太可能处理过它们的调用惯例,但我在这个系列里包括它们,谁知道呢,也许哪一天你就会买一台。
Intel提供了the Intel® Itanium®Architecture Software Developer’s Manual,从中你可以读到指令集与处理器架构详尽的信息。我准备只描述足够解释调用惯例的内容。
Itanium有128个寄存器,其中32个(r0到r31)是全局寄存器,不参与函数调用。函数向处理器声明余下的96个寄存器中,多少它希望用于纯粹的局部使用(localregion),其中头几个用于参数传递,多少用于向其他函数传递参数(输出寄存器)。
例如,假定一个函数接受两个参数,要求4个寄存器用于临时变量,并调用一个接受3参数的函数。(如果它调用多个函数,接受其中最大的参数数)。然后在函数入口将声明它希望6个寄存器在局部区(r32到r37),3个输出寄存器(r38,r39与r40)。寄存器r41到r127禁止使用。
给书呆子的提示:我知道这并不是它实际的做法。但这样解释要容易得多。
在函数希望调用子函数时,它将第一个参数放入r38,第二个放入r39,第三个在r40,然后调用该函数。处理器移动调用者的输出寄存器,使得它们可以作为被调用函数的输入寄存器。在这个情形里r38迁移到r32,r39迁移到r33,而r40迁移到r34。旧寄存器r32到r38被保存在另外的,不同于sp寄存器所指向的寄存器栈。(当然,实际上这些“溅出”被推迟了,与SPARC寄存器窗口直到需要时才溅出一样。事实上,你可以看到整个ia64参数传递惯例与SPARC寄存器窗口相同,只是窗口大小可变)!
当被调用函数返回时,寄存器被迁移回原来的位置,从寄存器栈恢复从r32到r38原来的值。
这对调用惯例传统的问题创造了一些令人惊奇的答案。
调用过程中要保留哪些寄存器?在局部区内的一切(因为它是由处理器自动压入与弹出的)。
哪些寄存器保存参数?参数进入调用者的输出寄存器,这依赖于调用者在局部区需要多少寄存器而有不同,但被调用者总是把它们视为r32,r33,以此类推。
谁从栈清理参数?没有人。一开始参数并不在栈上。
哪个寄存器保存返回值?这有点复杂。因为被调用函数不能访问调用者的寄存器,你可能认为不可能传值回来!这正是32个全局寄存器的意图。其中一个全局寄存器(我记得是r8)被任命为“返回值寄存器”。因为全局寄存器不参与寄存器窗口戏法,保存在那里的值能挺过函数调用切换与函数返回的切换。
返回值通常保存在局部区的一个寄存器中。这有一个栈变量的缓存溢出不会覆盖一个返回地址的净效应,因为返回地址一开始不是保存在栈上。它保存在局部区,然后溅出到寄存器栈,与栈分开的一块内存。
函数可以自由地下调sp寄存器以创建临时栈空间(例如,用作字符串缓存),当然这必须在返回前清理。
栈惯例的一个令人好奇的细节是,栈上的前16个字节(前两个quadword)总是草稿。(Peter Lund称之为“红区”)。因此如果在一个短的时间里需要一些内存,你可以使用栈顶这个内存。不过记住如果你调到另一个函数,这个内存就变成你所调用函数使用的草稿!因此如果你需要这个“免费草稿簿”的值在调用间保留,你需要下调sp以正式地保留它。
Ia64的另一个令人好奇的细节是:ia64上的一个函数指针不是指向代码的第一个字节。相反,它指向描述这个函数的一个结构体。该结构体的第一个quadword是代码第一个字节的地址,第二个quadword包含了称为“gp”寄存器的值。在以后的博客中我们会学习gp寄存器的更多知识。
(这个“函数指针实际指向一个结构体”的技巧不是ia64首创。在RISC机器里是常用的。我相信PPC也使用它)。
好吧,我承认这确实是乏味的词条。但不管信不信,我准备回到这个词条的几个要点,这样不至于徒劳无功。
在这个系列里我准备讨论的最后一个架构是AMD64(也称为x86-64)。
AMD64采取传统的x86,将寄存器扩展到64位,将它们命名为rax,rbx,以此类推。它还添加了8个额外的通用寄存器,名字从R8到R15。
· 函数的头4个参数在rcx,rdx,r8及r9中传递。更多的参数压到栈上。另外,在栈上保留寄存器参数的空间,以防被调用函数溅出它们;如果该函数是可变参数的,这是重要的。
· 小于64比特的参数不是零扩展的;高位比特是垃圾比特,因此记住,如果需要要显式清零。大于64比特的参数通过地址传递。
· 返回值放在rax里。如果返回值超过64比特,那么一个秘密的第一参数被传递,它包含要保存该返回值的地址。
· 在调用间所有的寄存器必须被保留,除了rax,rcx,rdx,r8,r9,r10与r11,它们是草稿寄存器。
· 被调用者不能清理栈。清理栈是调用者的工作。
· 栈必须保持与16字节边界对齐。因为call指令压入一个8字节返回地址,这意味着每个非叶子函数将以一个16n+8形式的值调整栈,以回复16字节对齐。
下面是一个样例:
void SomeFunction(int a, int b, int c, int d, int e);
void CallThatFunction()
{
SomeFunction(1, 2, 3,4, 5);
SomeFunction(6, 7, 8,9, 10);
}
在进入CallThatFunction时,栈看起来像这样:
由于返回地址的出现,栈是非对齐的。CallThatFunction设置其栈帧,它看起来可能像这样:
sub rsp, 0x28
注意局部栈帧大小是16n+8,因此结果是一个重新对齐的栈。
mov dword ptr [rsp+0x20], 5 ; output parameter 5现在我们可以设置第一个调用:
mov r9d, 4 ; output parameter 4
mov r8d, 3 ; output parameter 3
mov edx, 2 ; output parameter 2
mov ecx, 1 ; output parameter 1
call SomeFunction ; Go Speed Racer!
在SomeFunction返回时,栈不是干净的,因此它看起来仍然像上面那样。为了发布第二个调用,我们只需将新的值放入我们已经保留的空间:
mov dword ptr [rsp+0x20], 10 ; output parameter 5
mov r9d, 9 ; output parameter 4
mov r8d, 8 ; output parameter 3
mov edx, 7 ; output parameter 2
mov ecx, 6 ; output parameter 1
call SomeFunction ; Go Speed Racer!
现在CallThatFunction完成,可以清理它的栈并返回。
add rsp, 0x28
ret
注意在amd64代码中很少push指令,因为规范是让调用者保留参数空间,并持续重用它。