linux内核-系统调用

如果说外部中断是使CPU被动地、异步地进入系统空间的一种手段,那么系统调用就是CPU主动地、同步地进入系统空间的手段。这里所谓主动,是指CPU自愿的、事先计划好了的行为。而同步则是说,CPU(实际上是软件的设计人员)确切地知道在执行哪一条指令以后就一定会进入系统空间。相比之下,中断的发生带有很大的不可预测性。但是,尽管有着这样的区别,二者之间还是由很大的共性。这是因为,在使CPU的运行状态从用户态转入系统态,也就是从用户空间进入系统空间,这一个基本点上二者是一致的。当然,中断有可能发生在CPU已经运行在系统空间的时候,而系统调用却只发生于用户空间,这又是二者不同的地方。这里,关键是CPU运行状态的改变,没有了这样的手段,也就无所谓保护模式了。相比之下,在不分用户态和系统态的操作系统中,例如ODS,所谓系统调用实际上只不过是动态链接的库函数调用而已。虽然在DOS里面系统调用也是通过中断指令INT来实现的,但是跟预先规定好各种库函数入口地址的普通函数调用没有多大不同。如果用户程序知道具体函数的入口地址,就可以绕过系统调用而直接调用这些函数。

linux的系统调用时通过中断指令INT 0x80实现的。我们已经在前面几篇博客中讨论过进程通过陷阱门或中断门进入系统空间的机制,以及IDT表中陷阱门的初始化。本篇博客着重介绍进程在系统调用中进入系统空间,以及在完成了所需的服务以后从系统空间返回的过程。这个过程并不局限于某个特定的调用,而是所有的系统调用都要经历的共同的过程。虽然我们选择了一个具体的调用作为例子,但并不从功能的角度来关心具体的调用,而是着眼于这个公共的过程。系统调用时内核所提供的最基本的、最重要的基础设施。由于系统调用与中断的共同性,读者在阅读本博客时应该与前面的博客,特别是中断过程结合阅读。事实上,有些代码就是二者共用的,凡是以前已经介绍过的就不再重复。

由于我们并不关心内核在具体系统调用中所提供的服务,所以选择了一个非常简单的调用sethostname作为情景,通过对CPU在这个系统调用全过程中所走过的路线的分析,介绍内核的系统调用机制。

系统调用sethostname的功能非常简单,就是设置计算机(在网络中的)主机名,其使用也很简单:

int sethostname(const char *name, int namelen);

参数name就是要设置的主机名,而len则为该字符串的长度。调用结束后返回0表示成功,-1则表示失败。失败时用户程序的全局变量errno含有具体的出错代码。从程序设计的观点来看,linux的系统调用可以分成两类:一类比较接近于真正意义上的函数,调用的结果就是函数数值,例如getpid就是这样;而另一类就是像sethostname这样的,返回的值实际上只是一个是否成功的标志,而调用的目的是通过副作用来体现的。但是,在C语言中把所有可能通过调用指令来调用的程序段,也就是带有ret指令的程序段都称作函数。而中断服务程序和系统调用,由于ret(实际上是iret)指令的存在就成了函数。我们在讨论中也将遵循C语言的规定和传统概称之为函数。

为了帮助读者更好的理解系统调用的全过程,我们从用户空间对函数sethostname的调用开始我们的情景分析。其实,sethostname是一个库函数(在/usr/lib/libc.a中),而实际的系统调用就是在哪个函数中发出的。GNU的C语言库函数的源代码也是公开的,可以从GNU的网站上下载。但是,我们在这里采用从libc.a反汇编得到的代码。原因是,一来方便,得来全不费工夫;二来,读者多接触一些汇编代码也是有好处的。特别是对于系统程序员来说,阅读和使用汇编语言也是一种有用的技能。

进入函数 sethostname以后,堆栈指针%esp指向返回地址,而堆栈指针的内容加4的地方则树调用该函数时的第一个参数(name),加8的地方为第二个参数len,,以此类推。由于i386运行于32位模式,所有的参数都是按32位长整数压入堆栈的。指令mov 0x8(%esp),%ecx表示将相对于寄存器%esp的位移为0x8(位移单位为1)处的内容(在我们这个情景中就是参数len)存入寄存器%ecx。然后,又将参数name从堆栈中存入寄存器%ebx。最后是将代表sethostname的系统调用号0x4a存入寄存器%eax,接着就是中断指令call *0x0。这里,读者已经看到,linux内核在系统调用时是通过寄存器而不是通过堆栈传递参数的。

为什么要用寄存器传递参数?读者也许还记得:当CPU穿过陷阱门,从用户空间进入系统空间时,由于运行级别的变动,要从用户堆栈切换到系统堆栈。如果在INT指令之前把参数压入堆栈,那是在用户堆栈中,而进入系统空间以后就换成了系统堆栈。虽然进入系统空间之后也还可以从用户堆栈中读取这些参数,但毕竟比较费事了。而通过寄存器来传递参数,则读者下面会看到,是个巧妙的安排。我们暂时不随着CPU进入内核,而先看一下从系统调用返回以后的情况。首先是从%edx中恢复%ebx原先的内容,那是在系统调用之前保存在%edx中的(%edx中原先的内容就丢失了,这是一种约定,gcc在使用寄存器时会遵守这个约定)。然后就是检查系统调用的返回值,那是在寄存器%eax中。如果%eax中的内容是在0xfffff001-0xffffffff之间,也就是在-1至-4095之间,那就是出错了,就要转向sethostname+0x1e并从那里返回,最终会是__syscall_error。也在libc.a中:

 在__syscall_error中,先取%eax内容的负值,使其数值变成1-4095之间,这就是出错代码,并将其压入堆栈。接着,又调用__errno_location,将全局变量errno的地址取入%eax。然后从堆栈中抛出出错代买值%ecx、并将其写入全局变量errno。最后,在返回之前,将%eax的内容改成-1。这样,通过寄存器%eax返回用户进程的数值便是-1,而errno则含有具体的出错代码。这对大部分系统调用(返回整数的调用)返回值的约定。

搞清楚了发生在用户空间的过程,我们就进入内核,也就是系统空间中去了。CPU穿过陷阱门的过程与发生中断时穿过中断门的过程相同,这里就不重复了。不过,还是要指出,因外部中断而穿过中断门时是不检查中断门所规定的准入级别,而在通过INT指令穿过中断门或陷阱门时,则要核对所规定的准入级别与CPU的当前运行级别。为系统调用设置的陷阱门的准入级别DPL为3,。寄存器IDTR指向当前的中断向量表IDT,而IDT表中对应于0x80的表项就是为INT 0x80设置的陷阱门,其中的函数指针指向system_call。当CPU到达system_call时,已经从用户态切换到系统态,并且从用户堆栈换成了系统堆栈,相当于CPU在发生于用户空间的外部中断过程中到达IRQ0xYY_interrupt时的状态,读者不妨先回过头去重温一下。

如前所述,CPU在穿过陷阱门进入系统内核时并不自动关中断,所以系统调用的过程是可中断的。

函数system_call的代码在arch/i386/kernel/entry.S中:

ENTRY(system_call)
	pushl %eax			# save orig_eax
	SAVE_ALL
	GET_CURRENT(%ebx)
	cmpl $(NR_syscalls),%eax
	jae badsys
	testb $0x02,tsk_ptrace(%ebx)	# PT_TRACESYS
	jne tracesys
	call *SYMBOL_NAME(sys_call_table)(,%eax,4)
	movl %eax,EAX(%esp)		# save the return value
ENTRY(ret_from_sys_call)

