深入理解Linux内核--系统调用(阅读笔记)(原创)
由 王宇 原创并发布 :
第十一章系统调用
操作系统为在用户态运行的进程与硬件设备进行交互提供了一组接口。优点:
首先这使得编程更加容易,把用户从学习硬件设备的低级编程特性中解放出来。其次这极大地提高了系统的安全性,因为内核在试图满足某个请求之前在接口级就可以检查这种请求的正确性。最后,更重要的是这些接口使得程序更具有可移植性
1、POSIX API和系统调用
先强调一下应用编程接口(API)与系统调用之不同。前者只是一个函数定义,说明了如何获得一个给定的服务;而后者是通过软中断向内核态发出一个明确的请求。
Unix系统给程序员提供了很多API的库函数。libc的标准C库所定义的一些API引用了封装例程。通常情况下,每个系统调用对应一个封装例程,而封装例程定义了应用程序使用的API
反之则不然,顺便说一句,一个API没必要对应一个特定的系统调用。首先,API可能直接提供用户态的服务,其次一个单独的API函数可能调用几个系统调用。此外,几个API函数可能调用封装了不同功能的同一个系统调用。
从编程者的观点看,API和系统调用之间的差别是没有关系的:唯一相关的事情就是函数名、参数类型及返回代码的含义。然而,从内核设计者的观点看,这种差别确实有关系,因为系统调用属于内核,而用户态的库函数不属于内核。
大部分封装返回一个整数,其值的含义依赖于相应的系统调用。返回值-1通常表示内核不能满足进程的请求。
每个出错码都定义为一个常量宏。POSIX标准制定了很多出错码的宏名。
include/asm-i386/errno.h
/usr/include/error.h
include/asm-i386/error.h
2、系统调用处理程序及服务例程
当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核态函数。在80x86体系结构中,可以用两种不同的方式调用linux的系统调用。两种方式的最终结果都是跳转到所谓系统调用处理程序的汇编语言函数。
因为内核实现了很多不同的系统调用,因此进程必须传递一个名为系统调用号的参数来识别所需的系统调用,eax寄存器就用作此目的。
所有的系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。在内核中,正数或0表示系统调用成功结束,而负数表示一个出错条件。在后一种情况下,这个值就是存放在error变量中必须返回给应用程序的负出错码。
系统调用处理程序与其他异常处理程序的结构类似,执行下列操作:
在内核态栈保存大多数寄存器的内容
调用名为系统调用服务例程
退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换回到用户态
xyz()系统调用对应的服务例程的名字通常是sys_xyz()。不过也有一些例外
**图:10-1调用一个系统调用
为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表(dispatchtable)
3、进入和退出系统调用
通过两种不同的方式调用系统调用:
执行int$0x80汇编语言指令,在Linux内核的老版本中,这是从用户态切换到内核态的唯一方式
执行sysenter汇编语言指令。在Inter Pentium II微处理器芯片中引入了这条指令,现在Linux2.6内核支持这条指令。
通过两种不同的方式从系统调用退出,从而使CPU切换回到用户态:
执行iret汇编语言指令
执行sysexit汇编语言指令,它和sysenter指令同时在InterPentiumII微处理器中引入
支持进入内核的两种不同方式并不像看起来那么简单,因为:
内核必须既支持只使用int$0x80指令的旧函数库,同时支持也可以使用sysenter指令的新函数库
使用sysenter指令的标准库必须能处理仅支持int$0x80指令的旧内核
内核和标准库必须既能运行在不包含sysenter指令的旧处理器上,也能运行在包含它的新处理器上
[1]通过int$0x80指令发出系统调用
调用系统调用的传统方法是使用汇编语言指令int
向量128(十六进制0x80)对应于内核入口点。在内核初始化期间调用的函数trap_init(),用下面的方式建立对应于向量128的中断描述符表表型:
set_system_gate(0x80,&system_call);
该调用把下列值存如这个门描述符的相应字段:
SegmentSelecor:内核代码段__KERNEL_CS的段选择符
Offset:指向system_call()系统调用处理程序的指针
Type:置15表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断
当用户态进程发出int$0x80指令时,CPU切换到内核态并开始从地址system_call处开始执行指令
(1)system_call()
system_call()函数首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已自动保存的eflags、cs、eip、ss和esp寄存器
随后,这个函数在ebx中存放当前进程的thread_info数据结构的地址,这是通过获得内核栈指针的值并把它取整到4KB或8KB的倍数而完成的
接下来,system_call()函数检查thread_info结构flag字段的TIF_SYSCALL_TRACE和TIF_SYSCALL_AUDIT标志之一是否被设置为1,也就是检查是否有某一调试程序正在跟踪执行程序对系统调用的调用。如果是这种情况,那么system_call()函数两次调用do_syscall_trace()函数;一次正好在这个系统调用服务例程执行之前,一次在其之后。这个函数停止current,并因此允许调试进程收集关于current的信息
然后,对用户态进程传递来的系统调用号进行有效性检查。如果这个号大于或等于系统调用分派表中的表项数,系统调用处理程序就终止
如果系统融调用号无效,该函数就把-ENOSYS值存放在栈中曾保存eax寄存器的单元中。然后跳到resume_userspace,当进程恢复它在用户态的执行时,会在eax中发现一个负的返回码。
最后,调用与eax中所包含的系统调用号对应的特定服务例程:call*sys_call_table(0,%eax,4)
因此分派表中的每个表项占4个字节,因此首先把系统调用号乘以4,在加上sys_call_table分派的起始地址,然后从这个地址单元获取指向服务例程的指针,内核就找到了要调用的服务例程。
(2)从系统调用退出
当系统调用服务例程结束时,system_call()函数从eax获得它的返回值,比并把这个返回值存放在曾保存用户态eax存储器值的那个栈单元的位置上
然后,system_call()函数关闭本地中断并检查当前进程的thread_info结构中的标志:
只要有任何一种标志被设置,那么就要在返回用户态之前完成一些工作。
[2]通过sysenter指令发出系统调用
汇编语言指令int由于要执行几个一致性和安全性检查,所以速度较慢,在intel文档中被称为“快速系统调用”的sysenter指令,提供了一种从用户态到内核态的快速切换方法。
(1)sysenter指令
汇编语言指令sysenter使用三种特殊的寄存器,它们必须装入下述信息:
SYSENTER_CS_MSR:内核代码段的段选择符
SYSENTER_EIP_MSR:内核入口点的线性地址
SYSENTER_ESP_MSR:内核堆栈指针
执行sysenter指令时,CPU控制单元:
把SYSENTGER_CS_MSR的内容拷贝到cs
把SYSENTGER_EIP_MSR的内容拷贝到eip
把SYSENTGER_ESP_MSR的内容拷贝到esp
把SYSENTGER_CS_MSR的内容拷贝到ss
因此,CPU切换到内核态并开始执行内核入口点的第一条指令
内核堆栈段与内核数据段是一致的,而且在全局描述符表中,其描述符紧跟在内核代码段的描述符之后;所有第4步把正确的段选择符装入了ss寄存器。
在内核初始化期间,一旦系统中的每个CPU执行函数enable_sep_cpu(),三个特定与模型的寄存器就由该函数初始化了。enable_sep_cpu()函数执行以下步骤:
1、把内核代码(__KENNEL__CS)的段选择符写入SYSENTER_CS_MSR寄存器
2、把下面要说明的函数sysenter_entry()的线性地址写入SYSENTER_CS_EIP寄存器
3、计算本地TSS末端的线性地址,并把这个值写入SYSENTER_CS_ESP寄存器
(2)vsyscall页
只要CPU和Linux内核都支持sysenter指令,标准库libc中的封装函数就可以使用它。
这个兼容性问题需要非常复杂的解决方案。本质上,在初始化阶段,sysenter_setup()函数建立一个称为vsyscall页得页框,其中包括一个小的EFL共享对象(也就是一个很小的EFL动态链接库)。当进程发出exeve()系统调用而开始执行一个EFL程序时,vsyscall页中的代码就会自动地被链接到进程的地址空间。vsyscall页中的代码使用最有用的指令发出系统调用。
函数sysenter_setup()为vsyscall页分配一个新页框,并把它的物理地址与FIX_VSYSCLL固定映射的线性地址相关联。然后,函数sysenter_setup()把预先定义好的一个或两个EFL共享对象拷贝到该页中。
当标准库中的封装例程必须调用系统调用时,都调用__kernel_vsyscall()函数,不管它的实现代码是什么。
最后一个兼容性问题是由于老版本的Linux内核不支持sysenter指令,在这种情况下,内核当然不建立vsyscall页,而且函数__kernel_vsyscall()不会被链接到用户态进程的地址空间。当新近的标准库识别出这种状况后,就简单地执行int$0x80指令来调用系统调用。
(3)进入系统调用
当用sysenter指令发出系统调用时,依次执行下述步骤:
1、标准库中的封装例程把系统调用号装入eax寄存器,并调用__kernel_vsyscall()函数
2、函数__kernel_vsyscall()把ebp、edx和ecx的内容保存到用户态堆栈中(系统调用处理程序将使用这些寄存器),把用户栈指针拷贝到ebp中,然后执行sysenter指令
3、CPU从用户态切换到内核态,内核开始执行sysenter_entry()函数(由SYSENTER_EIP_MSR寄存器指向)
4、sysenter_entry()汇编语言函数执行下述步骤:
a、建立内核堆栈指针:
b、打开本地中断
c、把用户数据段的段选择符、当前用户栈指针、eflags寄存器、用户代码段的段选择符以及从系统调用退出时要执行的指令的地址保存到内核态堆栈中:
d、把原来由封装例程传递的寄存器的值恢复到ebp中
e、通过执行一系列指令调用系统调用处理程序。
(4)退出系统调用
当系统调用服务例程结束时,sysenter_enter()函数本质上执行与system_call()函数相同的操作。首先,它从eax获得系统调用系统服务例程的返回码,并将返回码存入内核栈中保存用户态eax寄存器值的位置。然后,函数禁止本地中断,并检查current的thread_info结构中的标志。
如果有任何标志被设置,那么在返回到用户态之前还需要完成一些工作。最后,汇编语言指令iret从内核堆栈中去取5个参数,这些参数是在sysenter_entry()函数的第4c步被保存到内核堆栈中的,这样CPU切换到用户态并开始执行SYSENTER_RETURN标记处得代码。
(5)sysexit指令
sysexit是与sysenter配对的汇编语言指令:它允许从内核态快速切换到用户态。执行这条指令时,CPU控制单元执行下述步骤:
1、把SYSENTER_CS_MSR寄存器中的值加16所得到的结果加载到cs寄存器
2、把edx寄存器的内容拷贝到eip寄存器
3、把SYSENTER_CS_MSR寄存器中的值加24所得到的结果加载到ss寄存器
4、把ecx寄存器d的内容拷贝到esp寄存器
因为SYSENTER_CS_MSR寄存器加载的是内核代码的段选择符,cs寄存器加载的是用户代码的段选择符,而ss寄存器加载的是用户数据段的段选择符
结果,CPU从内核态切换到用户态,并开始执行其地址存放在edx中那些指令
SYSENTER_RETURN的代码
SYSENTER_RETURN标记处的代码存放在vsyscall页中,当通过sysenter进入的系统调用被iret或sysexit指令终止时,该页框中的代码被执行
4、参数传递
系统调用通常也需要输入/输出参数。
因为system_call()和sysenter_entry()函数是Linux中所有系统调用的公共入口,因此每个系统调用至少有一个参数,即通过eax寄存器传递来的系统调用号。例如,如果一个应用程序调用fork()封装例程,那么在执行int$0x80或sysenter汇编指令之前就把eax寄存器置为2.因此这个寄存器的设置是由libc库中的封装例程进行的,因此程序员通常并不用关心系统调用号。
很多系统调用确实需要由应用程序明确地专递另外的参数。
普通C函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或者内核态栈)实现的。因为系统调用是一种横跨用户和内核两大陆地的特殊函数,所以既不能使用用户态栈也不能使用内核态栈。更确切地说,在发出系统调用之前,系统调用的参数被写入CPU寄存器,然后在调用系统调用服务例程之前,内核再把存放在CPU中的参数拷贝到内核态堆栈中,这是因为系统调用服务例程是普通的C函数。
为什么内核不直接把参数从用户态的栈拷贝到内核态的栈呢?首先,同时操作两个栈是比较复杂的,其次,寄存器的使用使得系统调用处理程序的结构与其他异常处理成的结构类似。
为了用寄存器传递参数,必须满足两个条件:
每个参数的长度不能超过寄存器的长度,即32位
参数的个数不能超过6个(除了eax中传递的系统调用号),因为80x86处理器的寄存器的数量是有限的
第一个条件总能成立,因为根据POSIX标准,不能存放在32为寄存器中的长参数必须通过指定它们的地址来传递。然而,确实存在多于6个参数的系统调用。在这样的情况下,用一个单独的寄存器指向进程地址空间中这些参数值所在的一个内存区
用于存放系统调用号和系统调用参数的寄存器是(以字母递增的顺序):eax(存放系统调用号)ebx、ecx、edx、esi、edi、ebp
[1]验证参数
检查的类型既依赖于系统调用,也依赖于特定的参数
有一种检查对所有的系统调用都是通用的。只要一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间之内。有两种可能的方式来执行这种检查:
验证这个线性地址是否属于进程的地址空间,如果是,这个线性地址所在的线性区就具有正确的访问权限
仅仅验证这个线性地址是否小于PAGE_OFFSET(即没有落在留给内核的线性地址区间内)
早期的Linux内核执行第一种检查,但是这是非常费时的
因此,从Linux2.2内核开始执行第二种检查,这是一种更高效的检查,因为不需要对进程的线性区描述符进行任何扫描。
接着采用的方法是将真正的检查尽可能向后推迟,也就是说,推迟到分页单元将线性地址转换为物理地址时
究竟为什么要进行这种粗略检查。事实上,这种粗略的检查是至关重要的,它确保了进程地址空间和内核地址空间都不被非法访问。
[2]访问进程地址空间
系统调用服务例程需要非常频繁地读写进程地址空间的数据。Linux包含的一组宏使这种访问更加容易。两个名为get_user()和put_user()的宏。第一个宏用来从一个地址读取1、2、或4个连续字节,而第二个宏用来把这几种大小的内容写入一个地址中
表10-1:中列出了内核态下用来访问进程地址空间的另外几个函数或宏。
注意:许多函数或宏的名字前缀有两个下划线(__)。首部没有下划线的函数或宏要用额外的时间对所请求的线性地区间进行有效性检查,而有下划线的则会跳过检查。当内核必须重复访问进程地址空间的同一块线性区时,比较高效的方法是开始时只对该地址检查一次,以后就不用在对该进程区进行检查了。
[3]动态地址检查:修改代码
access_ok()宏对系统调用以参数传递来的线性地址的有效性只进行粗略检查。该检查只保证用户态进程不会试图侵扰内核地址空间。但是,由参数传递的线性地址依然可能不属于进程地址空间。在这种情况下,当内核试图使用任何这种错误地址时,将会发生缺页异常。
内核态引起缺页异常的四种情况:(这些情况必须由缺页异常处理程序来区分)
1、内核试图访问属于进程地址空间的页,但是,或者是相应的页框不存在,或者是内核试图去写一个只读页。在这些情况下,处理程序必须分配和初始化一个新的页框
2、内核寻址到属于其地址空间的页,但是相应的页表项还没有被初始化。在这种情况下,内核必须在当前进程页表中适当地建立一些表项
3、某一内核函数包含编程错误,当这个函数运行时就引起异常;或者,可能由于瞬时的硬件错误引起异常。当这种情况发生时,处理程序必须执行一个内核漏洞。
4、系统调用服务例程试图读写一个内存区,而该内存区的地址是通过系统调用参数传递来的,但却不属于进程的地址空间。
通过确定错误的线性地址是否属于进程所拥有的线性地址区间,缺页处理程序可以很容易地识别第一种情况。通过检查相应的主内核页表是否包含一个映射该地址的非空项页可以检测第二种情况。
[4]异常表
决定缺页的来源关键在于内核使用有限的范围访问进程的地址空间,少数函数和宏用来访问进程的地址空间
把访问进程地址空间的每条内核指令的地址放到一个叫异常表(exceptiontable)的结构中并不用费太多功夫。当在内核态发生缺页异常时,do_page_fault()处理程序检查异常表;如果表中包含产生异常的指令地址,那么这个错误就是由非法的系统调用参数引起的,否则,就是由某一更严重的bug引起的
每一个异常表的表项是一个exception_tabel_entry结构,它有两个字段:
insn:访问进程地址空间的指令的线性地址。
fixup:当存放在insn单元中的指令所触发的缺页异常发生时,fixup就是要调用的汇编语言代码的地址。
search_exception_tables()函数用来在所有异常表中查找一个指定地址
[5]生成异常表和修正代码
5、内核封装例程
尽管系统调用主要由用户态进程使用,但也可以被内核线程调用,内核线程不能使用库函数,为了简化相应封装例程的声明,Linux定义了7个从_syscall0到_syscall6的一组宏
每个宏名字中的数字0-6对应着系统调用所用的参数个数(系统调用号除外),也可以用这些宏来声明没有包含在libc标准库中的封装例程。然后,不能用这些宏来为超过6个参数的系统调用或产生非标准返回值的系统调用定义封装例程。
每个宏严格第需要2+2*n个参数,n是系统调用的参数个数。前两个参数指明系统调用的返回值类型的名字;每一对附加参数指明相应的系统调用参数的类型和名字。因此,以fork()系统调用为例,其封装例程可以通过如下语句产生:
_syscall0(int,fork)
而write()系统调用的封装例程可以通过如下语句产生:
_syscall3(int,write,int,fd,constchar*but,unsignedint,count)
这个宏展开:
intwrite(intfd,constchar*buf,unsignedintcount){
。。。
}