系统调用
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是返回地址。
CS 和 SS 段也进行了调整,因此您的内核代码将能够在 CPU 级别 0(特权级别)上进一步运行。
如果您无权执行syscall 或该指令不存在,则可能会出现#UD。
RAX 是如何解释的?
这只是内核函数指针表的索引。首先内核进行边界检查(如果RAX > __NR_syscall_max,则返回-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 在实践中更加复杂,因为通过更改页表来解决诸如 Meltdown 和 L1TF 之类的 x86 漏洞,因此在用户空间运行时大部分内核内存都不会被映射。上面的代码是call *sys_call_table(, %rax, 8) 从ENTRY(entry_SYSCALL_64) 到Linux 4.12 arch/x86/entry/entry_64.S 的字面翻译(来自AT&T 语法)(在添加Spectre/Meltdown 缓解措施之前)。同样相关:What happens if you use the 32-bit int 0x80 Linux ABI in 64-bit code? 提供了有关系统调用调度的内核方面的更多详细信息。
快吗?
该指令被称为快速。这是因为在过去,人们必须使用诸如INT3 之类的指令。中断利用内核堆栈,它将许多寄存器压入堆栈并使用相当慢的RTE 退出异常状态并返回中断后的地址。这通常要慢得多。
使用syscall,您可以避免大部分开销。但是,按照您的要求,这并没有真正的帮助。
与syscall 一起使用的另一条指令是swapgs。这为内核提供了一种访问自己的数据和堆栈的方法。您应该查看有关这些说明的 Intel/AMD 文档以了解更多详细信息。
新流程?
Linux 系统有所谓的任务表。进程中的每个进程和每个线程实际上都称为一个任务。
当您创建一个新进程时,Linux 会创建一个任务。为此,它运行的代码执行以下操作:
- 确保可执行文件存在
- 设置新任务(包括解析该可执行文件中的 ELF 程序头,以在新创建的虚拟地址空间中创建内存映射。)
- 分配堆栈缓冲区
- 加载可执行文件的前几个块(作为按需分页的优化),为要映射到的虚拟页面分配一些物理页面。
- 在任务中设置起始地址(来自可执行文件的 ELF 入口点)
- 将任务标记为就绪(也就是正在运行)
当然,这是超级简化的。
起始地址在您的 ELF 二进制文件中定义。它实际上只需要确定一个地址并将其保存在任务当前RIP 指针中并“返回”到用户空间。正常的请求分页机制会处理剩下的事情:如果代码尚未加载,它将生成#PF page-fault 异常,内核将在此时加载必要的代码。尽管在大多数情况下,加载器已经加载了软件的某些部分作为优化以避免初始页面错误。
(未映射的页面上的#PF 将导致内核向您的进程发送 SIGSEGV 段错误信号,但“有效”页面错误由内核静默处理。)
所有新进程通常都加载到相同的虚拟地址(忽略 PIE + ASLR)。这是可能的,因为我们使用了 MMU(内存管理单元)。该协处理器在虚拟地址空间和物理地址空间之间转换内存地址。
(编者注:MMU 并不是真正的协处理器;在现代 CPU 中,虚拟内存逻辑与 L1 指令/数据缓存一起紧密集成到每个内核中。不过,一些古老的 CPU 确实使用了外部 MMU 芯片。 )
确定地址?
所以,现在我们知道所有进程都具有相同的虚拟地址(Linux 下的 0x400000 是ld 选择的默认地址)。为了确定真实的物理地址,我们使用 MMU。内核如何决定该物理地址?嗯,它有内存分配功能。就这么简单。
它调用“malloc()”类型的函数,该函数搜索当前未使用的内存块并在该位置创建(也称为加载)进程。如果当前没有可用的内存块,内核会检查是否从内存中交换了一些东西。如果失败,则进程创建失败。
在创建进程的情况下,它将分配相当大的内存块开始。分配 1Mb 或 2Mb 缓冲区来启动新进程并不罕见。这使事情进展得更快。
此外,如果进程已经在运行并且您再次启动它,那么已经运行的实例使用的大量内存可以被重用。在这种情况下,内核不会分配/加载这些部分。它将使用 MMU 共享进程的两个实例可以共享的那些页面(即,在大多数情况下,进程的代码部分可以共享,因为它是只读的,部分数据可以在以下情况下共享它也被标记为只读;如果未标记为只读,则如果尚未修改数据,仍然可以共享数据——在这种情况下,它被标记为写时复制。)