与内核通信

系统调用是在用户空间进程和硬件设备之间的中间层,主要有3种作用:

  • 为用户空间提供了硬件的抽象接口,例如读写文件。
  • 保证了系统的稳定和安全,内核通过权限和角色和其他规则控制进程对资源的访问。
  • 为了实现多任务和虚拟内存,构成进程运行的虚拟环境。

在Linux中,系统调用是用户空间访问内核的唯一手段。除了异常和陷入外,系统调用是内核唯一的合法入口。

API、POSIX 和 C库

应用程序、C库、系统调用、内核的关系如下图所示

类Unix系统中最流行的API是基于POSIX标准的,系统调用作为C库的一部分提供,C库实现了Unix系统的主要API,包括C语言标准库、系统调用接口、POSIX标准的大部分API。

Unix接口设计的格言:提供机制(mechanism)而不是策略(policy)。Unix系统调用抽象除了用于完成某种确定的目的的函数,至于这些函数怎么用完全不需要内核去关心。

The distinction between mechanism and policy is one of the best ideas behind the Unix design. Most programming problems can indeed be split into two parts: “what capabilities are to be provided” (the mechanism) and “how those capabilities can be used” (the policy). If the two issues are addressed by different parts of the program, or even by different programs altogether, the software package is much easier to develop and to adapt to particular needs.

系统调用

通常用C库中定义的函数来访问系统调用(syscall)。系统调用有0到多个参数,出现错误时会把错误写入全局变量errno中。

定义系统调用:

SYSCALL_DEFINE0(getpid)  // 等价于 asmlinkage long sys_get_pid(void)
{
    return task_tgid_vnr(current);
}

其中asmlinkage限定词是编译指令,通知编译器仅从栈中提取该函数的参数,所有系统调用都用这个限定词。

系统调用号

每个系统调用有一个系统调用号,一旦分配就不能再改变,如果系统调用被删除,占用的系统调用号也不能再分配,删除的系统调用后用sys_ni_syscall()占位。

完整的系统调用表定义在arch/x86/include/asm/unistd_64.h 文件。

#ifndef _ASM_X86_UNISTD_64_H
#define _ASM_X86_UNISTD_64_H

#ifndef __SYSCALL
#define __SYSCALL(a, b)
#endif

#define __NR_read                               0
__SYSCALL(__NR_read, sys_read)
#define __NR_write                              1
__SYSCALL(__NR_write, sys_write)
#define __NR_open                               2
__SYSCALL(__NR_open, sys_open)
#define __NR_close                              3
__SYSCALL(__NR_close, sys_close)
// ...
#define __NR_perf_event_open                    298
__SYSCALL(__NR_perf_event_open, sys_perf_event_open)
#define __NR_recvmmsg                           299
__SYSCALL(__NR_recvmmsg, sys_recvmmsg)
// ...
#endif /* _ASM_X86_UNISTD_64_H */

系统调用处理程序

中断号128(0x80),x86系统中用int $0x80sysenter指令触发异常,导致系统切换到内核态执行128号异常处理程序,也就是系统调用处理程序system_call()

x86系统上,程序在用户空间把系统调用号放入eax寄存器,系统调用号通过eax寄存器传递给内核,系统调用的返回值也存放在eax寄存器中传递给用户空间。

系统先检查系统调用号是否有效,如果>= NR_syscalls,则返回错误ENOSYS。如果系统调用号有效,接下来执行相应的系统调用:

call *sys_call_table(,%rax,8)

x86系统上,系统调用的其他参数存放在ebxecxedxesiedi 5个寄存器中。如果需要6个以上的参数,需要用一个独立的寄存器存放指向所有参数的用户空间地址的指针,这种情况很少见。

注意:这里描述的都是32位系统的实现,64位系统可能不同。

系统调用的实现

每个系统调用应该有一个明确的用途,Linux不提倡采用多用途的系统调用,ioctl()是一个反面例子。

系统调用的实现必须仔细检查所有的参数是否合法有效,例如文件描述符是否有效、PID是否有效、指针是否有效

内核在接收用户空间的指针之前,必须保证:

  • 指针指向的内存区域属于用户空间
  • 指针指向的区域在进程的地址空间里
  • 进程是否拥有该内存地址的读/写/执行权限

内核提供2个函数检查和拷贝数据,如果成功,函数返回0,否则返回未完成拷贝的字节数,系统调用返回EFAULT

  • copy_to_user(),向用户空间写入
  • copy_from_user(),从用户空间读取

系统调用上下文

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

进程上下文中,内核可以休眠,可以被抢占,必须保证系统调用是可重入的

绑定系统调用

  • 在系统调用表的末尾加入一个新的系统调用,位于arch/x86/kernel/syscall_table_32.S
  • 系统调用号必须定义在asm/unistd.h中。
  • 系统调用函数必须被编译进内核镜像,放在kernel目录下,不能被编译成模块,例如可以存放在kernel/sys.c文件中。
// syscall_table_32.S
NTRY(sys_call_table)
	.long sys_restart_syscall
	.long sys_exit
	.long ptregs_fork
	.long sys_read
	.long sys_write
	.long sys_open		/* 5 */
	.long sys_close
//...

// unistd_64.h
#ifndef _ASM_X86_UNISTD_64_H
#define _ASM_X86_UNISTD_64_H

#ifndef __SYSCALL
#define __SYSCALL(a, b)
#endif

#define __NR_read				0
__SYSCALL(__NR_read, sys_read)
#define __NR_write				1
__SYSCALL(__NR_write, sys_write)
#define __NR_open				2
__SYSCALL(__NR_open, sys_open)
#define __NR_close				3
__SYSCALL(__NR_close, sys_close)
#define __NR_stat				4
__SYSCALL(__NR_stat, sys_newstat)
#define __NR_fstat				5
__SYSCALL(__NR_fstat, sys_newfstat)
#define __NR_lstat				6
__SYSCALL(__NR_lstat, sys_newlstat)
//...

// kernel/sys.c
YSCALL_DEFINE3(setpriority, int, which, int, who, int, niceval){/*...*/}
//...

从用户空间访问系统调用

通过宏_syscalln(),也就是_syscall0~_syscall6(),这些宏都支持2+2n个参数,第一个参数是返回类型,第二个参数是系统调用名称,后面每两个参数组成一对,表示参数的类型和名称。例如:

#define NR_open	5
_syscall3(long, open, const char*, filename, int, flags, int, mode)  // 不通过库,直接调用系统调用