📄 5.txt
字号:
第5章 系 统 调 用大部分介绍Unix内核的书籍都没有仔细说明系统调用,我认为这是一个失误。实际上,我们实际需要的系统调用现在已经十分完美。因此,从某种意义上来说,研究系统调用的实现是无意义的—如果你想为Linux内核的改进贡献自己的力量,还有其他许多方面更值得投入精力。然而,对于我们来说,仔细研究少量系统调用是十分值得的。这样就有机会初步了解一些概念,这些概念将在本书中逐步详细介绍,例如进程处理和内存。这使你可以详细了解Linux内核编程的特点。这包括一些和你过去在学校里(或工作中)所学的内容不同的方法。和其他编程任务相比,Linux内核编程的一个显著特点是它不断同三个成见进行斗争—这三个成见就是速度、正确和清晰,我们不可能同时获取这三个方面,至少并不总是能够。5.1 什么是系统调用系统调用发生在用户进程(比如emacs)通过调用特殊函数(例如open)以请求内核提供服务的时候。在这里,用户进程被暂时挂起。内核检验用户请求,尝试执行,并把结果反馈给用户进程,接着用户进程重新启动,随后我们将详细讨论这种机制。系统调用负责保护对内核所管理的资源的访问,系统调用中的几个大类主要有:处理I/O请求(open、close、read、write、poll等等)、进程(fork、execve、kill等等)、时间(time、settimeofday等等)以及内存(mmap、brk等等)的系统调用。几乎所有的系统调用都可以归入这几类中。然而,从根本上来说,系统调用可能和它表面上有所不同。首先,在Linux中,C库中对于一些系统调用的实现是建立在其他系统调用的基础之上的。例如,waitpid是通过简单调用wait4实现的,但是它们两个都是作为独立的系统调用说明的。其他的传统系统调用,如sigmask和ftime是由C库而不是由Linux内核本身实现的;即使不是全部,至少大部分是如此。当然,从技巧的一面来看这是无害的—从应用程序的观点来看,系统调用就和其他的函数调用一样。只要结果符合预计的情况,应用程序就不能确定是否真正使用到了内核(这种处理方式还有一个潜在的优点:用户可以直接触发的内核代码越少,出现安全漏洞的机会也就越少)。但是,由于使用这种技巧所引起的困扰将会使我们的讨论更为困难。实际上,系统调用这一术语通常被演讲者用来说明在第一个Unix版本中的任何对系统的调用。但是在本章中我们只对“真正”的系统调用感兴趣—真正的系统调用至少包括用户进程对部分内核代码的调用。系统调用必须返回int的值,并且也只能返回int的值。为了方便起见,返回值如果为零或者为正,就说明调用成功;为负则说明发生了错误。就像老练的C程序员所知道的一样,当标准C库中的函数发生错误时,会通过设置全局整型变量errno指明发生错误的属性,系统调用的原理和它相同。然而,仅仅研究内核源程序代码并不能够获得这种系统调用方式的全部意义。如果发生了错误,系统调用简单返回自己所期望的负数错误号,其余部分则由标准C库实现(正常情况下,用户代码并不直接调用内核系统函数,而是要通过标准C库中专门负责翻译的一个小层次(thin layer)实现)。我们随便举一个例子,27825行(sys_nanosleep的一部分)返回-EINVAL指明所提供的值越界了。标准C库中实际处理sys_nanosleep的代码会注意到返回的负值,从而设置errno和EINVAL,并且自己返回-1给原始的调用者。在最近的内核版本中,系统调用返回负值偶尔也不一定表示错误。在目前的几个系统调用中(例如lseek),即使结果正确也会返回一个很大的负值。最近,错误返回值是在-1到-4095范围之内。现在,标准C库实现能够以更加成熟和高级的方式解释系统调用的返回值;当返回值为负时,内核本身就不用再做任何特殊的处理了。中断、内核空间和用户空间我们将在第6章中介绍中断和在第8章中介绍内存时再次明确这些概念。但是在本章中,我们只需要粗略地了解一些术语。第一个术语是中断(interrupt),它来源于两个方面:硬件中断,例如磁盘指明其中存放一些数据(这与本章无关);软件中断,一种等价的软件机制。在x86系列CPU中,软件中断是用户进程通知内核需要触发系统调用的基本方法(出于这种目的使用的中断号是0x80,对于Intel芯片的研究者来说更为熟悉的是INT 80h)。内核通过system_call(171行)函数响应中断,这一点我们马上就会介绍。另外两个术语是内核空间(kernel space)和用户空间(user space),它们分别对应内核保留的内存和用户进程保留的内存。当然,多用户进程也经常同时运行,而且各个进程之间通常不会共享它们的内存,但是,任何一个用户进程使用的内存都称为用户空间。内核在某一个时刻通常只和一个用户进程交互,因此实际上不会引起任何混乱。由于这些内存空间是相互独立的,用户进程根本不能直接访问内核空间,内核也只能通过put_user(13278行)和get_user(13254行)宏和类似的宏才可以访问用户空间。因为系统调用是进程和进程所运行的操作系统之间的接口,所以系统调用需要频繁地和用户空间交互,因此这些宏也就会不时的在系统调用中出现。在通过数值传递参数的情况下并不需要它们,但是当用户把指针(内核通过这个指针进行读写)传递给系统调用时,就需要这些宏了。5.2 如何激活系统调用系统调用的激活有两种方法:system_call函数和lcall7调用门(call gate)(请参见135行)。(你可能还听说过一种机制,syscall函数,是通过调用lcall7实现的—至少在x86平台上是如此,因此,它并不是一个特有的方法)。本节将细致地讨论一下这两种机制。在阅读的过程中请注意系统调用本身并不关心它们是由system_call还是由lcall7激活的。这种把系统调用和其实现方式区别开来的方法是十分精巧的。这样,如果出于某种原因我们不得不增加一种激活系统调用的方法,我们也不必修改系统调用本身来支持这种方法。在你浏览这些汇编代码之前要注意这些机器指令中操作数的顺序和普通Intel的次序相反。虽然还有一些其他的语法区别,但是操作数反序是最令人迷惑的。如果你还记得Intel的语法:mov eax, 0(本句代码的意思是把常数0传送到寄存器EAX中)在这里应该写作:mov1 $0, %eax这样你就能够正确通过(内核使用的语法是AT&T的汇编语法。在GNU汇编文档中有更多资料)。5.2.1 system_callsystem_call(171行)是所有系统调用的入口点(这是对于内部代码来说的,lcall7用来支持iBCS2,这一点我们很快就会讨论)。正如前面标题注释中说明的一样,目的是为普通情况简单地实现直接的流程,不采用跳转,因此函数的各个部分都是离散的—整体的流量控制已经因为要避免普通情况下的多分支而变得非常复杂(分支的避免是十分值得的,因为它们引起的代价非常昂贵。它们可以清空CPU管道,使现存CPU的并行加速机制失效)。图5-1显示了作为system_call的一部分出现的分支目标标号以及它们之间的流程控制方向,该图可以在你阅读本部分讨论内容时提供很大的帮助。图中system_call和restore_all两个标号比其他标号都要大,因为这两处是该函数正常的出口点和入口点;然而,还有另外两个入口点,这一点在本章的后续内容中很快就可以看到。图5-1 system_call的流程控制system_call是由标准C库激活的,该标准C库会把自己希望传递的参数装载到CPU寄存器中,并触发0x80软件中断(system_call在这里是一个中断处理程序)。内核记录了软件中断和6828行的system_call函数的联系(SYSCALL_VECTOR是在1713行宏定义为0x80的)。 system_call171:system_call的第一个参数是所希望激活的系统调用的数目;它存储在EAX寄存器中。system_call还允许有多达四个的参数和系统调用一起传送。在一些极其罕见的情况下使用四个参数的限制是负担繁重的,通常可以建立一个指向结构的指针参数来巧妙地完成同样功能,指针指向的结构中可以包含你所需要的一切信息。随后可能需要EAX值的一个额外拷贝,因此通过将其压栈而保存起来;这个值就是218行的ORIG_EAX(%esp)表达式的值。173:SAVE_ALL宏是在85行定义的,它把所有寄存器的值拷贝压入CPU的堆栈。随后,就在system_call返回之前,使用RESTORE_ALL(100行)把栈中的值弹出。在这中间,system_call可以根据需要自由使用寄存器的值。更重要的是,任何它所调用的C函数都可以从栈中查找到所希望的参数,因为SAVE_ALL已经把所有寄存器的值都压入栈中了。结果栈的结构从26行开始描述。像0(%esp)和4(%esp)一样的表达式指明了堆栈指针(ESP寄存器)的一种替换形式—分别表示ESP上的0字节,ESP上的4字节等等。特别要注意的是在前面一行中压入堆栈的EAX的拷贝已经变成本标题注释作为orig_eax所描述的内容,它们是由SAVE_ALL压入寄存器之上的堆栈的(orig_eax之上的寄存器在这里早已就绪了)。还需注意:这可能有点令人迷惑—由于我们调用orig_eax时EAX的拷贝已经压入了堆栈,它是否有可能在其他寄存器下面,而不是在其他寄存器上面呢?答案既是肯定的,也是否定的。x86的堆栈指针寄存器ESP在有数据压入堆栈时会减少—堆栈会向内存低地址发展。因此,orig_eax逻辑上是在其他值的下面,但是物理上却是在其他值的上面。从51行开始的一系列宏有助于使这些替换更容易理解。例如,EAX(%esp)就和18(%esp)相同—然而前一种方法通过表达式引用存储在堆栈中的EAX寄存器拷贝的决定可以使整个过程更加简单。174:从EBX寄存器中取得指向当前任务的指针。完成这个工作的宏GET_CURRENT(131行)对于在大部分代码中使用的C函数get_current(10277行)来说是一个无限循环。此后,当看到类似于foo(%ebx)或者foo(%esp)的表达式时,这意味着这些代码正在引用代表当前进程的结构的字段—16325行的struct task_struct—在第7章中将对它进行更详细的介绍(更确切的描述是, %ebx的置换在struct task_struct中,%esp的置换在与struct task_struct相关联的struct pt_regs结构中。但是这些细节在这里都并不重要)。175:检查(EAX中的)系统调用的数目是否超过系统调用的最大数量(此处EAX为一个无符号数,因此不可能为负值)。如果的确超过了,就向前跳转到badsys(223行)。177:检测系统调用是否正被跟踪。如strace之类的程序为有兴趣的人提供了系统调用的跟踪工具,或者额外的调试信息:如果能够监测到正在执行的系统调用,那么你就可以了解到当前程序正在处理的内容。如果系统调用正被跟踪,控制流程就向前跳转到tracesys(215行)。179:调用系统函数。此处有很多工作需要处理。首先,SYMBOL_NAME宏不处理任何工作,只是简单的为参数文本所替换,因此可以将其忽略。sys_call_table是在当前文件(arch/i386/kernel/entry.S)的末尾从373行开始定义的。这是一张由指向实现各种系统调用的内核函数的函数指针组成的表。本行中第二对圆括号中包含了三个使用逗号分隔开的参数(第一个参数为空);这里就是实现数组索引的地方。当然,这个数组是以sys_call_table作为索引的,这称为偏移(displacement)。这三个参数是数组的基地址、索引(EAX,系统调用的数目)和大小,或者每个数组元素中的字节数—在这里就是4。由于数组基地址为空,就将其当作0—但是它要和偏移地址sys_call_table相加,简单的说就是sys_call_table被当作数组的基地址。本行基本上等同于如下的C表达式:/* Call a function in an array of functions. */(sys_call_table[eax])();然而, C当然还要处理许多繁重的工作,例如为你记录数组元素的大小。不要忘记,系统调用的参数早已经存储在堆栈中了,这主要由调用者提供给system_call并使用SAVE_ALL把它们压栈。180:系统调用已经返回。它在EAX寄存器中的返回值(这个值同时也是system_call的返回值)被存储起来。返回值被存储在堆栈中的EAX内,以使得RESTORE_ALL可以迅速地恢复实际的EAX寄存器及其他寄存器的值。182:接下来的代码仍然是system_call的一部分,它是一个也可以命名为ret_from_sys_call和ret_from_intr的独立入口点。它们偶尔会被C直接调用,也可以从system_call的其他部分跳转过来。185:接下来的几行检测“下半部分(bottom half)”是否激活;如果激活了,就跳转到handle_bottom_half标号(242行)并立即开始处理。下半部分是中断进程的一部分,将在下一章中讨论。189:检查该进程是否为再次调度做了标记(记住表达式$0就是常量0的系统简单表示)。如果的确如此,就跳转到reschedule标号(247行)。191:检测是否还有挂起的信号,如果有的话,下一行就向前跳转到signal_return(197行)。193:restore_all标号是system_call的退出点。其主体就是简单的RESTORE_ALL宏(100行),该宏将恢复早先由SAVE_ALL存储的参数并返回给system_call的调用者。197:当system_call从系统调用返回前,如果它检测到需要将信号传送给当前的进程时,才会执行到signal_return。它通过使中断再次可用开始执行,有关内容将在第6章中介绍。199:如果返回虚拟8086模式(这不是本书的主题),就向前跳转到v86_signal_return(207行)。202:system_call要调用C函数do_signal(3364行,在第6章中讨论)来释放信号。do_signal需要两个参数,这两个参数都是通过寄存器传递的;第一个是EAX寄存器,另一个是EDX寄存器。system_call(在200行)早已把第一个参数的值赋给了EAX;现在,就把EDX寄存器和寄存器本身进行XOR操作,从而将其清0,这样do_signal就认为这是一个空指针。203:调用do_signal传递信号,并且跳回到restore_all(193行)结束。207:由于虚拟8086模式不是本书的主题,我们将忽略大部分v86_signal_return。然而,它和signal_return的情况非常类似。215:如果当前进程的系统调用正由其祖先跟踪,就像strace程序中那样,那么就可以执行到tracesys标号。这一部分的基本思想如同179行一样是通过syscall_table调用系统函数,但是这里把该调用和对syscall_trace函数的调用捆绑在一起。后面的这个函数在本书中并没有涉及到,它能够终止当前进程并通知其祖先注意当前进程将要激活一个系统调用。EAX操作和这些代码的交错使用最初可能容易令人产生困惑。system_call把存储在堆栈中的EAX拷贝赋给-ENOSYS,调用syscall_trace,在172行再从所做的拷贝中恢复EAX的值,调用实际的系统调用,把系统调用的返回值置入堆栈中EAX的位置,再次调用syscall_trace。这种方式背后的原因是syscall_trace(或者更准确地说是它所要用到的跟踪程序)需要知道它是在实际系统调用之前还是之后被调用的。-ENOSYS的值能够用来指示它是在实际系统调用执行之前被调用的,因为实际中所有实现的系统调用的执行都不会返回-ENOSYS。因此,EAX在堆栈中的备份在第一次调用syscall_trace之前是-ENOSYS,但是在第二次调用syscall_trace之前就不再是了(除非是调用sys_ni_syscall的时候,在这种情况下,我们并不关心是怎样跟踪的)。218行和219行中EAX的作用只是找出要调用的系统调用,这和无须跟踪的情况是一致的。222:被跟踪的系统调用已经返回,流程控制跳转回ret_from_sys_call(184行)并以与普通的无须跟踪的情况相同的方式结束。223:当系统调用的数目越界时,就可以执行到badsys标号。在这种情况下,system_call必须返回-ENOSYS(ENOSYS在82行将它赋值为38)。正如前面提到的一样,调用者会识别出这是一个错误,因为返回值在-1到-4 095之间。228:在诸如除零错误(请参见279行)之类的CPU异常中断情况下将执行到ret_from_exception标号;但是system_call内部的所有代码都不会执行到这个标号。如果有下半部分是激活的,现在就是它在起作用了。233:处理完下半部分之后或者从上面的情况简单执行下来(虽然没有下半部分是激活的,但是同样也触发了CPU异常),就执行到了ret_from_intr标号。这是一个全局可访问符号,因此可能在内核的其他部分也会有对它的调用。237:被保存的CPU的EFLAGS和CS寄存器在此已经被并入EAX,因而高24位的值(其中恰好包含了一位在70行定义的非常有用的VM_MASK)来源于EFLAGS,其他低8位的值来源于CS。该行隐式的同时对这两部分进行测试以判断进程是返回虚拟8086模式(这是VM_MASK的部分)还是用户模式(这是3的部分—用户模式的优先等级是3)。下面是近似的等价C代码:238:如果这些条件中有一个能得到满足,流程控制就跳转到ret_with_reschedule(188行)标号,测试在system_call返回之前进程是否需要再次调度。否则,调用者就是一个内核任务,因此system_call通过跳转到restore_all (193行)来跳过重新调度的内容。242:无论何时system_call使用一个下半部分服务时都可以执行到handle_bottom_half标号。它简单地调用第6章中介绍的C函数bottom_half(29126行),然后跳回到ret_from_intr(233行)。248:system_call的最后一个部分在reschedule标号之下。当产生系统调用的进程已经被标记为需要进行重新调度时,就可以执行到这个标号;一般说来,这是因为进程的时间片已经用完了—也就是说,进程到目前为止已经尽可能地拥有CPU了,应该给其他进程一个机会来运行了。因此,在必要的情况下就可以调用C函数schedule(26686行)交出CPU,同时流程控制转回249行。CPU调度是第7章中讨论的一个主题。5.2.2 lcall7Linux支持Intel二进制兼容规范标准的版本2(iBCS2)(iBCS2中的小写字母i显然是有意的,但是该标准却没有对此进行解释,这样看来似乎和现实的Intel系列的CPU例如i386、i486等等是一致的)。iBCS2的规范中规定了所有基于x86的Unix系统的应用程序的标准内核接口,这些系统不仅包括Linux,而且还包括其他自由的x86 Unix(例如FreeBSD),也还包括Solaris/x86、SCO Unix等等。这些标准接口使得为其他Unix系统开发的二进制商业软件在Linux系统中能够直接运行,反之亦然(而且,近期新开发软件向其他Unix移植的情况越来越多)。例如,Corel公司的WordPerfect的SCO Unix的二进制代码在还没有Linux的本地版本的WordPerfect之前就可以使用iBCS2在Linux上良好地运行。iBCS2标准有很多组成部分,但是我们现在关心的是这些系统调用如何协调一致来适应这些迥然不同的Unix系统。这是通过lcall7调用门实现的。它是一个相当简单的汇编函数(尤其是和system_call相比而言更是如此),仅仅定位并全权委托一个C函数来处理细节。(调用门是x86 CPU的一种特性,通过这种特性用户任务可以在安全受控的模式下调用内核代码。)这种调用门在6802行进行设定。 lcall7135:前面的几行将通过调整处理器堆栈以使堆栈的内容和system_call预期的相同—system_call中的一些代码将会完成清理工作,这样所有的内容都可以连续存放了。145:基于同样的思想,lcall7把指向当前任务的指针置入EBX寄存器,这一点和system_call的情况是相同的。但是,它的执行方式却与system_call不同,这就比较奇怪了。这三行可以等价地按如下形式书写:这种实现的执行速度并不比原有的更快,在将宏展开以后,实际上这还是同样的三条指令以不同的次序组合在一起而已。这样做的优点是可以和文件中的其他代码更为一致,而且代码也许会更清晰一些。148:取得指向当前任务exec_domain域的指针,使用这个域以获取指向其lcall7处理程序的指针,接着调用这个处理程序。本书中并没有对执行域(execution domains)进行详细说明—但是简单说来,内核使用执行域实现了部分iBCS2标准。在15977行你可以找到struct exec_domain结构。default_exec_domain(22807行)是缺省的执行域,它拥有一个缺省的lcall7处理程序。它就是no_lcall7(22820行)。其基本的执行方式类似于SVR4风格的Unix,如果调用进程没有成功,就传送一个分段违例信号(segmentation violation signal)给调用的进程。152:跳转到ret_from_sys_call标号(184行—注意这是在system_call内部的)清除并返回,就像是正常的系统调用一样。5.3 系统调用样例现在你已经知道了系统调用是如何激活的,接下来我们将通过对几个系统调用例子的剖析来了解一下它们的工作方式。注意系统调用foo几乎都是使用名为sys_foo的内核函数实现的,但是在某些情况下该函数也会使用一个名为do_foo的辅助函数。1. sys_ni_syscall29185:sys_ni_syscall的确是最简单的系统调用;它只是简单地返回ENOSYS错误。最初的时候这可能显得没有什么作用,但是它的确是有用的。实际上,sys_ni_syscall在sys_call_table中占据了很多位置—而且其原因并不只有一个。开始的时候,sys_ni_syscall在位置0(374行),因为,如果漏洞百出的代码错误地调用了system_call—例如,没有初始化作为参数传递给system_call的变量,在这种偶然的变量定义中,0是最可能的值。如果我们能够避免这种情况,那么在错误发生时就不用采取像杀掉进程这样的措施(当然,只要允许有用工作的进行,就不可能防止所有的错误)。这种使用表的元素0作为抵御错误的手段在内核中作为良好的经验而被广泛使用。而且,你还会发现sys_ni_syscall在表中明显出现的地方就多达十几处。这些条目代表了那些已经从内核中移出的系统调用—例如在418行,就代替了已经废弃了的prof系统调用。我们不能简单地把另外的实际系统调用放在这里,因为老的二进制代码可能还会使用到这些已经废弃了的系统调用号。如果一个程序试图调用这些老的系统调用,但是结果却与预期的完全不同,例如打开了一个文件,这会令人感到奇怪的。最后,sys_ni_syscall将占据表尾部所有未用的空间;这一点是在从572行到574行的代码中实现的,它根据需要重复使用这些项来填充表。由于sys_ni_syscall只是简单返回ENOSYS错误号,对它的调用和跳转到system_call中的badsys标号作用是相同的—也就是说,使用指向这些表项的系统调用号和在表外对整个表进行全部索引具有相同的作用。因此,我们不用改变NR_syscalls就可以在表中增加(或者删除)系统调用,但是其效果与我们真的对NR_syscalls进行了修改一样(不管怎样,这都是由NR_syscalls所建立的限制条件所决定的)。到现在也许你已经猜到了,sys_ni_syscall中的“ni”并不是指Monty Python的“说 ‘Ni’ 的骑士”;而是指“not implemented(没有实现)”。对于这个简单的函数我们需要研究的另外一个问题是asmlinkage标志。这是为一些gcc功能定义的一个宏,它告诉编译器该函数不希望从寄存器中(这是一种普通的优化 )取得任何参数,而希望仅仅从CPU堆栈中取得参数。回忆一下我们前面提到过system_call使用第一个参数作为系统调用的数目,同时还允许另外四个参数和系统调用一起传递。system_call通过把其他参数(这些参数是通过寄存器传递过来的)滞留在堆栈中简单地实现了这种技巧。所有的系统调用都使用asmlinkage标志作了标记,因此它们都要查找堆栈以获得参数。当然,在sys_ni_syscall的情况下这并没有任何区别,因为sys_ni_syscall并不需要任何参数。但是对于其他大部分系统调用来说,这就是个问题了。并且,由于在其他很多函数前面都有asmlinkage标志,我想你也应该对它有些了解。2. sys_time31394:sys_time是包含几个重要概念的简单系统调用。它实现了系统调用time,返回值是从某个特定的时间点(1970年1月1日午夜UTC)以来经过的秒数。这个数字被作为全局变量xtime(请参见26095行;它被声明为volatile型的变量,因为它可以通过中断加以修改,这一点我们在第6章中就会看到)的一部分,通过CURRENT_TIME宏(请参见16598行)可以访问它。31400:该函数非常直接地实现了它的简单定义。当前时间首先被存储在局部变量i中。31402:如果所提供的指针tloc是非空的,返回值也将被拷贝到指针指向的位置。该函数的一个微妙之处就在于此;它把i拷贝到用户空间中,而不是使用CURRENT_ TIME宏来重新对其进行计算,这基于两个原因:
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -