Linux内核学习笔记之中断与系统调用(七)

    我们知道用户程序访问内核代码的唯一方式就是通过系统调用,那系统调用是怎么实现的?具体过程是什么?本节我们将带着这两个问题来剖析源码(本节大部分都是科普性知识,所以文字描述较多)~

  • 鲸息功------陷空力

    大家是否还记得我在笔记(四)中讲解的操作系统初始化,其中在sched_init中完成了系统调用中断门的设置,所以系统调用是通过软中断来实现的。那么中断调用是个肿么样的过程?在继续研究这个问题前,我们要先复习一个重要的数据结构------栈。栈在程序的运行中有着举足轻重的作用,最重要的是栈保存了一个函数调用时所需要的维护信息,这常常称之为堆栈帧或者活动记录,主要包括函数的返回地址和参数以及临时变量。下面我们用个栗子来说明栈帧是如何在程序运行中发挥其作用的:

Linux内核学习笔记之中断与系统调用(七)_第1张图片图①

    这个是一个递归函数,很容易看出来这个就是求1+..+N的值,上面是递归开始回溯的时候,即iVal等于1的时候的截图,EBP-8就是iVal的值。我们来看下这个时候的用户栈情况:

图②------Sum函数调用Sum函数形成的部分栈帧

图③------Main函数调用Sum函数形成的部分栈帧

    我只截图了每个栈帧的EBP、返回地址、调用参数一共十二个字节,被略过的地址对应的内容都是0xcc,这是debug模式下函数开头抬高栈帧后的填充物,没有什么特殊意义。其中图①的EBP就是图②的起始地址,图②的起始地址处保存了前一个栈帧基地址,即幅③的起始地址;图②和图③最底下一行的0x00 00 00 01和0x00 00 00 02(注意这边是小端存储,数据要倒过来)就是输入参数,main函数我们调用方式是Sum(2),在Sum中递归调用的方式是Sum(1);图②的0x00 41 32 2b和图③的0x00 41 4e 75就是返回地址,这个返回地址究竟对应了什么指令呢?我们看下这两幅图:

Linux内核学习笔记之中断与系统调用(七)_第2张图片图④------Sum函数反汇编代码

Linux内核学习笔记之中断与系统调用(七)_第3张图片图⑤------main函数反汇编代码

    通过图④和图⑤我们发现栈帧保存的返回地址,就是我们在调用函数main/Sum中完成调用Sum函数后应该继续执行的指令。通过上面的栗子,我们看到是怎么为我们保存回溯路径使得我们在完成函数调用后能正确的返回到最初的被调位置继续执行的。

    通过上面的栗子我们知道栈能恢复函数调用发生时的环境,中断同样也使用栈来恢复中断环境,但是区别于函数调用的用户栈,中断使用的栈为内核栈,而且栈保存的恢复信息也多于函数调用。为什么操作系统的进程会有用户栈和内核栈?主要原因是出于安全考虑,CPU对运行在不同级别的进程加以不同的限制,比如对于运行在低级别的进程,其执行的代码将被硬件限定,不能进行某些操作,例如写入其他进程的存储空间,以防止给操作系统带来安全隐患,所以不同的运行级别就有不同的栈(Linux只用了级别0和3,即内核态和用户态)。现在我们可以继续研究第二个问题了~

    当用户程序使用INT 0x80触发中断后,首先CPU根据进程的任务控制结构TSS中的ss0和esp0重新设置栈环境,将用户栈切换为内核栈(每次由用户态陷入内核态的时候,即低向高级别陷入,内核栈都是空的,因为每次读取的ss0和esp0都是不变的),然后将用户态的ss、esp、eflags、cs和eip寄存器的值压入内核栈中,即压入用户态栈环境(ss、esp)和指令环境(eflags、cs、eip),并修正代码段寄存器和指令寄存器环境,使其指向内核对应中断服务程序入口点(内核栈情况如图⑥绿色及其以上部分)以上就是发生中断时候CPU自动完成的动作,和函数调用发生时的用户栈对比,内核栈多保存了ss、esp、eflags和cs寄存器的值,为什么要保存这些寄存器呢?我们函数调用的时候,调用函数和被调函数的代码都属于用户代码段,而中断调用,其调用函数是属于用户代码段,其被调函数则是内核代码段,所以除了要保存中断时用户态代码的返回地址还要保存用户态代码段的选择子和用户栈信息(现在可以回头看下笔记四,应该就能理解任务0“微服私访”那段逻辑了)。我们现在来看下系统调用中断程序的源码~

bad_sys_call:
	movl $-1,%eax
	iret
.align 2
reschedule:
	pushl $ret_from_sys_call
	jmp _schedule
.align 2
_system_call:
	cmpl $nr_system_calls-1,%eax
	ja bad_sys_call
	push %ds
	push %es
	push %fs
	pushl %edx
	pushl %ecx		# push %ebx,%ecx,%edx as parameters
	pushl %ebx		# to the system call
	movl $0x10,%edx		# set up ds,es to kernel space
	mov %dx,%ds
	mov %dx,%es
	movl $0x17,%edx		# fs points to local data space
	mov %dx,%fs
	call _sys_call_table(,%eax,4)
	pushl %eax
	movl _current,%eax
	cmpl $0,state(%eax)		# state
	jne reschedule
	cmpl $0,counter(%eax)		# counter
	je reschedule
