系统调用如何知道跳转到哪里?

How syscall knows where to jump?

Linux如何确定要使用系统调用执行的另一个进程的地址?就像这个例子一样?

mov rax, 59 
mov rdi, progName
syscall

我的问题似乎有点混乱,澄清一下,我想问的是系统调用是如何工作的,与传递的寄存器或参数无关。当另一个进程被调用时,它如何知道跳转到哪里,return 等。

系统调用

syscall 指令实际上只是一个 INTEL/AMD CPU 指令。这是概要:

IF (CS.L ≠ 1 ) or (IA32_EFER.LMA ≠ 1) or (IA32_EFER.SCE ≠ 1)
  THEN #UD;
FI;
RCX ← RIP;
RIP ← IA32_LSTAR;
R11 ← RFLAGS;
RFLAGS ← RFLAGS AND NOT(IA32_FMASK);
CS.Selector ← IA32_STAR[47:32] AND FFFCH
CS.Base ← 0;
CS.Limit ← FFFFFH;
CS.Type ← 11;
CS.S ← 1;
CS.DPL ← 0;
CS.P ← 1;
CS.L ← 1;
CS.D ← 0;
CS.G ← 1;
CPL ← 0;
SS.Selector ← IA32_STAR[47:32] + 8;
SS.Base ← 0;
SS.Limit ← FFFFFH;
SS.Type ← 3;
SS.S ← 1;
SS.DPL ← 0;
SS.P ← 1;
SS.B ← 1;
SS.G ← 1;

最重要的部分是保存和管理RIP寄存器的两条指令:

RCX ← RIP
RIP ← IA32_LSTAR

所以也就是说IA32_LSTAR(一个寄存器)保存的地址一定有代码,RCX就是return地址。

CSSS 段也进行了调整,因此您的内核代码将能够在 CPU 级别 0(特权级别)进一步 运行。 =33=]

如果您无权执行syscall或指令不存在,则可能出现#UD

RAX 是如何解释的?

这只是 table 内核函数指针的索引。首先内核执行 bounds-check(如果 RAX > __NR_syscall_max 则执行 returns -ENOSYS),然后分派到(C 语法)sys_call_table[rax](rdi, rsi, rdx, r10, r8, r9);

; Intel-syntax translation of Linux 4.12 syscall entry point
       ...                 ; save user-space registers etc.
    call   [sys_call_table + rax * 8]       ; dispatch to sys_execve() or whatever kernel C function

;;; execve probably won't return via this path, but most other calls will
       ...                 ; restore registers except RAX return value, and return to user-space

现代 Linux 在实践中更加复杂,因为通过更改页面 tables 来解决 x86 漏洞(如 Meltdown 和 L1TF)的变通方法,因此大部分内核内存未映射,而 user-space是运行宁。上面的代码是 Linux 4.12 arch/x86/entry/entry_64.S (before Spectre/Meltdown mitigations were added). Also related: ENTRY(entry_SYSCALL_64)call *sys_call_table(, %rax, 8) 的字面翻译(来自 AT&T 语法),其中包含有关 system-call 调度的内核方面的更多详细信息。

快?

据说该指令。这是因为在过去,人们必须使用诸如 INT3 之类的指令。中断利用了内核堆栈,它将许多寄存器压入堆栈并使用相当慢的 RTE 退出异常状态和 return 到中断后的地址。这通常要慢得多。

使用 syscall 您可以避免大部分开销。但是,根据您的要求,这并没有真正的帮助。

syscall 一起使用的另一条指令是 swapgs。这为内核提供了一种访问自己的数据和堆栈的方法。您应该查看有关这些说明的 Intel/AMD 文档以了解更多详细信息。

新进程?

Linux 系统有它所谓的任务 table。每个进程和进程中的每个线程实际上都称为任务。

创建新进程时,Linux 会创建一个任务。为了让它起作用,它 运行s 代码可以执行以下操作:

  • 确保 executable 存在
  • 设置新任务(包括从该 executable 解析 ELF 程序头以在 newly-created 虚拟地址 space 中创建内存映射。)
  • 分配堆栈缓冲区
  • 加载 execu 的前几个块table(作为需求分页的优化),为虚拟页面分配一些物理页面以映射到。
  • 设置任务中的起始地址(来自execu的ELF入口点table)
  • 将任务标记为就绪 (a.k.a。运行ning)

当然,这是超级简化的。

起始地址在您的 ELF 二进制文件中定义。它实际上只需要确定一个地址并将其保存在任务当前RIP指针和“return”到user-space中。正常的 demand-paging 机制会处理剩下的事情:如果代码还没有加载,它会生成一个 #PF page-fault 异常,内核会在那个时候加载必要的代码。尽管在大多数情况下加载器已经加载了软件的某些部分作为优化以避免初始 page-fault.

(未映射页面上的#PF 会导致内核向您的进程发送 SIGSEGV 段错误信号,但内核会静默处理“有效”页面错误。)

所有新进程通常都加载到相同的虚拟地址(忽略 PIE + ASLR)。这是可能的,因为我们使用了 MMU(内存管理单元)。该协处理器在虚拟地址 spaces 和物理地址 space.

之间转换内存地址

(编者注:MMU 并不是真正的协处理器;在现代 CPU 中,虚拟内存逻辑与 L1 instruction/data 缓存紧密集成到每个内核中。一些古老的 CPUs 确实使用了外部 MMU 芯片。)

确定地址?

所以,现在我们了解到所有进程都有相同的虚拟地址(Linux 下的 0x400000 是 ld 默认选择的)。我们使用 MMU 来确定真实的物理地址。内核如何决定该物理地址?嗯,它有一个内存分配功能。就这么简单。

它调用“malloc()”类型的函数来搜索当前未使用的内存块并在该位置创建(a.k.a。加载)进程。如果当前没有可用的内存块,内核会检查是否将某些东西换出了内存。如果失败,则进程创建失败。

在创建进程的情况下,它将分配相当大的内存块作为开始。分配 1Mb 或 2Mb 缓冲区来启动新进程并不罕见。这个让事情进展得更快。

此外,如果进程已经 运行ning 并且您再次启动它,则可以重复使用已经 运行ning 实例使用的大量内存。在那种情况下,内核不会 allocate/load 这些部分。它将使用 MMU 共享那些可以为进程的两个实例共用的页面(即在大多数情况下,进程的代码部分可以共享,因为它是 read-only,数据的某些部分可以是当它也标记为read-only时共享;如果未标记read-only,则数据在未被修改的情况下仍然可以共享——在这种情况下它被标记为copy on写.)