十、系统调用
10.1 系统调用处理程序及服务例程
系统调用处理程序与其他异常处理程序的结构类似,执行下列操作:
1、在内核态栈保存大多数寄存器的内容。
2、调用名为系统调用服务例程的相应的C函数来处理系统调用
3、退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换回用户态。
为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表。这个表存放在sys_call_table数组中,有NR_syscalls个表项:第n个表项包含系统调用号为n的服务例程地址。
10.2 进入和退出系统调用
本地应用可以通过两种不同的方式调用系统调用:
1、执行int $0x80
2、执行sysenter汇编语言指令
同样内核可以通过两种方式从系统调用退出:
1、执行iret汇编语言指令
2、执行sysexit汇编语言指令。
10.2.1 通过int $0x80指令发出系统调用
向量128对应于内核入口点。在内核初始化期间调用函数trap_init(),set_system_gate(0x80,&system_call),建立对应于向量128的中断描述符表表项。当用户进程发出int $0x80指令时,CPU切换到内核态并开始从地质system_call处开始执行指令。
10.2.1.1 system_call()函数
1、system_call()首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已保存的eflags、cs\eip\ss\esp寄存器。
system_call:
pushl %eax
SAVE_ALL
movl $0xffffe000,%ebx
andl %esp %ebx
这个函数在ebx中存放当前进程的thread_info数据结构的地址,这是通过esp内核栈指针的值并把它取整到4KB或8KB倍数而完成的
2、函数检查是否有某一调试程序正在跟踪程序对系统调用的调用。
3、对用户态进程传递来的系统调用号进行有效的检查。如果这个号大于或等于系统调用分派表中的表项数,系统调用处理程序就终止:
cmpl $NR_syscalls,%eax
jb nobadsys
movl &(-ENOSYS), 24(%esp)
jmp resume_userspace
nobadsys:
如果系统调用号无效,函数就把-ENOSYS值存放在栈中曾保存eax寄存器的单元中。然后跳到resume_userspace处。这样,当进程恢复它在用户态的执行时,会在eax中发现一个负的返回码。
4、最后,调用eax中所包含的系统调用号对应的特定服务例程。
10.2.1.2 从系统调用退出
当系统调用服务例程结束时,system_call()函数从eax获得它的返回值,并把这个返回值存放在曾经保存用户态eax寄存器值的那个栈单元的位置上:
movl %eax,24(%esp)
然后,函数关闭本地中断并检查当前进程的thread_info结构中的标志:
cli
movl 8(%ebp),%ecx
testw $0xffff,%ecx
je restore_all
flags字段在thread_info结构中的偏移量为8,掩码0xffff选择与表4-15中列出的所有标志(不包括TIF_POLLING_NRFLAG)相对应的位。如果所有的标志都没有设置,函数就跳转到restore_all标记处。
只要有任何一种标志被设置,那么就要在返回用户态之前完成一些工作。
10.2.2 通过sysenter指令发出系统调用
http://www.lupaworld.com/tutorial-view-aid-6816.html
汇编语言指令int由于要执行几个一致性的和安全检查,所以速度较慢。sysenter指令提供了一种从用户态到内核态快速切换的方法。
10.2.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//内核堆栈段描述符紧跟在内核代码段的描述符之后。
系统调用开始的时候,内核堆栈是空的,因此esp寄存器应该指向4KB或8KB内存区域的末端,该内存区域包括内核堆栈和当前进程的描述符。因为用户态的封装例程不知道这个内存区域的地址,所以它不能设置这个寄存器。由于必须在切换到内核态之前设置该寄存器的值,因此,内核初始化这个寄存器以便为本地CPU的任务状态段编址。系统调用处理程序读取esp寄存器,计算本地TSS的esp0字段的地址,然后把正确的内核堆栈指针装入esp寄存器。
10.2.2.2 vsyscall页
在初始化阶段,sysenter_setup()函数建立一个成为vsyscall页的页框,其中包括一个小EFL共享对象,也就是一个EFL动态链接库。当进程发出execve()系统调用而开始执行一个EFL程序的时候,vsyscall页中的代码就会自动地被链接到进程的地址空间。
如果CPU不支持sysenter,sysenter_setup()函数建立一个包括下列代码的vsyscall页:
__kernel_vsyscall:
int $0x80
ret
否则:
__kernel_vsyscall:
pushl %ecx
pushl %edx
pushl %ebp
movl %esp ,%ebp
sysenter
10.2.2.3 进入系统调用
当sysenter指令发出系统调用时,一次执行下述步骤:
1、标准库中的封装例程把系统调用号转入eax寄存器,并调用__kernel_vsyscall()函数
2、函数__kernel_vsyscall()把ebp,edx和ecx的内容保存到用户态堆栈中(系统调用处理程序会使用这些寄存器),把用户栈指针拷贝到ebp中,然后执行sysenter指令。
3、CPU从用户态切换到内核态,内核开始执行sysenter_entry()函数(由SYSENTER_EIP_MSR寄存器指向)
4、sysenter_entry()汇编语言函数执行下述步骤:
①、建立内核堆栈指针:movl -508(%esp),%esp
②、打开本地中断
③、把用户数据段的段选择符、当前用户栈指针、eflags寄存器、用户代码段的段选择符以及从系统调用退出时要执行的指令地址保存到内核堆栈中
④、把原来由封装例程传递的寄存器值恢复到ebp中
⑤、通过执行一系列指令调用系统调用处理程序。
10.2.2.4 退出系统调用
当系统调用服务例程结束时,sysenter_entry()函数本质上执行与system_call()函数相同的操作。首先,它从eax获得系统调用服务例程的返回码,并将返回码存入内核栈中保存用户态eax寄存器值的位置。然后函数禁止本地中断,并检查current的thread_info结构中的标志。如果有任何标志被设置,那么在返回到用户态之前还需哟完成一些工作。
sysexit指令:
sysexit指令允许从内核态快速切换到用户态:
1、把SYSENTER_CS_MSR寄存器中的值加16所得到的结果加载到cs寄存器
2、把edx寄存器的内容拷贝到eip寄存器
3、把SYSENTER_CS_MSR寄存器中的值+24所得结果加载到ss寄存器
4、把ecx寄存器的内容拷贝到esp寄存器
结果,CPU从内核态切换到用户态,并开始执行存放在edx中的指令
SYSENTER_RETURN的代码:
该代码判断恢复保存在用户态堆栈中ebp、edx、ecx寄存器的原始内容,并把控制权返回给标准库中的封装例程:
SYSENTER_RETURN:
popl %ebp
popl %edx
popl %ecx
ret
10.3 参数传递
每个系统调用至少有一个参数,即通过eax传递来的系统调用号。
普通C函数的参数传递时通过把参数值写入活动的程序栈(用户态或者内核态栈)实现的。因为系统调用是一种横跨用户和内核两大陆地的特殊函数,所以既不能使用用户态栈也不能使用内核态栈。更确切的说,在发生系统调用钱,系统调用的参数被写入CPU寄存器,然后在调用系统调用服务例程之前,内核再把存放在CPU中的参数拷贝到内核态堆栈中,这是因为系统调用服务例程是普通的C函数。
为什么内核不直接把参数从用户态的栈拷贝到内核态的栈呢?首先同时操作两个栈比较复杂;其次,寄存器的使用时的系统调用处理程序的结构与其他异常处理程序结构类似。
为了使用寄存器传递参数,必须满足两个条件:
1、每个参数的长度不能超过寄存器的长度,即32位
2、参数的个数不能超过6(除了eax的系统调用号),因为80x86的寄存器数量有限
根据POSIX标准,不能存放在32位寄存器中的长参数必须通过制定他们的地址来传递。确实存在多与6个参数的系统调用,在这样的情况下,用一个单独的寄存器指向进程地址空间中这些参数值所在的一个内存区。
用于存放系统调用号和系统调用参数的寄存器是:eax、ebx,ecx,edx,esi,edi,ebp.正如以前看到的一样,system_call()和sysenter_entry()使用SAVE_ALL宏把这些寄存器的值保存在内核态堆栈中。因此,当系统调用服务例程转到内核态堆栈时,就会找到system_call()活sysenter_entry()的返回地址,紧接着是存放在ebx中的参数。
在少数情况下,即使系统调用不使用任何参数,相应的服务例程也需要知道在发出系统调用之前CPU寄存器的内容。例如,实现了fork()的do_fork()函数需要知道有关寄存器的值,以便在子进程的thread字段中复制他们。在这些情况下,类型为pt_regs的一个单独参数允许服务例程访问由SAVE_ALL宏保存在内核态堆栈中的值。
10.3.1 验证参数
只要一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间之内,有两种可能的方式来执行这种检查:
1、验证这个线性地址知否属于进程的地址空间,如果是,这个线性地址所在线性区就具有正确的访问权限。
2、仅仅验证这个线性地址是否小于PAGE_OFFSET
linux从2.2开始执行第二种检查方式。这是一种更高效的检查,因为不需要遍历进程的线性区。同时,这也是一种非常粗略的检查,但是,因为其他的错误会在函数以后的执行中捕获到,所以内核使用这种有限的检查没有任何风险。接着采用的方法是,将真正全面的检查尽可能的往后推迟,一直到分页单元将线性地址转换为物理地址时。
地址检查通过access_ok()宏实现:
int access_ok(const void *addr,unsigned long size){
unsigned long a = (unsigned long)addr;
if(a+size<a||/**通过int溢出检查addr+size的值是否超出最大线性地址
a+size>current_thread_info()->addr_limit.seg)/**是否超过进程对线性地址的最大限制*/
return 0;
return 1;
}
10.3.2 访问进程地址空间
系统调用服务例程需要非常频繁的读写进程地址空间的数据。linux包含一组宏来实现这种操作。
get_user __get_user
put_user __put_user
copy_from_user __copy_from_user
copy_to_user __copy_to_user
strncpy_from_user __strncpy_from_user
strlen_user __strlen_user
clear_user __clear_user
首部没有下滑线的函数或宏需要额外的时间所请求的线性地址区间进行有效的检查,而带下划线的则会跳过检查。
10.3.3 动态地址检查:修正代码
access_ok()宏对系统调用以参数传递来的线性地址的有效性进行粗略的检查。该检查只能保证用户态进程不会试图侵扰内核地址空间。但是传递而来的线性地址仍有可能不属于进程的地址空间。
内核态引起缺页异常的4种情况:
1、内核试图访问属于进程的地址空间,但是,或者相应的页框不存在,或者是内核试图去写一个只读的页。这些情况下,处理程序必须分配和初始化一个新的页框
2、内核寻址到属于其地址空间的页,但是相应的页表还没有被初始化。这种情况下,内核必须在当前进程页表中适当的建立一些表项。
3、某一内核函数包含编程错误,当这个函数运行时就引起异常;或者,可能由于瞬时的硬件错误引起异常。当这种情况发生时,处理程序必须执行一个内核漏洞。
4、系统调用服务试图读写一个内存区,而该内存区的地址是通过系统调用参数传递而来,但却不属于进程的地址空间。
通过确定错误的线性地址是否属于进程所拥有的线性区,缺页处理程序可以很容易的识别第一种情况。通过检查相应的主内核页表是否包含一个映射该地址的非空项也可以检测第二种情况。
10.3.4 异常表
确定缺页的来源关键在于内核使用有限的范围访问进程的地址空间。如果一场是由一个无效的参数引起的,那么引起异常的指令一定包含在其中的一个函数中或者由展开的宏产生。
因此把访问进程地址空间的每条内核指令的地址放到一个叫异常表的结构中。当在内核态发生缺页异常时,do_page_fault()处理程序检查异常表:如果表中包含产生异常的指令地址,那么这个错误就是由非法的系统调用参数引起的,否则,就是由某一个更严重的bug引起的。
主要异常表在建立内核程序影响时,由C编译器自动生成。此外,每个动态装载模块都包含有自己的局部异常表。
每个异常表是一个exception_table_entry结构,有两个字段:
insn 访问进程地址空间指令的线性地址
fixup 放存放在insn单元中的指令所触发的缺页异常发生时,fixup就是要调用的汇编语言代码的地址
search_exception_tables()函数用来在所有异常表中查找一个指定地址:若这个地址在某一表中,则返回指向相应exception_table_entry结构的指针;否则返回NULL。因此,do_page_fault()执行下列语句:
if((fixup = serarch_exception_tables(regs->eip))){
regs->eip = fixup->fixup;
return 1;
}
10.3.5 生成异常表和修正代码
GNU汇编程序伪指令.section 允许程序员指定可执行文件的哪部分包含紧接着要执行的代码
.previous强制汇编程序把紧接着的代码插入到遇到上一个.section时激活的节
------------此段暂时不理解------------
10.4 内核封装例程
尽管系统调用主要由用户态进程使用,但也可以被内核线程使用,内核线程不能使用库函数。为了简化相应封装例程的声明,Linux定义了7个从_syscall0到_syscall6的一组宏。