何天杨+ 原创作品转载请注明出处 + 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000
一、什么是系统调用
linux内核中设置了一组用于实现系统功能的子程序,称为系统调用。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于核心态,而普通的函数调用由函数库或用户自己提供,运行于用户态。
一般进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作”保护模式”)。为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求(或者让应用程序暂时搁置)。实际上提供这组接口主要是为了保证系统稳定可靠,避免应用程序肆意妄行,惹出大麻烦。
操作系统为用户态进程与硬件设备进行交互提供了一组接口——系统调用:
1.把用户从底层的硬件编程中解放出来
2.极大的提高了系统的安全性
3.使用户程序具有可移植性
系统调用是用户态进入内核态的唯一入口
二、系统调用与API之间的关系
API和系统调用的区别:
应用编程接口(application program interface, API)和系统调用是不同的
1.API只是一个函数定义
2.系统调用通过软中断(trap的方式)向内核发出一个明确的请求(用int 指令/system enter指令…)
Libc库定义的一些API引用了封装例程(wrapper routine,唯一目的就是发布系统调用)使得程序员不需要通过汇编指令来触发系统调用
1.一般每个系统调用对应一个封装例程
2.库再用这些封装例程定义出给用户的API
不是每个API都对应一个特定的系统调用。
1.首先,API可能直接提供用户态的服务(比如一些数学函数)
2.其次,一个单独的API可能调用几个系统调用不同的
3.API可能调用了同一个系统调用
三、用户态、内核态和中断
内核态:一般现代CPU有几种指令执行级别。在高执行级别下,代码可以执行特权指令,访问任意的物理地址,这种CPU执行级别对应着内核态
用户态:在相应的低级别执行状态下,代码的掌控范围有限,只能在对应级别允许的范围内活动。如intel x86 CPU有四种不同的执行级别0-3,Linux只使用0级表示内核态,3级表示用户态。权限级别的划分使系统更稳定
区分用户态与内核态主要通过代码段选择寄存器cs和偏移量寄存器eip,cs寄存器的最低两位表明了当前代码特权级,CPU每条指令的读取都是通过cs:eip这两个寄存器一般在Linux中,(逻辑)地址空间是显著标志:0xc0000000以上的地址空间只能在内核态下访问,0x00000000-0xbfffffff的地址空间在两种状态下都能访问
中断处理是从用户态进入内核态的主要方式。系统调用只是一种特殊的中断。从用户态切换到内核态时:必须保存用户态的寄存器上下文,同时将内核态的寄存器相应的值放入当前CPU。中断/int指令会在堆栈上保存一些寄存器的值:如用户态栈顶地址、当前的状态字、当时cs:eip的值(当前中断程序的入口)Linux内核代码中定义了两个宏指令来进行保护和恢复。
保护现场:进入中断程序,保存需要用到的寄存器的数据(中断发生后的第一件事)
#define SAVE_ALL //将其他寄存器的值push到内核堆栈中
恢复现场:退出中断程序,恢复保存寄存器的数据(中断处理结束前最后一件事)
#RESTORE_ALL //将用户态保存的寄存器pop到当前CPU中
四、系统调用过程
用户空间的程序无法直接执行内核代码,它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核的地址空间上读写的话,系统安全就会失去控制。所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序来执行该系统调用了。
通知内核的机制是靠软件中断实现的。首先,用户程序为系统调用设置参数。其中一个参数是系统调用编号。参数设置完成后,程序执行“系统调用”指令。x86系统上的软中断由int产生。这个指令会导致一个异常:产生一个事件,这个事件会致使处理器切换到内核态并跳转到一个新的地址,并开始执行那里的异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。它与硬件体系结构紧密相关。
新地址的指令会保存程序的状态,计算出应该调用哪个系统调用,调用内核中实现那个系统调用的函数,恢复用户程序状态,然后将控制权返还给用户程序。系统调用是设备驱动程序中定义的函数最终被调用的一种方式。
系统调用处理程序也其他异常处理程序的结构类似,执行下列操作
1.在进程的内核态堆栈中保存大多数寄存器的内容(即保存恢复进程到用户态执行所需要的上下文)
2.根据用户态传递的系统调用号,确定系统
3.调用名为系统调用服务例程的相应的C函数来处理系统调用
4.从系统调用返回
以下图为例:
用户态中的xyz()函数是API,当中封装了系统调用。系统调用中触发了int 0x80的中断,这个中断向量对应着systemcall这个内核代码的起点)。system中调用对应的系统调用服务程序sys_xyz(),进入服务程序。进行完毕后ret_from_sys_call返回。此时是一个进程调度的时机,如果没有发生进程调度则直接iret返回用户态。
五、系统调用号和传参
1、内核通过自己的系统调用分派表sys_call_table(可以理解为一个系统调用号,对应一个函数入口地址)找到这个具体的系统调用服务例程对应的函数入口地址,如上面sys_read,sys_write等。
2、传递的参数规则:
在发起系统调用前,eax寄存器里面存储了系统调用号。如用户程序fork()函数,glibc 发出int 0x80或sysenter指令前,eax寄存器就会设置好内核的sys_fork函数对应的系统调用号,这是glibc里面的封装例程会自动设置好的,程序员无需关心。 有些系统调用可能调用很多参数(除了系统调用号之外),普通c函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或者内核态栈)实现的。因为系统调用是一种跨用户态和内核态的特殊函数,所以这两个栈都不能用。在发出系统调用之前,系统调用的参数写入了cpu的寄存器(如glibc去写好这些寄存器),然后发出系统调用之后,而在内核调用服务例程(如sys_fork()服务例程)之前,内核再把存放在cpu中的参数拷贝的内核态的堆栈中(因为sys_fork只是普通的c函数,前面说过普通c函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或者内核态栈)实现的)。内核为什么不直接把用户态的栈拷贝到内核态的栈而要去通过寄存器来传呢?首先,同事操作两个栈是比较复杂的,其次,寄存器的使用使得系统调用处理程序的结构与其它异常处理程序的结构类似。
使用寄存器传递参数,必须满足两个条件:
每个参数的长度不能超过寄存器的长度(比如寄存器长度32位,那参数长度就不能超过32位);
参数的个数不能超过6个(除了eax中传递的系统调用号),因为80x86处理器的寄存器的数量是有限的。
第一个条件总能成立,因为POSIX标准规定,如果寄存器里面装不下那个长度的参数,那么必须改用参数的地址来传递。
第二个条件有的系统调用参数大于6个,这种情况下,必须用一个单独的寄存器执行进程地址空间的这些参数所在的一个内存区。
六、实验过程显示
使用库函数API和C代码中嵌入汇编代码两种方式使用同一个系统调用,在这里选出系统调用号为64的系统调用sys_getppid(),该系统调用用于返回当前进程的父进程进程号数。
下面是直接使用库函数API使用系统调用。getpid.c代码中,函数getppid()是glibc对系统调用sys_getppid的封装,用于获取当前进程的父进程的进程号。sys_getppid系统调用号为64,在用户态时候,如果用户调用了getppid(),系统会产生一中断,进入到了内核态执行sys_getppid。getppid()的功能是返回当前进程的父进程的ID,它本身是不能完成的,必须请求操作系统服务即sys_getppid,让操作系统把当前进程的ID告诉给getpid()。
下面再使用C语言内嵌汇编代码的方式实现同一个系统调用。getpid_asm.c代码
#include
#include
int main()
{
pid_t pid;
asm volatile(
"mov $0,%%ebx\n\t"
/*ebx用来传递参数,getppid(void)的参数是void所以设置为零*/
"mov $0x40,%%eax\n\t"
/*eax用来传递系统调用号,getppid的系统调用号是64,所以是0x40*/
"int $0x80\n\t" /* 软中断汇编指令,系统进入内核态 */
"mov %%eax,%0\n\t"
/*eax保留返回值,把返回值放到输出参数中,即pid变量中*/
:"=m" (pid) /*输出参数是pid*/
);
printf("The NO of parent process is: %d\n",pid);
return 0;
}