首先是将寄存器%eax的内容压入堆栈。系统堆栈中的这个位置在代码中称为orig_eax,在外部中断过程中用来保存(经过变形的)中断请求号,而在系统调用中则用来保存系统调用号。SAVE_ALL我们已经在中断博客中看到过了。但是,这里要指出,对于压入堆栈中的寄存器内容的使用方式是不一样的。在中断过程中,SAVE_ALL以后,当调用具体的中断服务程序时已经保存在堆栈中的内容时作为一个pt_regs数据结构,当成参数传递给do_IRQ,然后又传递给具体的服务程序的,这一点读者在中断服务博客中已经看到。可是,在系统调用中就不同了,这里堆栈中每个寄存器的内容可以根据需要作为独立的参数传递给具体的服务程序。以sethostname为例,需要穿度的参数是两个,分别在%ebx和%ecx中。在SAVE_ALL中%ebx是最后压入堆栈的,%ecx次之。所以堆栈中%ebx的内容就称为参数1,而%ecx的内容就是参数2了。回到SAVE_ALL去看一下,可以看到被压入堆栈的寄存器依次为:%es、%ds、%eax、%ebp、%edi、%esi、%edx、%ecx和%ebx。这里的%eax 持有系统调用号(与orig_eax相同),显然不能再用来传递参数;而%ebp是用作子程序调用过程中的帧(frame)指针的,也不能用来传递参数。这样,实际上就只有最后5个寄存器可以用来传递参数,所以,在系统调用中独立传递的参数不能超过5个。从这里也可以看出,SAVE_ALL中将寄存器压入堆栈的次序并不是随意决定的,而有其特殊的考虑。

宏调用GET_CURRENT(%ebx)使寄存器%ebx指向当前进程的task_struct结构(关于GET_CURRENT我们将在进程博客中介绍)。然后,就检查寄存器%eax中的系统调用号是否超出了范围。在task_struct数据结构中有个成分flags,其中有个标志位叫PT_TRACESYS。一个进程可以通过系统调用ptrace,将一个子进程的PT_TRACESYS标志位设成1,从而跟踪该子进程的系统调用。linux系统有一个命令strace就是干这件事的,是一个很有用的工具。这里system_call中的第201行就是在检查当前进程的PT_TRACESYS是否为1。注意,flags(%ebx)并不是一个函数调用,而是表示相对于%ebx的内容,也就是当前进程的task_struct结构指针、位移为flags处的地址,而flags在entry.S中的75行定义为4。这一点以前已经讲过,这里再提醒一下。

当PT_TRACESYS标志位(0x20)为1时,就要转入tracesys,其代码也在entry.S中:

tracesys:
	movl $-ENOSYS,EAX(%esp)
	call SYMBOL_NAME(syscall_trace)
	movl ORIG_EAX(%esp),%eax
	cmpl $(NR_syscalls),%eax
	jae tracesys_exit
	call *SYMBOL_NAME(sys_call_table)(,%eax,4)
	movl %eax,EAX(%esp)		# save the return value
tracesys_exit:
	call SYMBOL_NAME(syscall_trace)
	jmp ret_from_sys_call

将这一段程序与前面正常执行时的203行做一比较,就可以看到不同之处在于:当PT_TRACESYS为1时,在调用具体的服务程序之前和之后都要调用一下函数syscall_trace,向父进程报告具体系统调用的进入和返回。我们将在讲述进程间通信时再深入到syscall_trace中去,但是有兴趣的读者不妨先自己看看。现在回到syscall_trace中继续看那里的203行。这是一条call指令,所call的地址在一个函数指针中,而这个函数指针在数组sys_call_table中以%eax的基础上并没有其他的位移,而4则表示计算为宜(%eax相对于sys_call_table)时的单位为4字节。系统调用跳转表sys_call_table是一个函数指针数组,由于篇幅较大,我们把它单独作为一篇。

表中凡是内核不支持的系统调用号全部都指向sys_ni_syscall,这个函数只是返回一个出错代码-ENOSYS,表示该系统调用尚未实现。结合前面讲过的libc.a中的处理,可知此时用户程序会得到返回值-1,而全局便令errno的值为ENOSYS。

跳转表中位移为0x4a,也就是74处的函数指针(见后面跳转表中的500行)为sys_sethostname,所以在我们这个情景中进入了sys_sethostname,这也是在kernel/sys.c中定义的:


