linux系统调用实现原理

1.POSIX API和系统调用

API(应用编程接口)与系统调用之不同在于,前者只是一个函数定义,说明了如何获得一个给定的服务,而后者是通过软中断向内核发出一个明确的请求

系统调用属于内核,而用户态的函数库不属于内核

2.为什么需要系统调用

linux内核中设置了一组用于实现系统功能的子程序,称为系统调用。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于内核态,而普通的函数调用由函数库或用户自己提供,运行于用户态。

一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作“保护模式”)。

为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求(或者让应用程序暂时搁置)。实际上提供这组接口主要是为了保证系统稳定可靠,避免应用程序肆意妄行,惹出大麻烦。

系统调用在用户空间进程和硬件设备之间添加了一个中间层。该层主要作用有三个:

1.它为用户空间提供了一种统一的硬件的抽象接口。比如当需要读些文件的时候,应用程序就可以不去管磁盘类型和介质,甚至不用去管文件所在的文件系统到底是哪种类型。

2.系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限和其他规则对需要进行的访问进行裁决。举例来说,这样可以避免应用程序不正确地使用硬件设备,窃取其他进程的资源,或做出其他什么危害系统的事情。。在Linux中,系统调用是用户空间访问内核的惟一手段;

3、每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是出于这种考虑。如果应用程序可以随意访问硬件而内核又对此一无所知的话,几乎就没法实现多任务和虚拟内存,当然也不可能实现良好的稳定性和安全性。在Linux中,系统调用是用户空间访问内核的惟一手段;除异常和中断外,它们是内核惟一的合法入口。

3.系统调用处理程序以及服务例程

当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核态函数。在80x86体系结构中,可以用两种不同的方式调用linux的系统调用。两种方式的最终结果都是跳转到所谓系统调用处理程序的汇编语言函数。

因为内核实现了很多不同的系统调用,因此进程必须传递一个名为系统调用号的编号来识别所需的系统调用,这个编号就是 sys_call_table 数组的下标,eax寄存器就用作此目的。

所有的系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。在内核中,正数或0表示系统调用成功结束,而负数表示一个出错条件。在后一种情况下,这个值就是存放在error变量中必须返回给应用程序的负出错码。

系统调用处理程序与其他异常处理程序的结构类似,执行下列操作:

  1. 在内核态栈保存大多数寄存器的内容

  2. 调用名为系统调用服务例程

  3. 退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换回到用户态

在这里插入图片描述

img

为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表(dispatchtable)

这个表存放在sys_call_table数组中,第n个表象包含系统调用号为n的服务调用例程的地址。从eax寄存器中传进来的。

4.进入和退出系统调用

通过两种不同的方式调用系统调用:

  • 执行int$0x80汇编语言指令,在Linux内核的老版本中,这是从用户态切换到内核态的唯一方式

  • 执行sysenter汇编语言指令。在Inter Pentium II微处理器芯片中引入了这条指令,现在Linux2.6内核支持这条指令。

通过两种不同的方式从系统调用退出,从而使CPU切换回到用户态:

  • 执行iret汇编语言指令

  • 执行sysexit汇编语言指令,它和sysenter指令同时在InterPentiumII微处理器中引入

4.1通过int$0x80指令发出系统调用

通过系统调用的传统方法是使用汇编语言·1指令int

向量128(十六进制0x80)对应于内核的入口点。在内核初始化期间调用的函数trap_init(),用下面的方式建立对应于向量128的中断描述符表表型

set_system_gate(0x80,&system_call);

该调用把下列值 存入这个门描述符的响应字段

SegmentSelecor:内核代码段__KERNEL_CS的段选择符

Offset:指向system_call()系统调用处理程序的指针

Type:置15表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断

1.trap_init() 函数中对 int 0x80 中断处理进行初始化,设置其中断处理过程入口为 system_call。

system_call 是一段由汇编语言编写的代码,我们看看关键部分,如下:

void __init trap_init(void)
{
    ...
    set_system_gate(SYSCALL_VECTOR, &system_call);
    ...
}

我们把上面的汇编改写成 C 代码如下

void system_call()
{
    ...
    // 变量 eax 代表 eax 寄存器的值
    syscall = sys_call_table[eax];
    eax = syscall();
    ...
}

sys_call_table 变量是一个数组,数组的每一个元素代表一个 系统调用 的入口,其定义如下(在文件 arch/i386/kernel/entry.S 中):

.data
ENTRY(sys_call_table)
    .long SYMBOL_NAME(sys_ni_syscall)
    .long SYMBOL_NAME(sys_exit)
    .long SYMBOL_NAME(sys_fork)
    .long SYMBOL_NAME(sys_read)
    .long SYMBOL_NAME(sys_write)
    .long SYMBOL_NAME(sys_open)
    .long SYMBOL_NAME(sys_close)
    ...

