操作系统的接口与实现

  • Post author:
  • Post category:其他







前言


当操作系统运行到main程序中有这样一行代码

if(!fork()){init();}

这是创建一个子进程,对于windows来说就是启动桌面,对于linux来说就是打开shell。这一篇文章说说操作系统的接口以及实现,即上层应用是如何穿过接口进入操作系统的





一、接口




1.接口的定义

一般情况下我们有三种方式来操作计算机:

1.命令行:即通过命令程序,linux系统中常用此种方式

2.图形按钮:通过鼠标点击等操作实现对计算机的操控。windows系统在这方面做的就非常优秀。这种方式通过消息框架程序和消息处理程序实现

3.应用程序

不管采用何种方式,我们都需要让操作系统和应用程序之间建立联系。如何建立连接 ? 操作系统接口

接口其实是一种抽象,比如插排,它将内部的电路全部封装起来,只提供两个插口,用电设备插上就能用;不用管插座内部是如何实现的。

操作系统接口也具有连接两个东西、屏蔽细节、方便用户使用的特点。它连接上层应用软件和底层硬件,屏蔽细节,用户直接通过程序(应用软件)使用计算机,方便用户使用。

操作系统的接口其实就是一个个函数,知道它的功能然后直接调用就行,而不用管它内核里面是怎么实现的,因为这个函数是系统调用的,所以也称为系统调用

。比如:write()、read()等等



2.接口分类

操作系统接口的功能就是提供一个用户使用系统的界面。根据服务对象的不同,

操作系统的接口可以划分为两类:一是供用户使用的用户级接口,二是供程序使用的程序级接口。

在这里插入图片描述

操作系统中有专门响应用户控制要求的接口,负责系统与用户之间的双向信息传送


(1)用户接口


用户接口就是操作系统向用户提供的使用界面。分为脱机接口与交互式接口两种


(2) 程序接口

程序级接口是为程序访问系统资源而提供的,它由一组系统调用组成。系统调用(System Call)可以看作是由操作系统内核提供的一组广义指令。程序员在设计程序时,凡涉及到系统资源访问的操作,如文件读/写、数据输入/输出、网络传输等,都必须通过系统调用来实现。所以说,

系统调用是操作系统提供给应用程序的唯一接口。

从层次上来看,用户接口属于高层接口,是用户与操作系统之间的接口。而程序接口则是低级接口,是任何核外程序(包括应用程序和系统程序)与操作系统内核之间的接口。用户接口的功能最终是通过程序接口来实现的。



二、系统调用的实现



1.系统调用

计算机硬件系统并

不允许我们在内存中通过jmp等跳转指令直接调用操作系统内核提供的函数,因为这样有可能会导致敏感信息的泄露

为了确保系统的安全性,计算机硬件为我们设计了

内核态和用户态的模式,内核态可以访问用户态的信息,但用户态不能访问内核态。

在这里插入图片描述

用户程序的CPL(Current Privilege Level)初始化结果是3,而操作系统内核里函数的DPL(Descriptor Privilege Level)是0,所以用户对内核的访问权限不够。

CPL寄存器表示当前程序执行在什么态,0表示内核态,3表示用户态;

DPL寄存器表示即将访问的数据在什么段,同样0表示内核段,3表示用户段。


每次访问数据的时候检查两个寄存器的大小关系,若DPL≥CPL,则可以访问,反之,则不能访问

把内存分为了操作系统内核段和用户程序用户段,把在内核段执行的代码和数据称为处于内核态,把在用户段执行的代码和数据称为处于用户态,将内核程序和用户程序隔离。所以:

内核态:处于内核态可以访问用户段和内核段的数据

用户态:处于用户态只能访问用户段的数据而不能访问内核段的数据

为了实现系统调用,操作系统为我们提供了用户程序调用内核函数的唯一方法:

中断指令int

。当程序执行到int指令时,int指令会将CS中的CPL修改为0,当访问内核结束之后,又将CPL重置为3继续执行用户程序。

系统调用的核心:

(1)用户程序中包含一段包含int指令的代码

(2)操作系统中有中断函数表,从中可以获取中断服务函数入口地址

