/* Clone the calling process, creating an exact copy.
Return -1 for errors, 0 to the new process,
and the process ID of the new process to the old process. */
extern __pid_t fork (void) __THROWNL;
#ifdef ARCH_FORK
pid = ARCH_FORK ();
#else
# error "ARCH_FORK must be defined so that the CLONE_SETTID flag is used"
pid = INLINE_SYSCALL (fork, 0);
#endif
在现代操作系统里,由于系统资源可能同时被多个应用程序访问,如果不加保护,那各个应用程序之间可能会产生冲突,对于恶意应用程序更可能导致系统奔溃。这里所说的系统资源包括文件、网络、各种硬件设备等。比如要操作文件必须借助操作系统提供的api(比如linux下的fopen)。
系统调用在我们工作中无时无刻不打着交道,那系统调用的原理是什么呢?在其过程中做了哪些事情呢?
本文将阐述系统调用原理,让大家对于系统调用有一个清晰的认识。
更多文章见个人博客:https://github.com/farmerjohngit/myblog
概述
现代cpu通常有多种特权级别,一般来说特权级总共有4个,编号从Ring 0(最高特权)到Ring 3(最低特权),在Linux上之用到Ring 0和RIng 3,用户态对应Ring 3,内核态对应Ring 0。
普通应用程序运行在用户态下,其诸多操作都受到限制,比如改变特权级别、访问硬件等。特权高的代码能将自己降至低等级的级别,但反之则是不行的。而系统调用是运行在内核态的,那么运行在用户态的应用程序如何运行内核态的代码呢?操作系统一般是通过中断来从用户态切换到内核态的。学过操作系统课程的同学对中断这个词肯定都不陌生。
中断一般有两个属性,一个是中断号,一个是中断处理程序。不同的中断有不同的中断号,每个中断号都对应了一个中断处理程序。在内核中有一个叫中断向量表的数组来映射这个关系。当中断到来时,cpu会暂停正在执行的代码,根据中断号去中断向量表找出对应的中断处理程序并调用。中断处理程序执行完成后,会继续执行之前的代码。
中断分为硬件中断和软件中断,我们这里说的是软件中断,软件中断通常是一条指令,使用这条指令用户可以手动触发某个中断。例如在i386下,对应的指令是int,在int指令后指定对应的中断号,如int 0x80代表你调用第0x80号的中断处理程序。
中断号是有限的,所有不会用一个中断来对应一个系统调用(系统调用有很多)。Linux下用int 0x80触发所有的系统调用,那如何区分不同的调用呢?对于每个系统调用都有一个系统调用号,在触发中断之前,会将系统调用号放入到一个固定的寄存器,0x80对应的中断处理程序会读取该寄存器的值,然后决定执行哪个系统调用的代码。
在Linux2.5(具体版本不是很确定)之前的版本,是使用int 0x80这样的方式实现系统调用的,但其实int指令这样的形式性能不太好,原因如下(出自这篇文章):
正是由于如此,在linux2.5开始支持一种新的系统调用,其基于Intel 奔腾2代处理器就开始支持的一组专门针对系统调用的指令
sysenter
/sysexit
。sysenter
指令用于由 Ring3 进入 Ring0,sysexit
指令用于由 Ring0 返回 Ring3。由于没有特权级别检查的处理,也没有压栈的操作,所以执行速度比 INT n/IRET 快了不少。本文分析的是int指令,新型的系统调用机制可以参见下面几篇文章:
https://www.ibm.com/developerworks/cn/linux/kernel/l-k26ncpu/index.html
https://www.jianshu.com/p/f4c04cf8e406
基于int的系统调用
触发中断
我们以系统调用
fork
为例,fork函数的定义在glibc(2.17版本)的unistd.h
fork
函数的实现代码比较难找,在nptl\sysdeps\unix\sysv\linux\fork.c
中有这么一段代码其作用简单的说就是将
__libc_fork
当作__fork
的别名,所以fork函数的实现是在__libc_fork
中,核心代码如下我们分析定义了
ARCH_FORK
的情况,ARCH_FORK
定义在nptl\sysdeps\unix\sysv\linux\i386\fork.c
中,代码如下:INLINE_SYSCALL代码在
sysdeps\unix\sysv\linux\i386\sysdep.h
INLINE_SYSCALL
主要是调用同文件下的INTERNAL_SYSCALL
这里是一段内联汇编代码, 其中
__NR_##name
的值为__NR_clone
即120。这里主要是两个步骤:int $0x80
陷入中断int $0x80
指令会让cpu陷入中断,执行对应的0x80中断处理函数。不过在这之前,cpu还需要进行栈切换。因为在linux中,用户态和内核态使用的是不同的栈(可以看看这篇文章),两者负责各自的函数调用,互不干扰。在执行
int $0x80
时,程序需要由用户态切换到内核态,所以程序当前栈也要从用户栈切换到内核栈。与之对应,当中断程序执行结束返回时,当前栈要从内核栈切换回用户栈。这里说的当前栈指的就是ESP寄存器的值所指向的栈。ESP的值位于用户栈的范围,那程序的当前栈就是用户栈,反之亦然。此外寄存器SS的值指向当前栈所在的页。因此,将用户栈切换到内核栈的过程是:
反之,从内核栈切换回用户栈的过程:恢复ESP、SS等寄存器的值,也就是用保存在内核栈的原ESP、SS等值设置回对应寄存器。
中断处理程序
在切换到内核栈之后,就开始执行中断向量表的
0x80
号中断处理程序。中断处理程序除了系统调用(0x80
)还有如除0异常(0x00
)、缺页异常(0x14
)等等,在arch\i386\kernel\traps.c
文件的trap_init
方法中描述了中断处理程序向中断向量表注册的过程:SYSCALL_VECTOR
定义如下:所以
0x80
对应的处理程序就是system_call
这个方法,该方法位于arch\i386\kernel\entry.S
主要分为几步:
1.保存各种寄存器
2.根据系统调用号执行对应的系统调用程序,将返回结果存入到eax中
3.恢复各种寄存器
其中保存各种寄存器的
SAVE_ALL
定义在entry.S中:sys_call_table
定义在entry.S中:sys_call_table
就是系统调用表,每一个long元素(4字节)都是一个系统调用地址,所以*sys_call_table(,%eax,4)
的含义就是sys_call_table
上偏移量为0+%eax*4
元素所指向的系统调用,即第%eax
个系统调用。上文中fork
系统调用最终设置到eax的值是120,那最终执行的就是sys_clone
这个函数,注意其实现和第2个系统调用sys_fork
基本一样,只是参数不同,关于fork和clone的区别可以看这里,代码如下:一次系统调用的基本过程已经分析完,剩下的具体处理逻辑和本文无关就不分析了,有兴趣的同学可以自己看看。
整体调用流程图如下:
End
想写这篇文章的原因主要是年前在看《《程序员的自我修养》》这本书,之前对于系统调用这块有一些了解但很零碎和模糊,看完本书系统调用这一章后消除了我许多疑问。总体来说这是一本不错的书,但我相关的基础比较薄弱,所以收获不多。