Linux系统调用

系统调用基本概念

为了和用户空间上运行的进程进行交互,内核提供了一组接口,透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口称为系统调用。
系统调用是用户空间和硬件设备之间添加的一个中间层,主要作用:
1)为用户空间提供一种硬件的抽象接口。
2)保证系统的稳定和安全。
3)每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是基于中间层的考虑。

Linux系统调用非常少,x86系统上大概250个系统调用(具体取决于特定体系结构)。

[======]

API、POSIX和C库

一般,应用程序通过应用程序编程接口(API)而非直接通过系统调用来编程。这样,应用程序使用的编程接口,并不需要跟内核提供的系统调用对应。一个API定义了一组应用程序使用的编程接口。

Unix中,最流行的应用编程接口是POSIX标准,而POSIX是由IEEE的一组标准组成,目的是提供一套大体上基于Unix 的可移植操作系统标准。
Linux与POSIX兼容;Windows NT提供了POSIX兼容库。

[======]

系统调用

系统调用在Linux中常称作syscalls,通常通过函数进行调用。
参数根据定义决定;返回long类型表示成功或错误,通常,0表示成功,非0表示错误。出错时,错误码写入errno全局变量,可通过perror库函数将其翻译为用户可理解字符串。

例如,getpid()系统调用,返回当前进程PID。内核实现:

asmlinkage long sys_getpid()
{
    return current->tgid;
}

注意:asmlinkage表示使用0个寄存器传递参数,也就是不用寄存器传参,常用于系统调用,确保是用栈传参。

#define asmlinkage __attribute__((regparm(0)))

系统调用号

Linux中,每个系统调用都被赋予一个独一无二、固定不变的系统调用号,用来关联系统调用。用户空间的进程执行一个系统调用时,该系统调用号酒杯用来指明到底要执行哪个系统调用;进程不会提及系统调用名称。

sys_ni_syscall() 代表未实现的系统调用,除了返回-ENOSYS外不做任何工作,该错误号专门针对无效的系统调用而设。如果一个系统调用被删除,或不可用,该函数就要负责“填补空位”。

可通过查看entry.s文件中的sys_call_table,查看系统调用表中所有已注册过的系统调用的列表。

系统调用的性能

Linux系统调用比其他许多操作系统执行得要快:上下文切换时间;进程内核优化得简洁、高效;系统调用处理程序和系统调用本身简洁。

[======]

系统调用处理程序

用户空间无法直接执行内核代码,不能直接调用内核空间中的系统调用,需要靠软中断实现:通过引发一个异常来促使系统切换到内核态去执行异常处理程序。这个异常处理程序实际上就是系统调用处理程序。
x86上,软中断(这里是软件中断)由int $0x80指令产生。该指令会触发一个异常导致系统切换到内核态,并执行第128号异常处理程序,而该程序正是相当于处理程序,名为system_call() 。该函数与硬件体系结构紧密相关,通常在entry.S文件中用汇编编写。

指定恰当的系统调用

所有系统调用陷入内核的方式都一样,必须把系统调用号一并传给内核。
x86上,系统调用号通过eax寄存器传递给内核。eax是x86汇编中通用寄存器的名称,32bit。EAX是累加器,是很多加法乘法指令的缺省寄存器。

system_call()检查给定系统调用号的有效性:如果系统调用号 >= NR_syscalls,函数返回-ENOSYS;否则,执行相应系统调用。

call *sys_call_table(, %eax, 4);

参数传递

除了系统调用号,大部分系统调用都还需要一些外部的参数输入。发生异常时,应该把这些参数从用户空间传递给内核。
最简单办法就是像传递系统调用号一样,把这些参数也存放在寄存器。如x86上,ebx, ecx, edx, esi, edi按顺序存放前五个参数;超过6个参数时,可以用一个单独寄存器存放指向这些参数的用户空间地址的指针。

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

[======]

系统调用的实现

一个系统调用实现时,不需要太关心它和系统调用处理程序之间的关系。
给Linux内核添加一个系统调用很容易,难点在于设计和实现。

编写系统调用原则

1)决定用途
它要做什么?每个系统调用都应有一个明确用途,不推荐多用途的系统调用。ioctl()是反例。

2)确定新系统调用的参数、返回值、错误码
接口应该力求简洁,参数尽可能少。

3)设计接口时,尽量为将来多做考虑
是不是对函数做了不必要限制?系统调用设计得越通用越好,有一定可移植性。

4)不对系统调用做错误的假设,否则将来可能会崩溃
别对字节长度和字节序做假设。提供机制而不是策略。

参数验证

系统调用必须仔细检查所有参数是否合法,阻止不合法输入传递给内核。

  1. 用户指针有效性检查
    最重要的一种检查就是检查用户提供的指针是否有效。在接收一个用户空间的指针之前,内核必须保证:
  • 指针指向的内存区域属于用户空间。进程决不能哄骗内核去读内核空间的数据。
  • 指针指向的内存区域有在进程的地址空间里,进程决不能哄骗内存去读其他进程的数据。
  • 如果是读,读内存应该被标记为可读。如果是写,内存应该被标记为可写。进程决不能绕过内存访问限制。

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

  • copy_to_user() 向用户空间写入数据,需要3个参数:1)进程空间中的目的内存地址;2)内核空间中的源地址;3)需要拷贝的数据长度(bytes)。
  • copy_from_user() 从用户空间读取数据,和copy_to_user()相似,3个参数:1)内核空间中的目的地址;2)进程空间中的源地址;3)需要拷贝的数据长度。

如果执行成功,返回0;如果失败,2个函数返回的都是没能完成拷贝的数据的字节数。如果指针不合法,则系统调用返回标准-EFAULT (Bad address )。

