Linux 系统调用过程

Glibc 对系统调用的封装

平时在编程的时候不会直接发出系统调用,而是通过 Glibc 这个库来发出系统调用。Glibc 是 Linux 下使用的开源的标准 C 库,它是 GNU 发布的 libc 库。Glibc 为程序员提供了丰富的 API,而且对系统调用的发出进行封装了。当我们调用某个库函数的时候,这个库函数将发出系统调用的过程封装进去了。

一般来说每个特定的系统调用都会对应一个 Glibc 封装的库函数。比如系统调用 sys_open 对应 Glibc 中的 open 函数。但是也有可能一个系统调用对应多个库函数,比如 Glibc 下的 malloc、calloc、free 都对应 sys_brk 的系统调用。还有可能是一个库函数对应多个系统调用,比如 printf 函数就会调用 sys_open、sys_mmap、sys_write、sys_close 等系统调用。

下面以 glibc 中的 open 函数为例进行讲解,

1
int open(const char *pathname, int flags, mode_t mode)

在 glibc 的源代码中,有个文件 syscalls.list,里面列着所有 glibc 的函数对应的系统调用,就像下面这个样子:

1
2
# File name Caller  Syscall name    Args    Strong name Weak names
open - open Ci:siv __libc_open __open open

glibc 还有一个脚本 make-syscalls.sh,可以根据上面的配置文件,对于每一个封装好的系统调用,生成一个文件。这个文件里面定义了一些宏,如下

1
2
3
4
5
6
#define SYSCALL_NAME        	syscall name
#define SYSCALL_NARGS number of arguments this call takes
#define SYSCALL_SYMBOL primary symbol name
#define SYSCALL_CANCELLABLE 1 if the call is a cancelation point
#define SYSCALL_NOERRNO 1 to define a no-errno version (see below)
#define SYSCALL_ERRVAL 1 to define an error-value version (see below)

glibc 还有一个文件 syscall-template.S,使用上面这个宏,定义了这个系统调用的调用方式。

1
2
3
4
5
T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS)
ret
T_PSEUDO_END (SYSCALL_SYMBOL)

#define T_PSEUDO(SYMBOL, NAME, N) PSEUDO (SYMBOL, NAME, N)

32 位的方式

PSEUDO 也是一个宏定义,这个宏定义跟架构相关,32 位中的定义如下所示。

1
2
3
4
5
6
7
// sysdeps/unix/sysv/linux/i386/sysdep.h
#define PSEUDO(name, syscall_name, args) \
.text; \
ENTRY (name) \
DO_CALL (syscall_name, args); \
cmpl $-4095, %eax; \
jae SYSCALL_ERROR_LABEL

这个还会调用 DO_CALL,这也是一个宏,也是跟架构相关的。

  • 在这里首先将请求参数放到寄存器中(相应寄存器也已经列出来)。
  • 之后,根据系统调用名称,得到系统调用号,放到寄存器 eax 中。可以看到 __NR##syscall_name 其实会出现在 unistd_32.h 文件中(见内核/系统调用表一节)。
  • 最后执行 ENTER_KERNEL,ENTER_KERNEL 也是一个宏定义,最终是执行 int $0x80 指令触发一个软中断。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// sysdeps/unix/sysv/linux/i386/sysdep.h
/* Linux takes system call arguments in registers:
syscall number %eax call-clobbered
arg 1 %ebx call-saved
arg 2 %ecx call-clobbered
arg 3 %edx call-clobbered
arg 4 %esi call-saved
arg 5 %edi call-saved
arg 6 %ebp call-saved
......
*/
#define DO_CALL(syscall_name, args) \
PUSHARGS_##args \
DOARGS_##args \
movl $SYS_ify (syscall_name), %eax; \
ENTER_KERNEL \
POPARGS_##args


