Update the system call number from 322 to 547. Translate a few English part. Fixed a few typo. Replaced "." by "。" as period in Chinese part. Replaced "," by "," as comma in Chinese part. Added space between Chinese and English. Unified the article format.
22 KiB
Linux 系统内核调用 第二节
Linux 内核如何处理系统调用
前一小节 作为本章节的第一部分描述了 Linux 内核system call 概念。 前一节中提到通常系统调用处于内核处于操作系统层面。 前一节内容从用户空间的角度介绍,并且 write系统调用实现的一部分内容没有讨论。 在这一小节继续关注系统调用,在深入 Linux 内核之前,从一些理论开始。
程序中一个用户程序并不直接使用系统调用。 我们并未这样写 Hello World程序代码:
int main(int argc, char **argv)
{
...
...
...
sys_write(fd1, buf, strlen(buf));
...
...
}
我们可以使用与 C standard library 帮助类似的方式:
#include <unistd.h>
int main(int argc, char **argv)
{
...
...
...
write(fd1, buf, strlen(buf));
...
...
}
不管怎样,write 不是直接的系统调用也不是内核函数。 程序必须将通用目的寄存器按照正确的顺序存入正确的值,之后使用 syscall 指令实现真正的系统调用。 在这一节我们关注 Linux 内核中,处理器执行 syscall 指令时的细节。
系统调用表的初始化
从前一节可知系统调用与中断非常相似。 深入的说,系统调用是软件中断的处理程序。 因此,当处理器执行程序的 syscall 指令时,指令引起异常导致将控制权转移至异常处理。 众所周知,所有的异常处理 (或者内核 C 函数将响应异常) 是放在内核代码中的。 但是 Linux 内核如何查找对应系统调用的系统调用处理程序的地址? Linux 内核由一个特殊的表:system call table。 系统调用表是Linux内核源码文件 arch/x86/entry/syscall_64.c 中定义的数组 sys_call_table 的对应。 其实现如下:
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
sys_call_table 数组的大小为 __NR_syscall_max + 1,__NR_syscall_max 宏作为给定架构的系统调用最大数量。 这本书关于 x86_64 架构, 因此 __NR_syscall_max 为 547,这也是本书编写时(当前 Linux 内核版本为 5.0.0-rc7) 的数字。 编译内核时可通过 Kbuild 产生的头文件查看该宏 - include/generated/asm-offsets.h`:
#define __NR_syscall_max 547
对于 x86_64,arch/x86/entry/syscalls/syscall_64.tbl 中也有相同的系统调用数量。 这里存在两个重要的话题; sys_call_table 数组的类型及数组中元数的初始值。 首先,sys_call_ptr_t 为指向系统调用表的指针。 其是通过 typedef 定义的函数指针的,返回值为空且无参数:
typedef void (*sys_call_ptr_t)(void);
其次为 sys_call_table 数组中元素的初始化。 从上面的代码中可知,数组中所有元素包含指向 sys_ni_syscall 的系统调用处理器的指针。 sys_ni_syscall 函数为 “not-implemented” 系统调用。 首先,sys_call_table 的所有元素指向 “not-implemented” 系统调用。 这是正确的初始化方法,因为我们仅仅初始化指向系统调用处理器的指针的存储位置,稍后再做处理。 sys_ni_syscall 的结果比较简单,仅仅返回 -errno 或者 -ENOSYS :
asmlinkage long sys_ni_syscall(void)
{
return -ENOSYS;
}
The -ENOSYS error tells us that:
ENOSYS Function not implemented (POSIX.1)
在 sys_call_table 的初始化中同时也要注意 ...。 我们可通过 GCC 编译器插件 - Designated Initializers 使用它。 插件允许使用不固定的顺序初始化元素。 在数组结束处,我们引用 asm/syscalls_64.h 头文件在。 头文件由特殊的脚本 arch/x86/entry/syscalls/syscalltbl.sh 从 syscall table 产生。 asm/syscalls_64.h 包括以下宏的定义:
__SYSCALL_COMMON(0, sys_read, sys_read)
__SYSCALL_COMMON(1, sys_write, sys_write)
__SYSCALL_COMMON(2, sys_open, sys_open)
__SYSCALL_COMMON(3, sys_close, sys_close)
__SYSCALL_COMMON(5, sys_newfstat, sys_newfstat)
...
...
...
宏 __SYSCALL_COMMON 在相同的源码中定义,作为宏 __SYSCALL_64 的扩展:
#define __SYSCALL_COMMON(nr, sym, compat) __SYSCALL_64(nr, sym, compat)
#define __SYSCALL_64(nr, sym, compat) [nr] = sym,
因而,到此为止,sys_call_table 为如下格式:
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
...
...
...
};
之后所有指向 “non-implemented” 系统调用元素的内容为 sys_ni_syscall 函数的地址,该函数仅返回 -ENOSYS。 其他元素指向 sys_syscall_name 函数。
至此,我们完成了系统调用表的填充并且 Linux 内核了解每个系统调用处理器的位置。 但是 Linux 内核在处理用户空间程序的系统调用时并未立即调用 sys_syscall_name 函数。 记住关于中断及中断处理的章节。 当 Linux 内核获得处理中断的控制权,在调用中断处理程序前,必须做一些准备如保存用户空间寄存器、切换至新的堆栈及其他很多工作。 系统调用处理也是相同的情形。 第一件事是处理系统调用的准备,但是在 Linux 内核开始这些准备之前,系统调用的入口必须完成初始化,同时只有 Linux 内核知道如何执行这些准备。 在下一章节我们将关注 Linux 内核中关于系统调用入口的初始化过程。
系统调用入口初始化
当系统中发生系统调用,开始处理调用的代码的第一个字节在什么地方? 阅读 Intel 的手册 - 64-ia-32-architectures-software-developer-vol-2b-manual:
SYSCALL 引起操作系统系统调用处理器处于特权级 0,其通过加载 IA32_LSTAR MSR 至 RIP 完成。
这就是说我们需要将系统调用入口放置到 IA32_LSTAR model specific register。 这一操作在 Linux 内核初始过程时完成 若你已阅读关于 Linux 内核中断及中断处理章节的第四节,Linux 内核调用在初始化过程中调用 trap_init 函数。 该函数在 arch/x86/kernel/setup.c 源代码文件中定义,执行 non-early 异常处理(如除法错误,协处理器 错误等 )的初始化。 除了 non-early 异常处理的初始化外,函数调用 arch/x86/kernel/cpu/common.c 中 cpu_init 函数,调用相同源码文件中的 syscall_init 完成 per-cpu 状态初始化。
该函数执行系统调用入口的初始化。 查看函数的实现,函数没有参数且首先填充两个特殊模块寄存器:
wrmsrl(MSR_STAR, ((u64)__USER32_CS)<<48 | ((u64)__KERNEL_CS)<<32);
wrmsrl(MSR_LSTAR, entry_SYSCALL_64);
第一个特殊模块集寄存器- MSR_STAR 的 63:48 为用户代码的代码段。 这些数据将加载至 CS 和 SS 段选择符,由提供将系统调用返回至相应特权级的用户代码功能的 sysret 指令使用。 同时从内核代码来看,当用户空间应用程序执行系统调用时,MSR_STAR 的 47:32 将作为 CS and SS 段选择寄存器的基地址。 第二行代码中我们将使用系统调用入口 entry_SYSCALL_64 填充 MSR_LSTAR 寄存器。 entry_SYSCALL_64 在 arch/x86/entry/entry_64.S 汇编文件中定义,包含系统调用执行前的准备(上面已经提及这些准备)。 目前不关注 entry_SYSCALL_64,将在章节的后续讨论。
在设置系统调用的入口之后,需要以下特殊模式寄存器:
MSR_CSTAR- targetripfor the compability mode callers;MSR_IA32_SYSENTER_CS- targetcsfor thesysenterinstruction;MSR_IA32_SYSENTER_ESP- targetespfor thesysenterinstruction;MSR_IA32_SYSENTER_EIP- targeteipfor thesysenterinstruction.
这些特殊模式寄存器的值与内核配置选项 CONFIG_IA32_EMULATION 有关。 若开启该内核配置选项,将允许 64 字节内核运行 32 字节的程序。 第一个例子中,若 CONFIG_IA32_EMULATION 内合配置选项开启,将使用兼容模式的系统调用入口填充这些特殊模式寄存器:
wrmsrl(MSR_CSTAR, entry_SYSCALL_compat);
对于内核代码段,将堆栈指针置零,entry_SYSENTER_compat 字的地址写入指令指针:
wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)__KERNEL_CS);
wrmsrl_safe(MSR_IA32_SYSENTER_ESP, 0ULL);
wrmsrl_safe(MSR_IA32_SYSENTER_EIP, (u64)entry_SYSENTER_compat);
另一方面,若 CONFIG_IA32_EMULATION 内核配置选项未开启,将把 ignore_sysret 字写入 MSR_CSTAR:
wrmsrl(MSR_CSTAR, ignore_sysret);
其在 arch/x86/entry/entry_64.S 汇编文件中定义,仅返回 -ENOSYS 错误代码:
ENTRY(ignore_sysret)
mov $-ENOSYS, %eax
sysret
END(ignore_sysret)
现在需要像之前代码一样,当 CONFIG_IA32_EMULATION 内核配置选项打开时,填充 MSR_IA32_SYSENTER_CS,MSR_IA32_SYSENTER_ESP,MSR_IA32_SYSENTER_EIP 特殊模式寄存器。 而在 CONFIG_IA32_EMULATION 配置选项未设置的情况下,将用零填充 MSR_IA32_SYSENTER_ESP 和 MSR_IA32_SYSENTER_EIP,同时将 Global Descriptor Table 的无效段加载至 MSR_IA32_SYSENTER_CS 特殊模式寄存器:
wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)GDT_ENTRY_INVALID_SEG);
wrmsrl_safe(MSR_IA32_SYSENTER_ESP, 0ULL);
wrmsrl_safe(MSR_IA32_SYSENTER_EIP, 0ULL);
可以从描述 Linux 内核启动过程的章节阅读更多关于 Global Descriptor Table 的内容。
在 syscall_init 函数的尾段,通过写入 MSR_SYSCALL_MASK 特殊寄存器的标志位,将标志寄存器中的标志位屏蔽:
wrmsrl(MSR_SYSCALL_MASK,
X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF|
X86_EFLAGS_IOPL|X86_EFLAGS_AC|X86_EFLAGS_NT);
这些标志位将在 syscall 初始化时清除。 至此,syscall_init 函数结束 也意味着系统调用已经可用。 现在我们可以开始关注当用户程序执行 syscall 指令发生什么。
系统调用处理执行前的准备
如之前写到,系统调用或中断处理在被 Linux 内核调用前需要一些准备。 宏 idtentry 完成异常处理被执行前的所需准备,宏 interrupt 完成中断处理被调用前的所需准备,entry_SYSCALL_64 完成系统调用执行前的所需准备。
entry_SYSCALL_64 在 arch/x86/entry/entry_64.S 汇编文件中定义,从下面的宏开始:
SWAPGS_UNSAFE_STACK
该宏在 arch/x86/include/asm/irqflags.h 头文件中定义,扩展为 swapgs 指令:
#define SWAPGS_UNSAFE_STACK swapgs
此宏将交换 GS 段选择符及 MSR_KERNEL_GS_BASE 特殊模式寄存器中的值。 换句话说,将其入内核堆栈。 之后使老的堆栈指针指向 rsp_scratch per-cpu 变量,并设置堆栈指针指向当前处理器的栈顶:
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
下一步中将堆栈段及老的堆栈指针入栈:
pushq $__USER_DS
pushq PER_CPU_VAR(rsp_scratch)
这之后使能中断,因为入口中断被关闭,保存通用目的寄存器 (除 bp,bx 及 r12 至 r15)、标志位、“non-implemented” 系统调用相关的 -ENOSYS 及代码段寄存器至堆栈:
ENABLE_INTERRUPTS(CLBR_NONE)
pushq %r11
pushq $__USER_CS
pushq %rcx
pushq %rax
pushq %rdi
pushq %rsi
pushq %rdx
pushq %rcx
pushq $-ENOSYS
pushq %r8
pushq %r9
pushq %r10
pushq %r11
sub $(6*8), %rsp
当系统调用由用户空间程序引起时,通用目的寄存器状态如下:
rax- 包含系统调用编号rcx- 包含回到用户空间返回地址contains return address to the user space;r11- 包含寄存器标志rdi- 包含 system call handler 的第一个参数rsi- 包含 system call handler 的第二个参数rdx- 包含 system call handler 的第三个参数r10- 包含 system call handler 的第四个参数r8- 包含 system call handler 的第五个参数r9- 包含 system call handler 的第六个参数
其他通用目的寄存器 (如 rbp,rbx 和 r12 至 r15) 在 C ABI 保留)。 将寄存器标志位入栈,之后是 “non-implemented” 系统调用的用户代码段,用户空间返回地址,系统调用编号,三个参数,dump 错误代码和堆栈中的其他信息。
下一步检查当前 thread_info 中的 _TIF_WORK_SYSCALL_ENTRY:
testl $_TIF_WORK_SYSCALL_ENTRY, ASM_THREAD_INFO(TI_flags, %rsp, SIZEOF_PTREGS)
jnz tracesys
宏 _TIF_WORK_SYSCALL_ENTRY 在 arch/x86/include/asm/thread_info.h 头文件中定义,提供一系列与系统调用跟踪有关的进程信息标志:
#define _TIF_WORK_SYSCALL_ENTRY \
(_TIF_SYSCALL_TRACE | _TIF_SYSCALL_EMU | _TIF_SYSCALL_AUDIT | \
_TIF_SECCOMP | _TIF_SINGLESTEP | _TIF_SYSCALL_TRACEPOINT | \
_TIF_NOHZ)
本章节中不讨论追踪/调试相关内容,这将在关于 Linux 内核调试及追踪相关独立章节中讨论。 在 tracesys 标签之后,下一标签为 entry_SYSCALL_64_fastpath。 在 entry_SYSCALL_64_fastpath 内检查头文件 arch/x86/include/asm/unistd.h 中定义的 __SYSCALL_MASK
# ifdef CONFIG_X86_X32_ABI
# define __SYSCALL_MASK (~(__X32_SYSCALL_BIT))
# else
# define __SYSCALL_MASK (~0)
# endif
其中 __X32_SYSCALL_BIT 为:
#define __X32_SYSCALL_BIT 0x40000000
众所周知,__SYSCALL_MASK 与 CONFIG_X86_X32_ABI 内核配置选项相关,且其为 64 位内核中 32 位 ABI 的掩码。
因此我们可以检查 __SYSCALL_MASK,若 CONFIG_X86_X32_ABI 未启用,我们会将 rax 寄存器的值与系统调用最大数量(__NR_syscall_max) 进行比较,而若 CNOFIG_X86_X32_ABI 有启用,我们会对 eax 寄存器与 X32_SYSCALL_BIT 进行掩码操作并接着做同样的比较:
#if __SYSCALL_MASK == ~0
cmpq $__NR_syscall_max, %rax
#else
andl $__SYSCALL_MASK, %eax
cmpl $__NR_syscall_max, %eax
#endif
至此检查最后一道比较指令的结果,ja 指令在 CF 和 ZF 标志为 0 时执行:
ja 1f
若其正确调用系统调用,第四个参数将从 r10 移动至 rcx,保持 x86_64 C ABI 开启,同时以系统调用的处理程序的地址为参数执行 call 指令:
movq %r10, %rcx
call *sys_call_table(, %rax, 8)
注意,上文提到 sys_call_table 是一个数组。 rax 通用目的寄存器为系统调用的编号,且 sys_call_table 的每个元素为 8 字节。 因此使用 *sys_call_table(, %rax, 8) 符号找到指定系统调用处理在 sys_call_table 中的偏移。
就这样。 完成了所需的准备,系统调用处理将被相应的中断处理调用。 例如 Linux 内核代码中 SYSCALL_DEFINE[N] 宏定义的 sys_read,sys_write 和其他中断处理。
退出系统调用
在系统调用处理完成任务后,将退回 arch/x86/entry/entry_64.S,正好在系统调用之后:
call *sys_call_table(, %rax, 8)
在从系统调用处理返回之后,下一步是将系统调用处理的返回值入栈。 系统调用将用户程序的返回结果放置在通用目的寄存器 rax 中,因此在系统调用处理完成其工作后,将寄存器的值入栈上 RAX 指定的位置:
movq %rax, RAX(%rsp)
之后调用在 arch/x86/include/asm/irqflags.h 中定义的宏 LOCKDEP_SYS_EXIT :
LOCKDEP_SYS_EXIT
宏的实现与 CONFIG_DEBUG_LOCK_ALLOC 内核配置选项相关,该配置允许在退出系统调用时调试锁。 再次强调,在该章节不关注,将在单独的章节讨论相关内容。 在 entry_SYSCALL_64 函数的最后,恢复除 rxc 和 r11 外所有通用寄存器,因为 rcx 寄存器为调用系统调用的应用程序的返回地址,r11 寄存器为老的 flags register。 在恢复所有通用寄存器之后,将在 rcx 中装入返回地址,r11 寄存器装入标志,rsp 装入老的堆栈指针:
RESTORE_C_REGS_EXCEPT_RCX_R11
movq RIP(%rsp), %rcx
movq EFLAGS(%rsp), %r11
movq RSP(%rsp), %rsp
USERGS_SYSRET64
最后仅仅调用宏 USERGS_SYSRET64,其扩展调用 swapgs 指令交换用户 GS 和内核 GS,sysretq 指令执行从系统调用处理退出。
#define USERGS_SYSRET64 \
swapgs; \
sysretq;
现在我们知道,当用户程序使用系统调用时发生的一切。 整个过程的步骤如下:
- 用户程序中的代码装入通用目的寄存器的值(系统调用编号和系统调用的参数);
- 处理器从用户模式切换到内核模式 开始执行系统调用入口 -
entry_SYSCALL_64; entry_SYSCALL_64切换至内核堆栈,在堆栈中存通用目的寄存器,老的堆栈,代码段,标志位等;entry_SYSCALL_64检查rax寄存器中的系统调用编号,系统调用编号正确时,在sys_call_table中查找系统调用处理并调用;- 若系统调用编号不正确,跳至系统调用退出;
- 系统调用处理完成工作后,恢复通用寄存器,老的堆栈,标志位及返回地址,通过
sysretq指令退出entry_SYSCALL_64。
结论
这是 Linux 内核相关概念的第二节。 在前一节我们从用户应用程序的角度讨论了这些概念的原理。 在这一节继续深入系统调用概念的相关内容,讨论了系统调用发生时 Linux 内核执行的内容。
若存在疑问及建议,在 twitter @0xAX,通过 email 或者创建 issue。
由于英语是我的第一语言由此造成的不便深感抱歉。若发现错误请提交 PR 至 linux-insides。