Linux 内核|系统调用

本文以最常见的系统调用 open, 打开一个文件为线索,展示 32/64 位系统调用的实现方式。

Linux 内核|初始化 中提到过,用户态代码无法执行更高权限的指令,要想访问核心资源,需要通过系统调用来访问。整个过程如图:

1.流程概述

再看具体的代码之前,先看一下大概的流程。这里引用这篇文章的描述, 言简意赅。

  1. 应用程序 代码调用系统调用( xyz ),该函数是一个包装系统调用的 库函数
  2. 库函数 ( xyz )负责准备向内核传递的参数,并触发 软中断 以切换到内核;(64 位是调用 寄存器 MSR_LSTAR 中的函数 entry_SYSCALL_64 进入内核的。)
  3. CPU 被 软中断 打断后,执行 中断处理函数 ,即 系统调用处理函数 ( system_call );
  4. 系统调用处理函数 调用 系统调用服务例程 ( sys_xyz ),真正开始处理该系统调用;

应用程序:application program
库函数:libc
系统调用处理函数:system call handler
系统调用服务例程:system call service routine

2.封装系统调用的库函数glibc

2.1 什么是 glibc

glibc 是 GNU 旗下的 C 标准库,后来逐渐成为了 Linux 的标准 C 库。glibc 提供很多 API,比如 open、read、write、malloc、printf 等。

2.2 fopen 到 DO_CALL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(int argc, char **argv)
{
FILE *fp;
char buff[255];

fp = fopen("test.txt", "r");
fgets(buff, 255, fp);
printf("%s\n", buff);
fclose(fp);

return 0;
}

上面是一个打开文件的程序,其中调用 glibc 库中的 fopen 打开文件,实际上最后调用的是 open 函数,glibc 库中的 open 函数的定义如下。

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

glibc 库中有个文件 syscalls.list,里面列着所有 glibc 的函数对应的系统调用。下面是 open 函数所对应的系统调用。

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

根据上述配置文件,glibc会调用脚本 make_syscall.sh 将其封装为宏,如 #define SYSCALL_NAME open 的形式。

这些宏会通过 T_PSEUDO 来调用(位于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)

这里的 PSEUDO 也是一个宏。从下面代码中可以看出,对于任何一个系统调用,最终会调用 DO_CALL(syscall_name, args)

1
2
3
4
5
6
#define PSEUDO(name, syscall_name, args)      \
.text; \
ENTRY (name) \
DO_CALL (syscall_name, args); \
cmpl $-4095, %eax; \
jae SYSCALL_ERROR_LABEL

DO_CALL 这个宏 32 位和 64 位的定义是不一样的,下面我们对于 32 位和 64 位分开来讨论。

3.32 位系统调用过程

3.1 DO_CALL 到 ENTER_KERNEL