asmlinkage long sys_sethostname(char *name, int len)
{
	int errno;

	if (!capable(CAP_SYS_ADMIN))
		return -EPERM;
	if (len < 0 || len > __NEW_UTS_LEN)
		return -EINVAL;
	down_write(&uts_sem);
	errno = -EFAULT;
	if (!copy_from_user(system_utsname.nodename, name, len)) {
		system_utsname.nodename[len] = 0;
		errno = 0;
	}
	up_write(&uts_sem);
	return errno;
}

可想而知,sys_sethostname应该是只有特权用户才可以进行的操作,所以一上来就先检查这一点。函数capable(CAP_SYS_ADMIN)检查当前进程是否享有CAP_SYS_ADMIN的授权。如没有的话就返回负的出错代码EPERM。然后,又对字符串的长度进行检查以保证安全。

在多处理器系统中,同时可以由多个进程在不同的CPU上运行。这样,就有可能发生两个进程同时调用sethostname,而形成这样的现象:

  1. 进程A调用sethostname,要把主机名设成AB。
  2. 进程C在另一个CPU上运行,也调用sethostname,要把主机名设成CD。
  3. 进程A先进入内核,并且已经在sys_sethostname中将A写入了内核中的system_utsname.nodename,可是还没有来得及写B之前发生了中断,而C在这个时候插了进来。
  4. 进程C进入内核,并且完成了对sethostname的调用,成功地将内核中的system_utsname.nodename设置成CD。
  5. 稍后,进程A恢复运行,继续把B写入system_utsname.nodename。
  6. 当进程A完成对sethostname的调用而成功返回时,内核中system_utsname.nodename的内容却是CB。

在操作系统理论中,这种现象称为race condition(抢道)。为了防止这种情况发生,就要将对system_utsname.nodename的操作放在受到信号量(semaphore)保护的临界区中,而这种保护,上述过程中当进程C到达979行时会发现已经有个进程正在里面操作,请勿打扰,而自愿暂缓, 让别的进程先运行,从而避免了互相抢道。

下面,就是本次系统调用所要完成的实质性的操作了,这就是将 参数name所指向的字符串写入内核中system_utsname.nodename。这个操作的源在用户空间中,而目标在系统空间中,所以要通过一个宏操作copy_from_user来完成复制。如前所述,系统调用时是通过寄存器传递参数的,能够通过寄存器传递的信息量显然不大,所以传递的参数大多是指针,这样才能通过指针找到更大块的数据。因此,对于系统调用的实现,类似于copy_from_user这样在用户空间和系统空间之间复制数据的操作时很重要、也很常用的。对于i386 CPU,宏操作copy_from_user是在asm-i386/uaccess.h中定义的:


#define copy_from_user(to,from,n)			\
	(__builtin_constant_p(n) ?			\
	 __constant_copy_from_user((to),(from),(n)) :	\
	 __generic_copy_from_user((to),(from),(n)))

当复制的长度为一些特殊的常数,例如4、8、...、512等等时,具体的操作是要略为简单一些,而一般的情况下则通过__generic_copy_from_user来完成。其代码在arch/i386/lib/usercopy.c中:

unsigned long
__generic_copy_from_user(void *to, const void *from, unsigned long n)
{
	if (access_ok(VERIFY_READ, from, n))
		__copy_user_zeroing(to,from,n);
	return n;
}

对于读操作,access_ok只是检查参数from和n的合法性,例如(from+n)是否超出了用户空间的上限,而并不检查改区间是否已经映射。然后,就通过另一个宏操作__copy_user_zeroing从用户空间复制。这里__copy_user_zeroing的代码可以说一块硬骨头。可是,这个操作对于系统调用又是很重要的。而且还有一些其他的类似操作,例如在copy_to_user中调用__copy_user,以及__constant_copy_user,还有__do_strncpy_from_user,get_user等等都是与此非常类似,所以还是值得啃一下的。另一方面,我们在内存管理中讲述do_page_fault时留下了一个尾巴,正是跟这些操作有关的。宏操作__copy_user_zeroing定义如下:

