JOS 系统调用的过程

系统调用,是用户态进程转向内核态的一种安全机制,在保证了内核空间安全并且不被破坏的的前提下,让用户态程序可以实现一定的功能。

通过JOS系统,来看系统调用的具体过程。

系统调用,在语言层面来看,其实可以把系统调用看成是一种函数的调用。只是这种函数调用,不同于一般的用户态下的函数调用,用户态下,函数调用,只需要用栈来保存各种信息就可以了,因为调用前后,保存的信息的栈都是用户地址空间下的栈,所以只需要ESP和EBP两个寄存器相互配合,就可以很好的实现函数调用的功能了。在用户态下调用函数,栈是如何保存信息的,可以看看:http://blog.csdn.net/fang92/article/details/46494665

系统调用的过程,包括用户进程转到内核态,然后由内核态转回用户进程。和上面的函数调用不同的是,在进行系统调用的时候,需要的是内核地址下的栈来保存程序的相关信息,包括各个寄存器的值,地址空间的值等等。所以,在这个过程中,就存在栈的切换,进入内核后,用户栈需要切换为内核栈,而回到用户进程后,内核栈就需要切换为用户栈的状态。

之所以要用内核栈,个人理解,是因为在用户进程陷入内核中后,在内核中,也有函数的调用等操作,所以内核也需要栈来存放变量等。而之所以要把进程信息保存进内核栈中,个人感觉是因为,在内核中,很容易发生进程之间的切换,这样这些信息统一放在一个内核中中,这样比较好管理,可以很方便的调用提取这些信息。

下面来看一下具体的系统调用是如何实现的。

首先,看一下在JOS里面的进程描述符:

struct Env {
	struct Trapframe env_tf;		// Saved registers
	struct Env *env_link;			// Next free Env
	envid_t env_id;				// Unique environment identifier
	envid_t env_parent_id;			// env_id of this env's parent
	enum EnvType env_type;		// Indicates special system environments
	unsigned env_status;			// Status of the environment
	uint32_t env_runs;				// Number of times environment has run

	// Address space
	pde_t *env_pgdir;				// Kernel virtual address of page dir
}
上面各项具体的内容,可以到另外一篇讲进程的文章里面去查看: http://blog.csdn.net/fang92/article/details/48372697

下面,跟踪一个具体的程序,来看看系统调用的过程是怎么样的:

hello:

// hello, world
#include <inc/lib.h>

void
umain(int argc, char **argv)
{
	envid_t envid = sys_getenvid();
	cprintf("hello, world\n");
	cprintf("i am environment %08x\n", envid);
}

envid_t
sys_getenvid(void)
{
	 return syscall(SYS_getenvid, 0, 0, 0, 0, 0, 0);
}


上面这个是最简单的程序,就是输出一个hello world。 

再上面的程序中,调用了一个系统进程sys_getenvid()——这些系统调用都是JOS系统里面的,和linux的可能有些不同。

直接跳过跳过前面的代码,直接来看系统调用的相关代码:

static inline int32_t
syscall(int num, int check, uint32_t a1, 
                      uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	int32_t ret;
	asm volatile("int %1\n"
		: "=a" (ret)
		: "i" (T_SYSCALL),
		  "a" (num),
		  "d" (a1),
		  "c" (a2),
		  "b" (a3),
		  "D" (a4),
		  "S" (a5)
		: "cc", "memory");
	
	if(check && ret > 0)
		panic("syscall %d returned %d (> 0)", num, ret);
	return ret;
}


上面的sys_getenvid()是众多系统调用中的一个函数,在JOS中,所有的系统调用全部通过syscall()这个函数来完成相应的功能。通过syscall()中的第一个参数num,来确定到底使用的是那个系统调用。

在syscall()这个函数中,主要是一个参数的传递,可以看到,在函数里面,有内联汇编。这个内敛汇编的功能也比较简单。只有一句汇编代码:

int  %1