在32位系统中,DO_CALL() 位于i386 目录下的 sysdep.h 文件中,定义如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注释里可以看到,这些寄存器是和参数的对应关系。
/* 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

代码主要逻辑是:将请求参数放在寄存器里面,根据系统调用的名称,得到系统调用号,放在寄存器 eax 里面,然后执行 ENTER_KERNEL

1
#define ENTER_KERNEL int $0x80

这里面的 ENTER_KERNEL 定义如下,int 就是 interrupt,表示中断,int $0x80 表示触发一个软中断。ENTER_KERNEL 实际调用的是80软中断,以此陷入(trap)内核。

3.2 软中断 entry_INT80_32

80 软中断是一个软中断的陷入门,是在内核启动的时候初始化的。trap_init() 通过 idt_setup_traps() 设置了很多的中断门(Interrupt Gate),用于处理各种中断,如系统调用的中断门:

1
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);

当接收到一个系统调用的时候,entry_INT80_32 就被调用了,从而从用户态陷入(trap)内核态。

entry_INT80_32 定义如下,进入内核之前,要保存所有的寄存器,即通过 push 和 SAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构里面。

1
2
3
4
5
6
7
8
9
10
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_syscall_32_irqs_on //最终会调用 do_syscall_32_irqs_on
.Lsyscall_32_done:
......
.Lirq_return:
INTERRUPT_RETURN // 返回用户态

保存完用户态寄存器数据后,会调用 do_syscall_32_irqs_on

3.3 do_syscall_32_irqs_on

do_syscall_32_irqs_on 定义如下。

  • 该函数将系统调用号从 eax 里面取出来。
  • 根据系统调用号,在 ia32_sys_call_table (就是系统调用表)中找到相应的函数进行调用。
  • 并将寄存器中保存的参数取出来,作为函数参数。(仔细比对就能发现,这些参数所对应的寄存器,和 DO_CALL() 的注释是一样的)
1
2
3
4
5
6
7
8
9
10
11
12
13
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)) {
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);
}
syscall_return_slowpath(regs);
}

其中,这个 ia32_sys_call_table 定义如下,实际上就是 sys_call_table 系统调用表,系统调用放在这个表里面。至于这个表是如何形成的,在本文后面会描述。

1
#define ia32_sys_call_table sys_call_table

3.4 返回用户态 INTERRUPT_RETURN

entry_INT80_32 定义中可以看到,当系统调用结束之后,紧接着调用的是 INTERRUPT_RETURN,也就是 iret 指令,定义如下:

1
#define INTERRUPT_RETURN    iret

iret 指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。

3.5 一张图总结

4.64 位系统调用过程

4.1 DO_CALL 到 syscall

在 64 位系统中,DO_CALL 位于x86_64 目录下的 sysdep.h 文件中,定义如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 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) \
lea SYS_ify (syscall_name), %rax; \
syscall

和 32 位系统类似,还是将系统调用名称转换为系统调用号,放到寄存器 rax。这里是真正进行调用,不是用中断了,而是改用 syscall 指令了。(通过注释可以看到,传递参数的寄存器也变了)

4.2 syscall 到 entry_SYSCALL_64

syscall 指令使用了一种特殊的寄存器,叫特殊模块寄存器(Model Specific Registers,简称 MSR)。这种寄存器是 CPU 为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。

在系统初始化的时候,trap_init() 除了初始化上面的中断模式,还会调用 cpu_init->syscall_init()。这里面有如下代码:

1
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
  • rdmsr 和 wrmsr 是用来读写特殊模块寄存器的。
  • MSR_LSTAR 就是一个特殊的寄存器,当 syscall 指令被调用的时候,会从这个寄存器里面拿出函数地址来调用,也就是调用 entry_SYSCALL_64

4.3 entry_SYSCALL_64

arch/x86/entry/entry_64.S 中定义了 entry_SYSCALL_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
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 */
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
pushq %rdx /* pt_regs->dx */
pushq %rcx /* pt_regs->cx */
pushq $-ENOSYS /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */
movq PER_CPU_VAR(current_task), %r11
testl $_TIF_WORK_SYSCALL_ENTRY|_TIF_ALLWORK_MASK, TASK_TI_flags(%r11)
jnz entry_SYSCALL64_slow_path
......
entry_SYSCALL64_slow_path:
/* IRQs are off. */
SAVE_EXTRA_REGS
movq %rsp, %rdi
call do_syscall_64 // 进入内核态 /* returns with IRQs disabled */
return_from_SYSCALL_64:
RESTORE_EXTRA_REGS
TRACE_IRQS_IRETQ
movq RCX(%rsp), %rcx
movq RIP(%rsp), %r11
movq R11(%rsp), %r11
......
syscall_return_via_sysret:
/* rcx and r11 are already restored (see code above) */
RESTORE_C_REGS_EXCEPT_RCX_R11
movq RSP(%rsp), %rsp
USERGS_SYSRET64 // 返回用户态

这里先保存了很多寄存器到 pt_regs 结构里面,例如用户态的代码段、数据段、保存参数的寄存器。

