linux系统调用
系统调用是linux内核为用户态程序提供的主要功能接口。通过系统调用,用户态进程能够临时切换到内核态,使用内核态才能访问的硬件和资源完成特定功能。系统调用由linux内核和内核模块实现,内核在处理系统调用时还会检查系统调用请求和参数是否正确,保证对特权资源和硬件访问的正确性。通过这种方式,linux在提供内核和硬件资源访问接口的同时,保证了内核和硬件资源的使用正确性和安全性。
本文主要对linux下系统调用的原理和实现进行分析。本文的分析基于x86架构,涉及到的linux内核代码版本为4.17.6。
用户态调用接口
用户态进程主要通过如下方式,直接使用系统调用:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <unistd.h>
#include <sys/syscall.h> /* For SYS_xxx definitions */
int syscall(int number, ...);
syscall接口由glibc提供和实现,第一个参数number表示需要调用的系统调用编号,后续的可变参数根据系统调用类型确定。内核具体支持的系统调用号可在<sys/syscall.h>中查看。函数调用失败会返回-1,具体错误原因保存在errno中,errno的含义可参考<errno.h>
需要注意的是,这里的返回值和errno是glibc封装提供的,内核的系统调用响应函数本身不提供errno,返回值也不同。
实现原理
一次系统调用的完整执行过程如下:
-
通过特定指令发出系统调用(int $80、sysenter、syscall)
-
CPU从用户态切换到内核态,进行一些寄存器和环境设置
-
调用system_call内核函数,通过系统调用号获取对应的服务例程
-
调用系统调用处理例程
-
使用特定指令从系统调用返回用户态(iret、sysexit、sysret)
系统调用指令
向内核发起系统调用需要使用特定的指令。在Linux中,传统的方法是使用汇编指令int发起中断,使用0x80(128)号中断使CPU进入内核态,之后调用对应的中断响应函数system_call来执行系统调用例程。
由于通过中断方式发起系统调用的性能较差,较新的CPU和内核都支持使用sysenter和syscall这两条专用指令来发起系统调用。其中sysenter在32位系统中使用,对应的退出指令为sysexit;syscall在64位系统中使用,对应的退出指令为sysret。
以sysenter为例,使用该指令时,首先调用__kernel_vsyscall()函数保存用户态堆栈;之后执行sysenter指令切换到内核态;最后开始执行sysenter_entry()函数设置内核态堆栈,并根据系统调用号调用处理例程,之后的逻辑和system_call相似。
内核实现逻辑
内核调用系统调用处理例程的核心数据结构是sys_call_table,这个数据结构在<arch/x86/entry/syscall_64.c>中定义如下:
/* 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 *);
#define __SYSCALL_64(nr, sym, qual) extern asmlinkage long sym(const struct pt_regs *);
#include <asm/syscalls_64.h>
#undef __SYSCALL_64
#define __SYSCALL_64(nr, sym, qual) [nr] = sym,
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>
};
sys_call_table是一个函数指针数组,其中保存了所有系统调用处理函数的指针。system_call等函数以系统调用号作为下标,从sys_call_table中查找对应的系统调用函数执行。
sys_call_table的初始化过程中,第一步是将所有指针数组元素赋值为sys_ni_syscall。这是为了避免有部分系统调用号没有被使用,没有定义对应的处理函数。sys_ni_syscall在<kernel/sys_ni.c>中定义,直接返回-ENOSYS,表示系统调用不存在。
sys_call_table的具体内容在<asm/syscalls_64.h>中提供,内容类似于:__SYSCALL_64(19, sys_readv, sys_readv)。从之前对__SYSCALL_64宏的两处定义可见,syscall_64.c先将__SYSCALL_64宏展开为函数声明extern asmlinkage long sym(const struct pt_regs *),再将其展开为数组元素初始化语句[nr] = sym。
需要注意的是<asm/syscalls_64.h>和提供系统调用号宏定义的头文件<asm/unistd_64.h>等文件在内核源码树中是不存在的,会在内核编译的预编译阶段自动生成。内核源码中真正定义系统调用号和处理函数的文件,是<arch/x86/entry/syscalls/syscall_64.tbl>,该文件的内容格式如下:
#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The __x64_sys_*() stubs are created on-the-fly for sys_*() system calls
#
# The abi is "common", "64" or "x32" for this file.
#
0 common read __x64_sys_read
1 common write __x64_sys_write
2 common open __x64_sys_open
3 common close __x64_sys_close
4 common stat __x64_sys_newstat
5 common fstat __x64_sys_newfstat
6 common lstat __x64_sys_newlstat
7 common poll __x64_sys_poll
8 common lseek __x64_sys_lseek
9 common mmap __x64_sys_mmap
内核预编译系统根据这个文件中提供的系统调用号、系统调用名称和对应的处理函数名称来生成对应的头文件。
添加新的系统调用
根据上述分析,如果需要添加一个新的系统调用号和处理函数,需要完成如下修改:
-
在syscall_64.tbl中添加新的系统调用号、名称和处理函数名称。例如“666 common mycall __x64_sys_mycall”
-
提供sys_mycall函数实现。函数应定义为asmlinkage long sys_mycall(...)
-
如果sys_mycall函数实现在独立的.c文件中,需要将其加入lib/路径下的makefile中,在obj-y中添加.c文件路径
之后重新编译内核即可提供自定义的系统调用功能。
需要注意的是,sys_call_table数据结构在源码中是一个const变量,因此系统调用函数指针初始化完成后是不能修改的。如果需要在运行中动态修改或添加系统调用处理函数(例如通过可加载内核模块来提供处理函数),可以将const限定去掉,然后在运行中切换调用处理函数。