// sysdeps/unix/sysv/linux/i386/sysdep.h
/* For Linux we can use the system call table in the header file
/usr/include/asm/unistd.h
of the kernel. But these symbols do not follow the SYS_* syntax
so we have to redefine the `SYS_ify' macro here. */
#undef SYS_ify
#define SYS_ify(syscall_name) __NR_##syscall_name


// sysdeps/unix/sysv/linux/i386/sysdep.h
# define ENTER_KERNEL int $0x80

64 位的方式

64 位中 PSEUDO 的宏定义和 32 位中 PSEUDO 的宏定义是一样的。

1
2
3
4
5
6
7
// sysdeps/unix/sysv/linux/x86_64/sysdep.h
# define PSEUDO(name, syscall_name, args) \
.text; \
ENTRY (name) \
DO_CALL (syscall_name, args); \
cmpq $-4095, %rax; \
jae SYSCALL_ERROR_LABEL

但是,DO_CALL 的宏定义是不一样,

  • 首先是将系统调用名称转换为系统调用号,存到 eax 寄存器中。
  • 与 32 位不同的是,这边使用 syscall 指令以调用的方式来进行系统,而不是使用中断的方式进行系统调用。同时,传递参数的寄存器也变了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// sysdeps/unix/sysv/linux/x86_64/sysdep.h
/* The Linux/x86-64 kernel expects the system call parameters in
registers according to the following table:
syscall number rax
arg 1 rdi
arg 2 rsi
arg 3 rdx
arg 4 r10
arg 5 r8
arg 6 r9
......
*/
# define DO_CALL(syscall_name, args) \
DOARGS_##args \
movl $SYS_ify (syscall_name), %eax; \
syscall;

syscall 指令还使用了一种特殊的寄存器,叫做特殊模块寄存器(Model Specific Registers,简称 MSR),这种 寄存器就是 CPU 为了完成某种特殊控制功能为目的的寄存器,其中就有系统调用。在系统初始化的时候,会进行如下的初始化。其中 wrmsrl 和 rdmsr 都是用来读写特殊模块寄存器的;MSR_LSTAR 就是一个特殊的寄存器,这里将 entry_SYSCALL_64 这个函数的地址填到这个寄存器中,那么当 syscall 指令调用的时候,会从这个寄存器中拿出函数地址来调用(所以说 64 位下面使用的是调用的方式而不是中断的方式),也就是调用 entry_SYSCALL_64 函数。

1
2
// arch/x86/kernel/cpu/common.c(Linux Kernel 4.19)
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

内核

32 位的方式

在内核启动的时候,有一个 trap_init(),其中有如下这样的代码。这是一个软中断的陷入门。当接受到一个系统调用的时候,entry_INT80_32 就被调用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// arch/x86/kernel/traps.c (Linux Kernel 4.19)
void __init trap_init(void) {
......
idt_setup_traps();
......
}

// arch/x86/kernel/idt.c (Linux Kernel 4.19)
void __init idt_setup_traps(void) {
idt_setup_from_table(idt_table, def_idts, ARRAY_SIZE(def_idts), true);
}

// arch/x86/kernel/idt.c (Linux Kernel 4.19)
static const __initconst struct idt_data def_idts[] = {
SYSG(IA32_SYSCALL_VECTOR, entry_INT80_32)
}

entry_INT80_32 代码如下所示,通过 pushl 和 SAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
# arch/x86/entry/entry_32.S (Linux Kernel 4.19)
ENTRY(entry_INT80_32)
ASM_CLAC
pushl %eax /* pt_regs->orig_ax */
SAVE_ALL pt_regs_ax=$-ENOSYS /* save rest */
movl %esp, %eax
call do_int80_syscall_32
.Lsyscall_32_done:
......
.Lirq_return:
INTERRUPT_RETURN
......
ENDPROC(entry_INT80_32)

之后调用 do_int80_syscall_32,实现如下。

  • 首先将系统调用号从 eax 里面取出来;
  • 然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// arch/x86/entry/common.c (Linux Kernel 4.19)
