x86-64 SysV ABI 中参数的高位和 return 值寄存器是否允许垃圾?

Is garbage allowed in high bits of parameter and return value registers in x86-64 SysV ABI?

x86-64 SysV ABI 指定了函数参数如何在寄存器中传递(第一个参数在 rdi,然后是 rsi 等等),以及整数 return 个值被传回(在 rax 中,然后 rdx 用于真正大的值)。

但是,我找不到的是传递小于 64 位的类型时参数或 return 值寄存器的高位应该是什么。

例如,对于以下函数:

void foo(unsigned x, unsigned y);

... x 将在 rdi 中传递,y 将在 rsi 中传递,但它们只是 32 位。 rdirsi 的高 32 位是否需要为零?直觉上,我会假设是的,但是所有 gcc、clang 和 icc 的 code generated 在开始时都有特定的 mov 指令将高位清零,因此编译器似乎另有假设。

同样,如果 return 值小于 64 位,编译器似乎假定 return 值 rax 的高位可能有垃圾位。例如,以下代码中的循环:

unsigned gives32();
unsigned short gives16();

long sum32_64() {
  long total = 0;
  for (int i=1000; i--; ) {
    total += gives32();
  }
  return total;
}

long sum16_64() {
  long total = 0;
  for (int i=1000; i--; ) {
    total += gives16();
  }
  return total;
}

... compileclang 中的以下内容(其他编译器类似):

sum32_64():
...
.LBB0_1:                               
    call    gives32()
    mov     eax, eax
    add     rbx, rax
    inc     ebp
    jne     .LBB0_1


sum16_64():
...
.LBB1_1:
    call    gives16()
    movzx   eax, ax
    add     rbx, rax
    inc     ebp
    jne     .LBB1_1

注意调用 returning 32 位后的 mov eax, eax 和调用 16 位后的 movzx eax, ax - 两者都具有清零前 32 位或分别为 48 位。所以这种行为有一些成本 - 处理 64 位 return 值的相同循环省略了这条指令。

我已经非常仔细地阅读了 x86-64 System V ABI document,但我找不到标准中是否记录了这种行为。

这样的决定有什么好处?在我看来有明显的成本:

参数成本

在处理参数值时,成本被强加在被调用者的实现上。并在处理参数时在函数中。当然,这个成本通常为零,因为该函数可以有效地忽略高位,或者归零是免费的,因为可以使用 32 位操作数大小指令,隐式地将高位归零。

但是,对于接受 32 位参数并执行一些可以从 64 位数学中受益的数学的函数,成本通常是非常真实的。以this function为例:

uint32_t average(uint32_t a, uint32_t b) {
  return ((uint64_t)a + b) >> 2;
}

直接使用 64 位数学来计算函数,否则必须小心处理溢出(以这种方式转换许多 32 位函数的能力是 64 位架构的一个经常被忽视的好处) .这编译为:

average(unsigned int, unsigned int):
        mov     edi, edi
        mov     eax, esi
        add     rax, rdi
        shr     rax, 2
        ret  

4 条指令中的 2 条(忽略 ret)只需要将高位清零。这在消除移动的实践中可能很便宜,但似乎仍然需要付出很大的代价。

另一方面,如果 ABI 指定高位为零,我真的看不到调用者有类似的相应成本。因为 rdirsi 以及其他参数传递寄存器是 scratch (即,可以被调用者覆盖),你只有几个场景(我们看在 rdi,但将其替换为您选择的参数 reg):

  1. rdi 中传递给函数的值在 post 调用代码中已失效(不需要)。在这种情况下,最后分配给 rdi 的任何指令都必须分配给 edi。这不仅是免费的,如果避免使用 REX 前缀,它通常会小一个字节。

  2. rdi中传递给函数的值是函数后需要的。在这种情况下,由于 rdi 是调用者保存的,调用者无论如何都需要对被调用者保存的寄存器执行 mov 值。您通常可以组织它,以便被调用者保存的寄存器中的值 starts(例如 rbx),然后像 mov edi, ebx 一样移动到 edi,所以它不花钱。

我看不到调零会花费调用者很多的场景。一些示例是,如果在分配 rdi 的最后一条指令中需要 64 位数学运算。不过这似乎很少见。

Return 价值成本

这里的决定似乎更中立。让被调用者清除垃圾有一个明确的代码(您有时会看到 mov eax, eax 说明这样做),但是如果允许垃圾,则成本转移给被调用者。总的来说,调用者似乎更有可能免费清除垃圾,因此允许垃圾似乎总体上不会损害性能。

我想这种行为的一个有趣用例是大小不同的函数可以共享相同的实现。例如,以下所有函数:

short sums(short x, short y) {
  return x + y;
}

int sumi(int x, int y) {
  return x + y;
}

long suml(long x, long y) {
  return x + y;
}

实际上可以共享相同的实现1:

sum:
        lea     rax, [rdi+rsi]
        ret

1 这种折叠是否实际上 允许 对于地址已被占用的函数非常 open to debate.

看来你有两个问题:

  1. return 值的高位是否需要在 returning 之前清零? (在调用之前是否需要将参数的高位清零?)
  2. 与此决定相关的 costs/benefits 是什么?

第一个问题的答案是不,可以在高位是垃圾,Peter Cordes已经写了一个 关于这个主题。

至于第二个问题,我怀疑保留高位未定义总体上对性能更好。一方面,使用 32 位操作时,预先零扩展值不会产生额外成本。但另一方面,事先将高位归零并不总是必要的。如果您允许高位垃圾,那么您可以将其留给接收值的代码,以便仅在实际需要时执行零扩展(或符号扩展)。

但我想强调另一个考虑因素:安全性

信息泄露

当结果的高位未被清除时,它们可能会保留其他信息片段,例如stack/heap中的函数指针或地址。如果存在一种机制来执行更高权限的函数并随后检索 rax(或 eax)的完整值,那么这可能会导致 信息泄漏 .例如,系统调用可能会从内核泄漏指针到用户 space,导致内核 ASLR. Or an IPC mechanism might leak information about another process' address space that could assist in developing a sandbox 突破失败。

当然,有人可能会争辩说,防止信息泄露不是 ABI 的职责;由程序员正确地实现他们的代码。虽然我同意,但强制编译器将高位置零仍然会产生消除这种特殊形式的信息泄漏的效果。

你不应该相信你的输入

另一方面,更重要的是,编译器不应盲目相信任何接收到的值的高位都已清零,否则函数可能不会按预期运行,这也可能导致可利用的漏洞状况。例如,考虑以下内容:

unsigned char buf[256];
...
__fastcall void write_index(unsigned char index, unsigned char value) {
    buf[index] = value;
}

如果允许我们假设 index 的高位清零,那么我们可以将上面的代码编译为:

write_index:  ;; sil = index, dil = value
      ; movzx esi, sil       ; skipped based on assumptions
    mov [buf + rsi], dil
    ret

但是如果我们可以从我们自己的代码中调用这个函数,我们可以提供 [0,255] 范围之外的值 rsi 并写入缓冲区边界之外的内存。

当然,编译器实际上不会生成这样的代码,因为如上所述,被调用者 有责任对其参数进行零扩展或符号扩展,而不是 调用者 的那个。我认为,这是让接收值的代码始终假设高位有垃圾并明确删除它的一个非常实际的原因。

(对于 Intel IvyBridge 和更高版本(mov-elimination),编译器希望零扩展到 不同的 寄存器以至少避免延迟,如果不是前端的话movzx 指令的吞吐量成本。)