示例:silly_copy() 既用了copy_from_user() ,也用了copy_to_user() 。

asmlinkage long sys_silly_copy(unsigned long* src, unsigned long* dst, unsigned long len)
{
    unsigned long buf;
    /* 如果内核字长与用户字长不匹配, 则失败 */
    if (len != sizeof(buf))
        return -EINVAL;

    /* 将用户地址空间中的src拷贝进buf */
    if (copy_from_user(&buf, src, len))
        return -EFAULT;
    /* 将buf拷贝进用户地址空间中的dst */
    if (copy_to_user(dst, &buf, len))
        return -EFAULT;
    /* 返回拷贝的数据量 */
    return len;
}

注意:copy_to_user()和copy_from_user()都可能引起阻塞,当包含用户数据的页被换出到硬盘上而非物理内存上的时候,这种情况就会发生。此时,进程就会休眠,直到缺页处理程序将该页从硬盘重新换回物理内存。

  1. 合法权限检查
    旧的Linux内核,可通过suser()来完成检查:检查用户是否是超级用户。
    新的Linux内核,可通过capable()检查是否有权能对指定的资源进行操作:如果返回非0值,调用者就有权进行操作;返回0,则无权操作。i.e. capable(CAP_SYS_NICE)可以检查调用者是否有权改变其他进程nice值。
    默认情况下,超级用户的进程有所有权利;非超级用户的进程没有任何权利。

例:下面系统调用展示了权能的使用

asmlinkage long sys_am_i_popular(void)
{
    /* 检查用户进程是否具有CAP_SYS_NICE 权能 */
    if (!capable(CAP_SYS_NICE))
        return -EPERM;
    /* 成功返回0 */
    return 0;
}

参见<linux/capability.h>,包含一份所有这些权能和其对应的权限的列表。

[======]

系统调用上下文

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

在进程上下文中,内核可以休眠(如系统调用阻塞或显式调用schedule())并且可以被抢占。

  • 能休眠,说明系统调用可以使用内核提供的绝大部分功能。
  • 能被抢占,说明当前进程同样可以被其他进程抢占,而新进程可能使用相同系统调用,所以必须小心,保证该系统调用是可重入的。

当系统调用返回时,控制权仍在system_call()中,该函数最终会负责切换到用户空间并让用户进程继续执行下去。

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

编写完一个系统调用后,怎么注册成一个正式的系统调用?
1)在系统调用表(sys_call_table)最后加入一个表项。
2)对于所支持的各种体系结构,系统调用号都必须定义于<asm/unistd.h>。
3)系统调用必须被编译进内核映像(不能被编译成模块),将其放入kernel/下一个相关文件即可。

例:虚构系统调用foo()来考察添加一个系统调用的步骤。
1)把sys_foo加入系统调用表
对大多数体系结构来说,sys_call_table表位于entry.s

ENTRY(sys_call_table)
    .long sys_restart_syscall /* 0 */
    .long sys_exit
    .long sys_fork
    .long sys_read
    .long sys_write
    .long sys_open            /* 5 */
    ...
    .long sys_mq_notify       /* 281 */
    .long sys_mq_getsetattr
    .long sys_foo             /* 283, 新添项 */

2)把系统调用号加入<asm/unistd.h>

/* 本文件包含系统调用号 */
#define _NR_restart_syscall 0
#define _NR_exit            1
#define _NR_fork            2
#define _NR_read            3
#define _NR_write           4
#define _NR_open            5
...
#define _NR_mq_notify       281
#define _NR_getsetattr      282
#define _NR_foo             283  /* 新添项 */

3)实现foo()系统调用,将该系统调用编译进内核映像。
我们将其实现放入kernel/sys.c,当然也可以放进功能紧密的代码中。如果功能与调度相关,也可以放进kernel/sched.c。

#include <asm/thread_info.h>

/* 返回每个进程的内核栈大小 */
asmlinkage long sys_foo(void)
{
    return THREAD_SIZE;
}

OK,现在,可以在用户控件调用foo()系统调用了。

从用户控件访问系统调用

通常,系统调用靠C库支持,用户程序包含标准头文件和C库链接,即可使用系统调用(或者调用库函数,通过库函数实际调用)。
glibc库不支持直接调用系统调用,需要通过Linux本身提供的一组宏(_syscalln(), n=0..6,代表参数个数),会设置好寄存器并调用陷入指令。
e.g. open()系统调用定义

long open(const char* filename, int flags, int mode);

不靠glibc库支持,直接调用该系统调用的宏形式:

#define _NR_open 5 /* open的系统调用号 */
_syscall3(long, open, const char* ) /* 3代表open系统调用需要3个参数 */

这样,应用程序就可以直接调用open()

编写一个宏来用前面编写的foo()系统调用,然后写出测试代码

#define _NR_foo 283
_syscall0(long, foo) /* 0代表foo不需要参数 */

int main()
{
    long stack_size;
    stack_size = foo();
    printf("The kernel stack size is %ld\n", stack_size);
    return 0;
}

TIPS:建立一个新系统调用很容易,但并不不推荐通过系统调用的方式实现。

[======]

小结

1)描述了系统调用到底是什么,跟库函数和API的关系。
2)考察如何实现系统调用,以及执行系统调用的连锁反应:陷入内核,传递系统调用号和参数,执行正确的系统调用函数,并把返回值带回用户空间。
3)讨论如何添加系统调用,从用户空间调用系统调用。

[======]

posted @ 2022-02-02 00:07  明明1109  阅读(988)  评论(0编辑  收藏  举报