【问题标题】:When a syscall is called by a userspace program, how does execution transfer back to kernelspace?当用户空间程序调用系统调用时,执行如何转移回内核空间?
【发布时间】:2016-07-19 07:57:08
【问题描述】:

我一直在研究 x86-64 的 ABI、编写汇编以及研究堆栈和堆的工作原理。

给定以下代码:

#include <linux/seccomp.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    // execute the seccomp syscall (could be any syscall)
    seccomp(...);

    return 0;
}

在 x86-64 的汇编中,这将执行以下操作:

  1. 对齐堆栈指针(因为它默认关闭 8 个字节)。
  2. 为调用 seccomp 的任何参数设置寄存器和堆栈。
  3. 执行以下程序集call seccomp
  4. seccomp 返回时,据我所知,C 很可能会调用exit(0)

我想谈谈上面第三步和第四步之间发生的事情。

我目前有我的堆栈用于当前正在运行的进程,它在寄存器和堆栈上都有自己的数据。用户空间进程如何将执行权交给内核?内核是否只是在调用时拿起然后从同一个堆栈推入和弹出?

我相信我在某处听说过系统调用不会立即发生,而是在某些 CPU 滴答声或中断时发生。这是真的?例如,这在 Linux 上是如何发生的?

【问题讨论】:

  • 对于函数调用,你是对的。但是,对于系统调用,没有。 函数 seccomp() 会将参数从堆栈中加载到特定的寄存器中以特定的顺序,将seccomp 的系统调用号加载到rax,然后执行指令int 0x80sysentersyscall。 CPU“捕获”并将控制权交给内核,内核执行中断服务例程,确定编号为rax的系统调用被请求并执行。内核在将控制权返回给进程时,会在rax 中报告返回值。
  • @IwillnotexistIdonotexist 很好的回复,应该是答案!
  • @PeterCordes 对我的评论进行了出色的阐述。正如他所指出的,CPU 不会等到下一个滴答声。如果 CPU 被中断,它会尽快处理中断(由于上下文切换,在几百个周期内)。不同之处在于程序可以通过syscall 指令自愿中断自己,或者计时器可以不自觉地中断程序的执行以将控制权交给内核。内核设置此计时器的目的是为了定期干预以在进程之间共享 CPU 时间片
  • 啊,现在涉及到 1) 跳转到处理系统调用的 ISR 2) 将线程的所有几十个寄存器值保存到一个簿记结构中 3) 用内核线程的替换它们寄存器值。然后系统调用的实现被调用,运行,返回,然后按照相反的过程切换回用户模式,除了rax的值被故意替换为系统调用的返回码。这是用户模式进程所期望的,并且是用户模式和内核模式之间的 ABI(合同或承诺)的一部分。
  • @IwillnotexistIdonotexist:系统调用期间的用户->内核转换确实保存了用户空间寄存器,但没有单独的内核线程可以切换和恢复寄存器。内核入口点代码确实修改了 RSP 以指向该线程的内核堆栈,但当入口点 asm 运行 CALL 指令以调用 C 系统调用处理代码时,其他寄存器保留保存系统调用参数或垃圾。见the source in arch/x86/entry/entry_64.S

标签: c assembly linux-kernel kernel


【解决方案1】:

系统调用不会立即发生,而是在某些 CPU 节拍或中断时发生

完全错误。在定时器中断之前,CPU 不会坐在那里什么都不做。在包括 x86-64 在内的大多数架构上,切换到内核模式需要数十到数百个周期,但这并不是因为 CPU 正在等待任何东西。这只是一个缓慢的操作。


请注意,glibc 几乎为每个系统调用提供了函数包装器,因此如果您查看反汇编,您只会看到一个看起来很正常的函数调用。


真正发生了什么(以 x86-64 为例):

查看 AMD64 SysV ABI 文档,链接自 标签 wiki。它指定将 args 放入哪些寄存器,并使用 syscall 指令进行系统调用。英特尔的 insn 参考手册(也从标签 wiki 链接)详细记录了 syscall 对 CPU 架构状态所做的每一次更改。如果你对它的设计历史感兴趣,I dug up some interesting mailing list posts 来自 AMD 架构师和内核开发人员之间的 amd64 邮件列表。 AMD 在第一个 AMD64 硬件发布之前更新了行为so it was actually usable for Linux (and other kernels)

