• 分析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存在段描述符里)

    image.png

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

    image.png

    ​ 查找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() 都是用户空间和内核空间之间的桥梁

  • 相关阅读:
    矩阵十题(7)
    线段树成段更新 poj 3468 A Simple Problem with Integers
    线段树单点更新 hdu 2795 Billboard
    线段树成段更新 poj 2528 Mayor's posters
    矩阵十题(10)
    矩阵十题(8)
    矩阵十题(5)
    矩阵十题(6)
    矩阵十题(9)
    矩阵十题(4)
  • 原文地址:https://www.cnblogs.com/zsben991126/p/13168199.html
Copyright © 2020-2023  润新知