(3)操作系统执行中断服务函数



2.具体实现


以printf为例:printf(“%d”,a)

内核中printf()函数:

//printf()产生格式化信息输出到标准设备stdout(1),在屏幕上显示
// 参数fmt:指定输出将采用的格式
static int printf(const char *fmt, ...)
{
	va_list args;
	int i;

	va_start(args, fmt);
	write(1,printbuf,i=vsprintf(printbuf, fmt, args));
	va_end(args);
	return i;
}

在printf()内部其实是调用了系统函数write()

ssize_t write(int fd, const void *buf, size_t count);
//fd:要进行写操作的文件描述符
//buf:需要输出的缓冲区
//count:最大输出字节计数

printf()函数的形参和write()的形参是不一样的,因此如果printf(“%d”,a)能调用write函数的话,肯定要对printf的形参进行处理,使其符合write函数的格式,或者说换一种方式调用。在printf()函数里面调用write()如下所示:

在这里插入图片描述

_syscall3 是一个嵌入汇编宏定义

#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
	: "=a" (__res) \
	: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
	return (type) __res; \
errno=-__res; \
return -1; \
}


分析:


_syscall3这个宏调用之后就是展开成上面的一段汇编代码

比如write调用:

_syscall3(int, write, int, fd, const char* buf, off_t, count)

就是将宏展开的代码中的

type=int,name=write,atype=int,a=fd,btype=const char * ,b=buf,ctype=off_t,c=count;

用这些来替换,所以

type name(atype a, btype b, ctype c)

就变成了

int write(int fd,const char * buf, off_t count)

这样,展开的汇编代码一样跟着变。int 0x80这个中断,前面已经说过在head.s里面会重新建立idt表,之后中断就是表示根据中断号查那个表,然后获取中断服务函数的入口地址,

0x80这个中断就是进入操作系统内核,这是上层应用进入操作系统的唯一手段

,int 0x80相当于是操作系统的一个门户

接着看_syscall3宏定义下面的代码

long __res; \    //定义一个寄存器
__asm__ volatile ("int $0x80" \  //中断指令
	: "=a" (__res) \      //输出,最后将eax-->res中,作为函数输出
	: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \   //输入
	// "0":表示使用与上面同个位置的输出相同的寄存器 eax
	// 将__NR_##name赋值给eax
	//__NR_write称为系统调用号
	// 然后,把形参的a、b、c依次赋值给ebx、ecx、edx三个寄存器
if (__res>=0) \
	return (type) __res; \
errno=-__res; \
return -1; \

这是一段内嵌汇编,分为四部分

__asm__("汇编语句"
		:输出寄存器
		:输入寄存器
		:会被修改的寄存器
		)

详情见下面这篇文章:


添加链接描述

__NR_write称为系统调用号

在linux/inlcude/unistd.h中
# define __NR_write 4   

所有的系统调用都是通过int 0x80这个中断来调用的,

根据这个系统调用号来区分__NR_write表示write调用,会接着执行write对应的内核代码,__NR_read表示read调用,同理,其他的系统调用号也是如此

在内嵌汇编中,”a”这种被称作

限制字符

"a"               将输入变量放入eax
"b"               将输入变量放入ebx
"c"               将输入变量放入ecx
"d"               将输入变量放入edx
"s"               将输入变量放入esi
"d"               将输入变量放入edi
"q"               将输入变量放入eax,ebx,ecx,edx中的一个
"r"               将输入变量放入通用寄存器,也就是eax,ebx,ecx,edx,esi,edi中的一个

所以,把形参的a、b、c依次赋值给ebx、ecx、edx三个寄存器

输入完成之后就通过int 0x80这个中断号进入操作系统,int 0x80这条指令执行完之后,eax中就会存放int 0x80的返回值,然后将这个返回值赋值给__res,__res就是int write()这个系统调用的返回值。write这个系统调用也就结束了

小结:_syscall3宏定义
格式:#define _syscall3(type,name,atype,a,btype,b,ctype,c)

type 表示函数返回值,name表示函数名,后面分别是三个形参的类型和行参名。

name不同,系统调用号不同,所以调用_syscall3之后执行的代码不同,在宏里面通过

int 0x80进入系统内核并将指条指令的结果存在eax寄存器中,然后返回到宏的调用处



INT 0x80 指令

int 0x80是进入中断服务函数的一条指令,所以int 指令首先要查idt表转去哪里执行

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

int 0x80对应的中断处理程序就是system_call,从这个init就知道这是一个初始化,0x80这个中断就是用后面这个system_call来处理,那么系统是怎么设置的呢?通过set_system_gate这个宏

在linux/include/asm/system.h中
#define set_system_gate(n, addr) \
_set_gate(&idt[n],15,3,addr); //idt是中断向量表基址

然后在set_system_gate这个宏又调用了_set_gate这个宏,

#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" \
	: \
	: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
	"o" (*((char *) (gate_addr))), \
	"o" (*(4+(char *) (gate_addr))), \
	"d" ((char *) (addr)),"a" (0x00080000))