翻译成 C 代码如下:

用户调用 系统调用 时,通过向 eax 寄存器写入要调用的 系统调用 编号,这个编号就是 sys_call_table 数组的下标。 system_call 过程获取 eax 寄存器的值,然后通过 eax 寄存器的值找到要调用的 系统调用 入口,并且进行调用。调用完成后,系统调用 会把返回值保存到 eax 寄存器中。

原理如下图:

在这里插入图片描述

因此,当用户态进程发出int$0x80指令时,CPU切换到内核态并开始从地址system_call处开始执行指令

  1. system_call()函数首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已自动保存的eflags、cs、eip、ss和esp寄存器

在这里插入图片描述

  1. 随后,这个函数在ebx中存放当前进程的thread_info数据结构的地址,这是通过获得内核栈指针的值并把它取整到4KB或8KB的倍数而完成的
  2. 接下来,system_call()函数检查thread_info结构flag字段的TIF_SYSCALL_TRACE和TIF_SYSCALL_AUDIT标志之一是否被设置为1,也就是检查是否有某一调试程序正在跟踪执行程序对系统调用的调用。如果是这种情况,那么system_call()函数两次调用do_syscall_trace()函数;一次正好在这个系统调用服务例程执行之前,一次在其之后。这个函数停止current,并因此允许调试进程收集关于current的信息
  3. 然后,对用户态进程传递来的系统调用号进行**有效性检查。**如果这个号大于或等于系统调用分派表中的表项数,系统调用处理程序就终止

在这里插入图片描述

  1. 如果系统融调用号无效,该函数就把-ENOSYS值存放在栈中曾保存eax寄存器的单元中。然后跳到resume_userspace,当进程恢复它在用户态的执行时,会在eax中发现一个负的返回码。
  2. 最后,调用与eax中所包含的系统调用号对应的特定服务例程:call*sys_call_table(0,%eax,4)

4.2从系统调用中退出

当系统调用服务例程结束时,system_call()函数从eax获得它的返回值,比并把这个返回值存放在曾保存用户态eax存储器值的那个栈单元的位置上

在这里插入图片描述

因此用户态进程将在eax中找到系统调用的返回码。

​ 然后,system_call()函数关闭本地中断并检查当前进程的thread_info结构中的标志

在这里插入图片描述

​ 只要有任何一种标志被设置,那么就要在返回用户态之前完成一些工作。

5.参数传递

系统调用通常也需要输入/输出参数。

因为system_call()和sysenter_entry()函数是Linux中所有系统调用的公共入口

因此每个系统调用至少有一个参数,即通过eax寄存器传递来的系统调用号。

例如:

如果一个应用程序调用fork()封装例程,那么在执行int$0x80或sysenter汇编指令之前就把eax寄存器置为2.因此这个寄存器的设置是由libc库中的封装例程进行的,因此程序员通常并不用关心系统调用号。

普通C函数的参数传递是通过把参数值写入活动的程序栈(用户态栈或者内核态栈)实现的。

因为系统调用是一种横跨用户和内核两大陆地的特殊函数,所以既不能使用用户态栈也不能使用内核态栈。

更确切地说,在发出系统调用之前,系统调用的参数被写入CPU寄存器,然后在调用系统调用服务例程之前,内核再把存放在CPU中的参数拷贝到内核态堆栈中,这是因为系统调用服务例程是普通的C函数。

为什么内核不直接把参数从用户态的栈拷贝到内核态的栈呢?

首先,同时操作两个栈是比较复杂的,其次,寄存器的使用使得系统调用处理程序的结构与其他异常处理成的结构类似。

为了用寄存器传递参数,必须满足两个条件:

  1. 每个参数的长度不能超过寄存器的长度,即32位

  2. 参数的个数不能超过6个(除了eax中传递的系统调用号),因为80x86处理器的寄存器的数量是有限的

用于存放系统调用号和系统调用参数的寄存器是(以字母递增的顺序):eax(存放系统调用号)ebx、ecx、edx、esi、edi、ebp

在这里插入图片描述

6.验证参数

检查的类型既依赖于系统调用,也依赖于特定的参数

