一、POSIX API和系统调用
应用编程接口(API)与系统调用之不同。前者只是一个函数定义,说明了如何获得一个给定的服务;而后者是通过软中断向内核态发出一个明确的请求。
大部分封装例程返回一个整数,其值的含义依赖于相应的系统调用。返回值-1通常表示内核不能满足进程的请求。系统调用处理程序的失败可能是由无效参数引起的,也可能是因为缺乏可用资源,或硬件出了问题等等。在libc库中定义的errno变量包含特定的出错码。
二、系统调用处理程序及服务例程
当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数。
因为内核实现了很多不同的系统调用,因为进程必须传递一个名为系统调用号(system call number)的参数来识别所需的系统调用,eax寄存器就用作此目的。
所有的系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。在内核中,正数或0表示系统调用成功结束,而负数表示一个出错条件。在后一种情况下,这个值就是存放在errno变量中必须返回给应用程序的负出错码。内核没有设置或使用errno变量,而封装例程从系统调用返回之后设置这个变量。
系统调用处理程序与其他异常处理程序的结构类似,执行下列操作:
* 在内核态栈保存大多数寄存器的内容。
* 调用名为系统调用服务例程(system call service routine)的相应的C函数来处理系统调用。
* 退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换回到用户态(所有的系统调用都要执行这一相同的操作,该操作用汇编语言代码实现)。
xyz()系统调用对应的服务例程的名字通常是sys_xyz()。不过也有一些例外。
为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表(dispatch table)。这个表存放在sys_call_table数组中,有NR_syscalls个表项:第n个表项包含系统调用号为n的服务例程的地址。
NR_syscalls宏只是对可实现的系统调用最大个数的静态限制,并不表示实际已实现的系统调用个数。实际上,分派表中的任意一个表项也可以包含sys_ni_syscall()函数的地址,这个函数是“未实现”系统调用的服务例程,它仅仅返回出错码-ENOSYS。
三、进入和退出系统调用
本地应用可以通过两种不同的方式调用系统调用:
1、执行int $0x80汇编语言执行。在Linux内核的老版本中,这是从用户态切换到内核态的唯一方式。
2、执行sysenter汇编语言执行。
同样,内核可以通过两种方式从系统调用退出,从而使CPU切换回到用户态:
1、执行iret汇编语言指令。
2、执行sysexit汇编语言指令。
但是,支持进入内核的两种不同方式并不像看起来那么简单,因为:
1、内核必须即支持只使用int $0x80指令的旧函数库,同时支持也可以使用sysenter指令的新函数库。
2、使用sysenter指令的标准库必须能处理仅支持int $0x80指令的旧内核。
3、内核和标准库必须既能运行在不包含sysenter指令的旧处理器上,也能运行在包含它的新处理器上。
3.1、通过int $0x80指令发出系统调用
向量128(十六进制0x80)对应于内核入口点。在内核初始化期间调用的函数trap_init(),用下面的方式建立对应于向量128的中断描述符表表项:
set_system_gate(0x80, &system_call);
该调用把下列值存入这个门描述符的相应字段:
Segmet Selector:内核代码段__KERNEL_CS的段选择符。
Offset:指向system_call()系统调用处理程序的指针。
Type:置为15.表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断。
DPL(描述符特权级):置为3。这就允许用户态进程调用这个异常处理程序。
因此,当用户态进程发出int $0x80指令时,CPU切换到内核态并开始从地址system_call处开始执行指令。
3.1.1、system_call函数
system_call函数首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已自动保存的eflags、cs、eip、ss和esp寄存器。
3.1.2、从系统调用退出
3.2、通过sysenter指令发出系统调用
在Intel文档中被称为“快速系统调用”的sysenter指令,提供了一种从用户态到内核态的快速切换方法。
3.2.1、sysenter指令
汇编语言指令sysenter使用三种特殊的寄存器,它们必须装入下述信息:
SYSENTER_CS_MSR:内核代码段的段选择符。
SYSENTER_EIP_MSR:内核入口点的线性地址。
SYSENTER_ESP_MSR:内核堆栈指针。
执行sysenter指令时,CPU控制单元:
1、把SYSENTER_CS_MSR的内核拷贝到cs。
2、把SYSENTER_EIP_MSR的内容拷贝到eip。
3、把SYSENTER_ESP_MSR的内容拷贝到esp。
4、把SYSENTER_CS_MSR加8的值装入ss。
在内核初始化期间,一旦系统中的每个CPU执行函数enable_sep_cpu(),三个特定于模型的寄存器就由该函数初始化了。enable_sep_cpu()函数执行以下步骤:
1、把内核代码(__KERNEL_CS)的段选择符写入SYSENTER_CS_MSR寄存器。
2、把下面要说明的函数sysenter_enry()的线性地址写入SYSENTER_CS_EIP寄存器。
3、计算本地TSS末端的线性地址,并把这个值写入SYSENTER_CS_ESP寄存器。
3.2.2、vsyscall页
3.4、进入系统调用
当用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、通过执行一系列指令调用系统调用处理程序,这些指令与前面”通过int $0x80指令发出系统调用“一节所描述的在system_call标记处开始的指令是一样的。
3.4.1、退出系统调用
当系统调用服务例程结束时,sysenter_entry()函数本质上执行与system_call()函数系统的操作。首先,它从eax获得系统调用服务例程的返回码,并将返回码存入内核栈中保存用户态eax寄存器值的位置。然后,函数禁止本地中断,并检查current的thread_info结构中的标志。
3.4.2、sysexit指令
sysexit是与sysenter配对的汇编语言指令:它允许从内核态快速切换到用户态。执行这条指令时,CPU控制单元执行下述步骤:
1、把SYSENTER_CS_MSR寄存器中的值加16所得到的结果加载到cs寄存器。
2、把edx寄存器的内容拷贝到eip寄存器。
3、把SYSENTER_CS_MSR寄存器中的值加24所得到的结果加载到ss寄存器。
4、把ecx寄存器的内容拷贝到esp寄存器。
因为SYSENTER_CS_MSR寄存器加载的是内核代码的段选择符,cs寄存器加载的是用户代码的段选择符,而ss寄存器加载的是用户数据段的段选择符。
结果,CPU从内核态切换到用户态,并开始执行其地址存在edx中的那条指令。
3.4.2、SYSENTER_RETURN的代码
SYSENTER_RETURN标记处的代码存放在vsyscall页中,当通过sysenter进入的系统调用被iret或sysexit指令终止时,该页框中的代码被执行。
四、参数传递
与普通函数类似,系统调用通常也需要输入/输出参数,这些参数可能是实际的值(例如数值),也可能是用户态进程地址空间的变量,甚至是指向用户态函数的指针的数据结构地址。
因为system_call()和sysenter_entry()函数是Linux中所有系统调用的公共入口点,因此每个系统调用至少有一个参数,即通过eax寄存器传递来的系统调用号。例如,如果一个应用程序调用fork()封装例程,那么在执行int $0x80或sysenter汇编指令之前就把eax寄存器置为2(即__NR_fork)。因为这个寄存器的设置是由libc库中的封装例程进行的,因此程序员通常并不用关心系统调用号。
普通C函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或者内核态栈)实现的。因为系统调用是一种横跨用户和内核两大陆地的特殊函数,所以既不能使用用户态栈也不能使用内核态栈。更确切地说,在发出系统调用之前,系统调用的参数被写入CPU寄存器,然后在调用系统调用服务例程之前,内核再把存放在CPU中的参数拷贝到内核态堆栈中,这是因为系统调用服务例程是普通的C函数。
为什么内核不直接把参数从用户态的栈拷贝到内核态的栈呢?首先,同时操作两个栈是比较复杂的。其次,寄存器的使用使得系统调用处理程序的结构与其他异常处理程序结构类似。
然后,为了用寄存器传递参数,必须满足两个条件:
* 每个参数的长度不能超过寄存器的长度,即32位。
* 参数的个数不能超过6个(除了eax中传递的系统调用号),因为80x86处理器的寄存器的数量是有限的。
用于存放系统调用号和系统调用参数的寄存器是(以字母递增的顺序):eax(存放系统调用号)、ebx、ecx、edx、esi、edi以及ebp。
4.1、验证参数
只要一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间之内。有两种可能的方式来执行这种检查:
* 验证这个线性地址是否属于进程的地址空间,如果是,这个线性地址所在的线性区就具有正确的访问权限。
* 仅仅验证这个线性地址是否小于PAGE_OFFSET(即没有落在留给内核的线性地址区间内。
上面第二种粗略的检查是至关重要的,它确保了进程地址空间和内核地址空间都不被非法访问。
4.2、访问进程地址空间
系统调用服务例程需要非常频繁地读写进程地址空间的数据。Linux包含的一组宏使这种访问更加容易。我们将描述其中两个名为get_user()和put_user()的宏。第一个宏用来从一个地址读取1、2或4个连续字节,而第二个宏用来把这几种大小的内容写入一个地址中。
4.3、动态地址检查:修正代码
access_ok()宏对系统调用以参数传递来的线性地址的有效性只进行粗略检查。该检查只保证用户态进程不会试图侵扰内核地址空间。但是,由参数传递的线性地址依然可能不属于进程地址空间。在这种情况下,当内核试图使用任何这种错误地址时,将会发生缺页异常。
在描述内核如何检测这种错误之前,我们先说明一下在内核态引起缺页异常的四种情况。这些情况必须由缺页异常处理程序来区分,因为不同情况采取的操作很不相同:
1、内核试图访问属于进程地址空间的页,但是,或者是相应的页框不存在,或者是内核试图去写一个只读页。在这些情况下,处理程序必须分配和初始化一个新的页框。
2、内核寻址到属于其他地址空间的页,但是相应的页表项还没有被初始化。在这种情况下,内核必须在当前进程页表中适当地建立一些表项。
3、某一内核函数包含编程错误,当这个函数运行时就引起异常;或者,可能由于瞬时的硬件错误引起异常。当这种情况发生时,处理程序必须执行一个内核漏洞。
4、本章所讨论的一种情况:系统调用服务例程试图读写一个内存区,而该内存区的地址是通过系统调用参数传递来的,但却不属于进程的地址空间。
通过确定错误的线性地址是否属于进程所拥有的线性地址空间,缺页处理程序可以很容易地识别第一种情况。通过检查相应的主内核页表是否包含一个映射该地址的非空项也可以检测第二种情况。
4.4、异常表
4.5、生成异常表和修正代码
五、内核封装例程
注意write()函数的参数是如何在执行int $0x80指令前被装入到CPU寄存器中的。如果eax中的返回值在-1~-129之间,则必须被解释为出错码。如果是这种情况,封装例程就在errno中存放-eax的值并返回值-1;否则,返回eax中的值。
阅读(439) | 评论(0) | 转发(0) |