系统调用是应用程序与操作系统内核之间的接口。本篇主要剖析 Linux 中系统调用的原理,详述系统调用从用户态到内核态的流程。

系统调用介绍

系统调用是操作系统为应用程序提供的一套接口,不仅包含应用程序运行所必需的支持,例如创建/退出进程,进程内存管理,也有对系统资源的访问,如文件、网络、进程通信、硬件设备访问等。

系统调用作为接口,首先必需要有明确的定义,这样应用程序才能正确使用它,其次是需要保持向后兼容,以保证系统升级后原来的应用程序也能够正常的使用。

系统调用与运行库

由于系统调用是各个操作系统提供的,所以会导致不同的操作系统的系统调用不能兼容,而且系统调用的接口相对比较原始,没有经过很好的封装。

运行库就是为了实现不同系统的兼容性,在系统调用与程序之间做了一层抽象层。作为语言级别的抽象库一般都封装的比较好,并且对于标准库,能够实现不同操作系统之间的兼容。

例如 Linux 下的 read/write 系统调用在 C 语言中是 fread/fwrite 标准库, 在 C++ 中是iofstream 标准库。但是运行库为了各个操作系统之间的兼容性,只能取各平台的交集,但是一些系统独有的系统调用就不同通过标准库调用了。

pic

系统调用与内核架构图

中断

操作系统一般是通过中断来完成从用户态切换到内核态的。即通过硬件或者软件向操作系统发送一个请求,要求 CPU 暂停当前的工作转而去处理更加重要的事。

中断一般具有两个属性,中断号以及中断处理程序(Interrupt Service Routine, ISR)。在内核中,有一个数组称为中断向量表(Interrupt Vector Table),能够通过中断号找到对应的中断处理程序,并且中断处理程序执行完成之后,CPU 会返回继续执行用户态代码。

pic 内核中断处理

Linux 使用 int 0x80 来触发所有的系统调用,并且系统调用号用 eax 存储,这样中断服务程序就可以从 eax 里取得系统调用号,进而调用对应的函数。

系统调用的实现原理

用户程序调用所有的系统调用的流程都是类似的,包含三部分:

  • 用户程序需要设置系统调用的参数,传递给内核态。这里不同架构,以及不同系统的参数是不同的;
  • 对于 x86,系统调用最终会调用 glibcDO_CALL,这也就是程序将工作交给内核的切入点;
  • 陷入内核态处理,并最终返回用户态,返回值也是内核完成对应任务后返回的值。如果内核执行该系统调用遇到错误,也会通过一个全局的错误号来存储错误信息。

系统调用参数传递

在 i386 系统中,系统调用由 int 0x80 中断完成,各个通用寄存器用于传递参数,eax 寄存器用于系统调用的接口号。

arch arg1 arg2 arg3 arg4 arg5 arg6
i386 ebx ecx edx esi edi ebp
x86_64 rdi rsi rdx r10 r8 r9

x86 架构通用寄存器参数对照表

  • 参数都是通过寄存器传递的,并且如上图所示,32 位和 64 位的寄存器也不一样。
  • 系统调用的参数限制在 6 个

系统调用在 glibc 中的实现

这里以常见的系统调用 open 为例,分析系统调用怎么实现的。glibc 的 open 函数定义如下:

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

在 glibc 的源码中,有个文件 syscalls.list, 里面罗列了所有 glibc 的函数对应的系统调用,如下:

# File name	Caller	Syscall name	Args	Strong name	Weak names

accept		-	accept		Ci:iBN	__libc_accept	accept
access		-	access		i:si	__access	access
...
open		-	open		Ci:siv	__libc_open __open open
...

我们来看 glibc 中的文件 open.c 的实现函数 __libc_open :

int
__libc_open (const char *file, int oflag, ...)
{
  int mode = 0;

  if (__OPEN_NEEDS_MODE (oflag))
    {
      va_list arg;
      va_start (arg, oflag);
      mode = va_arg (arg, int);
      va_end (arg);
    }

  return SYSCALL_CANCEL (openat, AT_FDCWD, file, oflag, mode);
}

最终,SYSCALL_CANCELsysdep.h 中的展开为:

#define __INLINE_SYSCALL3(name, a1, a2, a3) \
INLINE_SYSCALL (name, 3, a1, a2, a3)
  
#ifndef INLINE_SYSCALL
#define INLINE_SYSCALL(name, nr, args...) __syscall_##name (args)
#endif

__syscall_##name 在代码中完全找不到, 应该是 makefile 中有调用 make-syscalls.sh 来生成嵌套代码, 其使用模板是 syscall-template.S

syscall-template.S 中,定义了这个系统调用的调用方式:

T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS)
    ret
T_PSEUDO_END (SYSCALL_SYMBOL)

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


#define PSEUDO(name, syscall_name, args)           \
  .text;                                        \
  ENTRY (name)                                    \
    DO_CALL (syscall_name, args);                  \
    cmpl $-4095, %eax;                               \
    jae SYSCALL_ERROR_LABEL

对于任何一个系统调用,会调用 DO_CALL。对于 32 位系统而言,该实现(i386/sysdep.h)如下:

