全部博文(436)
分类: LINUX
2013-02-26 22:55:54
第十章
系统调用(system call)
红色需讨论,绿色为问答,灰色为补充。
前言:
本章详细讨论了Linux内核是如何实现由用户态进程向内核发出系统调用的。
专业项基本知识:
用户态(user mode):即Ring3,见后内核态与之对应。在计算机结构指两项类似的概念。
⑴在CPU的设计中,用户态指非特权状态。在此状态下,执行的代码被硬件限定,不能进行某些操作,比如写入其他进程的存储空间,以防止给操作系统带来安全隐患。
⑵在操作系统的设计中,用户态也类似,指非特权的执行状态。内核禁止此状态下的代码进行潜在危险的操作,比如写入系统配置文件、杀掉其他用户的进程、重启系统等。
我们这里的用户态指的是操作系统中的用户态,操作系统中的用户态,指权限等级中的一般级别,与之相对的是管理员或者超级用户(类Unix系统中,名为“root”或“super user”等)的特权级别,即Ring0内核态。用户态启动的每个进程,根据运行该进程的用户,都被系统赋予特定的权限。
操作系统的用户态通常是在相应的CPU用户态中运行代码,从而在硬件上,实现非法程序的控制。与CPU级别相比,操作系统容许用户态有更加复杂的权限设定。举例而言,默认下的Unix系统中,运行在用户态的代码,不准通过侦听1024以下的端口号,以伪装成常见的服务,而超级用户运行的代码则有权这样做。
额外层:一个能把低级语言转化为高级语言还能自动检查对错的软件。(百度没有,自己的见解,还请大家帮忙研究)
问:什么叫系统调用?
答:操作系统为用户态运行进程与硬件设备进行交互提供了一组接口,这组接口就是用户态进程。
POSIX API和系统调用
专业项基本知识:
POSIX标准:POSIX是可移植操作系统接口。提供开发可移植应用程序的 API。基于现有的UNIX,描述了操作系统和系统调用编程接口的API,这些系统调用编程接口主要是通过C库(LIBC)来实现的。
API(Application Programming
Interface,应用程序编程接口):是一些预先定义的函数,目的是提供应用程序与开发人员基于某软件或硬件的以访问一组例程的能力,而又无需访问源码。
C库(LIBC):LIBC是Linux下的ANSI C的函数库。
ANSI C是基本的C语言函数库,包含了C语言最基本的库函数。这个库可以根据头文件划分为15个部分,其中包括:字符类型 ()、错误码 ()、浮点常数 ()、数学常数 ()、标准定义 ()、标准 I/O ()、工具函数 ()、字符串操作 ()、 时间和日期 ()、可变参数表 ()、信号 ()、 非局部跳转 ()、本地信息 ()、程序断言 () 等等。这在其他的C语言的IDE中都是有的。
封装例程:实现API的对应功能,发布系统调用。
将对应接口中的参数复制到相应寄存器中,然后引发一个异常,从而系统进入内核去执行,最后当系统调用执行完毕后,封装例程还要将错误码返回到应用程序中。
问:封装例程的返回值有什么特点?
答:⑴大部分封装例程返回一个整数,其值的含义依赖于相应的系统调用。
⑵-1在多数情况下表示内核不能满足进程的请求。
⑶Libc中定义的errno变量包含特定的出错码。
软中断:软中断是利用硬件中断的概念,用软件方式进行模拟,实现宏观上的异步执行效果。“硬中断是外部设备对CPU的中断”,“软中断通常是硬中断服务程序对内核的中断”,“信号则是由内核(或其他进程)对某个进程的中断”。
问:软中断和函数调用的区别是什么?
答:函数调用时将返回地址和CPU状态寄存器内容压栈,函数执行完毕后出栈返回断点继续执行。
软中断调用时将返回地址和CPU状态寄存器内容压栈,修改特权级,根据中断号查找中断向量表,找到ISR中断服务例程地址,跳转执行。
综上,函数调用和软中断调用的区别是,软中断多了修改特权级和查找中断向量表的功能,其他部分完全一样。
ISR(interrupt service routine)中断服务程序:
所谓中断是指当CPU正在处理某件事情的时候,外部发生的某一事件(如一个电平的变化,一个脉冲沿的发生或定时器计数溢出等)请求CPU迅速去处理,于是CPU暂时中止当前的工作,转去处理所发生的事件。中断服务处理完该事件以后,再回到原来被中止的地方继续原来的工作。
在Linux系统中,glibc库中包含许多API,大多数API都对应一个系统调用,比如应用程序中使用的接口open()就对应同名的系统调用open()。在glibc库中通过封装例程(Wrapper Routine)将API和系统调用关联起来。API是头文件中所定义的函数接口,而位于glibc中的封装例程则是对该API对应功能的具体实现。事实上,我们知道接口open()所要完成的功能是通过系统调用open()完成的。
但是函数库中的API和系统调用并没有一一对应的关系。应用程序借助系统调用可以获得内核所提供的服务,像字符串操作这样的函数并不需要借助内核来实现,因此也就不必与某个系统调用关联。
不过,我们并不是必须通过封装例程才能使用系统调用,syscall()和_syscallx()两个函数可以直接调用系统调用。
问:API与系统调用之间有什么关系?
答:⑴API可能直接提供用户态的服务(比如一些数学函数)。
⑵一个单独的API可能调用几个系统调用。
⑶不同的API可能调用了同一个系统调用。
系统调用是在内核中实现的,而API(用户态的库函数)是在LIBC(函数库)中实现的。
系统调用处理程序及服务例程
专业项基本知识:
内核态:内核态与用户态是操作系统的两种运行级别,intel cpu提供Ring0-Ring3三种级别的运行模式。Ring0级别最高,Ring3最低。其中特权级0(Ring0)是留给操作系统代码,设备驱动程序代码使用的,它们工作于系统核心态;而特权极3(Ring3)则给普通的用户程序使用,它们工作在用户态。运行于处理器核心态的代码不受任何的限制,可以自由地访问任何有效地址,进行直接端口访问。
而运行于用户态的代码则要受到处理器的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中I/O许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问(此时处理器状态和控制标志寄存器EFLAGS中的IOPL通常为0,指明当前可以进行直接I/O的最低特权级别是Ring0)。以上的讨论只限于保护模式操作系统,像DOS这种模式操作系统则没有这些概念,其中的所有代码都可被看作运行在核心态。
问:内核态与用户态的相互转换关系是什么?
答:Linux使用了Ring3级别运行用户态,Ring0作为内核态,没有使用Ring1和Ring2。Ring3状态不能访问Ring0的地址空间,包括代码和数据。用户运行一个程序,该程序所创建的进程开始是运行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过 write,send等系统调用,这些系统调用会调用内核中的代码来完成操作,这时,必须切换到Ring0,然后进入3GB-4GB中的内核地址空间去执行这些代码完成操作,完成后,切换回Ring3,回到用户态。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。
系统调用转为对应服务例程一般在之前加sys_。
问:系统调用是如何操作的?
答:1.在内核态栈保持大多数寄存器的内容。
2.调用名为系统调用服务例程的相应的C函数来处理系统调用。
3.退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换回到用户态。
如图
问:怎样把系统调用号与相应的服务例程关联起来?
答:内核用一个系统调用分派表,存放在sys_call_table数组中,有NR_syscalls个表项(在Linux2.6.11内核中是289),第n个表项包含系统调用号为n的服务例程的地址,但这只是对可实现调用的最大个数限制,实际上sys_ni_syscall()函数是未实现系统调用的服务例程,只返回出错码-ENOSYS。
进入和退出系统
进入系统调用即进入内核态,退出系统调用即进入用户态。
CPU与内核态的区别?
问:如何进入系统调用?
答:1.执行int $0x80汇编语言指令(老版本)
2.执行sysenter汇编语言指令(Linux 2.6内核支持)
问:如何退出系统调用?
答:1.执行iret汇编语言指令
2.执行sysexit汇编语言指令
通过int$0×80指令发出系统调用
问:为什么当用户态进程发出int$0×80指令时,CPU切换到内核态并开始从地址system_call处开始执行?
答:内核初始化调用trap_init(),即set_system_gate(0×80,&system_call) ,这里的0×80为十六进制,即向量128,对应于内核入口点,其地址为system_call。
门描述符的相应字符段:
Segment Selector:内核代码段__KERNEL_CS的段选择符
Offset:指向system_call()系统调用处理程序的指针
Type:置为15,表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断。
DPL:置为3,这就允许用户态进程调用这个异常处理程序。
system_call( )函数
1.把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括有控制单元已自动保存的eflags,cs,eip,ss,esp寄存器。
2.这个函数在ebx中存放当前进程的thread_info数据结构的地址,只是通过获得内核栈指针的值并把它取整到4KB或8KB的倍数而完成的。
3.system_call()函数检查thread_info结构flag字段的TIF_SYSCALL_TRACE 和TIF_SYSCALL_AUDIT标志之一是否被设置为1,也就是检查是否有某一调试程序正在跟踪执行程序对系统调用的调用。
cmpl $NR_syscalls,&eax
jb nobadys
movl $(-ENOSYS), 24(&esp) /* 调用号无效,该函数就把-ENOSYS值存放在栈中曾保存eax寄存器的单元中(从当前栈顶开始偏移量为24的单元) */
jmp resume_usespace
当进程恢复它在用户态的执行时,会在eax中发现一个负的返回码。
nobadsys:
4.调用与eax中所包含的系统调用号对应的特定服务例程:call *sys_call_tabl(0, &eax, 4),0为起始地址,4为所占字节数,0+4*%eax为地址单元,我们从地址单元获取指针,内核就找到了要调用的服务例程。
从系统调用退出
问:用户态进程为什么在eax中找到系统调用的返回码?
答:当系统调用服务例程结束时,system_call()函数从eax获得它的返回值,并把这个返回值存放在曾保存用户态eax寄存器值的那个栈单元的位置上:
movl %eax,24(%esp)
system_call()函数关闭本地中断并坚持当前进程的thread_info结构中的标志:
cli
movl 8(&ebp), &ecx /* flags字段在thread_info结构中的偏移量为8 */
testw $oxffff, &cx /*掩码oxffff选择列出的标志相对应的位*/(不包括TIF_POLLING_NRFLAG)
je resore_all /* 没有被设置跳转 */
如果TIF_SYSCALL_TRACE标志被设置,system_call()函数就第二次调用do_syscall_trace()函数,然后跳转到resume_userspace标记处。没被设置,函数就跳转到work_pending标记处。
通过sysenter指令发出系统调用
专业项基本知识:
sysenter指令:提供了一种从用户态到内核态的快速切换方法。
sysenter指令使用三种特殊的寄存器:
SYSENTER_CR_MSR:内核代码段的段选择符
SYSENTER_EIP_MSR:内核入口点的线性地址
SYSENTER_ESP_MSR:内核堆栈指针
执行sysenter指令时,CPU控制单元:
1.把SYSENTER_CR_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_entry()的线性地址写入SYSENTER_CS_EIP寄存器
3. 计算本地TSS末端的线性地址,并把这个值写入SYSENTER_CR_ESP寄存器。
详见内核态灰色字部分。
vsyscall页
只要CPU和Linux内核都支持sysenter指令,标准库libc中的封装函数就可以使用它。
兼容性问题1:
在初始化阶段,sysenter_setup()函数建立一个称为vsyscall页的页框,其中包括一个EFL动态链接库。当进程execve()系统调用而开始执行一个EFL程序时,vsyscall页中的代码就会自动地被链接到进程的地址空间。vsyscall页中的代码使用最有用的指令发出系统调用。
如果CPU不支持sysenter,sysenter_setup()函数建立一个包括下列代码的vsyscall页
__kernel_vsyscall:
int $0×80
ret
如果CPU支持sysenter,sysenter_setup()函数建立一个包括下列代码的vsyscall页
__kernel_vsyscall:
pushl &ecx
pushl &edx
pushl &ebp
movl &esp, &ebp
sysenter
当标准库中的封装例程必须调用系统调用时,都调用__kernel_vsyscall()函数,不管它的实现代码是什么。
兼容性问题2:
老版的Linux内核不支持sysenter指令,内核不建立vsyscall页,函数__kernel_vsyscall()不会被链接到用户态进程的地址空间,就简单执行int $0×80指令来调用系统调用。
进入系统调用
当用sysenter指令发出系统调用时,执行的步骤:
1.标准库中的封装例程把系统调用号装入eax寄存器,并调用__kernel_vsyscall()函数。
2.函数__kernel_vsyscall()把ebp、edx和ecx的内容保存到用户态堆栈中(系统调用处理程序将使用这些寄存器),把用户栈指针拷贝到ebp中,然后执行sysenter指令。
3.CPU从用户态切换到内核态,内核开始执行sysenter_entry()函数(由SYSENTER_EIP_MSR寄存器指向)。
4. sysenter_entry()汇编语言函数执行步骤:
a.建立内核堆栈指针: movl-508(%esp), %esp
b.打开本地中断:sti
c.把用户数据段的段选择符、当前用户栈指针、eflags寄存器、用户代码段的段选择符以及从系统调用退出时要执行的指令的地址保存到内核堆栈中:
pushl $(__USER_DS)
pushl %ebp
pushfl
pushl $(__USER_CS)
pushl $SYSENER_RETYRN
d.把原来由封装例程传递的寄存器的值恢复到ebp中:
movl (%ebp), %ebp
e.通过执行一系列指令调用系统调用处理程序,这些指令与前面“通过int $0×80指令发出系统调用”在system_call标记处开始的指令是一样的。
退出系统调用
sysente_entry()函数从eax获得系统调用服务例程的返回码,并将返回码存入内核栈中保存用户态eax寄存器值的位置。然后,函数禁止本地中断,并检查current的thread_info结构中的标志。
如果任意标志被设置,为了避免代码复制,函数跳转resume_userspace或work_pending标记处。汇编语言指令iret从内核堆栈中去取5个参数。
如果sysente_entry()函数确定标志都被清0,它就快速返回到用户态:
movl 40(%esp),%edx
movl 52(%esp), %ecx
xorl %ebp, %ebp
sti
sysexit
sysexit指令
sysexit是与sysenter配对的汇编语言指令:它允许从内核态快速切换到用户态。执行这条指令时,CPU控制单元执行下述步骤:
1. 把SYSENTER_CS_MSR寄存器中的值加16所得到的结果加载到cs寄存器。
2. 把edx寄存器的内容拷贝到eip寄存器。
3. 把SYSENTER_CS_MSR寄存器中的值加24所得到的结果加载到ss寄存器。
4. 把ecx寄存器的内容拷贝到esp寄存器。
SYSENTER_RETURN的代码
SYSENTER_RETURN标记处的代码存放在vsyscall页中,当通过sysenter进入的系统调用被iret或sysexit指令终止时,该页框中的代码被执行。
该代码片段恢复保存在用户态堆栈中的ebp、edx和ecx寄存器的原始内容,并把控制权返回给标准库的封装例程:
SYSENTER_RETURN:
popl %ebp
popl %edx
popl %ecx
ret
参数传递
系统调用通常需要输入/输出参数。
因为system_call()和sysenter_entry()函数式Linux中所有系统调用的公共入口点,因此每个系统调用至少有一个参数,即通过eax寄存器传递来的系统调用号。
如果系统调用例程是普通C函数,则要在调用服务例程前,把存放在CPU的参数拷贝到内核态堆栈中。
问:为什么内核不直接把参数从用户态的栈拷贝到内核态的栈呢?
答:1.同时操作两个栈是比较复杂的。
2.寄存器的使用使得系统调用处理程序的结构域其他异常处理程序的结构类似
为了用寄存器传递参数,必须满足两个条件:
1. 每个参数的长度不能超过寄存器的长度,即32位。
2. 参数的个数不能超过6个,因为80x86处理器的寄存器的数量是有限的。
用于存放系统调用号和系统调用参数的寄存器是:eax,ecx,edx,esi,ebp。系统调用服务例程很容易通过使用使用C语言结构来引用它的参数,因为此栈与普通函数栈结构相同。
最后的return n即是将服务例程的返回值写入eax寄存器中(必须的)。
验证参数
通用检查:一个参数指定的是地址,内核则必须检查其是否在地址空间内。
有两种可能的方式来执行这种检查:
1. 验证这个线性地址是否属于进程的地址空间,如果是,这个线性地址所在的线性区就具有正确的访问权限。(比较费时)
2. 仅仅验证这个线性地址是否小于PAGE_OFFSET(即没有落在留给内核的线性地址区间内)。(高效粗略无风险,确保了进程地址空间和内核地址空间都不被非法访问)
对系统调用所传递地址的检查时通过access_ok()宏实现的,它有两个分别为addr和size的参数。该宏检查addr到addr+size-1之间的地址区间,本质上等价于下面的C函数:
int access_ok(const void*addr,unsigned long size)
{
unsigned long a = (unsigned long) addr; /* 检查溢出条件 */
if (a + sizea + size >current_thread_info()->addr_limit.seg)
return 0;
return l;
}
访问进程地址空间
访问进程地址空间的函数和宏:
函数 操作
get_user__get_user 从用户空间读一个整数(1、2或4字节)
put_user__put_user 给用户空间写一个整数(1、2或4字节)
copy_from_user_copy_from_user 从用户空间复制任意大小的块
copy_to_use__copy_to_user 把任意大小的块复制到用户空间
strncy_from_user__strncpy_from_user 从用户空间复制一个以null结束的字符串
strlen_user strnlen_user 返回用户空间以null结束的字符串的长度
clear_user__clear_user 用0填充用户空间的一个内存区域
动态地址检查:修正代码
问:什么时候会发生缺页异常?
答:通过粗略检查,由参数传递的线性地址依然不属于进程地址空间,当内核使用这种错误地址时,就会发生缺页异常。
内核态引起缺页异常的四种情况:
1. 内核试图访问属于进程地址空间的页,但相应的页框不存在,或者是内核试图去写一个只读页。在这些情况下,处理程序必须分配和初始化一个新的页框。
2. 内核寻址到属于其地址空间的页,但相应的页表项还没有被初始化。在这种情况下,内核必须在当前进程页表中适当地建立一些表项。
3. 某一内核函数包含编程错误,当这个函数运行时就引起异常;或可能由于瞬时的硬件错误引起异常。当这种情况发生时,处理程序必须执行一个内核漏洞。
4. 系统调用服务例程试图读写一个内存区,而该内存区的地址是通过系统调用参数传递来的,但却不属于进程的地址空间。
异常表
问:异常表是干什么用的?
答:存放每条内核指令的地址,出现异常时检查异常表,即可知引起错误的原因。
每个动态装载的内核模块都包含有自己的局部异常表。每一个异常表的表项是一个exception_table_entry结构,有两个字段:
insn:访问进程地址空间的指令的线性地址
fixup:当存放在insn单元中的指令所触发的缺页异常发生时,fixup就是要调用的汇编语言代码的地址
修正代码由几条汇编指令组成,用以解决由缺页异常所引起的问题。
缺页处理程序do_page_fault()执行下列语句:
if((fixup=search_exception_tables(regs->eip))){
/* regs->eip字段包含异常发生时保存到内核态栈eip寄存器中的值*/
regs->eip= fixup->fixup; /* 其值在某个异常表中 */
return l;
/* do_page_fault()就把所保存的值替换为seach_exception_tables()的返回地址*/
}
生成异常表和修正代码
每个异常表项由两个标号组成。
第一个是一个数字标号。如果缺页异常是由标号1、2或3处的指令产生,那么修正代码就执行。如果缺页异常由标号0指令引起,执行修正代码,把eax置零。
第二个section指令在__ex_table中增加一个表项,内容包括repne,scasb指令的地址和相应的修正代码的地址。
内核封装例程
为了简化相应封装例程的声明,Linux定义了7个从_syscall0到_syscall6的一组宏。
每个宏名字中的数字0~6对应着系统调用所有的参数个数。也可以用这些宏来声明没有包含在libc标准库中的封装例程。不能用这些宏来为超过6个参数的系统调用或产生非标准返回值的系统调用定义封装例程。
每个宏严格地需要2+2 x n个参数,n是系统调用的参数个数。