%1即下面的 "i" (T_SYSCALL), 这句话,就是调用编号为T_SYSCALL(0x30)的中断,所有的系统中断,都采用0x30作为中断号,只是根据不同的num,来区分所调用的具体的系统调用。下面看下去,可以知道,这段汇编的功能就是把前面的syscall()中的各个参数,赋值给各个寄存器。由于系统调用,需要把栈从用户栈变换位内核栈,所以一般的函数调用,把参数存在栈中的方法无法满足要求,所以就需要用寄存器来传递参数。

参数传递完成之后,直接用调用int 0x30,

在这里,先看一下esp,此时的栈指针应该指向的是用户栈:

在JOS中,整个地址空间被分成了一下几个重要的部分:

1.从 虚拟地址UTOP(0xeec00000)开始,到 ULIM(0xef800000),这一段中间的地址对于用户进程来说,是一段只读进程,用户进程可以访问,但是不能写。

2.在UTOP以上的空间,是用户空间,用户可以访问,可写可读可执行。

3.在ULIM以上的空间,是内核空间,用户不可访问。

其中,用户栈初始位置是在 USTACKTOP (0xeebfe000), 内核栈的初始位置为:KSTACKTOP(0xf0000000),而内核栈的初始地址被保存在一个名为TSS的数据就够中,其里面有esp0来存储内核栈的初始位置,即KSTACKTOP(0xf0000000)。

从上面的途中,可以看出,现在esp指向的是用户栈的位置,下一步,进入中断,

执行了int 0x30进入了相应的中断之后,可以看到,esp的指针已经指向了内核栈了,而且在内核栈中,已经有数据被压入内核栈。

可以看到,被压入内核栈的数据依次是: 0x23, 0xeebfdfc4, 0x86, 0x1b, 0x800bfc, 0x0

所以可以说,在JOS里面,int 0x30做了一下几件事情:

1.改变了esp指针,其指向了内核栈。

2.进入了相应的中断函数。

3.往内核栈中压入了一些值

通过查阅相关的文章,知道在linux中,通过int 0x80来进行系统调用,在这个过程中,int 0x80做了一下的几件事情:

(1) 由于INT指令发生了不同优先级之间的控制转移,所以首先从TSS(任务状态段)中获取高优先级的核心堆栈信息(SS和ESP);  
(2) 把低优先级堆栈信息(SS和ESP)保留到高优先级堆栈(即核心栈)中;  
(3) 把EFLAGS,外层CS,EIP推入高优先级堆栈(核心栈)中。  
(4) 通过IDT加载CS,EIP(控制转移至中断处理函数)  

上面几点,综合起来就是得到首先内核栈的初始地址,然后把SS,ESP,EFLAGS,CS,EIP等进程寄存器的值压入内核栈中.

最后,加载中断函数的CS和EIP,执行中断函数。

回到int 0x30执行之前的状态,可以得到上述几个寄存器的值如下图:

上面的内核栈里面的值一一对比,就可以看到,压入的前五个值就是相应的寄存器(eip不同是因为压入的是int 0x30的下一条语句的eip,这样iret之后就可以直接执行下一条语句)。

接下去,看进入中断之后的程序:

#define TRAPHANDLER_NOEC(name, num)					\
	.globl name;							\
	.type name, @function;						\
	.align 2;							\
	name:								\
	pushl $0;							\
	pushl $(num);							\
	jmp _alltraps


.globl _alltraps
_alltraps:
	pushl %ds
	pushl %es
	pushal
	movl $GD_KD, %eax
	movw %ax, %ds
	movw %ax, %es

	pushl %esp
	call trap

在内核栈的最后,压入了0x0,其实是压入了中断的错误号,有些中断有错误号,有些中断是没有错误号的,就用0代替。所以,最后会压入一个0来代替错误号(没有错误号还要压一个数字,是因为在中段描述符的寄存器变量里面有这一项,为了和有错误号的中断相统一)。

这段汇编比较简单,就是把寄存器和中断号压入内核栈中,这里最前面压入了一个0,是因为有些中断具有错误号,对这些中断,CPU会自动压入一个中断号,而有些中端是没有错误号的,此时,为了和前面的有错误号的中断相统一,就认为的压入一个0作为错误号,所以总共有两个预处理的宏,一个是上面的TRAPHANDLER_NOEC,这个由于CPU不会压入错误号,所以人为的压入。另一个是TRAPHANDLER,这个就没有pushl $0这一语句,因为CPU会自动压入错误号。

