系统调用的实现原理分析

1,527 阅读5分钟

概述

我们在上一章已经知道了操作系统是如何从磁盘中读入内存并且开始工作,这篇主要是为了研究操作系统提供了什么样的接口给上层的应用程序来使用

系统调用

操作系统提供的接口我们成为系统调用,变成的思想都是想通的。我们用java的接口封装了实现,只需要面向接口变成,操作系统也是一样的,unix体系有一个 POSIX规范,定义了系统调用的名称,不同操作系统自己实现 image.png

内核态和用户态

我们先来思考一个问题,操作系统和我们的应用程序都是放在内存当中,为什么我们需要调用操作系统提供的接口而不直接调用操作系统内部的方法呢? image.png 很简单,因为内核空间里面存储着很多重要信息,不能让用户直接访问,那么操作系统是如何阻止用户程序访问内核空间呢?这就需要依靠CPU的硬件来协助了。我们知道程序都是CPU通过CS+IP来定位的,CS就是程序段。用户程序处于的程序段就是用户段,操作系统处于的段就是内核段,所以CPU在执行指令的时候会判断当前请求想要执行的目标CS段犯规了,如果用户段的程序想要访问内核段,CPU就不会执行。

CPL和DPL

cs寄存器的最低两位用来表示当前指令处于什么状态,0是内核态,3是用户态

  1. CPL就是当前指令的等级,如果是用户代码的CPL就是3,如果是操作系统的代码就是0。
  2. DPL是跳转的目标指令的等级 所以CPU在执行指令的时候很简单,只需要判断 DPL>=CPL ,就可以保证用户态的程序不能访问内核的数据

int80中断

我们刚才知道了,用户态想访问内核态的数据是在cpu层面直接被拦住了,那么操作系统提供的系统调用也同样是在内核态啊。用户态的程序是如何调用内核态系统调用的呢?答案就是中断


中断是用户程序进入内核的 唯一方法。等等我们会看到int0x80的DPL是3,等于是操作系统开放了一个DPL3的接口让用户态的程序可以调用,而不会被操作系统给拦截。

  1. 用户程序中包含一段 int80指令的代码
  2. 操作系统中写中断处理,并且通过int指令传来的代码获得编号
  3. 操作系统根据编号执行相关代码

系统调用细节

知道了系统调用的原理之后,我们来看看细节实现。我们需要搞懂几个问题

  1. 所有用户程序都是通过int80中断来实现系统调用,那么有那么多的系统调用是如何知道调用哪一个的呢?
  2. 操作系统是如何处理int80中断的呢?

从write系统调用开始说起

image.png c语言函数库中定义了不同参数的宏命令,我们就来看看3个参数的宏命令 _syscall3

#define _syscall3(type,name,a type,a,btype,b,c type,c) \
type name(atype,a,btype b,ctype c)\
{
    long __res;\
    __asm__ valatile("int 0x80":"=a"(__res):""(__NR_##name)),"b"((long)(a)),"c"((long)(b))....
}

这个宏其实就是会根据传入的参数拼接成一个新的函数来调用,我们用write函数看看syscall是如何展开的.注意函数库printf调用_syscall3传入的的是 _syscall3(int,write,int,fd,const char *buf,off_t,count)所以宏展开的函数如下

int write(int fd,const char* buf,off_t count){
    long _res;
    // 内嵌汇编语言 name=write所以 __NR_##name = __NR_write
    int 0x80
    mov __NR_write , %eax
    mov fd , %ebx
    .....
    
    执行结束后
    _res = %eax
    return _res;
}

我们看到在write调用宏展开之后,宏里面拼接了一个 _NR_write给 eax寄存器。这个 __NR_write我们称为系统调用号

linux/include/unistd.h
#define __NR_write 4
......

所以我们第一个问题就解决了 ,为什么同样调用INT80中断,操作系统知道我们要调用什么中断,是因为通过宏展开的时候,根据函数名拼接了一个系统调用号传递给内核,内核拿着系统调用号就可以有对应的处理

int 0x80的中断处理

知道了操作系统是如何识别不同的系统调用后,我们来看看int0x80中断到底干了些什么。

void sched_init(void){
    set_system_cate(0x80,&system_call)
}

显然,sys_system_cate用来设置0x80的中断处理

idt表

在操作系统启动的时候说过,操作系统有一个 GDT表用来处理CS的段地址映射。对于中断函数入口,操作系统也有一个IDT表来处理中断函数的映射。知道了idt的含义之后,我们再来看set_system_cate函数里面做了什么。 注意,这里已经是内核的程序了

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

#define _set_gate(gate_addr,type,dpl,addr) \
__asm__("movw %%dx,%%ax\n\t" "movw %0, %%dx\n\t"\ "movl %%eax,%1\n\t" "movl %%edx,%2": ......)

看上面的代码,我们发现在 _set_gate的时候,操作系统把system_call函数的 dpl设置成了3,所以当用户调用system_call的时候不会出现问题。然后函数进来了之后内核再把CPL设置为0,这样就可以实现内核之间的方法调用了。 这一段还有点模糊,等操作系统的书到了之后看完再更新

中断处理程序:system_call

上面我们知道了用户态通过int0x80中断访问了内核设置好的一个system_call函数,我们接下来看看这个函数是在干什么

linux/kernel/system_call.s中
# 指向了内核的数据段
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es
# 我们在上面知道 这个 %eax 就是 __NR_write的系统调用号4
call _sys_call_table(,%eax,4)

这个_sys_call_table就是一个函数表,通过系统调用号去那个表里面找到对应的系统调用

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

_sys_call_table里面存储的都是每个系统调用的指针,下标为4的正好就是sys_write的函数地址,至此,我们终于从printf调用到了系统调用的sys_write函数。sys_write的内部调用以后研究了

image.png