#define __copy_user_zeroing(to,from,size)				\
do {									\
	int __d0, __d1;							\
	__asm__ __volatile__(						\
		"0:	rep; movsl\n"					\
		"	movl %3,%0\n"					\
		"1:	rep; movsb\n"					\
		"2:\n"							\
		".section .fixup,\"ax\"\n"				\
		"3:	lea 0(%3,%0,4),%0\n"				\
		"4:	pushl %0\n"					\
		"	pushl %%eax\n"					\
		"	xorl %%eax,%%eax\n"				\
		"	rep; stosb\n"					\
		"	popl %%eax\n"					\
		"	popl %0\n"					\
		"	jmp 2b\n"					\
		".previous\n"						\
		".section __ex_table,\"a\"\n"				\
		"	.align 4\n"					\
		"	.long 0b,3b\n"					\
		"	.long 1b,4b\n"					\
		".previous"						\
		: "=&c"(size), "=&D" (__d0), "=&S" (__d1)		\
		: "r"(size & 3), "0"(size / 4), "1"(to), "2"(from)	\
		: "memory");						\
} while (0)

首先来看__copy_user_zeroing代码中常规的部分,这些代码是在操作顺利,一切都正常的情况下执行的。这一部分实质上只有267-270四行,加上286-288三行。286行为输出部,共说明了三个变量,分别为%0、%1以及%2.其中%0对应于参数size,与寄存器%%ecx结合:%1对应于局部变量__d0,与寄存器%%edi结合;而%2则对应于局部变量__d1,与寄存器%%esi结合。297行为输入部,说明了四个变量。第一个为%3,是一个寄存器变量,初值为(size&3),而后面两个则分别等价于%1,%2和%3,分别应该设置初始值为(size/4),参数to,以及参数from。完成了输入部所规定的的初始化以后,就开始执行267-270行的汇编语言程序。程序中利用了x86处理器的REP和MOVS指令进行串MOVE,寄存器%%ecx为计数器,%%esi为源指针,%%esi为目标指针。先按长整数进行,然后对剩余的部分(不超过3个)字节字节进行。如果C语言来写这段程序,那就相当于:

__copy_user_zeroing(to,from,size)
{
    int r;
    r = size&4;
    size = size/4;
    while(size--)  *((int *)to)++ = *((int *)from)++;
    while(r--)   *((char *)to)++ = *((char *)from)++;
}

显然,二者的效率是不能相比的。读者在前几篇博客中已经看到过类似的代码,所以这一部分代码是容易理解的。

可是,为什么要有从271行至280行这些代码呢?代码的作者特地写了个说明,就是文件documentation/exception.txt,解释其原因(如果读者的计算机安装了linux,可以在/usr/src/linux/documentation目录中找到这个文件)。不过读者在阅读那篇说明时可能还会感到困难,所以我们结合本博客的情景分析加以补充说明。当内核从一个进程得到从用户空间传递进来的指针时,就像这个情景中的name,是很难保证这个指针的合法性的,更难保证在长度为len的整个区间都是合法的。所以,为安全起见应该先检查这个区间的合法性,看看由指针和长度两个参数所决定的徐迅区间是否已经建立映射。每个进程都有个代表它的虚存空间的mm_struct数据结构,记录着该进程在用户空间所有已建立映射的区间。只要搜索这个数据结构中的链表,就可以发现从name开始,长度为len的区间是否已经建立,并且是否允许所需的操作(读或写)。内核中专门有个函数verify_area用于这个目的。而linux内核老一些的版本中却是就是这样做的。但是,每次从用户区读或写都要进行这样的检查实在是个负担,测试表明这个负担在典型的应用中却是显著地影响了效率。在实际应用中,虽然指针有问题的可能新也是有的,甚至可能还不小,但毕竟总是少数,也许可以说95%以上的指针都是好的,实在犯不上为少数的坏指针而打击一大片,致使总体效率下降。所以,新版本就决定把对指针合法性的检查取消了。万一碰上了坏指针,那就让页面异常发生吧,内核可以在页面异常的服务程序中个别地处理这个问题。

