系统调用
操作系统为在用户态运行的进程与硬件设备(如CPU、磁盘、打印机等等)进行交互提供了一组接口。
Unix系统通过向内核发出系统调用(system call)实现用户态进程和硬件设备之间的大部分接口。
POSIX API和系统调用
让我们先强调下应用编程接口(API)与系统调用之间的不同。前者只是一个函数定义,说明了如何获得一个给定的服务;而后者是通过软中断向内核态发出一个明确地请求。
Unix系统给程序员提供了很多API的库函数。libc的标准C库所定义的一些API引用了封装例程(wrapper routine)。通常情况下,每个系统调用对应一个封装例程,而封装例程定义了应用程序使用的API。
系统调用处理程序及服务例程
当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数。
因为内核实现了很多不同的系统调用,因此进程必须传递一个名为系统调用号(system claa number)的参数来识别所需的系统调用,eax寄存器就用作此目的。当调用一个系统调用时通常还要传递另外的参数。
所有系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。
在内核中,整数和0表示系统调用成功结束,而负数表示一个出错条件。
在后一种情况下,这个值就是存放在errno变量中必须返回给应用程序的负出错码。内核没有设置和使用errno变量,而封装例程从系统调用返回之后设置这个变量。
系统调用处理程序与其他异常处理程序的结构类似,执行下列操作:
在内核态栈保存大多数寄存器的内容
调用名为系统调用服务例程的相应的C函数来处理系统调用。
退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换到用户态。
xyz()系统调用对应的服务例程的名字通常是sys_xyz()。不过也有例外。
为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表(dispatch table)。这个表存放在sys_call_table数组中,由NR_syscalls个表项(在Linux2.6.11内核中是289):第n个表项包含系统调用号为n的服务例程的地址。
NR_syscalls宏只是对可实现的系统调用最大个数的静态限制,并不表示实际已实现的调用个数。
进入和退出系统调用
本地应用可以通过两种不同的方式调用系统调用:
1.执行int $0x80汇编语言指令。在Linux老版本中,这是从用户态切换到内核态的唯一方式。
2.执行sysenter汇编语言指令。
同样,内核可以通过两种方式从系统调用退出,从而使CPU切换回到用户态:
1.执行iret汇编语言指令。
2.执行sysexit汇编语言指令。
通过int $0x80指令发出系统调用
调用系统调用的传统方法是使用汇编语言指令int。
向量128(十六进制0x80)对应于内核入口点。在内核初始化阶段调用的函数trap_init(),用下面的方式建立对于向量128的中断描述符表表项:
set_system_gate(SYSCALL_VECTOR,&system_call);
该调用把下列值存入这个门描述符的相应字段:
Segment Selector:内核代码段__KERNEL_CS的段选择符
Offset:指向system_call()系统调用处理程序的指针
Type:置为15.表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断
DPL:置为3.这就允许用户态进程调用这个异常处理程序
因此当用户态进程发出int $0x80指令时,CPU切换到内核态并开始从地址system_call处开始执行命令。
system_call()函数首先把系统调用好和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中。
ENTRY(system_call)
pushl %eax # save orig_eax
SAVE_ALL
GET_THREAD_INFO(%ebp)
# system call tracing in operation
testb $(_TIF_SYSCALL_TRACE|_TIF_SYSCALL_AUDIT),TI_flags(%ebp)
jnz syscall_trace_entry
cmpl $(nr_syscalls), %eax
jae syscall_badsys
syscall_call:
call *sys_call_table(,%eax,4) //调用eax中所包含的系统调用号对应的特定服务例程
movl %eax,EAX(%esp) # store the return value
syscall_exit:
cli # make sure we don‘t miss an interrupt
# setting need_resched or sigpending
# between sampling and the iret
movl TI_flags(%ebp), %ecx
testw $_TIF_ALLWORK_MASK, %cx # current->work
jne syscall_exit_work
从系统调用退出
当系统调用服务例程结束时,system_call()函数从eax获得它的返回值,并把这个返回值存放在曾保存用户态eax寄存器值的那个栈单元的位置上。
通过sysenter指令发出系统调用
汇编语言指令int由于要执行几个一致性和安全性检查,所以速度较慢。
sysenter指令
汇编语言指令sysenter使用三种特殊寄存器,它们必须装入下列信息:
SYSENTER_CS_MSR:内核代码段选择符
SYSENTER_EIP_MSR:内核入口点的线性地址
SYSENTER_ESP_MSR:内核堆栈指针
vsyscall页
只要CPU和Linux内核都支持sysenter指令,标准库libc中的封装函数就可以使用它。
进入系统调用
当用sysenter指令发出系统调用时,依次执行下述步骤:
1.标准库中封装例程把系统调用号装入eax寄存器,并调用__kernel_vsyscall()函数。
2.函数__kernel_vsyscall()把ebp、edx和ecx的内容保存到用户态堆栈中,把用户战之中拷贝到ebp中,然后执行sysenter指令。
3.CPU从用户态切换到内核态,内核开始执行sysenter_entry()函数。
4.sysenter_entry()汇编语言函数执行
退出系统调用
当系统调用服务例程结束时,sysenter_entry()函数本质上执行与system_call()函数相同的操作。
首先,它从eax获得系统调用服务例程的返回值,并将返回码存入内核栈中保存用户态eax寄存器值的位置。
然后,函数禁止本地中断,并检查current的thread_info结构中的标志。
如果有任何标志被设置,那么在返回到用户态之前还需要完成一些工作。
sysexit指令
sysexit是与sysenter配对的汇编语言指令:它允许从内核态快速切换到用户态。
SYSENTER_RETURN的代码
SYSENTER_RETURN标记处的代码存放在vsyscall页中,当通过sysenter进入的系统调用被iret或sysexit指令终止时,该页框中的代码被执行。
参数传递
和普通函数类似,系统调用通常也需要输入/输出参数,这些参数可能是实际的值,也可能是用户态进程地址空间的变量,甚至是指向用户态函数的指针的数据结构。
在发出系统调用之前,系统调用的参数被写入CPU寄存器,然后在系统调用服务例程之前,内核再把存放在CPU中的参数拷贝到内核态堆栈中,这是因为系统调用服务例程是普通的C函数。
验证参数
在内核打算满足用户的请求之前,必须仔细地检查所有的系统调用参数。检查的类型即依赖于系统调用,也依赖于特定的参数。
有一种检查对所有的系统调用都是通用的。只要一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间之内。由两种可能的方式来执行这种检查:
验证这个线性地址是否属于进程的地址空间,如果是,这个线性地址所在的线性区就具有正确的访问权限。(早期使用)
仅仅验证这个线性地址是否小于PAGE_OFFSET。(从Linux2.2内核开始)
这种粗略的检查是至关重要的,它确保了进程地址空间和内核地址空间都不被非法访问。
对系统调用所传递地址的检查是通过access_ok()宏实现,它由两个分别为addr和size的参数。
#define access_ok(type,addr,size) (likely(__range_ok(addr,size) == 0))
访问进程地址空间
系统调用服务例程需要非常频繁地读写进程地址空间的数据。Linux包含一组宏使这种访问更加容易。我们将描述其中两个名为get_user()和put_user()的宏。第一个宏用来从一个地址读取1、2或4个连续字节,而第二个宏用来把这几种大小的内容写入一个地址。
#define get_user(x,ptr) \
({ int __ret_gu; \
unsigned long __val_gu; \
__chk_user_ptr(ptr); \
switch(sizeof (*(ptr))) { \
case 1: __get_user_x(1,__ret_gu,__val_gu,ptr); break; \
case 2: __get_user_x(2,__ret_gu,__val_gu,ptr); break; \
case 4: __get_user_x(4,__ret_gu,__val_gu,ptr); break; \
default: __get_user_x(X,__ret_gu,__val_gu,ptr); break; \
} \
(x) = (__typeof__(*(ptr)))__val_gu; \
__ret_gu; \
})
#define put_user(x,ptr) \
__put_user_check((__typeof__(*(ptr)))(x),(ptr),sizeof(*(ptr)))
动态地址检查:修正代码
正如当前所看到的,access_ok()宏对系统调用以参数传递来的线性地址的有效性进行了粗略的检查。该检查值保证用户态进程不会试图侵扰内核地址空间。
但是,由参数传递的线性地址依然可能不属于进程地址空间。在这种情况下,当内核试图使用任何这种错误地址时,将会发生缺页异常。
在内核态引起缺页异常的四种情况:
1.内核师徒访问属于进程地址空间的页,但是,或者是相应的页框不存在,或者是内核试图去写一个只读页。在这种情况下,处理程序必须分配和初始化一个新的页框。
2.内核寻址到属于其地址空间的页,但是相应的页表项还没有被初始化。在这种情况下,内核鼻血在当前进程页表中适当建立一些表项。
3.某一内核函数包含编程错误,当这个函数运行时就引起异常;或者,可能由于瞬间的硬件错误引起异常。当这种情况发生时,处理程序必须执行一个内核漏洞。
4.本章所讨论的一种情况:系统调用服务例程试图读写一个内存区,而该内存区的地址是通过系统调用参数传递来的,但却不属于进程的地址空间。
异常表
决定缺页的来源在于内核使用有线的访问访问进程的地址空间。
把访问进程地址空间每条内核指令的地址放到一个叫异常表(excepyion table)的结构中并不用非多大功夫。
当在内核态发生缺页异常时,do_page_fault()处理程序检查异常表:如果表中包含产生异常的指令地址,那么这个错误就是有非法的系统调用参数引起的,否则,就是由某一更严重的bug引起的。
Linux定义了几个异常表。主要的异常表在建立内核程序映像时由C编译器自动生成。它存放在内核代码的__ex_table节,其起始和终止地址由C编译器产生的两个符号__start__ex_table和__stop__ex_table来标识。
每一个异常表的表项是一个exception_table_entry结构,它有两个字段:
insn:访问进程地址空间的指令的线性地址。
fixup:当存放在insn单元中的指令所触发的缺页异常发生时,fixup就是要调用的汇编语言代码的地址。
生成异常表和修正代码
GNU汇编程序(Assembler)伪指令 .section允许程序员指定可执行文件的那部分包含紧接着要执行的代码。
内核封装例程
尽管系统调用主要有用户态进程使用,但也可以被内核线程调用,内核线程不能使用库函数。为了简化相应封装例程的声明,Linux定义了7个从_syscall0到_syscall6的一组宏。
每个宏名字中的数字0~6对应着系统调用所用的参数个数(系统调用号除外)。