__visible void do_int80_syscall_32(struct pt_regs *regs) {
enter_from_user_mode();
local_irq_enable();
do_syscall_32_irqs_on(regs);
}

static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs) {
struct thread_info *ti = current_thread_info();
unsigned int nr = (unsigned int)regs->orig_ax;
......
if (likely(nr < IA32_NR_syscalls)) {
nr = array_index_nospec(nr, IA32_NR_syscalls);
#ifdef CONFIG_IA32_EMULATION
regs->ax = ia32_sys_call_table[nr](regs);
#else
/*
* It's possible that a 32-bit syscall implementation
* takes a 64-bit parameter but nonetheless assumes that
* the high bits are zero. Make sure we zero-extend all
* of the args.
*/
regs->ax = ia32_sys_call_table[nr](
(unsigned int)regs->bx, (unsigned int)regs->cx,
(unsigned int)regs->dx, (unsigned int)regs->si,
(unsigned int)regs->di, (unsigned int)regs->bp);
#endif /* CONFIG_IA32_EMULATION */
}

syscall_return_slowpath(regs);
}

当系统调用结束之后,紧接着调用的是 INTERRUPT_RETURN,我们能够找到它的定义,也就是 iret。iret 指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。

1
2
// arch/x86/include/asm/irqflags.h
#define INTERRUPT_RETURN iret

64 位的方式

entry_SYSCALL_64 这个函数中会先保存很多寄存器的值到 pt_regs 结构里面,例如用户态的代码段、数据段、保存参数的寄存器,然后调用 do_syscall_64。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# arch/x86/entry/entry_64.S (Linux Kernel)
ENTRY(entry_SYSCALL_64)
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
......
/* IRQs are off. */
movq %rax, %rdi
movq %rsp, %rsi
call do_syscall_64 /* returns with IRQs disabled */
......
popq %rdi
popq %rsp
USERGS_SYSRET64
END(entry_SYSCALL_64)

在 do_syscall_64 里面,先从 rax 里面拿出系统调用号,然后根据系统调用号,在系统调用表 sys_call_table 中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// arch/x86/entry/common.c
__visible void do_syscall_64(struct pt_regs *regs) {
struct thread_info *ti;

local_irq_enable();
ti = current_thread_info();
if (READ_ONCE(ti->flags) & _TIF_WORK_SYSCALL_ENTRY)
nr = syscall_trace_enter(regs);

nr &= __SYSCALL_MASK;
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
}

syscall_return_slowpath(regs);
}

64 位的系统调用返回的时候,执行的是 USERGS_SYSRET64(见上述代码)。定义如下:

1
2
3
4
// arch/x86/include/asm/irqflags.h
#define USERGS_SYSRET64 \
swapgs; \
sysretq;

系统调用表

系统调用在内核中的实现函数要有一个声明,声明往往在 include/linux/syscalls.h 文件中。例如 sys_open 是这样声明的:

1
asmlinkage long sys_open(const char __user *filename, int flags, umode_t mode);

真正的实现这个系统调用的一般在一个 c 文件中,例如 sys_open 的实现就在 fs/open.c 这个文件中。

1
2
3
4
5
6
7
// fs/open.c (Linux Kernerl 4.19)
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode) {
if (force_o_largefile())
flags |= O_LARGEFILE;

return do_sys_open(AT_FDCWD, filename, flags, mode);
}

SYSCALL_DEFINE3 是一个宏定义,根据参数的数目来选择宏。具体如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)


#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)


