AMD 有一个 ABI 规范,描述了在 x86-64 上使用的调用约定。所有操作系统都遵循它,除了具有自己的 x86-64 调用约定的 Windows。为什么?
有谁知道这种差异的技术、历史或政治原因,还是纯粹是 NIH 综合症的问题?
我知道不同的操作系统可能对更高级别的东西有不同的需求,但这并不能解释为什么 Windows 上的寄存器参数传递顺序是 rcx - rdx - r8 - r9 - rest on stack
而其他人都使用 rdi - rsi - rdx - rcx - r8 - r9 - rest on stack
。
PS 我知道这些调用约定通常有何不同,并且如果需要,我知道在哪里可以找到详细信息。我想知道的是为什么。
编辑:有关方法,请参见例如 wikipedia entry 和那里的链接。
在 x64 上选择四个参数寄存器 - UN*X / Win64 通用
关于 x86 需要记住的一件事是,“reg number”编码的寄存器名称并不明显;就指令编码而言(MOD R/M 字节,参见 http://www.c-jump.com/CIS77/CPU/x86/X77_0060_mod_reg_r_m_byte.htm),寄存器编号 0...7 依次为 - ?AX
、?CX
、?DX
、?BX
、?SP
、?BP
、?SI
、?DI
。
因此,选择 A/C/D (regs 0..2) 作为返回值和前两个参数(这是“经典”32 位 __fastcall
约定)是一个合乎逻辑的选择。就 64 位而言,“更高”的 reg 是有序的,Microsoft 和 UN*X/Linux 都将 R8
/ R9
作为第一个。
请记住,如果您选择 ,Microsoft 选择 RAX
(返回值)和 RCX
、RDX
、R8
、R9
(arg[0..3]) 是可以理解的选择四个 注册参数。
我不知道为什么 AMD64 UN*X ABI 在 RCX
之前选择了 RDX
。
在 x64 上选择六个参数寄存器 - 特定于 UN*X
在 RISC 架构上,UN*X 传统上会在寄存器中传递参数 - 特别是对于前六个参数(至少在 PPC、SPARC、MIPS 上也是如此)。这可能是 AMD64 (UN*X) ABI 设计人员选择在该架构上使用六个寄存器的主要原因之一。
因此,如果您希望 六个 寄存器传入参数,并且为其中四个选择 RCX
、RDX
、R8
和 R9
是合乎逻辑的,你应该选择另外两个?
“更高”的 regs 需要一个额外的指令前缀字节来选择它们,因此具有更大的指令大小占用空间,因此如果您有选项,您不会想要选择其中的任何一个。在经典寄存器中,由于 RBP
和 RSP
的 隐式 含义,它们不可用,并且 RBX
传统上在 UN*X(全局偏移表)上具有特殊用途,它似乎 AMD64 ABI 设计者不想不必要地变得不兼容。
因此,唯一的选择是 RSI
/ RDI
。
因此,如果您必须将 RSI
/ RDI
作为参数寄存器,它们应该是哪些参数?
将它们设为 arg[0]
和 arg[1]
有一些优势。请参阅 cHao 的评论。
?SI
和 ?DI
是字符串指令源/目标操作数,正如 cHao 所提到的,它们用作参数寄存器意味着使用 AMD64 UN*X 调用约定,最简单的 strcpy()
函数,例如,仅包含两条 CPU 指令 repz movsb; ret
,因为源/目标地址已被调用者放入正确的寄存器中。特别是在低级和编译器生成的“胶水”代码中(例如,一些 C++ 堆分配器在构造时零填充对象,或在 sbrk()
上的内核零填充堆页面,或复制-write pagefaults)大量的块复制/填充,因此它对于经常用于保存两个或三个 CPU 指令的代码很有用,否则这些指令会将此类源/目标地址参数加载到“正确的”寄存器中。
因此,在某种程度上,UN*X 和 Win64 的不同之处仅在于 UN*X 在有意选择的 RSI
/RDI
寄存器中“预先”添加了两个附加参数,以自然选择 RCX
中的四个参数,{ 4}、R8
和 R9
。
除此之外 ...
UN*X 和 Windows x64 ABI 之间的区别不仅仅是将参数映射到特定寄存器。有关 Win64 的概述,请查看:
http://msdn.microsoft.com/en-us/library/7kcdt6fy.aspx
Win64 和 AMD64 UN*X 在堆栈空间的使用方式上也有显着差异;例如,在 Win64 上,调用者必须为函数参数分配堆栈空间,即使参数 0...3 在寄存器中传递。另一方面,在 UN*X 上,如果叶函数(即不调用其他函数的函数)需要不超过 128 个字节(是的,您拥有并且可以使用一定数量的堆栈而不分配它......好吧,除非你是内核代码,一个漂亮的错误的来源)。所有这些都是特定的优化选择,其中的大部分基本原理都在原始发布者的维基百科参考指向的完整 ABI 参考中进行了解释。
IDK 为什么 Windows 做了他们所做的事情。请参阅此答案的结尾以进行猜测。我很好奇 SysV 调用约定是如何决定的,所以我深入研究了 the mailing list archive,发现了一些简洁的东西。
阅读 AMD64 邮件列表中的一些旧线程很有趣,因为 AMD 架构师对此很活跃。例如,选择寄存器名称是困难的部分之一:AMD 考虑过 renaming the original 8 registers r0-r7, or calling the new registers UAX
etc.
此外,来自内核开发人员的反馈确定了构成 syscall
and swapgs
unusable 原始设计的因素。这就是 AMD updated the instruction 在发布任何实际芯片之前解决此问题的方法。有趣的是,在 2000 年末,英特尔可能不会采用 AMD64。
SysV (Linux) 调用约定,以及关于应保留多少寄存器与调用者保存的决定是 made initially in Nov 2000, by Jan Hubicka(gcc 开发人员)。他compiled SPEC2000查看了代码大小和指令数量。该讨论线程围绕一些与此 SO 问题的答案和评论相同的想法反弹。在第二个线程中,他proposed the current sequence as optimal and hopefully final, generating smaller code than some alternatives。
他使用术语“全局”来表示保留调用的寄存器,如果使用,则必须推送/弹出。
选择 rdi
、rsi
、rdx
作为前三个参数的动机是:
在其 args 上调用 memset 或其他 C 字符串函数的函数中节省了少量代码大小(其中 gcc 内联了 rep 字符串操作?)
rbx 是保留调用的,因为在没有 REX 前缀(rbx 和 rbp)的情况下可以访问两个保留调用的 reg 是一种胜利。之所以选择它们,是因为它们是唯一未被任何通用指令隐式使用的“遗留”寄存器。 (rep 字符串、移位计数和 mul/div 输出/输入涉及其他所有内容)。
通用指令强制您使用的所有寄存器都不是调用保留的(请参阅上一点),因此想要使用可变计数移位或除法的函数可能必须将函数 args 移动到其他地方,但不必保存/恢复调用者的值。 cmpxchg16b 和 cpuid 需要 RBX,但很少使用,所以不是一个大因素。 (cmpxchg16b 不是原始 AMD64 的一部分,但 RBX 仍然是显而易见的选择。cmpxchg8b 存在,但已被 qword cmpxchg 淘汰)
我们试图在序列的早期避免 RCX,因为它是通常用于特殊目的的寄存器,如 EAX,因此在序列中丢失它具有相同的目的。它也不能用于系统调用,我们希望使系统调用序列尽可能匹配函数调用序列。
(背景:syscall
/ sysret
不可避免地会破坏 rcx
(with rip
)和 r11
(with RFLAGS
),所以当 syscall
时内核无法看到最初在 rcx
中的内容跑了。)
选择内核系统调用 ABI 来匹配函数调用 ABI,除了 r10
而不是 rcx
,因此像 mmap(2)
这样的 libc 包装函数只能是 mov %rcx, %r10
/ mov $0x9, %eax
/ syscall
。
请注意,与 Window 的 32 位 __vectorcall 相比,i386 Linux 使用的 SysV 调用约定很糟糕。 It passes everything on the stack, and only returns in edx:eax
for int64, not for small structs。毫不奇怪,几乎没有努力保持与它的兼容性。当没有理由不这样做时,他们会做一些事情,比如保留 rbx
调用,因为他们认为在原始 8 中有另一个(不需要 REX 前缀)是好的。
从长远来看,使 ABI 优化远比任何其他考虑因素都重要。我认为他们做得很好。我不完全确定是否返回打包到寄存器中的结构,而不是不同注册表中的不同字段。我猜想通过值传递它们而不实际对字段进行操作的代码会以这种方式获胜,但是解包的额外工作似乎很愚蠢。他们可以有更多的整数返回寄存器,而不仅仅是 rdx:rax
,所以返回一个有 4 个成员的结构可以在 rdi、rsi、rdx、rax 或其他东西中返回它们。
他们考虑在向量 regs 中传递整数,因为 SSE2 可以对整数进行操作。幸运的是,他们没有那样做。 Integers are used as pointer offsets very often, and a round-trip to stack memory is pretty cheap。 SSE2 指令也比整数指令占用更多的代码字节。
我怀疑 Windows ABI 设计者可能一直致力于最大限度地减少 32 位和 64 位之间的差异,以便那些必须将 asm 从一个移植到另一个的人的利益,或者可以在某些 ASM 中使用几个 #ifdef
以便相同的源可以更容易构建 32 位或 64 位版本的函数。
最小化工具链中的变化似乎不太可能。 x86-64 编译器需要一个单独的表,其中包含哪个寄存器用于什么,以及调用约定是什么。与 32 位有少量重叠不太可能显着节省工具链代码大小/复杂性。
shl rax, cl
、mul
) 要求您使用 RBX 或 RBP。只有 cmpxchg16b
和 cpuid
需要 RBX,并且 RBP 仅由 leave
隐式使用(以及不可用的慢 enter
指令)。因此,对于 RBP,唯一的隐含用途只是操纵 RBP,如果不将其用作帧指针,则不是您想要的
请记住,Microsoft 最初“官方对 AMD64 的早期工作不置可否”(来自 Matthew Kerner 和 Neil Padgett 的 "A History of Modern 64-bit Computing"),因为他们是英特尔在 IA64 架构上的强大合作伙伴。我认为这意味着即使他们本来愿意在 ABI 上与 GCC 工程师合作以在 Unix 和 Windows 上都使用,他们也不会这样做,因为这意味着当他们没有公开支持 AMD64 工作时' t 尚未正式这样做(并且可能会让英特尔感到不安)。
最重要的是,在那些日子里,微软绝对没有对开源项目友好的倾向。当然不是 Linux 或 GCC。
那么为什么他们会在 ABI 上进行合作呢?我猜 ABI 之所以不同,仅仅是因为它们或多或少是同时设计的,而且是孤立的。
“现代 64 位计算的历史”的另一句话:
在与微软合作的同时,AMD 还与开源社区一起为芯片做准备。 AMD 与 Code Sorcery 和 SuSE 签订了工具链工作合同(红帽已经与英特尔合作开发 IA64 工具链端口)。 Russell 解释说,SuSE 生产了 C 和 FORTRAN 编译器,而 Code Sorcery 生产了 Pascal 编译器。 Weber 解释说,该公司还与 Linux 社区合作准备 Linux 移植。这一努力非常重要:它激励了微软继续投资 AMD64 Windows 的努力,同时也确保了当时正在成为重要操作系统的 Linux 能够在芯片发布后可用。韦伯甚至说 Linux 工作对于 AMD64 的成功绝对至关重要,因为它使 AMD 能够在必要时无需任何其他公司的帮助即可生产端到端系统。这种可能性确保了即使其他合作伙伴退出,AMD 也有一个最坏的生存策略,这反过来又让其他合作伙伴参与进来,以免自己落后。
这说明即使是 AMD 也不觉得 MS 和 Unix 之间的合作一定是最重要的,但有 Unix/Linux 的支持是非常重要的。也许甚至试图说服一方或双方妥协或合作都不值得激怒他们中的任何一方的努力或风险(?)?或许 AMD 认为,即使提出一个通用的 ABI 也可能会延迟或破坏更重要的目标,即在芯片准备好时简单地准备好软件支持。
我个人猜测,但我认为 ABI 不同的主要原因是 MS 和 Unix/Linux 方面没有在这方面合作的政治原因,而 AMD 并不认为这是一个问题。
__vectorcall
,因为在堆栈上传递 __m128
很糟糕。为某些向量 reg 的低 128b 保留调用语义也很奇怪(部分原因是英特尔最初没有使用 SSE 设计可扩展的保存/恢复机制,但仍然没有使用 AVX。)
alloca
的函数或其他一些情况)。如果您习惯将 gcc -fomit-frame-pointer
作为 Linux 上的默认值,这很正常。 ABI 定义了允许异常处理仍然有效的堆栈展开元数据。 (我认为它的工作原理类似于 .eh_frame
中的 GNU/Linux x86-64 System V 的 CFI 内容)。 gcc -fomit-frame-pointer
一直是 x86-64 上的默认值(启用优化),其他编译器(如 MSVC)做同样的事情。
Win32 对 ESI 和 EDI 有自己的用途,并且要求它们不被修改(或至少在调用 API 之前将它们恢复)。我想 64 位代码对 RSI 和 RDI 的作用相同,这可以解释为什么它们不用于传递函数参数。
不过,我无法告诉您为什么要切换 RCX 和 RDX。
__fastcall
调用约定。您声称 Win32/Win64 不兼容,但仔细观察:对于采用 两个 32 位参数并返回 32 位的函数,Win64 和 Win32 __fastcall
实际上 是 100% 兼容(传递两个 32 位参数的相同规则,相同的返回值)。甚至一些二进制(!)代码也可以在两种操作模式下工作。 UNIX 方面完全打破了“旧方式”。有充分的理由,但休息就是休息。
__fastcall
对于不超过 32 位的不超过两个参数并返回不大于 32 位的值的情况是 100% 相同的。这不是一小部分功能。用于 i386 / amd64 的 UN*X ABI 之间根本不可能有这种向后兼容性。RDX
在RCX
之前通过?strcpy
不是 2 条指令,而是 3 条(加上一个mov rcx, rdx
)?memcpy
,而不是strcpy
。