为什么 x86-64 Linux 系统调用使用 6 个寄存器集?

Why do x86-64 Linux system calls work with 6 registers set?

我正在用 C 语言编写一个仅依赖于 Linux 内核的独立程序。

研究了相关的manual pages,了解到在x86-64上,Linux系统调用入口点通过7个寄存器rax接收系统调用号和6个参数,rdirsirdxr10r8r9

这是否意味着每个系统调用接受六个参数?

我研究了几个 libc 实现的源代码,以了解它们如何执行系统调用。有趣的是,musl 包含两种截然不同的系统调用方法:

  1. src/internal/x86_64/syscall.s

    这个汇编源文件定义了一个__syscall函数,它将系统调用号和正好六个参数移动到寄存器在 ABI 中定义。该函数的通用名称暗示它可以与任何系统调用一起使用,尽管它总是向内核传递六个参数。

  2. arch/x86_64/syscall_arch.h

    此 C 头文件定义了 七个独立的 __syscallN 函数,其中 N 指定了它们的数量。这表明仅传递系统调用所需的确切数量的参数的好处超过了拥有和维护七个几乎相同的函数的成本。

所以我自己试了一下:

long
system_call(long number,
            long _1, long _2, long _3, long _4, long _5, long _6)
{
    long value;

    register long r10 __asm__ ("r10") = _4;
    register long r8  __asm__ ("r8")  = _5;
    register long r9  __asm__ ("r9")  = _6;

    __asm__ volatile ( "syscall"
                     : "=a" (value)
                     : "a" (number), "D" (_1), "S" (_2), "d" (_3), "r" (r10), "r" (r8), "r" (r9)
                     : "rcx", "r11", "cc", "memory");

    return value;
}

int main(void) {
    static const char message[] = "It works!" "\n";

    /* system_call(write, standard_output, ...); */
    system_call(1, 1, message, sizeof message, 0, 0, 0);

    return 0;
}

我 运行 这个程序和 verified 它确实将 It works!\n 写入标准输出。这给我留下了以下问题:

系统调用最多接受 6 个参数,在寄存器中传递(几乎与 SysV x64 C ABI 相同的寄存器,用 r10 替换 rcx 但它们是 callee preserved 在系统调用的情况下),并且 "extra" 参数被简单地忽略。

下面是对您问题的一些具体回答。

src/internal/x86_64/syscall.s 只是一个 "thunk",它将所有参数转移到正确的位置。也就是说,它将采用系统调用编号和另外 6 个参数的 C-ABI 函数转换为具有相同 6 个参数和 rax 中的系统调用编号的 "syscall ABI" 函数。它适用于任何数量的参数 "just fine" - 如果不使用这些参数,系统调用将简单地忽略额外的寄存器移动。

由于在 C-ABI 中所有参数寄存器都被认为是临时的(即调用者保存),如果您假设此 __syscall 方法是从 C 调用的,那么破坏它们是无害的。 事实上 内核对被破坏的寄存器做出更强有力的保证,仅破坏rcxr11 所以假设C 调用约定是 安全但悲观。特别是,此处实现的调用 __syscall 的代码将根据 C ABI 不必要地保存任何参数和暂存寄存器,尽管内核承诺会保留它们。

arch/x86_64/syscall_arch.h 文件几乎是一样的东西,但在 C 头文件中。在这里,您需要所有七个版本(零到六个参数),因为如果您使用错误数量的参数调用函数,现代 C 编译器将发出警告或错误。所以没有真正的选择 "one function to rule them all" 就像在装配案例中那样。这还有一个好处是可以减少少于 6 个参数的系统调用。

您列出的问题,已回答:

  • 为什么我可以传递比系统调用更多的参数?

因为调用约定主要是基于寄存器和调用者清理。在这种情况下(包括在 C ABI 中),您始终可以传递更多参数,而其他参数将被被调用者简单地忽略。由于 syscall 机制在 C 和 .asm 级别是 generic,因此编译器无法确保您传递正确数量的参数 - 您需要传递正确的系统调用 ID 正确的参数数量。如果传递的少,内核会看到垃圾,如果传递的多,它们将被忽略。

  • 这是合理的、有记录的行为吗?

是的,当然 - 因为整个 syscall 机制是 "generic gate" 进入内核。 99% 的时间你不会使用它:glibc 用正确的签名将绝大多数有趣的系统调用包装在 C ABI 包装器中,所以你不必担心。这些是系统调用安全发生的方式。

  • 我应该将未使用的寄存器设置为什么?

您没有将它们设置为任何内容。如果你使用 C 原型 arch/x86_64/syscall_arch.h 编译器会为你处理它(它不会将它们设置为任何东西)并且如果你正在编写自己的 asm,你不会将它们设置为任何东西(而且你应该假设它们在系统调用后被破坏了)。

  • 内核将如何处理它不使用的寄存器?

它可以自由使用它想要的所有寄存器,但会遵守内核调用约定,即在 x86-64 上除 raxrcx 和 [=16 之外的所有寄存器=] 被保留(这就是为什么你在 C 内联汇编的破坏列表中看到 rcxr11)。

  • 七函数方法是否因为指令更少而更快?

是的,但差异非常小,因为 reg-reg mov 指令通常具有零延迟并且在最近的英特尔架构上具有高吞吐量(最多 4 个/周期)。因此,移动额外的 6 个寄存器可能需要大约 1.5 个周期的系统调用,即使它什么都不做,通常至少需要 50 个周期。所以影响很小,但可能是可以衡量的(如果你非常仔细地衡量!)。

  • 这些函数中的其他寄存器会怎样?

我不确定你的意思是什么,但是其他寄存器可以像所有 GP 寄存器一样使用,如果内核想要保留它们的值(例如,通过 push 将它们放在堆栈上然后 pop 稍后再处理它们)。