这里需要注意,在所有中断函数,各自执行相应的属于自己的函数之前,都需要经过以上两个步骤,即TRAPHANDLER/TRAPHANDLER_NOEC, 然后_alltraps。

这里这两个步骤的作用就是,把各种寄存器和错误号按照一定的顺序压入内核栈中,然后改变ds和es寄存器的值,使其指向内核的数据段。


接下来,看trap。

void
trap(struct Trapframe *tf)
{
	
	asm volatile("cld" ::: "cc");

	assert(!(read_eflags() & FL_IF));

	cprintf("Incoming TRAP frame at %p\n", tf);

	if ((tf->tf_cs & 3) == 3) {
		assert(curenv);
		curenv->env_tf = *tf;
		tf = &curenv->env_tf;
	}

	last_tf = tf;
	trap_dispatch(tf);<span style="white-space:pre">			</span>//执行相应的中断函数

	assert(curenv && curenv->env_status == ENV_RUNNING);
	env_run(curenv);
}
trap函数,这里看一下函数的输入变量,是Trapframe型的一个结构体。

看一下这个结构体的具体的内容:


回忆一下int 0x30之后, 内核栈的一系列的行为:首先,系统硬件自动压入SS,ESP,EFLAGS,CS,EIP;然后接下去执行的TRAPHANDLER_NOEC,压入了数字0和中断号,接着在_alltraps里面,压入了ds,es,并且执行了pusha, 压入了所有的普通寄存器。

和上面的图片进行对比,可以看出,整个的变量入栈的过程,其实就是在创建一个Trapframe结构体,最后的esp栈指针,指向的就是Trapframe结构体的首地址,然后传给trap。这个结构体变量,包含了调用内核的用户进程的所有的寄存器信息,所以,在内核进入trap()函数之前,其实前面的所有的工作,就是生成这样的一张表,这张表里面记录了用户进程的寄存器信息。

在trap()函数中,接下去执行:

if ((tf->tf_cs & 3) == 3){

}

这里,主要是为了判断进入这个中断程序的是不是用户态程序。

cs是调用中断程序的进程的代码段选择子。其结构如下图所示:

可以看到,cs寄存器的最后两位是RPL,其表示程序段的特权级。在linux系统中,总共有4个特权级,依次为:0,1,2,3.其中,0位最高级,3位最低级。

在JOS系统里面,只用了其中的两个,0和3.0代表内核程序段,3代表的是用户程序段。

所以再看上面那段代码,就比较清楚了,如果cs的最后两位是11,则代表调用内核的是用户进程。如果不是,则表示调用内核的是内核进程,实现中断嵌套。

如果调用的是用户进程,则把tf存入curenv中,在文章的最开始,有 struct Env (进程描述符)的相关介绍,其中的env_tf,即存入的是进程寄存器的信息。

curenv表示的是最后一个执行的用户进程,所以这段代码的作用就是:如果调用内核的是用户进程,则调用的用户进程就是curenv这个进程,则把用户进程的相应寄存器信息存入,这样,以后在返回的时候就可以按照这些寄存器信息返回。

如果现在调用内核的是内核进程,则cs的最后两位必为0,则此时的tf里面存储的寄存器信息是内核的寄存器信息,也就不必要更新curenv的寄存器。因为是内核调用内核,所以栈指针没有改变,还是指向内核栈。这种中断调用,就和函数调用是一样的,最后可以通过栈esp和ebp来恢复,不必存入特定的结构体中。

接下去,就是执行相应的中断函数:trap_dispatch(tf)

这里需要注意一点,当是用户进程调用这段内核时,tf不在是前面刚刚进入trap时,而是指向curenv->env_tf的地址,这样,在中断执行的过程中,就可以通过改变tf里面的寄存器的值,来传递参数了。而如果是内核调用这段内核,则tf指向的其实还是内核栈里面的Trapframe,由于栈空间不变化,所以不需要用寄存器来传递参数,直接用栈,就可以传递输出参数,这一点,和函数调用是一样的。

