分析OS系统调用

Posted zsben991126

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了分析OS系统调用相关的知识,希望对你有一定的参考价值。

分析OS系统调用

一些基本概念

系统调用概念

系统库中为系统调用编写了许多接口函数(API),不同的API对应了不同的真正的(OS内核中)系统调用

从实模式到保护模式

x86系统在刚开机时处于实模式,即cs:ip的寻址方式为cs左移四位+ip,寻址能力只有20位,当bootsect.ssetup.s完成工作,将整个os模块读入内存中,并初始化完成后,就会进入保护模式,保护模式下,cs寄存器也称为段选择子,ip寄存器表示cs选择段中的偏移量,此时寻址能力有32位。因此,在内核加载完毕,切换到用户模式下时,会做一些初始化工作(如main.c里一大堆_init()函数),最后一步启动shell,用户在shell中可以进行系统调度

os的特权级

为了保护一些重要信息,防止被随意修改,os将内核程序和用户程序进行隔离,区分出内核态和用户态。同时用CS寄存器的最低两位来表示当前程序运行在哪一态(CPL:当前进程的权限级别)

CPL=0: 内核态
CPL=3:用户态

运行在某一态的程序要访问另一态的数据,而目标数据所在段特权级为DPL(规定了访问该段的权限级别),当DPL>=CPL时,可以直接访问目标数据,反之不能访问

简单的说,cs寄存器中存储了当前进程的特权级CPL,当前进程要访问某内存段时,对应的内存段会有一个DPL特权级,CPL>=DPL,才能访问该内存段

ps:其实还有RPL,是进程对段访问的请求权限,比如一个进程的CPL=0,但是它设段选择子的RPL=3,那么它就只能访问DPL=3的段了

中断指令int

用户程序发起调用内核代码的唯一方式是使用int指令,而系统调用为 int 0x80,用户程序进行系统调用的流程为

1.用户程序中包含一段包含int代码指令的代码:比如printf()最终展开后为包含int指令的代码
2.os写中断处理,获取想调程序的编号
3.os根据编号执行相应代码

系统调用的实现细节

用户程序触发int 0x80过程

我们以一个系统调用int close(int fd)的API为例

include/unistd.h中定义有一个宏_syscall1,用来展开只有一个参数的系统调用

#define __LIBRARY__
#include <unistd.h>
#define _syscall1(type,name,atype,a)

上面的宏定义中,type表示返回值类型,name表示函数名,atype表示第一个参数类型,a表示第一个参数名,因此int clode(int fd)可以被表示成下面这样

_syscall1(int, close, int, fd)

该宏展开后会变成下面这样

int close(int fd) 
{ 
    long __res;      
    __asm__ volatile ("int $0x80" //调用 int 0x80 中断
        : "=a" (__res) //从EAX里取出值,存入__res
        //将宏 __NR_close 存入 EAX,将参数 fd 存入 EBX              
        : "0" (__NR_close),"b" ((long)(fd))); 
    if (__res >= 0) //判断应该返回什么值
        return (int) __res; 
    errno = -__res; 
    return -1; 
}

上面代码的执行过程为

  1. 先将宏 __NR_close 存入 EAX,将参数 fd 存入 EBX
  2. 调用 int 0x80
  3. 调用返回后,从 EAX 取出返回值,存入 __res
  4. 通过对 __res 的判断决定传给 API 的调用者什么样的返回值

其中,__NR_close就是系统调用close的对应的编号,该宏在include/unistd.h中定义(__NR_name其实就是系统调用name的对应的编号)

通过中断进入内核

OS内核初始化时(init/main.c函数进行初始化),调用了一个sched_init()函数

void main(void)    
{            
//    ……
    time_init();
    sched_init(); //就是这个
    buffer_init(buffer_memory_end);
//    ……
}

sched_init()kernel/sched.c中定义。作用是将各种编号的中断和中断处理程序的入口地址进行绑定,而系统调用0x80中断则和system_call函数进行绑定,因此我们将system_call称为系统调用处理程序

void sched_init(void)
{
//    ……
    set_system_gate(0x80,&system_call);
}

