天星
读完需要
8分钟
速读仅需 3 分钟
linux 系统调用,是以应用程序编程接口(API)的形式,内核提供有一些列服务供程序访问、包括创建新进程、执行 I/O、以及进程间通信创建管道等。
一个最基本的 write 操作,是如何传递到内核呢?为什么说系统调用十分耗 CPU 资源?
1
系统调用的本质
系统调用的本质是一种异常,当调用一个系统调用时会触发 CPU 异常,CPU 进入异常处理流程。CPU 在异常处理流程中可以识别到本次异常是由于系统调用引起的,从而进入系统调用的异常处理流程中。
2
异常
异常是异常控制流的一种形式,任何打断当前正在执行程序的过程,都叫做异常。它一部分由硬件实现,一部分由操作系统实现。如图所示,为异常处理流程:
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接的过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(execption handler))。
当异常处理完成后,根据引起异常的事件类型,会发生以下 3 种情况的一种:
- 处理程序将控制返回给当前指令,即当时事件发生时正在执行的命令。
- 处理程序将控制返回给下一条指令,如果没有发生异常将会执行下一条指令。
- 处理程序终止被中断的程序。
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort):
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自 I/O 设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
系统调用,就发生在陷阱这个异常中,又叫陷入内核。陷阱是有意的异常,是执行一条指令的结果。陷阱最主要的作用是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
3
系统调用过程分析
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve)、终止当前程序(exit)。
为了允许对这些内核服务的访问,处理器提供了一条“syscall n”指令,当用户程序想要请求服务 n 时,可以执行这条命令。执行 syscall 指令会导致一个到异常处理的陷阱(trap),这个处理程序解析参数,并调用适当的内核程序。
整个异常处理流程如下:
下面以 Linux MIPS 为主,分析一下整个系统调用过程。
3.1
异常开始的地方
CPU 中所有异常入口点都位于固定区域,不需要高速缓存的入口点位于 kseg1,需要高速缓存的点位于 ksg0。如图为 MIPS 架构异常入口点(参考《MIPS 体系结构透视》):
3.2
异常判断
以 mips 平台为例。CPU0 的 Cause 寄存器中的 ExcCode(参考《MIPS 体系结构透视》),记录着各种异常 。
ExcCode 是一个 5 位编码,告诉你发生了哪些异常:
首先系统调用触发 CPU 异常时会陷入base+0x180(其他异常)地址进行处理。在进行具体异常处理前,要读取 Cause(ExcCode)寄存器判断何种异常。
ExcCdoe 寄存器用来找出发生异常的类型,决定调用哪个异常处理流程。
3.3
异常向量表
异常初始化是在内核中进行的。在 linux 内核中维护了一个异常向量处理函数表,这个表保存了所有异常处理入口点:异常向量表:
unsigned long exception_handlers[32];
异常初始化:
/* init/main.c */
start_kernel();
/* arch/mips/kernel/traps.c */
-> trap_init();
将各种异常的处理函数地址放入异常向量处理函数表中:
将处理异常的代码放入 base+0x180 地址处
异常处理代码(except_vec3_generic):
except_vec3_generic 函数是异常处理函数,这个函数是广义上的异常,即不区分是系统调用还是中断。
所有的异常处理都以这个函数为入口点。
在这个函数中会去读取 CPU0 的 CAUSE 寄存器的 ExcCode 域,判断到底是什么触发了异常。
然后根据 ExcCode 和之前配置的 except_handlers 异常向量处理函数表找到对应异常处函数进行处理。
3.4
syscall 调用
系统调用号
每个系统调用被赋予一个系统调用号。这样,通过独一无二的系统调用号,就可用关联系统调用。
当用户空间的进程执行一个系统调用,这个系统调用号就用来指明到底执行哪个系统调用,进程不会提及系统调用的名称。
系统调用号相当重要,一旦分配,就不能再有任何变更,否则编译好的应用程序就会崩溃。此外,如果一个系统调用被删除,它所占用的系统调用号也不允许被回收利用。
linux 系统有一个未实现的系统调用 illegal_syscall(),它除了返回-ENOSYS 外,不做其他工作,这个错误号是专用针对无效的系统调用而设置的。
linux 内核系统调用号:定义在 arch/mips/include/uapi/asm/unistd.h
glibc 系统调用号:/opt/mips-gcc540-glibc222-64bit/mips-linux-gnu/libc/usr/include/asm/unistd.h
glibc 系统调用号和 linux 内核系统调用号是一一对应关系。
系统调用号从 4000 开始。
系统调用号最多可以支持到 4999,即 1000 个系统调用。
系统调用表
内核记录了系统调用表中的所有已注册的系统调用,存储在 sys_table_call 中,每一种体系结构中(不同的平台),都明确定义了这个表。这个表为每一个有效的系统调用指定了唯一的系统编号。
如图为mips32位平台下(arch/mips/kernel/scall32-o32.S)sys_table_call。系统调用表是一张指向实现各种系统调用的内核函数的函数指针表,该表可以基于系统调用号进行索引,来定位函数地址,完成系统调用。
系统调用之 glibc
glibc 在应用程序和内核之间起了一个桥梁的作用。在应用程序中,我们操作 open、write、read、ioctl 等函数,其实是对系统调用的封装。glibc 将诸多系统调用进行封装,是我们可以以函数的形式,方便的调用系统调用:
glibc 如何传递到内核
应用程序如果想要调用内核的一个系统调用,只能通过上层应用和内核都认可的系统调用号来完成。
所以在 glibc 中,如果想要调用内核的一个系统调用,唯一的方法就是:将想要的系统调用号和需要传入的参数通过特定的方法传入到内核中。
使用 syscall 指令传递
syscall 是 MIPS(其他平台也有这条指令)的一条指令,该指令的作用就是产生一个“系统异常”,该指令执行完成后,系统会进入异常处理流程,并且 ExcCode 被置为 8,指明为系统调用:
在 MIPS Linux 中系统调用约定如下:
- v0:保存系统调用号。
- a0~a3:保存系统调用的前四个参数,多余四个使用栈传递。
glibc 处理系统调用流程
如图是 glibc 中对 MIPS Linux 中带一个参数的系统调用处理流程(internal_syscall1)
参数展开如下:
- input:NR_name,系统调用名
- arg1:携带的一个参数。
linux 内核系统异常处理
CPU 进行异常处理后,根据 ExcCode 的值判断是系统调用产生的异常,此时就会进入 handle_sys 进行处理:
- 判断系统调用号是否符合规范。
- 从系统调用表中取出系统调用的地址和支持的参数个数。
- 判断系统调用是否需要参数。
- 执行系统调用。
3.5
系统调用完整路径
4
总结
本文以 Linux MIPS 平台为主,详细分析了 linux 的系统调用过程。自上而下,从应用(app)到运行时(Runtime)再到内核、最后深入 CPU 寄存器分析整个系统调用处理流程。现在回过头,再看前文的两个问题,应该就明白了。
系统调用的本质是异常;系统调用俗称“嵌入内核”,那么应该了解了,为什么都不推荐轮询操作 IO 了吧(一次调用过程,流程非常复杂,频繁的调用 IO 操作,会使得 CPU 不停的进入异常处理流程,打断当前的操作,会大大的消耗 CPU 的资源)