第四周 扒开系统调用的“三层皮”
一、内核、用户态和中断
(一)如何区分用户态、内核态
1.一般现在的CPU有几种不同的指令执行级别
①在高级别的状态下,代码可以执行特权指令,访问任意的物理地址,这种CPU执行级别就对应着内核态,可以执行所有指令。
②在相应的低级别执行状态下,代码的掌控范围会受到限制,只能在对应级别允许的范围内活动。
为什么会有权限级别的划分?
答:当所有程序员写的代码都有特权指令时,系统很容易崩溃,没有访问权限划分容易使得系统混乱。
③Intel x86 CPU有四种不同的执行级别0—3,Linux只使用了其中的0级和3级来表示内核态和用户态。
2.如何区分用户态、内核态
CPU每条指令的读取都是通过cs:eip(代码段选择寄存器:偏移量寄存器)这两个寄存器,由硬件完成判断。
- 内核态时,cs与eip的值可以访问任意地址
- 用户态时,cs与eip只可以访问0x00000000—0xbfffffff的地址空间
P.S.这里的地址空间指逻辑地址而非物理地址
(二)中断处理
中断处理是用户态进入内核态主要的方式,系统调用只是一种特殊的中断
①硬件中断,中断服务进程
②用户态执行系统调用,进入内核态
注意:从用户态切换到内核态时
必须保存用户态的寄存器上下文
中断指令会把内核态相应的寄存器值放在当前CPU中
中断/int指令会在堆栈上保存一些寄存器的值(eg:用户态栈顶地址(ss:esp),标志寄存器(eflags),cs:eip(为了返回的时候popl弹出保存的返回地址)。同时,将相关联的中端服务历程的入口加载到cs:eip,把当前的堆栈段esp也加载到CPU里面)
系统调用需要int触发,int 80要模拟中断,由硬件来处理,80号中断即为系统调用
中断发生后第一件事就是保存现场,进入中断处理程序,保存需要用到的push到寄存器的值。
中断处理结束前最后一件事是恢复现场,就是退出中断程序,恢复用户态的保存寄存器的数据
iret对应着中断信号恢复指令。
二、系统调用概述
系统调用
- 是内核提供的最基本、最重要的服务设施
- 所有内核服务都通过系统调用的形式提供
(一)系统调用的意义
操作系统为用户态进程与硬件设备进行交互提供了一组接口——系统调用。
①把用户从底层的硬件编程中解放出来
②极大的提高了系统的安全
③使用户程序具有可移植性
(系统调用减少了系统与硬件之间的耦合,所以极大提高了系统可移植性)
(二)操作系统提供的API和系统调用的关系
①应用编程接口(API)和系统调用是不同的,使用API是为了让用户从底层硬件编程中解放出来。
- API只是一个被封装好的函数定义
- 系统调用通过软中断向内核发出一个明确的请求
②Libc库定义的一些API引用了封装例程(wrapper routine,唯一目的就是发布系统调用)使程序员在写代码时不用以汇编指令触发系统调用而是直接调用函数。
- 一般每个系统调用对应一个封装例程
- 库再用这些封装例程定义出给用户的API
③不是每个API都对应一个特定的系统调用
- API可能直接提供用户态的服务
- 例如一些数学函数没有用到系统调用
- API与系统调用不是单一的一对一的关系
④返回值
- 大部分封装例程返回一个整数,其值的含义依赖于相应的系统调用
- 返回值-1在多数情况下表示内核不能满足进程的请求
- Libc中定义的errno变量包含特定的出错码
(三)系统调用的三层皮
一层皮:API
二层皮:中断向量对应的中断服务程序
三层皮:系统调用对应的很多不同种类的服务程序
详细过程:
用户态进程中,xyz()函数是系统调用对应的API,该编程接口里封装了一个系统调用,会触发一个int 0x80的中断,产生向量为128的编程异常。该中断对应着内核态的内核代码入口起点system_call,执行SAVE_ALL,执行到中断服务程序sys_xyz()时,进入程序处理,该中断服务程序执行完后,会ret_from_sys_call,在ret中可能会发生进程调度,如果没发生就会iret,返回用户态,继续执行。
(四)传参
①内核实现了很多不同的系统调用,
②进程必须指明需要哪个系统调用,这需要传递一个名为系统调用号的参数,使用eax寄存器
③系统调用还需要参数
做函数调用时,可以采用参数压栈的方式来传递。
从用户态到内核态如何传递参数呢?
system_call是linux中所有系统调用的入口点,每个系统调用至少有一个参数,即由eax传递的系统调用号。具体过程如下:1.一个应用程序调用fork()封装例程,那么在执行int $0x80之前就把eax寄存器的值置为2(即__NR_fork)。
2.这个寄存器的设置是libc库中的封装例程进行的,因此用户一般不关心系统调用号
3.进入sys_call之后,立即将eax的值压入内核堆栈
超过6个,就将某一个寄存器作为指针,指向内存,进入内核态后可以访问所有地址空间,即通过内存传递数据
三、使用库函数API来获取系统当前时间
(一)使用库函数API获取当前系统时间
编译:gcc time.c -o time
结果:打印出的就是系统时间下的 年:月:日:时:分:秒
(二)C代码中嵌入汇编代码的写法
__asm__( 汇编语句模板: 输入部分: 输出部分: 破坏描述部分:);
这里,汇编代码相当于一个函数。
include <stdio.h>
int main()
{
/*想要实现的功能:val1+val2=val3*/
unsigned int val1 = 1;
unsigned int val2 = 2;
unsigned int val3 = 0;
printf("val1:%d,val2:%d,val3:%d
",val1,val2,val3);
asm volatile(
"movl $0,%%eax
" /*%%表示转义字符,把0赋给eax,即把%eax清零*/
"addl %1,%%eax
" /*%1指输出和输入的部分,用ecx寄存器存储val1的值*/
"addl %2,%%eax " /*把val1+val2放入eax*/
"movl %%eax,%0
" /*把val1+val2的值存储在eax里面*/
: "=m" (val3) /* =表示把val3的值写到内存变量里面*/
: "c" (val1),"d" (val2) /*用%ecx存储val1,用%edx存储val2*/
);
printf("val1:%d+val2:%d=val3:%d
",val1,val2,val3);
return 0;
}
内嵌汇编常用修饰符
嵌入式汇编中每一个输出或输入的前面都可以添加一个限定符
(三)用汇编方式触发系统调用获取系统当前时间
#include <stdio.h> #include <time.h> int main() { time_t tt;//int型数值 struct tm *t; asm volatile( "mov $0,%%ebx "//ebx清0 "mov $0xd,%%eax "//把0xd放入eax中,eax传递系统调用号13(13的16进制即为d) "int $0x80 "//中断 "mov %%eax,%0 "//返回值通过eax这个寄存器返回,eax——%0放入tt中 :"=m"(tt) ); t = localtime(&tt); printf("time:%d:%d:%d:%d:%d:%d: ",t->tm_year+1900,t->tm_mon,t->tm_mda,t->tm_hour,t->tm_min,t->tm_sec); return 0; }
这段代码让我们更清楚的知道用户态向内核态
- 传递了系统调用号
- 传递了参数null向ebx
四、实验——使用库函数API和C代码中嵌入汇编代码两种方式使用同一个系统调用
实验要求:
-
选择一个系统调用(13号系统调用time除外),系统调用列表参见http://codelab.shiyanlou.com/xref/linux-3.18.6/arch/x86/syscalls/syscall_32.tbl
-
参考视频中的方式使用库函数API和C代码中嵌入汇编代码两种方式使用同一个系统调用,
本次实验我选择了24号和47号系统调用,分别获取当前用户uid(用户ID)和gid(组ID)。
代码:
实验结果:
代码:
嵌入汇编代码版本中将原来两行通过API函数获取uid和gid的代码注释掉,用汇编代码替换。
首先将ebx寄存器清零,表示无参数传入。
然后分别将0x18和0x2f(十进制24和47)赋值给eax寄存器,表示需要调用的系统调用号,24为getuid,47为getgid。
执行int 0x80来执行系统调用。
之后eax寄存器保存了返回值,将它分别赋值给输出uid或gid变量。
完成整个汇编代码的系统调用。
实验结果:
五、总结——系统调用的工作机制
首先在高执行级别下,代码可以执行特权指令,访问任意的物理地址,这时CPU执行级别就对应着内核态。在相应的低级别执行状态下,代码的掌控范围会受到限制。只能在对应级别允许的范围内活动,即为用户态。Linux使用了其中的0级和3级分别来表示内核态和用户态。
用户态:只能访问0x00000000-0xbfffffff的地址空间
内核态:可以访问0xc0000000以上的地址空间
中断处理是从用户态进入内核态主要的方式。当用户态进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数,Linux通过执行int $0x80来执行系统调用的,这条汇编指令产生向量为128的编程异常。进程需要指明哪个系统调用,就需要eax寄存器传递一个系统调用号参数。
system_call是linux中所有系统调用的入口点,每个系统调用至少有一个参数,即由eax传递的系统调用号,进入sys_call之后,立即将eax的值压入内核堆栈。然后执行正确的系统调用函数并把返回值带回用户态。系统调用是用户访问内核的唯一手段,内核只管执行封装好的指令并输出结果,用户只需应用API得出相应的结果而无需理会内核执行了哪些指令。