最后会调用 entry_SYSCALL64_slow_path->do_syscall_64,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
__visible void do_syscall_64(struct pt_regs *regs)
{
struct thread_info *ti = current_thread_info();
unsigned long nr = regs->orig_ax;
......
if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {
regs->ax = sys_call_table[nr & __SYSCALL_MASK](
regs->di, regs->si, regs->dx,
regs->r10, regs->r8, regs->r9);
}
syscall_return_slowpath(regs);
}

do_syscall_64 里面,主要流程是:

  • 从 rax 里面拿出系统调用号。
  • 根据系统调用号,在系统调用表 sys_call_table 中找到相应的函数进行调用。
  • 将寄存器中保存的参数取出来,作为函数参数。(仔细比对就能发现,与 32 位系统类似,这些参数所对应的寄存器,和 DO_CALL() 的注释又是一样的)

4.4 返回用户态 USERGS_SYSRET64

entry_SYSCALL_64 的定义中可以看到,64 位的系统调用返回的时候,执行的是 USERGS_SYSRET64。定义如下,返回用户态的指令变成了 sysretq。

1
2
3
#define USERGS_SYSRET64    \
swapgs; \
sysretq;

4.5 一张图总结

5.系统调用表

前面我们重点关注了系统调用的流程,32/64 位系统最终都需要根据系统调用号,在系统调用表 sys_call_table 中查找相应的函数。下面来看看系统调用表。

5.1 sys_call_table 的内容

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

1
5    i386    open    sys_open  compat_sys_open

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

1
2    common    open    sys_open
  • 第一列的数字是系统调用号。可以看出,32 位和 64 位的系统调用号是不一样的。
  • 第三列是系统调用的名字。
  • 第四列是系统调用在内核的实现函数。不过,它们都是以 sys_ 开头。

5.2 声明 syscalls.h

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

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

5.3 实现 open.c

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

1
2
3
4
5
6
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 是一个有三个参数的宏系统调用。定义如下:SYSCALL_DEFINE 最多六个参数,根据参数的数目选择不同的宏。

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
#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 __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)) \
__attribute__((alias(__stringify(SyS##name)))); \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)

如果我们把所有宏展开之后,实现如下,和声明的是一样的。

1
2
3
4
5
6
7
8
9
10
asmlinkage long sys_open(const char __user * filename, int flags, int mode)
{
long ret;

if (force_o_largefile())
flags |= O_LARGEFILE;

ret = do_sys_open(AT_FDCWD, filename, flags, mode);
asmlinkage_protect(3, ret, filename, flags, mode);
return ret;

5.4 编译 syscalls_32/64.h

在编译的过程中,需要根据 syscall_32.tbl 和 syscall_64.tbl 生成自己的 unistd_32.h 和 unistd_64.h。生成方式在 arch/x86/entry/syscalls/Makefile 中。

这里面会使用两个脚本:

  • 第一个脚本 arch/x86/entry/syscalls/syscallhdr.sh,会在文件中生成 #define __NR_open
  • 第二个脚本 arch/x86/entry/syscalls/syscalltbl.sh,会在文件中生成 __SYSCALL(__NR_open, sys_open)

这样最终生成 syscalls_32.h 和 syscalls_64.h 就保存了系统调用号和系统调用实现函数之间的对应关系,如下:

1
2
3
4
5
6
__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宏定义如下,主要是将对应的数字序号和系统调用名对应。

1
2
#define __SYSCALL_COMMON(nr, sym, compat) __SYSCALL_64(nr, sym, compat)
#define __SYSCALL_64(nr, sym, compat) [nr] = sym,

最终形成的表如下:

1
2
3
4
5
6
7
8
9
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,
...
...
...
};

5.5 使用 syscall_32/64.c

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

1
2
3
4
5
6
7
8
__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 了这个头文件syscalls_64.h,这样所有的 sys_ 系统调用就都在这个表里面了。

1
2
3
4
5
6
7
8
9
/* System call table for x86-64. */
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>
};

6.一张图总结

以64位系统为例

7.参考


Linux 内核|系统调用
https://www.aimtao.net/system-call/
Posted on
2022-04-12
Licensed under