32

我正在阅读有关 x86 和 x64 之间的装配差异。

在 x86 上,系统调用号放在 中eax,然后int 80h执行以产生软件中断。

但是在 x64 上,系统调用号放在 中rax,然后syscall被执行。

有人告诉我,这syscall比生成软件中断更轻、更快。

为什么在 x64 上比 x86 更快,我可以使用 x64 进行系统调用int 80h吗?

4

2 回答 2

36

一般部分

编辑:删除 Linux 无关部分

虽然并非完全错误,但将问题缩小int 0x80syscall过度简化,因为sysenter至少有第三种选择。

使用 0x80 和 eax 作为系统调用号、ebx、ecx、edx、esi、edi 和 ebp 来传递参数只是实现系统调用的许多其他可能选择之一,但这些寄存器是 32 位 Linux ABI 选择的寄存器.

在仔细研究所涉及的技术之前,应该说明它们都围绕着逃离每个进程运行的特权监狱的问题。

此处提供的 x86 架构提供的另一种选择是使用调用门(参见:http ://en.wikipedia.org/wiki/Call_gate )

所有 i386 机器上唯一存在的其他可能性是使用软件中断,它允许 ISR(中断服务程序或简单的中断处理程序)以与以前不同的特权级别运行。

(有趣的事实:一些 i386 操作系统使用无效指令异常进入内核进行系统调用,因为这实际上比int386 CPU 上的指令快。请参阅OsDev syscall/sysret 和 sysenter/sysexit 指令以获取可能的摘要系统调用机制。)

软件中断

触发中断后究竟会发生什么取决于切换到 ISR 是否需要更改权限:

(英特尔® 64 和 IA-32 架构软件开发人员手册)

6.4.1 中断或异常处理过程的调用和返回操作

...

如果处理程序的代码段与当前执行的程序或任务具有相同的特权级,则处理程序使用当前堆栈;如果处理程序以更高的特权级别执行,则处理器切换到处理程序特权级别的堆栈。

……

如果确实发生了堆栈切换,处理器将执行以下操作:

  1. 临时保存(内部)SS、ESP、EFLAGS、CS 和 > EIP 寄存器的当前内容。

  2. 将新堆栈(即被调用的特权级别的堆栈)的段选择器和堆栈指针从 TSS 加载到 SS 和 ESP 寄存器并切换到新堆栈。

  3. 将中断过程堆栈的临时保存的 SS、ESP、EFLAGS、CS 和 EIP 值推送到新堆栈上。

  4. 在新堆栈上推送错误代码(如果合适)。

  5. 将新代码段的段选择器和新指令指针(来自中断门或陷阱门)分别加载到 CS 和 EIP 寄存器中。

  6. 如果调用是通过中断门,清除 EFLAGS 寄存器中的 IF 标志。

  7. 以新的特权级别开始执行处理程序过程。

... 叹息这似乎有很多事情要做,即使我们完成了它也不会变得更好:

(摘自上述同一来源:英特尔® 64 和 IA-32 架构软件开发人员手册)

当从与中断过程不同的特权级别执行中断或异常处理程序的返回时,处理器执行以下操作:

  1. 执行权限检查。

  2. 将 CS 和 EIP 寄存器恢复到中断或异常之前的值。

  3. 恢复 EFLAGS 寄存器。

  4. 将 SS 和 ESP 寄存器恢复到中断或异常之前的值,导致堆栈切换回中断过程的堆栈。

  5. 恢复执行被中断的过程。

系统输入

您的问题中根本没有提到但 Linux 内核使用的 32 位平台上的另一个选项是sysenter指令。

(英特尔® 64 和 IA-32 架构软件开发人员手册第 2 卷(2A、2B 和 2C):指令集参考,AZ)

说明 执行对 0 级系统过程或例程的快速调用。SYSENTER 是 SYSEXIT 的配套指令。该指令经过优化,可为从以特权级别 3 运行的用户代码到以特权级别 0 运行的操作系统或执行过程的系统调用提供最大性能。

使用此解决方案的一个缺点是,它并非存在于所有 32 位机器上,因此int 0x80仍必须提供该方法以防 CPU 不知道它。

Pentium II 处理器的 IA-32 架构中引入了 SYSENTER 和 SYSEXIT 指令。这些指令在处理器上的可用性通过 CPUID 指令返回到 EDX 寄存器的 SYSENTER/SYSEXIT 存在 (SEP) 功能标志来指示。符合 SEP 标志的操作系统还必须符合处理器系列和型号,以确保实际存在 SYSENTER/SYSEXIT 指令

系统调用

最后一种可能性,syscall指令,几乎允许与sysenter指令相同的功能。两者的存在是因为一个 ( systenter) 是由 Intel 引入的,而另一个 ( syscall) 是由 AMD 引入的。

特定于 Linux

在 Linux 内核中,可以选择上述三种可能性中的任何一种来实现系统调用。

另请参阅Linux 系统调用权威指南

如上所述,该int 0x80方法是 3 种选择实现中唯一一种可以在任何 i386 CPU 上运行的方法,因此这是唯一一种始终可用于 32 位用户空间的方法。

(syscall是唯一一个始终可用于 64 位用户空间的,也是您应该在 64 位代码中使用的唯一一个;x86-64 内核可以在没有 的情况下构建CONFIG_IA32_EMULATION,并且int 0x80仍然调用截断指针的 32 位 ABI到 32 位。)

为了允许在所有 3 个选项之间切换,每个进程运行都可以访问一个特殊的共享对象,该共享对象可以访问为正在运行的系统选择的系统调用实现。linux-gate.so.1这是您在使用等时可能已经遇到的未解析库的奇怪外观ldd

(arch/x86/vdso/vdso32-setup.c)

 if (vdso32_syscall()) {                                                                               
        vsyscall = &vdso32_syscall_start;                                                                 
        vsyscall_len = &vdso32_syscall_end - &vdso32_syscall_start;                                       
    } else if (vdso32_sysenter()){                                                                        
        vsyscall = &vdso32_sysenter_start;                                                                
        vsyscall_len = &vdso32_sysenter_end - &vdso32_sysenter_start;                                     
    } else {                                                                                              
        vsyscall = &vdso32_int80_start;                                                                   
        vsyscall_len = &vdso32_int80_end - &vdso32_int80_start;                                           
    }   

要使用它,您只需将所有寄存器系统调用号加载到 eax 中,将参数加载到 ebx、ecx、edx、esi、edi 中,就像int 0x80系统调用实现和call主例程一样。

不幸的是,这并不是那么容易。为了最大限度地降低固定预定义地址的安全风险,vdso虚拟动态共享对象)在进程中可见的位置是随机的,因此您必须首先找出正确的位置。