32 位 x86 使用 int 0x80 指令进行系统调用,或 sysentersyscall 在 32 位模式下不可用,sysenter 在 64 位模式下不可用。您可以在 64 位代码中运行 int 0x80,但您仍然可以获得将指针视为 32 位的 32 位 API。 (即不要这样做)。顺便说一句,也许您对由于int 0x80 而必须等待中断的系统调用感到困惑?运行该指令会立即触发该中断,直接跳转到中断处理程序。 0x80 也不是硬件可以触发的中断,因此中断处理程序只能在软件触发的系统调用之后运行。


AMD64 系统调用示例:

#include <stdlib.h>
#include <unistd.h>
#include <linux/unistd.h>    // for __NR_write

const char msg[]="hello world!\n";

ssize_t amd64_write(int fd, const char*msg, size_t len) {
  ssize_t ret;
  asm volatile("syscall"  // volatile because we still need the side-effect of making the syscall even if the result is unused
               : "=a"(ret)                   // outputs
               : [callnum]"a"(__NR_write),   // inputs: syscall number in rax,
                "D" (fd), "S"(msg), "d"(len)    // and args, in same regs as the function calling convention
               : "rcx", "r11",               // clobbers: syscall always destroys rcx/r11, but Linux preserves all other regs
                 "memory"                    // "memory" to make sure any stores into buffers happen in program order relative to the syscall 
              );
}

int main(int argc, char *argv[]) {
    amd64_write(1, msg, sizeof(msg)-1);
    return 0;
}

int glibcwrite(int argc, char**argv) {
    write(1, msg, sizeof(msg)-1);  // don't write the trailing zero byte
    return 0;
}

compiles to this asm output, with the godbolt Compiler Explorer:

gcc 的 -masm=intel 输出有点类似于 MASM,因为它使用 OFFSET 键来获取标签的地址。

.rodata
msg:
        .string "hello world!\n"

.text
main:   // using an in-line syscall
        mov     eax, 1    # __NR_write
        mov     edx, 13   # string length
        mov     esi, OFFSET FLAT:msg      # string pointer
        mov     edi, eax  # file descriptor = 1 happens to be the same as __NR_write
        syscall
        xor     eax, eax  # zero the return value
        ret

glibcwrite:  // using the normal way that you get from compiler output
        sub     rsp, 8       // keep the stack 16B-aligned for the function call
        mov     edx, 13      // put args in registers
        mov     esi, OFFSET FLAT:msg
        mov     edi, 1
        call    write
        xor     eax, eax
        add     rsp, 8
        ret

glibc 的 write 包装函数只是将 1 放入 eax 并运行 syscall,然后检查返回值并设置 errno。还处理在 EINTR 和其他东西上重新启动系统调用。

// objdump -R -Mintel -d /lib/x86_64-linux-gnu/libc.so.6
...
00000000000f7480 <__write>:
   f7480:       83 3d f9 27 2d 00 00    cmp    DWORD PTR [rip+0x2d27f9],0x0        # 3c9c80 <argp_program_version_hook+0x1f8>
   f7487:       75 10                   jne    f7499 <__write+0x19>
   f7489:       b8 01 00 00 00          mov    eax,0x1
   f748e:       0f 05                   syscall
   f7490:       48 3d 01 f0 ff ff       cmp    rax,0xfffffffffffff001   // I think that's -EINTR
   f7496:       73 31                   jae    f74c9 <__write+0x49>
   f7498:       c3                      ret
   ... more code to handle cases where one of those branches was taken

【讨论】:

    【解决方案2】:

    系统调用不会立即发生,而是在某些 CPU 节拍或中断时发生

    当然,您的系统调用的效果可能取决于许多因素,包括滴答声。调度程序粒度或时间分辨率可以限制为滴答周期,例如但是调用本身应该“立即”发生(与执行一致)。

    用户空间进程如何将执行权交给内核?内核是不是在调用时才开始接收,然后从同一个堆栈中推入和弹出?

    架构之间可能略有不同,但通常系统调用参数由libc 组装,然后生成处理器异常以更改上下文。

    更多详情请见:“How system calls work on x86 linux

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2014-08-02
      • 1970-01-01
      • 2018-11-29
      • 1970-01-01
      • 2011-12-15
      • 2017-12-10
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多