目录
一、系统调用介绍
1、什么是系统调用?
系统调用是一组接口,也可以理解为一组函数接口的统称。操作系统为用户态运行的进程与硬件设备进行交互提供了一组接口,这组接口使得程序具有可移植性,这组接口就是系统调用。
2、系统调用的本质
系统调用的本质就是函数调用,只是调用的是处于内核态的系统函数。
3、系统调用与API
API是指应用编程接口,可以理解为一个函数的定义。Linux的应用编程接口(API)遵循了在UNIX世界中最流行的应用编程接口标准——POSIX标准。POSIX标准是针对API而不是针对系统调用的。
API和系统调用的调用形式异同:
相同:API的调用形式有可能与系统调用的一致,比如:read()函数就和read()系统调用的调用形式一致。
不同:
(1)几个不同的API其内部实现可能调用了同一个系统调用,如Linux的libc库实现了内存分配和释放的函数malloc()、calloc()和free(),这几个函数的实现都调用了brk()系统调用。
(2)一个API的实现调用了多个系统调用。
(3)有些API不需要任何系统调用。
从编程者的观点看,API和系统调用之间没有什么差别,二者关注的都是函数名,参数类型及返回代码的含义。从设计者的观点看,系统调用实现在内核完成的,而用户态的函数(API)是在函数库中实现的。
4、系统调用与系统命令
系统命令相对API更高一层,每个系统命令都是一个可执行程序,比如常用的系统命令ls,hostname,cd等,这些命令的实现调用了系统调用。可以通过strace命令查看系统命令所调用的系统调用,比如starce ls,就会发现它们调用了诸如open()、brk()、fstat()、ioctl()等系统命令。
5、系统调用与内核函数
内核函数与普通函数形式上没有什么区别。
但是编程要求上有区别:
(1)内核程序一般不能引用C库函数
(2)缺少内存保护措施
(3)堆栈有限制(因此调用嵌套不能太多,具体原因看内存管理,我的博客有)
(4)必须考虑内核执行路径的连续性,不能有长睡眠等行为。
系统调用是用户进程进入内核的接口层,它本身并非内核函数,但却由内核函数实现。进入内核后,不同的系统调用会找到各自对应的内核函数,这些内核函数被称为系统调用的“服务例程”。比如系统调用getpid()实际调用的服务例程为sys_getpid(),或者说系统调用getpid()是服务例程sys_getpid()的“封装例程”。
6、中断、异常和系统调用的比较
相同:都是用IDT表(中断向量表)描述的。(不懂中断和异常去我的博客里找)
不同:
(1)源头不同。产生中断或者异常或者系统调用的来源不同。
(2)服务响应方式不同。产生后如何响应中断或者异常或者系统调用的方式不同。
(3)处理机制不同。响应后如何处理中断或者异常或者系统调用的方式不同。
不同点 | 中断 | 异常 | 系统调用 |
---|---|---|---|
源头不同 | 是外设发出的请求 | 是应用程序意想不到的行为 | 应用程序请求OS提供 |
服务响应方式不同 | 同步 | 同步 | 同步或者异步 |
处理机制不同 | 中断服务程序在内核态运行,对用户透明 | 异常出现时,或者杀死进程,或者重新执行引起异常的指令 | 用户发出请求后等待OS的服务 |
二、系统调用涉及的东西
用户在调用时会向内核传递一个系统调用号,然后系统调用处理程序通过此号从系统调用表中找到相应的内核函数执行系统调用服务例程,最后返回。
1、系统调用号
Linux系统有几百个系统调用,每一个系统调用有唯一的编号,这个编号称为系统调用号。它定义在文件linux/arch/x86/include/asm/unistd_32.h(注意,在不同的版本中,头文件位置稍不同)。系统调用号一旦分配好就不能有任何改变,否则编译好的应用程序就会因为调用到错误的系统调用而导致程序崩溃。
系统调用号的作用:
(1)唯一标识一个系统调用。
(2)作为系统调用表的下标,当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底是要执行哪个系统调用。
2、系统调用表
作用:把系统调用号与对应的服务例程关联起来。
位置:此表定义在linux/arch/x86/kernel/syscall_table_32.S中。存放在sys_call_table数组中。这是一个函数指针数组,每一个函数指针都指向其系统调用的封装例程。
特点:有NR_syscalls个表项,第n个表项包含系统调用号为n的服务例程的地址。NR_syscalls宏只是对可实现的系统调用最大个数的静态限制,并不表示实际已实现的系统调用个数。
3、系统调用服务例程
每一个系统调用,如bar(),在内核态都有一个对应的内核函数sys_bar(),这个内核函数就是系统调用bar()的实现。可以理解为在用户态调用bar(),最终会由内核函数sys_bar()为用户服务,这里的sys_bar()就是系统调用服务例程。
4、系统调用处理程序
系统调用既然最终会由相应的内核函数完成,那么为什么不直接调用内核函数呢?
这是因为用户空间的程序无法直接执行内核代码,因为内核驻留在受保护的地址空间上,不允许用户进程在内核地址空间上读写。
所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,这种通知内核的机制是靠软中断来实现的,通过引发一个异常来促使系统切换到内核态去执行异常处理程序,此时的异常处理程序就是系统调用处理程序。
三、系统调用的实现
Linux对系统调用的调用必须通过执行int $0x80 汇编指令,这条汇编指令产生向量为128的编程异常。因为内核实现了很多不同的系统调用,因此进程必须传递一个系统调用号的参数来识别所需的系统调用;EAX寄存器是负责传递系统调用号的,即在用户态把系统调用号传递给系统调用处理程序。
系统调用处理程序执行以下操作:
(1)在内核栈保存大多数寄存器的内容(这个操作系统对所有的系统调用都是通用的,并且用汇编语言编写)
(2)调用系统调用服务例程的相应的C函数来处理系统调用。
(3)通过syscall_exit_work()函数从系统调用返回(这个函数用汇编语言编写)。
1、初始化系统调用
内核初始化期间调用trap_init()函数建立IDT表中128号向量对应的表项:
set_system_gate(0x80, &system_call);
该调用把下列值装入该门描述符的相应域 :
(1)段选择子:因为系统调用处理程序属于内核代码,填写内核代码段_KERNEL_CS的段选择子。
(2) 偏移量:指向system_call()异常处理程序。
(3) 类型:置为15,表示该异常是一个陷阱。
(4)DPL(描述符特权级):置为3,这就允许用户态进程调用这个异常处理程序。
因此系统调用(在用户空间中)是通过系统门进入系统调用处理程序(在内核空间中)。
2、system_call()函数
system_call()函数实现了系统调用处理程序。具体函数流程如下:
(1)它首先把系统调用号和该异常处理程序用到的所有CPU寄存器保存到相应的栈中。
(2)把当前进程PCB的地址存放在ebx中。
(3)对用户态进程传递来的系统调用号进行有效性检查。若调用号大于或等于NR_syscalls,系统调用处理程序终止。
(4)若系统调用号无效,函数就把-ENOSYS值存放在栈中eax寄存器所在的单元,再跳到ret_from_sys_call( )。
(5) 根据eax中所包含的系统调用号调用对应的特定服务例程。
3、参数传递
因为system_call()函数是Linux中所有系统调用唯一的入口点,因此每个系统调用至少有一个参数,即通过EAX寄存器传递来的系统调用号。因为这个寄存器的设置是有libc中封装的例程进行的,所以程序员并不需要关心系统调用号。
在少数情况下,系统调用不使用任何参数。 如:fork()系统调用没有参数,但其服务例程do_fork()需要知道有关寄存器的值。不过,很多系统调用确实需要由应用程序明确地传递另外的参数,如mmap()系统调用。
参数传递的过程:系统调用的参数通常是通过寄存器传递个系统调用处理程序的,然后在拷贝到内核堆栈。
为什么内核不直接把参数从用户态的栈拷贝到内核态的栈呢?
(1)同时操作两个栈是比较复杂的。
(2)寄存器的使用使得系统调用处理程序的结构与其他异常处理程序的结果类似。
用寄存器传递参数必须满足两个条件:
(1)每个参数的长度不能超过寄存器的长度,这里是32位。它跟CPU体系结构有关。
(2)参数的个数不能超过6个(包括eax中传递的系统调用号),否则,用一个单独的寄存器指向进程地址空间中这些参数值所在的一个内存区即可。
服务例程的返回值必须写回到EAX寄存器中,这是在执行return n指令时由C编译程序自动执行的。
4、跟踪系统调用的执行
分析系统调用的方法有两种:
(1)查看entry.s中的代码细节,阅读相关的源码来分析其运行过程。
(2)借助一些内核调试工具,动态跟踪执行路径。
结合用户空间的执行路径,一个程序的执行大致可归结为以下几个步骤:
(1)程序调用libc库的封装函数。
(2)调用软中断 int 0x80 进入内核。
(3)在内核中首先执行system_call函数,接着根据系统调用号在系统调用表中查找到对应的系统调用服务例程。
(4)执行该服务例程。
(5)执行完毕后,转入ret_from_sys_call例程,从系统调用返回。
四、封装例程
为了简化对相应的封装例程的声明,Linux定义了从_syscall0到_syscall5的六个宏。之所以定义6个宏,是因为系统调用的参数给谁一般不超过6个。
每个宏名字的数字0到5对应着系统调用所用的参数个数(系统调用号除外)
每个宏严格地需要2+2*n个参数,n是系统调用的参数个数。另外两个参数指明系统调用的返回值类型和名字;每一对参数指明相应的系统调用参数的类型和名字 。
例:fork( )的封装例程:
__syscall0(int,fork)
write()的封装例程可以通过如下语句生产:
__syscall3(int,write,int,fd,const char *,buf,unsigned int,count)
系统调用一般用在用户程序中,但在内核中同样可以调用这种封装了的系统调用。二者区别如下:
(1)在用户态进行系统调用时,转换到内核态的系统调用处理程序时要进行用户态堆栈到内核态堆栈的切换,即从“int 0x80”指令转换到内核态的“system_call”函数时,要保存寄存器ss、esp;而当“iret”指令从“system_call”返回用户态时要取回ss、esp的值。
(2)在内核中进行系统调用时,不用进行堆栈切换,即“int 0x80”指令不用切换到内核态“system_call”函数,也不必保存寄存器ss、esp;而当“iret”指令从“system_call”返回,仍然是内核态,所以也不用取回ss、esp的值。
五、添加新系统调用
实现一个新的系统调用的第一步是决定它的用途,每个系统调用都应该有一个明确的用途。在Linux中不提倡采用多用途的系统调用。
编写系统调用时要时刻注意可移植性和健壮性。
系统调用的实现需要调用内核中的函数,因此,内核版本的不同,其内核函数名可能稍有差异。
添加系统调用的步骤:
(这里只讲理论流程,不讲具体代码实现,详情在我的博客里找对应的实现过程)
(1)添加系统调用号
(2)在系统调用表中添加对应表项
(3)实现系统调用服务例程
(4)重新编译内核
(5)编写用户态程序
六、实例-系统调用日志收集系统
利用系统调用实现一个调用日志收集系统,实时获取系统调用日志。
本实例需要完成以下几个基本功能:
(1)记录系统调用日志,将其写入缓冲区(内核中),以便用户读取(由syscall_audit()实现)。
(2)建立新的系统调用,将内核缓冲中的系统调用日志返回到用户空间(由Sys_audit()实现)。
(3)循环利用系统调用,以便能动态实时返回系统调用的日志(由 auditd() 实现)
1、 代码结构体系介绍
(这里只讲理论流程,不讲具体代码实现,详情在我的博客里找对应的实现过程)
(1)日志记录例程syscall_audit() 。内核态的服务例程,负责记录系统调用的运行日志,包括调用时刻、调用者PID、程序名等。
(2)模块例程sys_audit() 。新添加的系统调用,其功能是从缓冲区中取数 据返回用户空间 。
(3)模块的初始化和退出。 为了实现对函数的动态加载和卸载。
(4)用户空间服务程序auditd 。取回系统中搜集到的系统调用日志信息。
2、把代码集成到内核中
(1)添加系统调用号
(2)在系统调用表中添加相应的表项
(3)修改系统调用入口
(4)添加自己的文件
(5)修改Makefile文件
(6)导出函数名,以提供内核接口函数
(7)编译并加载模块
(8)重新编译内核