#define DO_CALL(syscall_name, args)			          \
    PUSHARGS_##args							  \
    DOARGS_##args							  \
    movl $SYS_ify (syscall_name), %eax;	      \
    ENTER_KERNEL							  \
    POPARGS_##args

其中 movl $SYS_ify (syscall_name), %eax; 表示将前面所讲的系统调用的调用号 __NR_open 赋给 eaxENTER_KERNEL 宏展开就是 int $0x80 中断。

其中,__NR_open 是一个宏,表示 open 系统调用的调用号。32 位系统,该宏的定义可以在 Linux 系统中 /usr/include/asm/unistd_32.h 中找到,64 位系统可以在 /usr/include/asm/unistd_64.h 中找到。以下是 32 位系统的调用号的宏定义:

#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
#define __NR_close        6
......

对于 64 位系统而言,DO_CALL实现(x86_64/sysdep.h)的定义如下:

#define DO_CALL(syscall_name, args)					      \
  lea SYS_ify (syscall_name), %rax;					      \
  syscall

这里 64 位系统还是将系统调用名称转换为系统调用号,放在寄存器 rax 中。但是不是通过中断,而是通过 syscall 指令进行真正的内核调用。syscall 指令使用了一种特殊的寄存器,特殊模块寄存器(Model Specific Registers, MSR)

内核对调用函数的处理

(以下内核代码都是基于 4.10.1 版本)

触发中断与堆栈切换

在内核启动时调用 start_kernel 作为内核启动的函数入口, 其中有一个函数 trap_init 用于初始化中断的,里面有一句对于 int $0x80 的中断入口:

set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);

以下是 entry_INT80_32 的实现:

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
.Lsyscall_32_done:
......
.Lirq_return:
	INTERRUPT_RETURN

通过 pushSAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构里,然后调用do_syscall_32_irqs_on

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);
}

这里将系统调用号从 eax 中取出,然后根据系统调用号,在系统调用表里找到对应函数进行带哦用,并将寄存器中保存的参数取出来作为函数参数传递。

最终 INTERRUPT_RETURN 宏展开后为 iret,将原来用户态保存的线程恢复回来。包含代码段、指令指针寄存器等。

内核对系统函数的处理

内核中会维护一个系统调用号与对应函数的系统调用表。这里以 32 位的系统调用表(arch/x86/entry/syscalls/syscall_32.tbl)为例:

# <number> <abi> <name> <entry point> <compat entry point>
5	i386	open			sys_open  compat_sys_open

其中第一列数字就是系统调用号,第四列是系统调用在内核的实现函数,都是以 sys_ 开头。

系统调用在内核中的实现函数声明一般在 include/linux/syscalls.h 文件中, 可以看到 sys_open 的声明:

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

真正实现一般在对应的 .c 文件中,例如 sys_open 的实现在 fs/open.c 文件中。

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 是一个表示带有三个参数的宏,将其展开后,实现如下:

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;
 }

结合以上分析,这里总结下 32 位的系统调用从用户态到内核态是如何执行的。

pic 32 位的系统调用 图片来源:趣谈Linux操作系统

系统调用实例

通过以上的分析,相信大家已经了解了系统调用的整个流程。下面通过三种不同的方式,实现对终端的输出。

  • 通过 write() 系统调用实现
  • 通过 syscall() 系统调用实现
  • 通过汇编,调用 syscall 指令

write() 系统调用

这里我们没有使用 printf 这个标准库,而是使用 Linux 的系统调用 write 。参数 1 表示标准输出文件描述符。

#include <fcntl.h>
#include <unistd.h>
int main () 
{
    write (1, "Hello World", 11);
return 0; 
}

syscall() 系统调用

现在,我们使用 glibc 提供的 syscall 接口,实现同样的逻辑。

#include <unistd.h>
#include <sys/syscall.h>
int main () 
{
    syscall (1, 1, "Hello World", 11);
return 0; 
}

这里第一个参数表示系统调用的调用号,我是在 64 位系统上运行的,所以 write 对应的调用号为 1 ,如果是 32 位系统,则调用号为 4

也就是说 syscall 也是一个系统调用,而且接口更加原始,其他的系统调用都可以看作是通过 syscall 实现的一种封装。

syscall 指令

下面是通过汇编代码,实现同样的逻辑。这里的方法是将值放到对应的寄存器中,然后调用 syscall 指令。

section .text global _start
_start: ; ELF entry point ; 1 is the number for syscall write ().
mov rax, 1
; 1 is the STDOUT file descriptor.
mov rdi, 1
    ; buffer to be printed.
    mov rsi, message
    ; length of buffer
    mov rdx, [messageLen]
    ; call the syscall instruction
syscall
; sys_exit
mov rax, 60
; return value is 0
mov rdi, 0
; call the assembly instruction
syscall
section .data
messageLen: dq message.end-message message: db 'Hello World', 10
.end:

对应汇编代码的 makefile 如下:

all:
    nasm -felf64 write.asm
    ld write.o -o elf.write
clean:
    rm -rf *.o

可以看到,这里将系统调用号 1 放入了 eax,后面的几个参数分别放入了寄存器 rdi, rsi, rdx, 顺序是与之前的 64 位系统通用寄存器传递参数的顺序是一致的。