该地址对每个进程都是单独的,并且一旦启动就会传递给该进程。

如果您不知道,在 Linux 中启动时,每个进程都会获得指向一旦启动时传递的参数的指针,以及指向它在其堆栈上运行的环境变量的描述的指针——每个进程都以 NULL 终止。

除了这些之外,第三个所谓的精灵辅助向量块在前面提到的那些之后被传递。正确的位置被编码在其中一个带有 type-identifier 的位置AT_SYSINFO

所以堆栈布局看起来像这样(地址向下增长):

  • 参数-0
  • ...
  • 参数-m
  • 空值
  • 环境-0
  • ……
  • 环境-n
  • 空值
  • ...
  • 辅助精灵矢量: AT_SYSINFO
  • ...
  • 辅助精灵矢量: AT_NULL

使用示例

要找到正确的地址,您必须首先跳过所有参数和所有环境指针,然后开始扫描,AT_SYSINFO如下例所示:

#include <stdio.h>
#include <elf.h>

void putc_1 (char c) {
  __asm__ ("movl $0x04, %%eax\n"
           "movl $0x01, %%ebx\n"
           "movl $0x01, %%edx\n"
           "int $0x80"
           :: "c" (&c)
           : "eax", "ebx", "edx");
}

void putc_2 (char c, void *addr) {
  __asm__ ("movl $0x04, %%eax\n"
           "movl $0x01, %%ebx\n"
           "movl $0x01, %%edx\n"
           "call *%%esi"
           :: "c" (&c), "S" (addr)
           : "eax", "ebx", "edx");
}


int main (int argc, char *argv[]) {

  /* using int 0x80 */
  putc_1 ('1');


  /* rather nasty search for jump address */
  argv += argc + 1;     /* skip args */
  while (*argv != NULL) /* skip env */
    ++argv;            

  Elf32_auxv_t *aux = (Elf32_auxv_t*) ++argv; /* aux vector start */

  while (aux->a_type != AT_SYSINFO) {
    if (aux->a_type == AT_NULL)
      return 1;
    ++aux;
  }

  putc_2 ('2', (void*) aux->a_un.a_val);

  return 0;
}

通过查看/usr/include/asm/unistd_32.h我系统上的以下片段,您将看到:

#define __NR_restart_syscall 0
#define __NR_exit            1
#define __NR_fork            2
#define __NR_read            3
#define __NR_write           4
#define __NR_open            5
#define __NR_close           6

我使用的系统调用是在 eax 寄存器中传递的编号为 4(写入)的系统调用。以 filedescriptor (ebx = 1)、data-pointer (ecx = &c) 和 size (edx = 1) 作为参数,分别传入相应的寄存器。

