为啥 x86-64 Linux 系统调用使用 6 个寄存器集?
Posted
技术标签:
【中文标题】为啥 x86-64 Linux 系统调用使用 6 个寄存器集?【英文标题】:Why do x86-64 Linux system calls work with 6 registers set?为什么 x86-64 Linux 系统调用使用 6 个寄存器集? 【发布时间】:2018-01-21 16:52:16 【问题描述】:我正在用 C 语言编写一个仅依赖于 Linux 内核的独立程序。
研究了相关的manualpages,了解到在x86-64上,Linux系统调用入口点通过rax
、rdi
、rsi
这七个寄存器接收系统调用号和六个参数, rdx
、r10
、r8
和 r9
。
这是否意味着每个系统调用都接受六个参数?
我研究了几个 libc 实现的源代码,以了解它们如何执行系统调用。有趣的是,musl 包含两种不同的系统调用方法:
src/internal/x86_64/syscall.s
这个汇编源文件定义了一个__syscall
函数,它将系统调用号和正好六个参数移动到ABI中定义的寄存器。该函数的通用名称暗示它可以用于任何系统调用,尽管它总是向内核传递六个参数。
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
写入标准输出。这给我留下了以下问题:
0
好吗?
内核将如何处理它不使用的寄存器?
它会忽略它们吗?
七函数方法是否由于指令较少而更快?
这些函数中的其他寄存器会发生什么变化?
【问题讨论】:
如果您向__syscall
传递的参数多于系统调用所需的参数,它们将无用但无害复制到相应的寄存器中。 syscall
指令将控制权转移到内核,内核将控制权转移到系统调用实现的入口点。如果该实现不使用某些寄存器,它将假定它们是未使用的,就像它通常所做的那样,并忽略它们中保存的值(这又是 harmless)。相反,如果它完全使用它们,实现会将它们用作临时寄存器。
【参考方案1】:
系统调用最多接受 6 个参数,在寄存器中传递(几乎与 SysV x64 C ABI 相同的寄存器,r10
替换 rcx
,但在系统调用情况下它们是保留的被调用者) , 和“额外”参数被简单地忽略。
以下是您问题的一些具体答案。
src/internal/x86_64/syscall.s
只是一个“thunk”,它将所有参数转移到正确的位置。也就是说,它从接受系统调用号和另外 6 个参数的 C-ABI 函数转换为具有相同 6 个参数和 rax
中的系统调用号的“系统调用 ABI”函数。它对任意数量的参数都“很好” - 如果不使用这些参数,系统调用将简单地忽略额外的寄存器移动。
由于在 C-ABI 中所有参数寄存器都被视为临时寄存器(即调用者保存),因此如果您假设从 C 调用此 __syscall
方法,则破坏它们是无害的。事实上内核对破坏的寄存器做出了更强有力的保证,只破坏了rcx
和r11
,因此假设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
之外的所有寄存器都被保留(这就是为什么您会在 C 内联汇编的 clobber 列表中看到 rcx
和 r11
。
是的,但差异非常小,因为 reg-reg mov
指令在最新的英特尔架构上通常具有零延迟和高吞吐量(高达 4 个/周期)。因此,对于一个系统调用,移动额外的 6 个寄存器可能需要大约 1.5 个周期,即使它什么也不做,通常也需要至少 50 个周期。所以影响很小,但可能是可以测量的(如果你非常仔细地测量!)。
我不确定你的确切意思,但是如果内核想要保留它们的值(例如,通过push
ing 他们在堆栈上然后@987654346),其他寄存器可以像所有 GP 寄存器一样使用稍后@他们)。
【讨论】:
MUSL 决定使用单独的函数很有用,因为它们是内联,所以一刀切的版本会使每个调用站点的代码膨胀。 GLIBC 决定使用一个是有道理的,因为它不是内联在标头中,因此所有调用者都通过相同的函数。对于以这种方式使用多个系统调用的程序(假设 args 的数量不同),您将有额外的 PLT 条目、要解析的额外共享库符号以及内部单独函数的更大 I-cache 占用空间等开销libc.so
。如果您已经内联了寄存器设置,这将消失。
@peter gcc 的方法是什么? AFAICT 上面链接和讨论的 OP 的两种方法都来自 MUSL...
哦,对了。那么这两种方法在 MUSL 中对于内联和非内联都是有意义的。你的意思是glibc的方法? gcc 对进行系统调用没有任何特殊支持。 GLIBC 仅提供可变参数syscall(2)
function。在code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/x86_64/… 实施。它解码返回值并设置errno
,不像MUSL。 (但与普通的 write()
包装器不同,它仍然不与 pthread 交互或重试。)
对于像实际write()
glibc 包装函数这样的正常情况,这是一个出色的 ABI 设计。 glibc 的 write(2)
只使用 mov eax, imm32
/ syscall
因为参数已经到位。关于内核的有趣点;将所有 reg 保存到堆栈后,该值仍然存在于正确的寄存器中以调用 sys_write()
(如果调度发生在 asm 中......)。 syscall(2)
通用包装函数主要用于轻松地从 C 中处理新的系统调用;没有理由为 it 优化系统调用 ABI。
在研究 What happens if you use the 32-bit int 0x80 Linux ABI in 64-bit code? 时,我发现 64 位本机系统调用是 dispatched directly from asm,在完成 mov %r10, %rcx
之后通过函数指针表。快速路径甚至不会将调用保留的 reg 推送到 pt_regs
,而是让 C 函数保留它们。以上是关于为啥 x86-64 Linux 系统调用使用 6 个寄存器集?的主要内容,如果未能解决你的问题,请参考以下文章
Oracle Database 11.2.0.4.0 已在 中标麒麟Linux x86-64 NeoKylin Linux Advanced Server 6 上通过认证