现在,我们再回过头去看看do_page_fault。当碰上坏指针而页面异常真的发生时,在do_page_fault中,首先就是通过find_vma搜索当前进程的虚存区间链表,如果搜索失败就转入bad_area。在内存管理中,我们对于bad_area只讲了当异常发生于CPU运行在用户空间时的情况。而在我们现在这个情景中,则异常发生于当CPU运行在系统空间的时候。虽然访问失败的目标地址在用户空间中,但CPU的执行地址却是在系统空间中。为方便起见,我们再列出do_page_fault中有关的几行代码:


do_sigbus:
......
	/* Kernel mode? Handle exceptions or die */
	if (!(error_code & 4))
		goto no_context;
	return;

no_context:
	/* Are we prepared to handle this kernel fault?  */
	if ((fixup = search_exception_table(regs->eip)) != 0) {
		regs->eip = fixup;
		return;
	}

就是说,如果内核能够在一个异常表中找到发生异常的指令所在地址,并得到相应的修复地址fixup,就将CPU在异常返回后将要重新执行的地址替换成这个修复地址。为什么要这样做呢?因为在这种情况下内核不能为当前进程不上一个页面(那样的话name所指的字符串就变成空白了)。而如果任其自然的话,则从异常返回以后,当前进程必然会接连不断地因执行同一条指令而产生新的异常,落入万劫不复的地步。所以,必须把它从泥坑里拉出来。函数search_exception_table的定义如下:


unsigned long
search_exception_table(unsigned long addr)
{
	unsigned long ret;

#ifndef CONFIG_MODULES
	/* There is only the kernel to search.  */
	ret = search_one_table(__start___ex_table, __stop___ex_table-1, addr);
	if (ret) return ret;
#else
	/* The kernel is the last "module" -- no need to treat it special.  */
	struct module *mp;
	for (mp = module_list; mp != NULL; mp = mp->next) {
		if (mp->ex_table_start == NULL)
			continue;
		ret = search_one_table(mp->ex_table_start,
				       mp->ex_table_end - 1, addr);
		if (ret) return ret;
	}
#endif

	return 0;
}

不管38行的CONFIG_MODULES是否有定义,即是否支持可安装模块(取决于系统配置),最终总是要调用search_one_table。那也是在同一个文件中:


static inline unsigned long
search_one_table(const struct exception_table_entry *first,
		 const struct exception_table_entry *last,
		 unsigned long value)
{
        while (first <= last) {
		const struct exception_table_entry *mid;
		long diff;

		mid = (last - first) / 2 + first;
		diff = mid->insn - value;
                if (diff == 0)
                        return mid->fixup;
                else if (diff < 0)
                        first = mid+1;
                else
                        last = mid-1;
        }
        return 0;
}

显然,这里所实现的是一个exception_table_entry结构数组中进行的二分搜索。数据结构struct exception_table_entry定义如下:



/*
 * The exception table consists of pairs of addresses: the first is the
 * address of an instruction that is allowed to fault, and the second is
 * the address at which the program should continue.  No registers are
 * modified, so it is entirely up to the continuation code to figure out
 * what to do.
 *
 * All the routines below use bits of fixup code that are out of line
 * with the main instruction path.  This means when everything is well,
 * we don't even have to jump over them.  Further, they do not intrude
 * on our cache or tlb entries.
 */

struct exception_table_entry
{
	unsigned long insn, fixup;
};

结构中的insn表示可能产生异常的指令所在的地址,而fixup则为用来替换的修复地址。读者会问:可能发生问题的指令有那么多,怎么能为每一条可能发生问题的指令都建立这样一个数据结构呢?回答是:首先,可能发生问题的指令其实并不像想象的那么多;其次,由谁来为这些指令建立这样的数据结构呢?很简单,就是谁使用,谁负责。例如,我们这里的__copy_user_zeroing要从用户空间拷贝,可能发生问题,它就应该负责在异常表中为其可能发生问题的指令建立起这样的数据结构。

