对技术执着
分类: LINUX
2015-07-16 10:50:52
原文地址:linux内核设计与实现笔记<四>---系统调用 作者:snowboy9859
从程序员的角度看,系统调用无关紧要;他们只需要跟API打交道就可以了。相反,内核只跟系统调用打交道;库函数及应用程序是怎么使用系统调用不是内核所关心的。但是,内核必须时刻牢记系统调用所有潜在的用途并保证它们有良好的通用性和灵活性。
关于Unix的界面设计有一句通用的格言“提供机制而不是策略”。换句话说,Unix的系统调用抽象出了用于完成某种确定目的的函数。至于这些函数怎么用完全不需要内核去关心。(区别对待机制(mechanism)和策略(policy)是Unix设计中的一大亮点。大部分的编程问题都可以被切割成两个部分:“需要提供什么功能”(机制)和“怎样实现这些功能”(策略)。如果由程序中的独立部分分别负责机制和策略的实现,那么开发软件就更容易,也更容易适应不同的需求。—译者注)
2. 系统调用
系统调用(在Linux中常称作syscalls)通常通过函数进行调用。它们通常都需要定义一个或几个参数(输入)而且可能产生一些副作用,例如写某个文件或向给定的指针拷贝数据等等。系统调用还会通过一个long类型的返回值来表示成功或者错误。通常,但也不绝对,用一个负的返回值来表明错误。返回一个0值通常(当然仍不是绝对的)表明成功。Unix系统调用在出现错误的时候会把错误码写入errno全局变量。通过调用perror()库函数,可以把该变量翻译成用户可以理解的错误字符串。
3. 系统调用号
在Linux中,每个系统调用被赋予一个系统调用号。这样,通过这个独一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底是要执行哪个系统调用;进程不会提及系统调用的名称。
系统调用号相当关键;一旦分配就不能再有任何变更,否则编译好的应用程序就会崩溃。此外,如果一个系统调用被删除,它所占用的系统调用号也不允许被回收利用,否则,以前编译过的代码会调用这个系统调用,但事实上缺调用的是另一个系统调用。Linux有一个“未实现”系统调用sys_ni_syscall(),它除了返回-ENOSYS外不做任何其他工作,这个错误号就是专门针对无效的系统调用而设的。虽然很罕见,但如果一个系统调用被删除,或者变得不可用,这个函数就要负责“填补空位”。
内核记录了系统调用表中的所有已注册过的系统调用的列表,存储在sys_call_table中。它与体系结构有关,一般在entry.s中定义。这个表中为每一个有效的系统调用指定了惟一的系统调用号。
4. 系统调用处理程序
用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核的地址空间上读写的话,系统安全就会失去控制。
所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序来执行该系统调用了。
通知内核的机制是靠软中断实现的:通过引发一个异常来促使系统切换到内核态去执行异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。x86系统上的软中断由int $0x80指令产生。这条指令会触发一个异常导致系统切换到内核态并执行第128号异常处理程序,而该程序正是系统调用处理程序。这个处理程序名字起得很贴切,叫system_call()。它与硬件体系结构紧密相关,通常在entry.S文件中用汇编语言编写。最近,x86处理器增加了一条叫做sysenter的指令。 与int中断指令相比,这条指令提供了更快、更专业的陷入内核执行系统调用的方式。对这条指令的支持很快被加入内核。且不管系统调用处理程序被如何调用,单是用户空间引起异常或陷入内核就是一种重要的概念。
1)指定恰当的系统调用
因为所有的系统调用陷入内核的方式都一样,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86上, 系统调用号是通过eax寄存器传递给内核的。在陷入内核之前,用户空间就把相应系统调用所对应的号放入eax中了。这样系统调用处理程序一旦运行,就可以从eax中得到数据。其他体系结构上的实现也都类似。
system_call()函数通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果它大于或者等于NR_syscalls,该函数就返回-ENOSYS。否则,就执行相应的系统调用。
2)参数传递
除了系统调用号以外,大部分系统调用都还需要一些外部的参数输入。所以,在发生异常的时候,应该把这些参数从用户空间传给内核。最简单的办法就是像传递系统调用号一样:把这些参数也存放在寄存器里。在x86系统上,ebx、ecx、edx、esi和edi按照顺序存放前五个参数。需要六个或六个以上参数的情况不多见,此时,应该用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针。给用户空间的返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中
5. 系统调用的实现
实现一个新的系统调用的第一步是决定它的用途。它要做些什么?每个系统调用都应该有一个明确的用途。在Linux中不提倡采用多用途的系统调用(一个系统调用通过传递不同的参数值来选择完成不同的工作)。ioctl()就应该被视为一个反例。
系统调用的接口应该力求简洁,参数尽可能少。系统调用的语义和行为非常关键;因为应用程序依赖于它们,所以它们应力求稳定,不做改动。系统调用设计得越通用越好,要确保不对系统调用做错误的假设,否则将来这个调用就可能会崩溃。当你写一个系统调用的时候,要时刻注意可移植性和健壮性,不但要考虑当前,还要为将来做打算。基本的Unix系统调用经受住了时间的考验;它们中的很大一部分到现在都还和30年前一样适用和有效。
1)参数验证
系统调用必须仔细检查它们所有的参数是否合法有效。系统调用在内核空间执行,如果任由用户将不合法的输入传递给内核,那么系统的安全和稳定将面临极大的考验。
最重要的一种检查就是检查用户提供的指针是否有效。试想,如果一个进程可以给内核传递指针而又无须被检查,那么它就可以给出一个它根本就没有访问权限的指针,哄骗内核去为它拷贝本不允许它访问的数据,如原本属于其他进程的数据。在接收一个用户空间的指针之前,内核必须保证:
指针指向的内存区域属于用户空间。进程决不能哄骗内核去读内核空间的数据。
指针指向的内存区域在进程的地址空间里。进程决不能哄骗内核去读其他进程的数据。
如果是读,该内存应被标记为可读。如果是写,该内存应被标记为可写。进程决不能绕过内存访问限制。
6. 绑定一个系统调用的最后步骤
当编写完一个系统调用后,把它注册成一个正式的系统调用是件琐碎的工作:
首先,在系统调用表的最后加入一个表项。每种支持该系统调用的硬件体系都必须做这样的工作(大部分的系统调用都针对所有的体系结构)。从0开始算起,系统调用在该表中的位置就是它的系统调用号。如第10个系统调用分配到的系统调用号为9。
对于所支持的各种体系结构,系统调用号都必须定义于
系统调用必须被编译进内核映象(不能被编译成模块)。这只要把它放进kernel/下的一个相关文件中就可以。
7. 从用户空间访问系统调用
通常,系统调用靠C库支持。用户程序通过包含标准头文件并和C库链接,就可以使用系统调用(或者调用库函数,再由库函数实际调用)。但如果你仅仅写出系统调用,glibc库恐怕并不提供支持。
值得庆幸的是,Linux本身提供了一组宏,用于直接对系统调用进行访问。它会设置好寄存器并调用陷入指令。这些宏是_syscalln(),其中n的范围从0到6。代表需要传递给系统调用的参数个数,这是由于该宏必须了解到底有多少参数按照什么次序压入寄存器。
而不靠库支持,直接调用此系统调用的宏的形式为:
这样,应用程序就可以直接使用open()。
对于每个宏来说,都有2+2×n个参数。第一个参数对应着系统调用的返回值类型。第二个参数是系统调用的名称。再以后是按照系统调用参数的顺序排列的每个参数的类型和名称。_NR_open在
8. 为什么不通过系统调用的方式实现
值得庆幸的是,前面的章节已经告诉大家,建立一个新的系统调用非常容易,但却绝不提倡这么做。的确,在我尽力描述系统调用如何工作,如何增加新的系统调用后,我现在反倒建议在增加新的系统调用时千万慎重考虑。通常都会有更好的办法用来代替新建一个系统调用以作实现。让我们看看采用系统调用作为实现方式的利弊和替代的方法。
建立一个新的系统调用的好处:
系统调用创建容易且使用方便。
Linux系统调用的高性能显而易见。
问题是:
你需要一个系统调用号,而这需要在一个内核在处于开发版本的时候由官方分配给你。
系统调用被加入稳定内核后就被固化了,为了避免应用程序的崩溃,它的界面不允许做改动。
需要将系统调用分别注册到每个需要支持的体系结构中去。
在脚本中不容易调用系统调用,也不能从文件系统直接访问系统调用。
如果仅仅进行简单的信息交换,系统调用就大材小用了。
替代方法:
创建一个设备节点,通过read()和write()访问它。用ioctl()进行特别的设置操作和获取特别信息。
一些接口如信号量,可以用文件描述符表示以进行操作。像信号量这样的某些接口,可以用文件描述符来表示,因此也就可以按上述方式对其进行操作。
把增加的信息作为一个文件放在sysfs的合适位置。
对于许多接口来说,系统调用都被视为正确的解决之道。但Linux系统尽量避免每出现一种新的抽象就简单的加入一个新的系统调用。这使得它的系统调用接口简洁得令人叹为观止,也就避免了许多后悔和反对意见(系统调用再也不被使用或支持)。新系统调用增添频率很低也反映出Linux是一个相对较为稳定并且功能已经较为完善的操作系统。