现在我们已经得到了int 0x80的处理程序,可是仍有一个问题,用户程序无法直接访问内核态的程序!而set_system_gate在绑定好0x80system_call 时,还做了另一件事,那就是给用户程序一个进入内核的入口

set_system_gate,在include/asm/system.h中有定义

#define set_system_gate(n,addr)     _set_gate(&idt[n],15,3,addr)

这里n就是那个0x80,addr可对应为处理程序system_call的地址,n实际上是中断描述符表idt的偏移量

这里又涉及到另一个函数_set_gate(),其也是一个宏,展开后如下

#define _set_gate(gate_addr,type,dpl,addr) __asm__ ("movw %%dx,%%ax
	"     "movw %0,%%dx
	"     "movl %%eax,%1
	"     "movl %%edx,%2"     :     : "i" ((short) (0x8000+(dpl<<13)+(type<<8))),     "o" (*((char *) (gate_addr))),     "o" (*(4+(char *) (gate_addr))),     "d" ((char *) (addr)),"a" (0x00080000))

上面这一段(看不懂没关系..),它做的事情就是将DPL改为了3,我们知道内核态的DPL0,此处改为3是为了让DPL=CPL,所以用户程序就有了从用户态进入内核态的机会

处理中断

现在我们已经成功进入内核啦!下面要看的就是system_call怎么处理

system_call定义在 kernel/system_call.s

!……
! # 这是系统调用总数。如果增删了系统调用,必须做相应修改
nr_system_calls = 72
!……

.globl system_call
.align 2
system_call:

! # 检查系统调用编号是否在合法范围内
    cmpl $nr_system_calls-1,%eax
    ja bad_sys_call
    push %ds
    push %es
    push %fs
    pushl %edx
    pushl %ecx

! # push %ebx,%ecx,%edx,是传递给系统调用的参数
    pushl %ebx

! # 让ds, es指向GDT,内核地址空间
    movl $0x10,%edx
    mov %dx,%ds
    mov %dx,%es
    movl $0x17,%edx
! # 让fs指向LDT,用户地址空间
    mov %dx,%fs
    call sys_call_table(,%eax,4)
    pushl %eax
    movl current,%eax
    cmpl $0,state(%eax)
    jne reschedule
    cmpl $0,counter(%eax)
    je reschedule

值得注意的是,system_call.globl 修饰为其他函数可见

这里最核心的代码就是 call sys_call_table(,%eax,4):根据寻址方式根据寻址方式 展开成 call sys_call_table + 4 * %eax,其中eax是系统调用号,

sys_call_table 是一个指针数组,每个指针指向一个系统调用函数,其定义在 include/linux/sys.h

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,...

所以4*%eax的意思其实是eax对应的函数指针在表中的地址,因为一个指针占四位..

至此已经找到了内核中系统调用函数的那个指针,所以直接调用该指针对应的函数就可以啦!

用户态和内核态之间传递数据

至此我们已经了解了大部分过程,但是还有最后一个值得注意的地方,那就是用户态和内核态如何传递数据?我们知道用户态和内核态的寻址方式是不同的,并且指针参数传递的是应用程序所在地址空间的逻辑地址,在内核中如果直接访问这个地址,访问到的是内核空间中的数据,不会是用户空间的。所以这里还需要一点儿特殊工作,才能在内核中从用户空间得到数据

我们拿open(char *filename)来举例

int open(const char * filename, int flag, ...)
{  
//    ……
    __asm__("int $0x80"
            :"=a" (res)
            :"0" (__NR_open),"b" (filename),"c" (flag),
            "d" (va_arg(arg,int)));
//    ……
}

同上面close一样,eax里存了open的调用号,ebx里存了filename字符串指针,即指向了用户态的一串字符串,ecx存了flag...

现在的问题是,内核态要找ebx里的地址对应的字符串时,是在内核态的地址里找的,而ebx里的地址对应的是用户态的

所以要进行一些转换,我们再来看system_call函数里:

system_call: //所有的系统调用都从system_call开始
!    ……
    pushl %edx
    pushl %ecx        
    pushl %ebx                # push %ebx,%ecx,%edx,这是传递给系统调用的参数,将这些寄存器压栈保存(因为之后要修改)
    movl $0x10,%edx            # 让ds,es指向GDT,指向核心地址空间
    mov %dx,%ds
    mov %dx,%es
    movl $0x17,%edx            # 让fs指向的是LDT,指向用户地址空间
    mov %dx,%fs
    call sys_call_table(,%eax,4)    # 即call sys_open

可见,在call sys_open前,system对一些寄存器做了处理,在此之前,简单说一下什么是gdtldt

gdtldt

保护模式下,虽然段值仍然由原来的csds等寄存器表示,但此时它仅仅变成了一个索引,这个索引指向了一个数据结构的一个表项,表项中详细定义了段的起始地址、界限、属性等内容。这个数据结构就是全局描述符gdt,gdt中每个描述符都占64位/8字节(可以发现DPL存在段描述符里)

技术图片

此外,内存中有一个概念称为段(os内存管理中会提到),段的信息存在在段描述符中,ldtgdt都是段描述符表为了存放这些描述符,需要在内存中开辟出一段空间。在这段空间里,所有的描述符都是挨在一起,集中存放的,这就构成一个描述符表。描述符表的长度可变,最多可以包含8K个这样的描述符(为什么呢?因为段选择子是16位的,其中的13bit用来作index),其结构如下

技术图片

? 查找GDT在线性地址中的基地址,需要借助GDTR;而查找LDT相应基地址,需要的是GDT中的段描述符。访问LDT需要使用段选择符,为了减少访问LDT时候的段转换次数,LDT的段选择符,段基址,段限长都要放在LDTR寄存器之中。

然后我们重点来看dsfs寄存器

? 首先ds指向的是gdt(全局描述符)fs指向的是ldt,而ldt对应的就是用户态的地址空间,此时我们可以通过fs获取用户地址数据啦

总之,依靠fs可以获取用户态的数据,下面我们看sys_open,在fs/open.c文件中定义

int sys_open(const char * filename,int flag,int mode)  //filename这些参数从哪里来?
/*是否记得上面的pushl %edx,    pushl %ecx,    pushl %ebx?
  实际上一个C语言函数调用另一个C语言函数时,编译时就是将要
  传递的参数压入栈中(第一个参数最后压,…),然后call …,
  所以汇编程序调用C函数时,需要自己编写这些参数压栈的代码…*/
{
    ……
    if ((i=open_namei(filename,flag,mode,&inode))<0) {
        ……
    }
    ……
}

sys_open将最重要的功能交给open_namei(),事实上,open_namei()再交给dir_namei(),get_dir(),最后get_dir()中调用了一个get_fs_byte()

static struct m_inode * get_dir(const char * pathname)
{
    ……
    if ((c=get_fs_byte(pathname))==‘/‘) {
        ……
    }
    ……
}

显然,get_fs_byte(pathname)获得了一个用户空间的fs寄存器指向的字节数据,那么同理,也有put_fs_byte函数向fs指向的用户空间输出函数,这两个函数被定义在include/asm/segment.h

extern inline unsigned char get_fs_byte(const char * addr)
{
    unsigned register char _v;
    __asm__ ("movb %%fs:%1,%0":"=r" (_v):"m" (*addr));
    return _v;
}
extern inline void put_fs_byte(char val,char *addr)
{
    __asm__ ("movb %0,%%fs:%1"::"r" (val),"m" (*addr));
}

事实上,他俩以及所有 put_fs_xxx()get_fs_xxx() 都是用户空间和内核空间之间的桥梁

以上是关于分析OS系统调用的主要内容,如果未能解决你的问题,请参考以下文章

应用调试系统调用SWI

20135201李辰希《Linux内核分析》第三次 构造一个简单的Linux系统OS

C#程序员经常用到的10个实用代码片段 - 操作系统

linux内核分析第四周-使用库函数API和C代码中嵌入汇编代码两种方式使用同一个系统调用

Raw-OS源代码分析之消息系统-Queue_Buffer

以python代码解释fork系统调用