【IT168 技术】在Linux中,系统调用是用户空间访问内核的唯一手段,它们是内核唯一的合法入口。实际上,其他的像设备文件和/proc之类的方式,最终也还是要通过系统调用进行的。
一般情况下,应用程序通过应用编程接口(API)而不是直接通过系统调用来编程,而且这种编程接口实际上并不需要和内核提供的系统调用对应。一个API定义了一组应用程序使用的编程接口。它们可以实现成一个系统调用,也可以通过调用多个系统调用来实现,即使不使用任何系统调用也不存在问题。实际上,API可以在各种不同的操作系统上实现,给应用程序提供完全相同的接口,而它们本身在这些系统上的实现却可能迥异。
在Unix世界中,最流行的应用编程接口是基于POSIX标准的,Linux是与POSIX兼容的。
从程序员的角度看,他们只需要给API打交道就可以了,而内核只跟系统调用打交道;库函数及应用程序是怎么使用系统调用不是内核关心的。
系统调用(在linux中常称作syscalls)通常通过函数进行调用。它们通常都需要定义一个或几个参数(输入)而且可能产生一些副作用。这些副作用通过一个long类型的返回值来表示成功(0值)或者错误(负值)。在系统调用出现错误的时候会把错误码写入errno全局变量。通过调用perror()函数,可以把该变量翻译成用户可以理解的错误字符串。
系统调用的实现有两个特别之处:
1)函数声明中都有asmlinkage限定词,用于通知编译器仅从栈中提取该函数的参数。
2)系统调用getXXX()在内核中被定义为sys_getXXX()。这是Linux中所有系统调用都应该遵守的命名规则。
系统调用号:在linux中,每个系统调用都赋予一个系统调用号,通过这个少有的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底要执行哪个系统调用;进程不会提及系统调用的名称。系统调用号一旦分配就不能再有任何变更(否则编译好的应用程序就会崩溃),如果一个系统调用被删除,它所占用的系统调用号也不允许被回收利用。Linux有一个"未使用"系统调用sys_ni_syscall(),它除了返回-ENOSYS外不做任何其他工作,这个错误号就是专门针对无效的系统调用而设的。虽然很罕见,但如果有一个系统调用被删除,这个函数就要负责“填补空位”。
内核记录了系统调用表中所有已注册过的系统调用的列表,存储在sys_call_table中。它与体系结构有关,一般在entry.s中定义。这个表中为每一个有效的系统调用指定了唯一的系统调用号。
用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间的函数,因为内核驻留在受保护的地址空间上,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,系统系统切换到内核态,这样内核就可以代表应用程序来执行该系统调用了。这种通知内核的机制是通过软中断实现的。x86系统上的软中断由int$0x80指令产生。这条指令会触发一个异常导致系统切换到内核态并执行第128号异常处理程序,而该程序正是系统调用处理程序,名字叫system_call().它与硬件体系结构紧密相关,通常在entry.s文件中通过汇编语言编写。
所有的系统调用陷入内核的方式都是一样的,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86上,这个传递动作是通过在触发软中断前把调用号装入eax寄存器实现的。这样系统调用处理程序一旦运行,就可以从eax中得到数据。上述所说的system_call()通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果它大于或者等于NR_syscalls,该函数就返回-ENOSYS.否则,就执行相应的系统调用:
由于系统调用表中的表项是以32位(4字节)类型存放的,所以内核需要将给定的系统调用号乘以4,然后用所得到的结果在该表中查询器位置。如图图一所示:
上面已经提到,除了系统调用号以外,还需要一些外部的参数输入。最简单的办法就是像传递系统调用号一样把这些参数也存放在寄存器里。在x86系统上ebx,ecx,edx,esi和edi按照顺序存放前5个参数。需要六个或六个以上参数的情况不多见,此时,应该用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针。给用户空间的返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中。
系统调用必须仔细检查它们所有的参数是否合法有效。系统调用在内核空间执行。如果任由用户将不合法的输入传递给内核,那么系统的安全和稳定将面临极大的考验。最重要的一种检查就是检查用户提供的指针是否有效,内核在接收一个用户空间的指针之前,内核必须要保证:
1)指针指向的内存区域属于用户空间。
2)指针指向的内存区域在进程的地址空间里。
3)如果是读,读内存应该标记为可读。如果是写,该内存应该标记为可写。
内核提供了两种方法来完成必须的检查和内核空间与用户空间之间数据的来回拷贝。这两个方法必须有一个被调用。
copy_to_user():向用户空间写入数据,需要3个参数。第一个参数是进程空间中的目的内存地址。第二个是内核空间内的源地址。第三个是需要拷贝的数据长度(字节数)。
copy_from_user():向用户空间读取数据,需要3个参数。第一个参数是进程空间中的目的内存地址。第二个是内核空间内的源地址。第三个是需要拷贝的数据长度(字节数)。
注意:这两个都有可能引起阻塞。当包含用户数据的页被换出到硬盘上而不是在物理内存上的时候,这种情况就会发生。此时,进程就会休眠,直到缺页处理程序将该页从硬盘重新换回到物理内存。
内核在执行系统调用的时候处于进程上下文,current指针指向当前任务,即引发系统调用的那个进程。在进程上下文中,内核可以休眠(比如在系统调用阻塞或显式调用schedule()的时候)并且可以被抢占。当系统调用返回的时候,控制权仍然在system_call()中,它最终会负责切换到用户空间并让用户进程继续执行下去。
给linux添加一个系统调用时间很简单的事情,怎么设计和实现一个系统调用是难题所在。实现系统调用的第一步是决定它的用途,这个用途是明确且唯一的,不要尝试编写多用途的系统调用。ioctl则是一个反面教材。新系统调用的参数,返回值和错误码该是什么,这些都很关键。一旦一个系统调用编写完成后,把它注册成为一个正式的系统调用是件琐碎的工作,一般下面几步:
1)在系统调用表(一般位于entry.s)的最后加入一个表项。从0开始算起,系统表项在该表中的位置就是它的系统调用号。如第10个系统调用分配到系统调用号为9。
2)任何体系结构,系统调用号都必须定义于include/asm/unistd.h中。
3)系统调用必须被编译进内核映像(不能编译成模块)。这只要把它放进kernel/下的一个相关文件就可以。
通常,系统调用靠C库支持,用户程序通过包含标准头文件并和C库链接,就可以使用系统调用(或者使用库函数,再由库函数实际调用)。庆幸的是linux本身提供了一组宏用于直接对系统调用进行访问。它会设置好寄存器并调用int $0x80指令。这些宏是_syscalln(),其中n的范围是从0到6.代表需要传递给系统调用的参数个数。这是由于该宏必须了解到底有多少参数按照什么次序压入寄存器。以open系统调用为例:
open()系统调用定义如下是:
直接调用此系统调用的宏的形式为:
_syscall3(long, open, const char *, filename, int , flags, int, mode)
这样,应用程序就可以直接使用open().调用open()系统调用直接把上面的宏放置在应用程序中就可以了。对于每个宏来说,都有2+2*n个参数。每个参数的意义简单明了,这里就不详细说明了。