现在我们可以回到__copy_user_zeroing的代码中了。首先,在这里可能发生问题的指令其实只有两条,一条是267行标号为0的movsb。所以应该建立两个表项,这就是282行至284行所说明额,关键之处在283行和284行。283行表示,如果异常发生在前面标号为0处的地址,也就是指令movsl所在的地址,那么其修复地址fixup为前面标号为3处的地址,也就是指令lea所在的地址。这时,CPU从修复地址开始做些什么修复呢?在这里是通过stosb把system_utsname.nodename中剩余的部分设成0(当然也可以是什么都不做)。然后,就通过279行的JMP指令跳转到前面标号为2处,也就是结束的地方。这样,虽然从用户空间拷贝的目的没有达到,却避免了陷入异常-重执之间可能发生的无限循环。

大家知道,程序经过编译(或汇编)链接医护,其可执行代码分成text和data两个段。但是,其实GNU的gcc和ld还支持另外两个段。一个是fixup,专门用于异常发生后的修复,实际上跟text段没有太大区别。另一个是__ex_table,专门用于异常地址表。而__copy_user_zeroing中的271行和281行就是告诉gcc应该把相应的代码分别放在fixup和__ex_table段中,链接时ld会按地址排序将这些表项装入异常地址表中。

实际上,不光是像__copy_user_zeroing这样的函数要准备好修复弟子,任何在内核中运行时可能发生问题的都要有所准备,其中还包括我们在前一篇中看到过的SAVE_ALL。当时为了让读者把注意力集中在中断的基本机制上而没有讲述有关的内容,我们在下面讲到从系统调用返回时会加以补充。这里,读者还应该注意一下__generic_copy_from_user的返回值。从代码中可以看到,返回的是调用参数,也就是从用户空间拷贝的长度,这是怎么回事呢?这是因为__copy_user_zeroing不是一个函数,而是一个宏定义。在执行的过程中,n随着复制而减小,一致到0为止。如果中途失败的话,则n代表了还剩下未完成的部分的大小。回头看一下__copy_user_zeroing中的第273行,这里的%0就是参数size,因而也就是n。同时,它就是寄存器%%ecx。在movsl或movsb执行的过程中,%%ecx的值一直减小,直到为0时movsl或movsb就结束了。当操作中途事变而到达273行时,%%ecx的值一定是非0.可是,下面在276行还要用%%ecx,所以先把它保存在堆栈中,而到278行再来恢复。所以,最后在__generic_copy_from_user中返回的n表示还有几个字节尚未完成。而在sys_sethostname中,则根据这个返回值来判断copy_from_user是否成功。当返回值为0时,就把errno也设成0.这样最后sys_sethostname返回0表示成功,而若在copy_from_user过程中失败则返回-EFAULT。

由于sys_sethostname本身很简单,现在回到本博客开头的system_call。CPU从具体系统调用的服务程序返回时,由服务程序准备好的返回值在寄存器%%eax中,所以在第204行将它写入到堆栈中与%eax对应的地方,这样在RESTORE_ALL以后,这个返回值仍通过%eax传回用户空间。这以后,CPU就到达了ret_from_sys_call。

system_call=>ret_from_sys_call

ENTRY(ret_from_sys_call)
#ifdef CONFIG_SMP
	movl processor(%ebx),%eax
	shll $CONFIG_X86_L1_CACHE_SHIFT,%eax
	movl SYMBOL_NAME(irq_stat)(,%eax),%ecx		# softirq_active
	testl SYMBOL_NAME(irq_stat)+4(,%eax),%ecx	# softirq_mask
#else
	movl SYMBOL_NAME(irq_stat),%ecx		# softirq_active
	testl SYMBOL_NAME(irq_stat)+4,%ecx	# softirq_mask
#endif
	jne   handle_softirq
	
ret_with_reschedule:
	cmpl $0,need_resched(%ebx)
	jne reschedule
	cmpl $0,sigpending(%ebx)
	jne signal_return
restore_all:
	RESTORE_ALL
......
handle_softirq:
	call SYMBOL_NAME(do_softirq)
	jmp ret_from_intr

读者已经读过从中断返回时的代码,对上面这些代码应该不会有问题了。

需要补充的是,在RESTORE_ALL中有三条指令可能会引起异常,所以需要为之准备修复。这三条指令是popl %ds、popl %es以及iret。我们先看代码(entry.S),再加以讨论:

