4系统调用的工作机制
安大大 + 原创作品转载请注明出处 + 《Linux操作系统分析》MOOC课程
用户态、内核态和中断处理过程
程序员通过库函数的方式和系统调用打交道,库函数把系统调用给封装起来了。
一般现代CPU都有几种不同的指令执行级别
♦ 在高执行级别下,代码可以执行特权指令,访问任意的物理地址,这种CPU执行级别就对应着内核态
♦ 而在相应的低级别执行状态下,代码的掌控范围会受到限制。只能在对应级别允许的范围内活动
♦ 举例:intel x86 CPU有四种不同的执行级别0-3,Linux只使用了其中的0级和3级分别来表示内核态和用户态
为什么有权限级别的划分
防止程序员非法访问系统或者是其它资源而使得系统崩溃
Linux中怎么区分用户态和内核态:
♦ cs寄存器的最低两位表明了当前代码的特权级
♦ CPU每条指令的读取都是通过cs:eip这两个寄存器:
其中cs是代码段选择寄存器,eip是偏移量寄存器。
♦ 上述判断由硬件完成
♦ 一般来说在Linux中,地址空间是一个显著的标志:
0xc0000000以上的地址空间只能在内核态下访问,0x00000000-0xbfffffff的地址空间在两种状态下都可以访问
注意:这里所说的地址空间是逻辑地址而不是物理地址
在内核态时,cs和eip可以是任意的地址
中断处理是从用户态进入内核态主要的方式
用户态进入内核态一般来说都是用中断来触发的,可能是硬件中断。也可能是用户态程序运行当中调用了系统调用进入了内核态(trap)。系统调用是一种特殊的中断。
♦ 寄存器上下文
– 从用户态切换到内核态时
• 必须保存用户态的寄存器上下文,同时内核态相应的值放到CPU中
• 要保存哪些?
• 保存在哪里?
♦ 中断/int指令会在堆栈上保存一些寄存器的值
– 如:用户态栈顶地址、当时的状态字、当时的cs:eip的值
中断发生后第一件事就是保存现场 SAVE_ALL
保护现场 就是进入中断程序,保存需要用到的寄存器的数据
恢复现场 就是退出中断程序,恢复保存寄存器的数据
中断处理结束前最后一件事是恢复现场 RESTORE_ALL
中断处理的完整过程
系统调用概述
以系统调用为例,看看中断服务具体是怎么执行的:
系统调用的意义
- 操作系统为用户态进程与硬件设备进行交互提供了一组接口——系统调用
- 把用户从底层的硬件编程中解放出来
- 极大的提高了系统的安全性
- 使用户程序具有可移植性
操作系统提供的API和系统调用的关系
应用编程接口(application program interface, API) 和系统调用是不同的
- API只是一个函数定义
- 系统调用通过软中断(trap)向内核发出一个明确的请求
Libc库定义的一些API引用了封装例程(wrapper routine,唯一目的就是发布系统调用) - 一般每个系统调用对应一个封装例程
-
库再用这些封装例程定义出给用户的API
不是每个API都对应一个特定的系统调用。 - API可能直接提供用户态的服务,如一些数学函数
- 一个单独的API可能调用几个系统调用
- 不同的API可能调用了同一个系统调用
返回值 - 大部分封装例程返回一个整数,其值的含义依赖于相应的系统调用
- -1在多数情况下表示内核不能满足进程的请求
- Libc中定义的errno变量(error number)包含特定的出错码
应用程序、封装例程、系统调用处理程序及系统调用服务例程之间的关系
左边是用户态User Mode,右边是内核态Kernel Mode,最左边api:xyz()封装了一个系统调用,这个系统调用会触发一个0x80的中断。0x80这个中断向量就对应着system_call这个内核代码的入口起点。这个内核代码里可能有SAVE_ALL,sys_xyz()中断服务程序,在中断服务程序执行完后,可能ret_from_sys_call,在return的过程中可能发生进程调度,这是一个进程调度的时机。如果没有发生系统调度,就会iret,再返回到用户态接着执行。
系统调用的三层皮:xyz(api)、system_call(中断向量)和sys_xyz
当用户态进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数。
- 在Linux中是通过执行int $0x80来执行系统调用的,这条汇编指令产生向量为128的编程异常
系统调用号讲xyz和sys_xyz关联起来了
传参:
内核实现了很多不同的系统调用,进程必须指明需要哪个系统调用,这需要传递一个名为系统调用号的参数
-使用eax寄存器
系统调用的参数传递方法
系统调用也需要输入输出参数,例如:
- 实际的值
- 用户态进程地址空间的变量的地址
- 甚至是包含指向用户态函数的指针的数据结构的地址
如果是函数调用的时候,它可以把函数压栈的方式来传递。而用户态到内核态的函数传递的方法:
system_call是linux中所有系统调用的入口点,每个系统调用至少有一个参数,即由eax传递的系统调用号
- 一个应用程序调用fork()封装例程,那么在执行int $0x80之前就把eax寄存器的值置为2(即__NR_fork)。
- 这个寄存器的设置是libc库中的封装例程进行的,因此用户一般不关心系统调用号
- 进入sys_call之后,立即将eax的值压入内核堆栈
•寄存器传递参数具有如下限制:
•1)每个参数的长度不能超过寄存器的长度,即32位
•2)在系统调用号(eax)之外,参数的个数不能超过6个(ebx,ecx,edx,esi,edi,ebp)
•超过6个怎么办? 如果超过六个,某一个寄存器作为一个指针指向一块内存,进入到内核态以后,它可以访问所有的内存空间,可以通过这块内存来传递数据。
使用库函数API来获取系统当前时间
简单的系统调用time,来获取当前系统的时间:
#include <stdio.h>
#include <time.h>
int main()
{
time_t tt;// tt只是int型的数值
struct tm *t;// tm为了输出的时候变成可读的
tt = time(NULL);//time系统调用,返回值赋给tt//使用了time这个库函数,api
t = localtime(&tt);//把tt改成t这种格式的,即tm格式
printf("time:%d:%d:%d:%d:%d:%d\n",t->tm_year+1900, t->tm_mon, t->tm_mday,t->tm_hour,t->tm_min,t->tm_sec);
return 0;
}
Paste_Image.png
使用库函数的方式比较简单,下边使用汇编的方式:
用汇编方式触发系统调用获取系统当前时间
#include <stdio.h>
#include <time.h>
int main()
{
time_t tt;
struct tm *t;
asm volatile(
"mov $0,%%ebx\n\t"//把ebx清零
"mov $0xd,%%eax\n\t"//把0xd(13)放到eax里,eax是用来传递系统调用号的
"int $0x80\n\t"
"mov %%eax,%0\n\t"//通过eax返回值
:"=m"(tt)
);
t = localtime(&tt);
printf("time:%d:%d:%d:%d:%d:%d\n",t->tm_year+1900, t->tm_mon, t->tm_mday,t->tm_hour,t->tm_min,t->tm_sec);
return 0;
}
可以看到执行的效果是完全一样的。用户态进程向内核态传递了一个系统调用号。在那段汇编代码里,先是给ebx传参数,然后给eax传系统调用号,int指令,系统调用执行完后返回结果eax,这就完成了系统调用。
系统调用号的定义在 /usr/include/asm/unistd.h 文件中