ret_from_sys_call:
	movl _current,%eax		# task[0] cannot have signals
	cmpl _task,%eax
	je 3f
	;为了不影响对系统调用的整体理解,这部分汇编已经省略
3:	popl %eax
	popl %ebx
	popl %ecx
	popl %edx
	pop %fs
	pop %es
	pop %ds
	iret

    我们可以看到_system_call没像硬件中断一样压入所有寄存器,是因为这些压入的寄存器只是系统调用参数。为什么不用保护所有寄存器?个人觉得是因为这个中断是用户程序主动发起的,所以就像函数调用一样,如果对应C系统函数如果使用了其他寄存器,则需要自己先保存然后在函数结束后恢复。下面这张图展现了目前内核栈的情况:

Linux内核学习笔记之中断与系统调用(七)_第4张图片图⑥

    在压入系统调用参数后,调整数据段寄存器为内核数据段选择子。如果细心的小伙伴会发现,fs寄存器指向的还是局部数据段地址,这个主要是为了把用户态的参数拷贝到内核里面(有些时候系统调用参数很多,不是几个寄存器就能装的下的,有关系统调用传参细节将在学习笔记之创建进程篇进行深度剖析),在完成这么多步骤后,后面就和在用户态执行函数没什么区别了(后面所有调用都是内核代码,所以内核栈记录栈帧和用户栈记录用户代码之间的调用时候的栈帧内容都是差不多的,调用完成都是用ret返回),这边是通过call _sys_call_table(,%eax,4)查询系统调用C处理函数数组表定位真正的处理函数,在完成函数调用后再返回这里,如果没有发生进程切换,那么将在弹出中断程序开头保存的其他寄存器后使用iret中断返回指令函数调用是ret,弹出CPU在中断发生时保存的寄存器)返回到用户态继续执行

  • 鲸息功------涡旋劲

    在研究了系统调用之后,我们再来看看硬件中断,其实两者区别不大,只是构造出的内核栈不同,系统调用的内核栈如图⑥所示,我们来看看硬件中断的内核栈是什么样子的(引用Linux内核注释的一幅图):

Linux内核学习笔记之中断与系统调用(七)_第5张图片图⑦

    图⑦中esp0以上部分和图⑥绿色以上部分相同这边没画全而已,我们来看下以下部分两者的区别。我们发现硬件中断保存了所有寄存器的值,但是和系统调用不同的是,除了eax是调用参数其他的都是为了保护中断环境,硬件中断最终的参数就是esp3指向的栈顶的error_code和esp0。我们先来看下有出错号的中断服务程序(就是在笔记四提及过的标准汇编程序):

_divide_error:
	pushl $_do_divide_error
no_error_code:
	xchgl %eax,(%esp)
	pushl %ebx
	pushl %ecx
	pushl %edx
	pushl %edi
	pushl %esi
	pushl %ebp
	push %ds
	push %es
	push %fs
	pushl $0		# "error code"
	lea 44(%esp),%edx
	pushl %edx
	movl $0x10,%edx
	mov %dx,%ds
	mov %dx,%es
	mov %dx,%fs
	call *%eax
	addl $8,%esp
	pop %fs
	pop %es
	pop %ds
	popl %ebp
	popl %esi
	popl %edi
	popl %edx
	popl %ecx
	popl %ebx
	popl %eax
	iret

    首先压入C语言的中断服务程序入口地址到堆栈,然后使用xchgl将eax的内容和栈顶元素交换,就相当于push %eax mov%eax,$_do_divide_error,但是前者指令执行比后者快,这个时候eax保存的就是中断服务的地址。在call *%eax之前,我们发现还压入了两个值0(由于无错误码,所以都是0)和当前esp+44的值(就是图中esp0表示的地址),对应图⑦(a)的esp3指向的内容(现在应该看得懂图⑦了吧~),这个才是C语言中断服务程序的真正参数,我们看下对应的C函数就知道了~

void do_divide_error(long esp, long error_code)
{
	die("divide error",esp,error_code);
}

    在完成C函数调用后,首先执行addl $8,%esp平衡堆栈(前面我们压入了两个参数),然后按照开始压入寄存器顺序的逆顺序依次弹出寄存器,最后用iret指令返回(iret返回的内容见图⑥),就和系统调用返回一样。同理有错误码的硬件中断流程也差不多,大家仿照上面的分析流程应该很容易理解图⑦(b)。

    本节的内容就这么多,下节将介绍进程切换,这个也是建立在中断基础上的,所以大家要好好体会下本节的内容,理解函数调用和中断调用中的不同和相同之处,理解iret和ret指令的区别。



    

你可能感兴趣的:(Linux0.11内核学习笔记)