#define RESTORE_ALL	\
	popl %ebx;	\
	popl %ecx;	\
	popl %edx;	\
	popl %esi;	\
	popl %edi;	\
	popl %ebp;	\
	popl %eax;	\
1:	popl %ds;	\
2:	popl %es;	\
	addl $4,%esp;	\
3:	iret;		\
.section .fixup,"ax";	\
4:	movl $0,(%esp);	\
	jmp 1b;		\
5:	movl $0,(%esp);	\
	jmp 2b;		\
6:	pushl %ss;	\
	popl %ds;	\
	pushl %ss;	\
	popl %es;	\
	pushl $11;	\
	call do_exit;	\
.previous;		\
.section __ex_table,"a";\
	.align 4;	\
	.long 1b,4b;	\
	.long 2b,5b;	\
	.long 3b,6b;	\
.previous

这里准备了三个修复地址 ,分别在127-129行;而可能出问题的指令则分别在109行、、110行和112行。那么,为什么从堆栈中恢复%ds会有可能发生问题呢?读者也许还记得,每当装入一个段寄存器时,CPU都要根据这新的段选择码以及GDTR或LDTR的内容在相应的段描述表中找到所选择的段描述项,并加以检查。如果描述项与选择码都有效并且相符,就将描述项装入到CPU中段寄存器的不可见部分,使得以后不必每次都要到内存中去访问该描述项。可是,如果因为不管什么原因而使得选择码或描述项无效或不符时,CPU就会产生一次全面保护(general protection)异常(称为GP异常)。当这样的异常发生于系统空间时,就要为之准备好修复手段。在这里,为popl %ds准备修复手段是从标号为4处,即114行的movl $0,(%esp)指令开始的程序段,实际上只有两行。这条指令将%ds在堆栈中的副本先清成0,然后再115行转回109行重新执行popl %ds。为什么这样就能修复呢?其实并不是真的修复,而只是避免进一步GP异常。以0作为段选择码称为空选择码。将空选择码装入一个段寄存器(除CS和SS以外)本身不会引起GP异常,而要到以后企图通过这个空选择码访问内存时才会引起异常,但那时回到用户空间以后的事了。在用户空间发生异常最多也不过是把这个进程杀了,而不会在系统一级上产生问题。所以,这里的修复手段实际上是把问题往下推、往后推而已。110行的popl %es与此相同。

最后,为什么iret也可能发生问题,又怎样修复呢?当i386 CPU从系统空间中断返回到用户空间时,要从系统堆栈中恢复用户堆栈的指针,包括堆栈寄存器的内容,并从系统堆栈中恢复在用户空间的返回地址,包括代码段寄存器的内容。与数据寄存器%ds类似,这两个步骤都有可能发生问题而产生GP异常,使CPU回不到用户空间中去。那么,怎样修复呢?对CS和SS不能通过使用空选择码的瞒天过海手段,因为CS和SS根本不接受空选择码(会产生GP异常)。所以,问题比popl %ds所可能发生的问题更严重。而解决的办法,则只好通过do_exit(详见进程与进程调度系列博客),将当前进程丢卒保车杀掉算了(见118-123行)。把当前进程杀了以后,内核会调度另一个进程成为当前进程。所以,当再要从系统空间返回到用户空间时,是返回到另一个进程的用户空间中去,那时候要从系统堆栈中恢复的寄存器副本也是另一个进程的副本了。

系统调用sethostname的实现虽然很简单,但是从内核中的入口system_call到进入sys_sethostname前的这一段代码,以及从sys_sethostname返回后直到完成RESTORE_ALL中的iret指令这一段代码,则是所有系统调用所共用的。不管什么系统调用,其进入内核以及退出内核的过程是相同的。以后,当我恩谈到系统调用时,就直接从内核中的实现,如RESTORE_ALL那样开始。

最后,还要指出一个读者已经看到但是未必清楚地意识到的事实,那就是从内核中可以直接访问当前进程的用户空间,所使用的虚拟地址也与当进程处于用户空间时的地址完全相同。当然,反过来就不可以了。

猜你喜欢

转载自blog.csdn.net/guoguangwu/article/details/121187828