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

Mat*_*ira 7 operating-system x86-64 system-calls abi linux-kernel

我正在用C编写一个独立程序,它只依赖于Linux内核.

我研究了相关手册 ,得知X86-64上的Linux系统调用入口点接收通过七个寄存器的系统调用号和六个参数rax,rdi,rsi,rdx,r10,r8,和r9.

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

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

  1. src/internal/x86_64/syscall.s

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

  2. arch/x86_64/syscall_arch.h

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

所以我自己尝试了一下:

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;
}
Run Code Online (Sandbox Code Playgroud)

我运行了这个程序并验证它是否写入It works!\n标准输出.这给我留下了以下问题:

  • 为什么我可以传递比系统调用更多的参数?
  • 这是合理的,有记录的行为吗?
  • 我应该将未使用的寄存器设置为什么?
    • 0好吗?
  • 内核会对它不使用的寄存器做什么?
    • 它会忽略它们吗?
  • 由于指令较少,七功能是否更快?
    • 这些函数中的其他寄存器会发生什么?

Bee*_*ope 6

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

以下是您的问题的一些具体答案.

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

由于在C-ABI所有的参数寄存器被认为是从头开始(即调用者保存),重挫,如果你以为这是他们无害的__syscall方法是从C.称为其实内核使有关破坏的寄存器更强的保证,只有重挫rcxr11因此假设C调用约定是安全的但是悲观.特别是,__syscall这里实现的代码调用将根据C ABI不必要地保存任何参数和临时寄存器,尽管内核承诺保留它们.

arch/x86_64/syscall_arch.h文件几乎相同,但在C头文件中.在这里,您需要所有七个版本(对于零到六个参数),因为如果您使用错误数量的参数调用函数,现代C编译器将发出警告或错误.因此,在装配案例中没有"一个函数来统治它们"的实际选择.这样做的优点是可以减少少于6个参数的系统调用.

您列出的问题,已回答:

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

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

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

是的,确定 - 因为整个syscall机制是进入内核的"通用门".99%的时间你不打算使用它:glibc在C ABI包装器中使用正确的签名包装绝大多数有趣的系统调用,这样你就不用担心了.这些是系统调用访问安全发生的方式.

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

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

  • 内核会对它不使用的寄存器做什么?

它是免费使用它想要的所有寄存器,但会坚持到内核调用约定这是X86-64上比其他所有的寄存器rax,rcx并且r11被保留(这就是为什么你看到的rcxr11在在C内联汇编的名单乱码).

  • 由于指令较少,七功能是否更快?

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

  • 这些函数中的其他寄存器会发生什么?

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

  • MUSL 决定拥有单独的函数是有用的,因为它们是*内联*的,所以一个通用的版本会在每个调用站点上膨胀代码。GLIBC 决定使用单个函数是有道理的,因为它不在标头中内联,因此所有调用者都通过相同的函数。对于以这种方式使用多个系统调用的程序(假设 args 的数量不同),您将有额外的 PLT 条目、要解析的额外共享库符号以及来自内部单独函数的更大的 I-cache 占用空间等开销`libc.so`。如果您已经内联了寄存器设置,这就会消失。 (2认同)
  • 对于像实际的`write()`glibc包装函数这样的正常情况,它是一个出色的ABI设计.glibc的`write(2)`只使用`mov eax,imm32` /`syscall`,因为args已经到位了.关于内核的有趣观点; 在将所有regs保存到堆栈之后,该值仍然在正确的寄存器中用于调用`sys_write()`(如果在asm中发生调度...).`syscall(2)`通用包装函数主要用于从C轻松播放新的系统调用; 没有理由为*it*优化系统调用ABI. (2认同)
  • 在研究[如果您以64位代码使用32位int 0x80 Linux ABI会发生什么?](/sf/ask/3226141131/ bit-int-0x80-linux-abi-in-64-bit-code)我发现64位本机系统调用是[直接从asm分派](https://github.com/torvalds/linux/blob/e7d0c41ecc2e372a81741a30894f556afec24315 /arch/x86/entry/entry_64.S#L180)通过仅执行`mov%r10,%rcx`之后的函数指针表。快速路径甚至没有将保留调用的reg推送到pt_regs中,而是让C函数保留了它们。 (2认同)