#define __SYSCALL_DEFINEx(x, name, ...) \
__diag_push(); \
__diag_ignore(GCC, 8, "-Wattribute-alias", \
"Type aliasing is used to sanitize syscall arguments");\
asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)) \
__attribute__((alias(__stringify(__se_sys##name)))); \
ALLOW_ERROR_INJECTION(sys##name, ERRNO); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));\
asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__));\
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
__diag_pop(); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))

声明和实现都好了之后,接下去需要生成相应的系统调用表。


32 位的系统调用表定义在 arch/x86/entry/syscalls/syscall_32.tbl 文件里。例如 open 是这样定义的:

1
5	i386	open			sys_open			__ia32_compat_sys_open

64 位的系统调用定义在另一个文件 arch/x86/entry/syscalls/syscall_64.tbl 里。例如 open 是这样定义的:

1
2
> 2	common	open			__x64_sys_open
>

这个表定义了系统调用号(第一列)、系统调用名称(第三列)、系统调用在内核的实现函数(第四列)(可以看出,32 位和 64 位的系统调用号是不一样的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
out := arch/$(SRCARCH)/include/generated/asm
uapi := arch/$(SRCARCH)/include/generated/uapi/asm

# Create output directory if not already present
_dummy := $(shell [ -d '$(out)' ] || mkdir -p '$(out)') \
$(shell [ -d '$(uapi)' ] || mkdir -p '$(uapi)')

syscall32 := $(srctree)/$(src)/syscall_32.tbl
syscall64 := $(srctree)/$(src)/syscall_64.tbl

syshdr := $(srctree)/$(src)/syscallhdr.sh
systbl := $(srctree)/$(src)/syscalltbl.sh

quiet_cmd_syshdr = SYSHDR $@
cmd_syshdr = $(CONFIG_SHELL) '$(syshdr)' '$<' '$@' \
'$(syshdr_abi_$(basetarget))' \
'$(syshdr_pfx_$(basetarget))' \
'$(syshdr_offset_$(basetarget))'
quiet_cmd_systbl = SYSTBL $@
cmd_systbl = $(CONFIG_SHELL) '$(systbl)' $< $@

quiet_cmd_hypercalls = HYPERCALLS $@
cmd_hypercalls = $(CONFIG_SHELL) '$<' $@ $(filter-out $<,$^)

syshdr_abi_unistd_32 := i386
$(uapi)/unistd_32.h: $(syscall32) $(syshdr)
$(call if_changed,syshdr)

syshdr_abi_unistd_32_ia32 := i386
syshdr_pfx_unistd_32_ia32 := ia32_
$(out)/unistd_32_ia32.h: $(syscall32) $(syshdr)
$(call if_changed,syshdr)

syshdr_abi_unistd_x32 := common,x32
syshdr_offset_unistd_x32 := __X32_SYSCALL_BIT
$(uapi)/unistd_x32.h: $(syscall64) $(syshdr)
$(call if_changed,syshdr)

syshdr_abi_unistd_64 := common,64
$(uapi)/unistd_64.h: $(syscall64) $(syshdr)
$(call if_changed,syshdr)

syshdr_abi_unistd_64_x32 := x32
syshdr_pfx_unistd_64_x32 := x32_
$(out)/unistd_64_x32.h: $(syscall64) $(syshdr)
$(call if_changed,syshdr)

$(out)/syscalls_32.h: $(syscall32) $(systbl)
$(call if_changed,systbl)
$(out)/syscalls_64.h: $(syscall64) $(systbl)
$(call if_changed,systbl)

$(out)/xen-hypercalls.h: $(srctree)/scripts/xen-hypercalls.sh
$(call if_changed,hypercalls)

$(out)/xen-hypercalls.h: $(srctree)/include/xen/interface/xen*.h

uapisyshdr-y += unistd_32.h unistd_64.h unistd_x32.h
syshdr-y += syscalls_32.h
syshdr-$(CONFIG_X86_64) += unistd_32_ia32.h unistd_64_x32.h
syshdr-$(CONFIG_X86_64) += syscalls_64.h
syshdr-$(CONFIG_XEN) += xen-hypercalls.h

targets += $(uapisyshdr-y) $(syshdr-y)

PHONY += all
all: $(addprefix $(uapi)/,$(uapisyshdr-y))
all: $(addprefix $(out)/,$(syshdr-y))
@:

这两个文件在编译的过程中会用到,具体是 arch/x86/entry/syscalls/Makefile 这个文件会用到。这个文件会根据 syscall_32.tbl 和 syscall_64.tbl 生成相应的 unistd_32.h、unistd_64.h、syscalls_32.h、syscalls_64.h 等文件。这个文件中其实还用到下面这两个脚本,

  • 第一个脚本 arch/x86/entry/syscalls/syscallhdr.sh,会在 unistd_32.h、unistd_64.h 等文件中生成 #define __NR_open 5
  • 第二个脚本 arch/x86/entry/syscalls/syscalltbl.sh,会在 syscalls_32.h、syscalls_64.h 文件中生成 __SYSCALL(__NR_open, sys_open)

其中 unistd_32.h 部分内容如下

1
2
3
4
5
6
#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

syscalls_32.h部分内容如下

1
2
3
4
5
6
7
8
9
__SYSCALL_I386(0, sys_restart_syscall, )
__SYSCALL_I386(1, sys_exit, )
#ifdef CONFIG_X86_32
__SYSCALL_I386(2, sys_fork, )
#else
__SYSCALL_I386(2, sys_fork, )
#endif
__SYSCALL_I386(3, sys_read, )
__SYSCALL_I386(4, sys_write, )

在文件 arch/x86/entry/syscall_32.c 中对 __SYSCALL_I386 进行了定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// arch/x86/entry/syscall_32.c (Linux Kernel 4.19)

/* System call table for i386. */
#ifdef CONFIG_IA32_EMULATION
/* On X86_64, we use struct pt_regs * to pass parameters to syscalls */
#define __SYSCALL_I386(nr, sym, qual) extern asmlinkage long sym(const struct pt_regs *);

/* this is a lie, but it does not hurt as sys_ni_syscall just returns -EINVAL */
extern asmlinkage long sys_ni_syscall(const struct pt_regs *);

#else /* CONFIG_IA32_EMULATION */
#define __SYSCALL_I386(nr, sym, qual) extern asmlinkage long sym(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long);
extern asmlinkage long sys_ni_syscall(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long);
#endif /* CONFIG_IA32_EMULATION */

#include <asm/syscalls_32.h>
#undef __SYSCALL_I386

#define __SYSCALL_I386(nr, sym, qual) [nr] = sym,

__SYSCALL_I386 还进行了两次定义,我们重点关注第二次定义。那么,syscalls_32.h 中的内容如下所示

1
2
3
4
[0] = sys_restart_syscall,
[1] = sys_exit,
[2] = sys_fork,
...

之后,还是在文件 arch/x86/entry/syscall_32.c 中,定义了这样一个表,里面 include 了这个头文件,从而所有的 sys_ 系统调用都在这个表里面了。

1
2
3
4
5
6
7
8
9
// arch/x86/entry/syscall_32.c (Linux Kernel 4.19)
__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {
/*
* Smells like a compiler bug -- it doesn't work
* when the & below is removed.
*/
[0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
#include <asm/syscalls_32.h>
};

同理,在文件 arch/x86/entry/syscall_64.c,定义了这样一个表,里面 include 了这个头文件,这样所有的 sys_ 系统调用就都在这个表里面了。

1
2
3
4
5
6
7
8
9
10
> // arch/x86/entry/syscall_64.c (Linux Kernel 4.19)
> asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
> /*
> * Smells like a compiler bug -- it doesn't work
> * when the & below is removed.
> */
> [0 ... __NR_syscall_max] = &sys_ni_syscall,
> #include <asm/syscalls_64.h>
> };
>

巨人的肩膀

  1. 关于系统调用过程 sys_call_table 的生成更详细的其实可以看这里:https://github.com/chenpengcong/blog/issues/6
  2. http://blog.csdn.net/gatieme/article/details/50779184
  3. https://zhuanlan.zhihu.com/p/28984642
  4. 极客时间-《趣谈Linux操作系统》
程序锅 wechat
欢迎关注微信公众号【一口程序锅】,不定期的技术分享、资源分享。
让我多买本书学习学习
0%