一. 概述
系统调用是用户态程序与内核之间的交互的直接接口,用户态程序通过系统调用来请求各种各样的服务。linux提供了约200多个系统调用,在实现上,所有系统调用都有着相同的入口,遵循着相同的执行框架。
这套框架的核心是对所有系统调用进行编号,所有系统调用都是从同一入口进入,该入口是一条能实现特权级提升的指令,该指令完成用户态到系统态的转变,并最终跳转到内核中一个叫做系统调用处理程序的函数中,根据提供的系统调用号,处理程序再跳转到相应的事务程序中。
二. 系统调用的分类
在2.6版本中,约有200余个系统调用,按照功能,可以分成以下几个大类。
- 进程管理
fork, clone, vfork用于进程的创建,exit用于进程的退出,setrlimit用于设置对进程的资源限制,nice用于调整进程的优先级,execve装载一个新进程,此外还有大量用于查询进程属性的系统调用。
- 进程管理
- 内存管理
brk用于malloc内存分配,mmap、munmap用于映射和解除映射,swapon用于开启交换区。
- 内存管理
- 文件操作
open、 close、read、 write、chdir、mkdir、 rmdir、 rename、 link、 symlink、mount和umount等,这部分系统调用的名称与c库中的函数名基本可以对应。
- 文件操作
- 信号处理
signal设置处理函数、sigpending检查是否有需要处理的信号。
- 信号处理
- 进程间通信和网络功能
虽然该部分功能复杂,但只有两个系统调用,socketcall实现了非常多的功能,与网络有关的功能都是通过这一调用,ipc调用实现本地的通讯。
- 进程间通信和网络功能
- 时间操作
adjtimex调整内核中的时间变量,gettimeofday、settimeofday操作当前系统时间,time返回自1970年来经过的秒数。
- 时间操作
三. 系统调用的实现
正如上文提到的,系统调用的执行过程分为系统调用处理程序和事务程序两个部分,事务程序用于实现具体的事务我们暂时不去关注,在本节中介绍系统调用处理程序的实现框架。
在早期版本中,linux使用int 0x80实现系统调用,int指令使用软件的方式来触发一次中断,中断号为0x80,使用0x80中断的中断处理程序来作为系统调用的入口。但int指令毕竟不是专门用于系统调用,x86在奔腾II中引入了sysenter指令用于实现快速系统调用,下面分别对两种方式进行介绍。
3.1 int 0x80方法
中断描述符初始化:在内核初始化阶段,使用set_system_gate(0x80,&system_call)语句来设置80号中断使用的中断门,中断门设置的过程中,需要指定中断处理函数的入口——即system_call函数,将DPL字段置为3,使得用户态下的代码可以访问这个中断门,这也是实现用户态到内核态跳转的关键一步,同时在中断门中,将type设置为15,代表其为陷进不可被屏蔽。
//@arch/i386/kernel/traps.c 设置系统调用所需的中断门
set_system_gate(0x80,&system_call);
//@arch/i386/kernel/traps.c
static void __init set_system_gate(unsigned int n, void *addr)
{
_set_gate(idt_table+n,15,3,addr,__KERNEL_CS);
}
//@arch/i386/kernel/vsyscall-int80.S
__kernel_vsyscall: //vsyscall方法,用于实现两种调用方法并存
int $0x80 // 该指令是整个系统调用的核心
ret
系统调用的进入:随后,一般是由c库来执行int 0x80指令,该指令在执行时,会将现eip现esp等信息压入内核栈中,并触发中断,系统调用号通过eax来传递。进入system_call函数后,1)首先保存eax中系统调用号,再调用SAVE_ALL宏保存架构寄存器的现场。2)system_call使用GET_THREAD_INFO宏来获得thread_info的地址,并对进程的属性做一些检查。3)随后使用一条cmpl语句来检查系统调用号是否合法,如果正确则使用 call *sys_call_table(,%eax,4)指令调用相应的系统调用事务程序, 4)在这里,内核使用一个称为sys_call_table的数组保存事务程序的入口地址,系统调用号就是数组的下标,所以使用数组的基址和下标就可以找到各个调用的事务程序。
//@arch/i386/kernel/entry.S //系统调用处理函数,int 80后执行的第一行代码
ENTRY(system_call)
pushl %eax # 系统调用号
SAVE_ALL # 保存用户态现场
GET_THREAD_INFO(%ebp)
testb $(_TIF_SYSCALL_TRACE|_TIF_SYSCALL_AUDIT),TI_flags(%ebp)
jnz syscall_trace_entry
cmpl $(nr_syscalls), %eax # 检查调用号是否合法
jae syscall_badsys
syscall_call:
call *sys_call_table(,%eax,4) # 跳转到事务函数
movl %eax,EAX(%esp) # 保存事务函数的返回值
//@arch/i386/kernel/entry.S 由系统调用事务函数入口组成的数组
.data
ENTRY(sys_call_table)
.long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */
.long sys_exit
.long sys_fork
.long sys_read
.long sys_write
.long sys_open /* 5 */
.long sys_close
系统调用的返回:从事务程序返回后,处理程序保存返回值,1)如果没有异常,则调用RESTORE_ALL宏来回复寄存器现场,并使用一条iret指令来返回用户态程序,iret指令使用栈顶的一个地址填入eip,并恢复esp和eflag。2)如果还有一些标志被设置,则退出前还有一些工作需要做,resume_userspace和work_pending检查调度请求、v86模式、挂起信号等,这些工作完成后返回RESTORE_ALL处退出。
事务程序的参数传递:对于一般的程序,在调用前将用到的参数压栈即可,而系统调用横跨内核栈和用户栈,同时操作两个栈不切实际,所以事务程序的参数采用寄存器和用户空间变量传递,x86有7个通用寄存器,除eax用于传递调用号之外,其余6个均可用于参数传递,在进入服务程序后,先将这些寄存器压入内核栈中,随后跳转到事务程序,事务程序就可以像普通函数那样使用参数。如果有超过6个参数,可以在寄存器中传递指向用户空间的指针,使用指针来从用户空间获取更多参数,内核提供了get_user()和put_user()方法操作用户空间中的变量。
3.2 sysenter方法
引入sysenter的目的:系统调用实现方式是计算机系统结构中,系统软件与硬件协同发展一个例子。linux 0.11设计的目标机型是i386,这是第一款实现保护模式的x86处理器,所以硬件和软件的仍然有许多需要磨合的地方,系统调用在事务密集型的应用中大量出现,所以要尽量缩短系统调用的时间。使用软中断的方式并不适合,在执行int 0x80指令时会进行一些安全检查和一致性检查,对于系统调用来说,这是没有必要的。
sysenter指令:因此intel在奔腾II中引入了sysenter指令,用来快速从用户态切换到系统态。该指令配合SYSENTER_CS、SYSENTER_ESP、SYSENTER_EIP 3个寄存器使用,1)在执行sysenter指令时,处理器会将特权级由3提升到0,并分别将这3个寄存器的值压入cs、esp、eip中,同时,将cs的下一个段描述符自动压入ss寄存器;2)在内核初始化时,enable_sep_cpu函数会将SYSENTER_CS、SYSENTER_EIP寄存器的值进行初始化,但SYSENTER_ESP是动态的,所以在每次调用时由处理函数手动计算。
//@arch/i386/kernel/sysenter.c 该函数设置sysenter指令所需的3个寄存器
void enable_sep_cpu(void *info)
{
.......
wrmsr(MSR_IA32_SYSENTER_CS, __KERNEL_CS, 0); //设定sysenter指令跳转的目的地
wrmsr(MSR_IA32_SYSENTER_ESP, tss->esp1, 0);
wrmsr(MSR_IA32_SYSENTER_EIP, (unsigned long) sysenter_entry, 0);
.......
}
sysenter的进入:当执行sysenter指令时,提升特权级并进入sysenter_entry函数,1)进去后从TSS中取出esp0的值,完成内核栈的切换,2)因为执行完成后还是从原来int 80处理程序的iret返回用户态,所以需要将eip、esp等的值压入栈中供iret指令使用,3)随后保存用户态的现场,执行一些检查后,call *sys_call_table(,%eax,4) 调用事务函数。
//@arch/i386/kernel/entry.S //系统调用处理函数,sysenter后执行的第一行代码
ENTRY(sysenter_entry)
movl TSS_sysenter_esp0(%esp),%esp # 从TSS中取出esp的值放入esp寄存器
sysenter_past_esp:
sti
pushl $(__USER_DS) # push5个寄存器的值,为iret做准备
pushl %ebp
pushfl
pushl $(__USER_CS)
pushl $SYSENTER_RETURN
SAVE_ALL
......
call *sys_call_table(,%eax,4)
四. vsyscall处理兼容性问题
我们目前有两种系统调用的实现方式,就必须要坚决CPU、系统调用接口、c库之间的匹配问题。2.6中的解决方案是,c库不直接执行int 80或sysenter指令,而是将它们封装到一个vsyscall接口中,c库调用vsyscall,而vsyscall再根据cpu的支持情况调用int 80或者sysenter。实现的方式类似于动态连接,将vsyscall函数在装载时动态链接,只不过vsyscall是在内核初始化阶段设置地址的。
Reference:
[1] Daniel P. Bovet, Marco Cesati. Understanding the Linux kernel, Third Edition[M]. O'Reilly & Associates Inc, 2005.
[2] Robert Love. Linux kernel development [M]. China Machine Press, 2011.
[3] Wolfgang Mauerer. Professional Linux kernel architecture[M]. 人民邮电出版社, 2010.