《Linux内核设计与实现》读书笔记——系统调用

写在前面:之前我粗略的整理过linux在IA32处理器上的系统调用的过程…这篇就当做补充和复习了。


与内核通信

系统调用在用户空间进程和硬件设备之间添加了一个中间层。在Linux中,系统调用是用户空间访问内核的唯一手段:除了异常和陷入外,它们是内核唯一的合法入口。

API、POSIX 和 C库

一般情况下,应用程序都是直接调用在用户空间实现的API来编程,而不是直接通过系统调用。这些API与系统调用也并非一一对应(甚至不使用系统调用)。举个简单的例子,应用程序调用C库中的printf(),C库中的printf()再调用C库中的write(),而C库中write()才调用内核提供的write()系统调用。

不难想到,通过一套标准来规范 API ,就能带来源码级的移植性。而在Unix世界里,POSIX标准最为流行。

Linux中,C库实现了Unix系统的主要API,包括标准库规定的函数以及封装的系统调用接口。

系统调用

进行系统调用(syscall),通常可以通过C库中的函数来完成。内核必须提供系统调用需要完成的功能,但是在实现上没有规定。这也是Unix哲学中的“separating mechanism and policy”。

举个例子,getpid()

1
2
3
4
SYSCALL_DEFINE0(getpid)
{
return task_tpid_vnr(current); // return current->tgid
}

这里SYSCALL_DEFINE0是个宏,展开后代码如下:

1
asmlinkage long sys_getpid(void)

这里的asmlinkage是gcc的拓展,用于通知编译器仅从栈中提取该函数的参数。所有系统调用都有这个限定词。其次,函数返回long类型,为了保证32位和64位系统的兼容。系统调用在用户空间返回值类型为int,在内核空间返回值类型为long。最后,形如sys_bar()是在Linux中的命名规则。

系统调用号

Linux中的每个系统调用被赋予一个系统调用号。用户态的进程通过这个号来指明进行哪个系统调用;进程不会提及系统调用的名称。

内核记录了系统调用表中所有已经注册的系统调用的列表,存储在sys_call_table中。

系统调用的性能

Linux系统调用很快,一个原因是上下文切换快,进出内核被优化地简洁高效,另一个原因是系统调用处理程序和系统调用本身也十分简洁。

系统调用处理程序

应用程序通过软中断通知内核,告诉内核需要进行系统调用:引发一个异常来促使系统切换到内核态去执行异常处理程序。x86上系统预定义的软中断号是128,通过int $0x80指令触发该中断。这条指令会触发一个异常导致系统切换到内核态并执行第128号异常处理程序。而该程序正是系统调用处理程序,叫system_call()。它与硬件体系结构密切相关。最近,x86处理器增加了一条叫做sysenter的指令。与int指令相比,这条指令提供了更快,更专业的陷入内核进行系统调用的方式。

制定恰当的系统调用

x86上系统调用号通过eax寄存器传递给内核。在陷入内核前,用户空间把相应的系统调用号放入eax中。其他体系结构上类似。

system_call()通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果它大于或等于NR_syscalls,该函数就返回-ENOSYS。否则,执行相应的系统调用:call *sys_call_table(,%rax,8)

由于系统调用表中的表项是以64位(8字节)类型存放的,所以内核需要将给定的系统调用号乘以8。x86-32系统上,就用4代替8。

参数传递

除了系统调用号外,大部分系统调用还需要一些外部的参数传入。在x86-32系统上,ebxecxedxesi按照顺序存放前5个参数。需要6个或者6个以上的情况不多见,此时应该用一个单独的寄存器存放指向这些参数在用户空间地址的指针。

给用户空间的返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中。

系统调用的实现

一个Linux系统调用在实现时不需要太关心它的系统调用处理程序的关系,给linux添加一个系统调用相对容易。

实现系统调用

一个系统调用应该有明确的用途,不提倡通过传递不同的参数来选择完成不同的工作。ioctl()就是一个反面例子。还要求时刻注意可移植性和健壮性。

参数验证

系统调用必须验证它们所有的参数是否合法有效。系统调用在内核空间执行,如果任由用户将不合法的输入传递个内核,那么安全性和稳定性将没有保障。

最重要的一种检查就是指针是否有效。内核必须保证:

  • 指针指向的内存区域属于用户空间。
  • 指针指向的内存区域在进程的地址空间内。
  • 如果是读,该内存应被标记为可读;如果是写,该内存应被标记为可写;如果是可执行,该内存应被标记为可执行。

内核提供了两个方法用于完成必须的检查和内核空间与用户空间数据的来回拷贝。

  • copy_to_user(),向用户空间写入数据,需要三个参数。第一个是进程空间中的目的内存地址,第二个是内核空间中的源地址,最后一个是字节数。

  • copy-from_user(),从用户空间读数据。它的三个参数和copy_to_user()类似。

如果执行失败,这两个函数返回的都是没能完成拷贝的数据字节数;如果成功,返回0。当出现上述错误,系统调用返回标准-EFAULT。注意,这两个函数都有可能引起阻塞,当缺页的时候。

最后一项检查针对是否有合法权限。调用者可以通过capable()函数来检查是否有权能对制定的资源进行操作。如果返回非0就有权,否则无权。

系统调用上下文

内核在执行系统调用的时候处于进程上下文,current指针指向当前任务。

在进程上下文中,内核可以休眠并且可以被抢占。当系统调用返回的时候,控制权仍在system_call()中,它最终会负责切换到用户空间,并让用户进程继续执行下去。

绑定一个系统调用的最后一个步骤

当编写完一个系统调用后,把它注册成一个正式的系统调用:

  • 首先,在系统调用表的最后一项加入一个表项。
  • 对于所支持的各种体系结构,系统调用号必须定义于<asm/unistd.h>中。
  • 系统调用必须被编译进内核映像。这只要把它放进kernel/下的一个相关文件中就可以了,如sys.c,它包含了各种各样的系统调用。

从用户空间访问系统调用

通常,系统调用靠C库支持。用户程序通过包含头文件并和C库链接,就可以使用系统调用。但如果你如果仅仅写出了系统调用,glibc并不提供支持。可以通过Linux本身提供的一组宏,直接对系统调用进行访问。这些宏是_syscalln(),n的范围从0到6,代表需要传递给系统调用的参数个数。对于每个宏,都有2+2×n个参数。第一个参数表示返回值类型,第二个参数是系统调用的名称,接着是按照系统调用参数顺序排列的每个参数的类型和名称。

举个例子:

1
2
3
// 对于 long open(const char *filename, int flags, int mode)
#define _NR_open 5
_syscall3(long, open, const char *, filename, int, flags, int, mode)

这组宏会被拓展成内嵌汇编的C函数。

为什么不通过建立系统调用的方式实现

Linux系统尽量避免没出现一种新的抽象就加入一个新的系统调用,这使得它的系统调用接口十分简洁。