有一种检查对所有的系统调用都是通用的。只要一个**参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间之内。**有两种可能的方式来执行这种检查:

  1. 验证这个线性地址是否属于进程的地址空间,如果是,这个线性地址所在的线性区就具有正确的访问权限
  2. 仅仅验证这个线性地址是否小于PAGE_OFFSET(即没有落在留给内核的线性地址区间内。

早期的Linux内核执行第一种检查,但是这是非常费时的。

因此,从Linux2.2内核开始执行第二种检查,这是一种更高效的检查,因为不需要对进程的线性区描述符进行任何扫描。

​ 接着采用的方法是将真正的检查尽可能向后推迟,也就是说,推迟到分页单元将线性地址转换为物理地址时

究竟为什么要进行这种粗略检查。事实上,这种粗略的检查是至关重要的,它确保了进程地址空间和内核地址空间都不被非法访问。

如果不进行这种检查,用户态进程就有可能把内核态空间的地址作为参数来传递,然后还能对内存中现有的任何页进行读写而不引起缺页异常。

本质上等价于一下c函数

int access_ok(const void * addr, unsigned long size) {
    unsigned long a = (unsigned long) addr;
    if (a + size < a || a + size > current_thread_info()->addr_limit.seg) 
        // 第一个是判断是否溢出 addr_limit.seg 大部分情况下等价于 PAGE_OFFSET的值
        return 0;
    return 1;
}

6.1访问进程地址空间

系统调用服务例程需要非常频繁地读写进程地址空间的数据。Linux包含的一组宏使这种访问更加容易。两个名为get_user()和put_user()的宏。

第一个宏用来从一个地址读取1,2,或4个连续字节,而第二个宏用来把这几种大小的内容写入一个地址中。

在这里插入图片描述

在这里插入图片描述

6.2动态地址检查:修正代码

access_ok()宏对系统调用以参数传递来的线性地址的有效性只进行粗略检查。该检查只保证用户态进程不会试图侵扰内核地址空间。但是,由参数传递的线性地址依然可能不属于进程地址空间。在这种情况下,当内核试图使用任何这种错误地址时,将会发生缺页异常。

内核引起缺页异常的四种情况(这些情况必须由缺页异常处理程序来)

1.内核试图访问属于进程地址空间的页,但是,或者是相应的页框不存在,或者是内核试图去写一个只读页。在这些情况下,处理程序必须分配和初始化一个新的页框(请求调页和写时复制)

2.内核寻址到属于其地址空间的页,但是相应的页表项还没有被初始化。在这种情况下,内核必须在当前进程页表中适当地建立一些表项

3.某一内核函数包含编码错误,当这个函数运行时就引起异常;或者,可能由于瞬时的硬件错误引起异常。当这种情况发生时,处理程序必须执行一个内核漏洞。

4、系统调用服务例程试图读写一个内存区,而该内存区的地址是通过系统调用参数传递来的,但却不属于进程的地址空间。

通过确定错误的线性地址是否属于进程所拥有的线性地址区间,缺页处理程序可以很容易地识别第一种情况。通过检查相应的主内核页表是否包含一个映射该地址的非空项页可以检测第二种情况。

6.3缺页异常

决定缺页的来源关键在于内核使用有限的范围访问进程的地址空间,少数函数和宏用来访问进程的地址空间

**把访问进程地址空间的每条内核指令的地址放到一个叫异常表(exceptiontable)的结构中并不用费太多功夫。当在内核态发生缺页异常时,do_page_fault()处理程序检查异常表;如果表中包含产生异常的指令地址,那么这个错误就是由非法的系统调用参数引起的,否则,就是由某一更严重的bug引起的

每一个异常表的表项是一个exception_tabel_entry结构,它有两个字段:

insn:访问进程地址空间的指令的线性地址。

fixup:当存放在insn单元中的指令所触发的缺页异常发生时,fixup就是要调用的汇编语言代码的地址。

search_exception_tables()函数用来在所有异常表中查找一个指定地址

7.内核封装例程

尽管系统调用主要由用户态进程使用,但也可以被内核线程调用,内核线程不能使用库函数,为了简化相应封装例程的声明,Linux定义了7个从_syscall0到_syscall6的一组宏

每个宏名字中的数字0-6对应着系统调用所用的参数个数(系统调用号除外),也可以用这些宏来声明没有包含在libc标准库中的封装例程。然后,不能用这些宏来为超过6个参数的系统调用或产生非标准返回值的系统调用定义封装例程。

总结:

系统调用流程:

用户态发起调用:

1.保存需调用的参数进入寄存器

2.将系统调用名称转为系统调用号 存入eax寄存器

3.int0x80指令进入内核态

内核态:

1.保存参数到内核栈

2.从eax寄存器中,找到系统调用号,然后去系统调用表找到对应的系统调用例程

3.对参数进行检查

4.执行系统调用

5.iret指令从系统调用返回用户态

最后回到用户态:

从eax寄存器中拿到返回的结果,例如fork()执行完是返回pid还是-1。

img

参考:

趣谈linux操作系统

《深入理解linux内核》

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值