长话短说

将在任何Intel CPU 上运行缓慢的int 0x80系统调用与使用(真正由 AMD 发明的)指令的(希望)更快的实现进行比较,就是将苹果与橙子进行比较。syscall

恕我直言:很可能sysenter指令而不是int 0x80应该在这里进行测试。

于 2013-03-02T00:22:02.273 回答
23

调用内核(进行系统调用)时需要发生三件事:

  1. 系统从“用户模式”进入“内核模式”(环 0)。
  2. 堆栈从“用户模式”切换到“内核模式”。
  3. 跳转到内核的适当部分。

显然,一旦进入内核,内核代码将需要知道您实际上希望内核做什么,因此在 EAX 中放置一些东西,并且通常在其他寄存器中放置更多东西,因为有诸如“您要打开的文件的名称”之类的东西。 "或"从文件中读取数据的缓冲区"等,等等。

不同的处理器有不同的方式来实现上述三个步骤。在 x86 中,有多种选择,但手写 asm 最流行的两种是int 0xnn(32 位模式)或syscall(64 位模式)。(还有 32 位模式sysenter,由英特尔引入,原因与 AMD 引入 32 位模式版本的原因相同syscall:作为慢速的更快替代方案int 0x80。32 位 glibc 使用任何可用的有效系统调用机制,仅使用int 0x80如果没有更好的东西,那就慢一点。)

syscall指令的 64 位版本是在 x86-64 架构中引入的,作为进入系统调用的更快方式。它有一组寄存器(使用 x86 MSR 机制),其中包含我们希望跳转到的地址 RIP、要加载到 CS 和 SS 中的选择器值以及用于执行 Ring3 到 Ring0 的转换。它还将返回地址存储在 ECX/RCX 中。[请阅读指令集手册以了解该指令的所有细节 - 这并非完全无关紧要!]。由于处理器知道这将切换到 Ring0,它可以直接做正确的事情。

关键点之一是syscall只操作寄存器;它不执行任何加载或存储。 (这就是它用保存的 RIP 覆盖 RCX 并用保存的 RFLAGS 覆盖 R11 的原因)。内存访问取决于页表,并且页表条目有一点可以使它们仅对内核有效,而不是对用户空间有效,因此更改特权级别时进行内存访问可能需要等待而不是仅写入寄存器。一旦进入内核模式,内核通常会使用swapgs或其他方式来查找内核堆栈。(syscall不修改 RSP;它进入内核时仍然指向用户堆栈。)

当使用 SYSRET 指令返回时,这些值是从寄存器中的预定值恢复的,所以同样很快,因为处理器只需要设置几个寄存器。处理器知道它将从 Ring0 变为 Ring3,因此可以快速做正确的事情。

(AMD CPU 支持syscall来自 32 位用户空间的指令;Intel CPU 不支持。x86-64 最初是 AMD64;这就是我们syscall在 64 位模式下使用的原因。AMD 重新设计了 64 位模式的内核端syscall,所以64 位syscall内核入口点与 64 位内核中的 32 位入口点有很大不同syscall。)

The int 0x80 variant used in 32-bit mode will decide what to do based on the value in the interrupt descriptor table, which means reading from memory. There it finds the new CS and EIP/RIP values. The new CS register determines the new "ring" level - Ring0 in this case. It will then use the new CS value to look into the Task State Segment (based on the TR register) to find out which stack pointer (ESP/RSP and SS), and then finally jumps to the new address. Since this is a less direct and more generic solution it is also slower. The old EIP/RIP and CS is stored on the new stack, along with the old values of the SS and ESP/RSP.

返回时,处理器使用 IRET 指令从堆栈中读取返回地址和堆栈指针值,同时从堆栈中加载新的堆栈段和代码段值。同样,该过程是通用的,并且需要大量的内存读取。由于它是通用的,处理器还必须检查“我们是否将模式从 Ring0 更改为 Ring3,如果是,请更改这些内容”。

所以,总而言之,它更快,因为它本来就是这样工作的。

对于 32 位代码,是的,int 0x80如果你愿意,你绝对可以使用慢且兼容的。

For 64-bit code, int 0x80 is slower than syscall and will truncate your pointers to 32-bit, so don't use it. See What happens if you use the 32-bit int 0x80 Linux ABI in 64-bit code? Plus, int 0x80 isn't available in 64-bit mode on all kernels, so it's not safe even for a sys_exit which doesn't take any pointer args: CONFIG_IA32_EMULATION can be disabled, and notably is disabled on Windows Subsystem for Linux.

于 2013-03-02T01:20:38.080 回答