_set_gate这个宏的作用就是建立一个类似这样的下图表,处理函数入口点偏移=system_call,DPL就是3,段选择符就是0x0008,即CS是8

在这里插入图片描述

用户态的程序如果要进入内核,必须使用0x80号中断,那么就必须先要进入idt表。用户态的CPL=3,且idt表的DPL故意设置成3,因此能够跳到idt表,跳到idt表中之后就能找到之后程序跳转的地方,也就是中断服务函数的起始地址,CS就是段选择符(8),ip就是”处理函数入口点偏移“。

在setup.s中指令: jmpi 0,8

这条指令表示根据gdt表跳转到内核代码的地址0处。CS=8,ip=system_call就是跳到内核的system_call这个函数;另外如果CS=8,那么CPL=0,因为CPL是CS最低两位。也就是说当前程序的特权级变了,变成内核态的了。完整流程:初始化的时候0x80号中断的DPL设成3,让用户态的代码能跳进来,跳进来之后根据CS=8将CPL设为0,到了内核态,到了内核态就什么都能干了,将来int 0x80返回的之后,CS最后两位肯定变成3,变成用户态

中断处理函数system_call

在linux/kernel/system_call.s中

nr_system_calls=72
.globl _system_call
_system_call: cmpl $nr_system_calls-1,%eax
ja bad_sys_call
push %ds push %es push %fs
pushl %edx pushl %ecx pushl %ebx //调用的参数
movl $0x10,%edx mov %dx,%ds mov %dx,%es //内核数据
movl $0x17,%edx mov %dx,%fs //fs可以找到用户数据
call _sys_call_table(,%eax,4) //a(,%eax,4)=a+4*eax
pushl %eax //返回值压栈,留着ret_from_sys_call时用
... //其他代码
ret_from_sys_call: popl %eax, 其他pop, iret

前面都是压栈和赋值,接着调用了_sys_call_table(,%eax,4)。

a(,%eax,4)=a+4

eax,所以_sys_call_table(,%eax,4)=_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, sys_write,
...};

在include/linux/sched.h中
typedef int (fn_ptr*)();

sys_call_table是一个fn_ptr类型的全局函数表,fn_ptr是一个函数指针,4个字节,这就是_sys_call_table+4*%eax;这里为什么要*4的原因,sys_call_table的每一项都是4个字节,然后就可以根据eax来知道要调用的真正中断服务函数的入口地址了,对于write系统函数来说,就sys_write

系统调用进入内核态之后的过程:

在这里插入图片描述


printf ->_syscall3 ->write -> int 0x80 -> system_call -> sys_call_table -> sys_write


printf通用_syscall3这个宏调用write函数,在write函数里面用system_call来处理int 0x80,在system_call中会调用system_call_table这个表,根据eax中存储的系统调用号就可以找到真正的sys_write了。





总结


提示:这里对文章进行总结:

操作系统接口就是由C语言代码和由操作系统提供的一些重要函数组成。又因为这些函数调用时系统提供的,所以又叫系统调用(system_call)。我们通过系统调用就能在应用程序和操作系统之间建立连接



版权声明:本文为qq_53144843原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。