接下来,看trap_dispatch()函数是如何执行的:

static void
trap_dispatch(struct Trapframe *tf)
{
	if(tf->tf_trapno == T_PGFLT){
		page_fault_handler(tf);
		return;
	}
	if(tf->tf_trapno == T_BRKPT){
		monitor(tf);
		return;
	}
	if(tf->tf_trapno == T_SYSCALL){
		tf->tf_regs.reg_eax= syscall(tf->tf_regs.reg_eax, 
						tf->tf_regs.reg_edx,
                            				tf->tf_regs.reg_ecx,
                            				tf->tf_regs.reg_ebx,
                            				tf->tf_regs.reg_edi,
                            				tf->tf_regs.reg_esi);
                            return;	
	}
	// Unexpected trap: The user process or the kernel has a bug.
	print_trapframe(tf);
	if (tf->tf_cs == GD_KT)
		panic("unhandled trap in kernel");
	else {
		env_destroy(curenv);
		return;
	}
}
在JOS系统里面,总共只有三种中断类型,page fault, 中断,系统调用。

我们这里主要看系统调用,看一下syscal函数:

int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	int ret = 0;
	switch(syscallno){
		case SYS_cputs: 		sys_cputs( (const char *)a1, (size_t) a2);
						break;
		case SYS_cgetc: 		ret = sys_cgetc();		
						break;
		case SYS_getenvid:	 ret =sys_getenvid();	
						break;
		case SYS_env_destroy:	ret= sys_env_destroy(a1);
						break;
		default:
			return -E_NO_SYS;
	}
	return ret;
}
首先看一下,输入参数。回顾一下,在最开始,用户进程执行系统调用的时候,是把系统调用的类型存入eax的,把其他参数存入相应的寄存器。而在这里,读取相应的寄存器的值,来进行syscall的调用。 通过寄存器,实现了系统调用过程中,从用户态往内核态传参数。

具体的系统调用实现比较简单,在执行了sys_getenvid()之后,会返回相应的进程id号。然后返回给上一层中的eax寄存器。注意,这个eax寄存器是用户进程的eax寄存器,并不是现在执行的内核中的eax寄存器。当返回用户进程之后,所有的寄存器都会通过env_tf恢复成系统调用之前的数值。所以通过这种,方法,又一次完成了从内核态到用户态的参数传递工作。

回到trap,可以看到,函数执行了env_run(curenv),即最后执行用户进程,完成用户进程的返回。

关于用户进程的返回,可以查看另外一份文档:http://blog.csdn.net/fang92/article/details/48372697,在这篇文章的第4点进程运行中,详细分析了env_run()函数.


总结:

1.系统调用的过程:

(1)首先,需要从TSS段中,找出内核栈的地址和,其存在esp0和ss0中.

(2)按照一定的格式(和进程描述符中的寄存器信息的存储方式有关),把寄存器压入内核栈中.

(3)给用户进程的进程描述符中的tf赋值,以便能够方便的回到用户进程调用系统之前的状态.

(4)执行对应的系统调用.

(5)返回用户进程.

2.从用户进程到内核进程的过程中,栈是会变化的,其会从用户栈转变为内核栈(int 0x30 硬件自动转换),所以参数的传递和一般的函数参数传递不一样.在用户进程和内核进程之间传递参数,只能通过寄存器.

3.在执行内核程序的过程中,也可能会出现中断,此时,这种中断就类似于函数调用,因为栈没有改变,一直是内核栈,理解这个,对很多机制都有帮助.

4.现在的内核基本就是纯分页的,不在用分段的机制了.要实现这点,只需要把段基址设为0, 段的范围设为0xffffffff,此时就可以达到纯分页的效果了.现在感觉,这个段的唯一作用,就是指明段的特权级,是在内核态中还是在用户态中了.通过这种保护,用户态的程序不能直接访问内核空间.

用户态的程序想要访问内核空间,方法就是陷入中断,采用 int 0x30(LINUX下是 int 0x80)来从用户态转到内核态.

而内核态,相对的,也使用iret,中断返回,来进入用户态.


  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值