写在最前面:这一部分属于操作系统的第二部分,接着第一部分(操作系统的启动)介绍。

刚开始就提到,学习操作系统的步骤:从应用程序,穿透操作系统,实现硬件的功能。而这一节就是接收应用程序如何到达操作系统的。

应用程序要到达操作系统需要经过接口,而接口的介绍分为以下两个部分:接口的定义、接口的实现

操作系统接口

什么是接口

接口,顾名思义,就是连接两个东西的媒介。通过接口,实现这两个东西的信号转换,同时向上层屏蔽了底层的实现细节

什么是操作系统接口

操作系统接口可以套用接口的定义,只不过对于所连接的两个东西有了具体而明确的定义:上层用户(应用系统)和操作系统(软件系统)。

上层用户通过接口对操作系统进行操作以实现对应的功能。那么用户是怎么使用操作系统的呢

用户使用操作系统的三种情景

用户通过以下三种方式使用操作系统,或许还有许多更加复杂的场景,但是归根结底还是以下三种方式。

命令行

上图给出了用户使用命令行使用操作系统的示例:用户在命令行输入相应指令,而操作系统进行执行实现输出功能。

shell本身也是一段程序,并且是操作系统在完成初始化后调用执行的。其作用就是:建立一个死循环,接收指令并执行之。

图形界面

上图给出了用户使用图形界面使用操作系统的实例:一个输入文字并保存到txt文件的例子。

图形界面的使用要涉及到消息机制,即如上图所示,通过消息队列实现消息的获取,而后调用fopen函数实现写入。

什么是操作系统接口2

前面我们介绍说操作系统接口就是连接上层用户和操作系统的媒介,这是从整体功能上进行的定义。

通过上文介绍的三种情景,我们可以给出操作系统接口更加具体的定义:系统调用。

什么是系统调用?

答案很简单:就是一些定义好的具体的函数

上图给出了一些常见的系统调用,更多请参考

系统调用的实现

从一个直观想法出发

加入我们想要实现whoami的功能,一个比较直观的想法就是使用jmp跳转到指定位置执行指令,打印字符串即可。

但是这种操作是不可以的!

为什么不可以?

安全问题。如果一个用户态程序可以随意访问内核态区域,就可能会造成系统信息的泄露、破坏系统结构等。

如何保证不可以?

通过内核态和用户态来区分“门里”和“门外”

内核态和用户态

  • 内核态操作系统代码执行时的状态
  • 用户态应用程序代码执行时的状态

不论是内核态代码还是用户态代码都是载入内存中执行的,只是其存储的位置不同。我们将存储内核态代码的区域称为内核态区域,反之则为用户态区域

而位于用户态区域的代码不能进去内核态区域,即无法jmp到内核代码,也无法通过mov获取内核态区域数据

特权级与特权环

通过给定代码和区域的特权级别来判断是否可以执行对应指令。

先看下图:

  • CPL:当前特权级,用于表示当前执行指令的特权级
  • DPL:描述符特权级,用于表示目标区域的特权级

如何获取特权级?

  • CPL放置在CS寄存器中,使用最后两位二进制数来表示特权级。需要注意的是,这里的CS是指当前代码所处的CS.
  • DPL放置在GDT表中,通过段描述符获取目标区域的特权级(第2、3位二进制数表示)

当获取到CPL和DPL后,就可以比较其大小,当CPL>=DPL时可以执行当前代码。
通过特权级的方式即可实现操作系统核心代码的安全性:当GDT表初始化后,操作系统内核代码区域的DPL就被设置为0;而对于用户态代码,其CPL为3DPL<CPL,所以保证用户无法直接访问内核代码。

如何实现可以?

既然用户态代码无法访问内核态区域,那么我们该如何操作以实现此操作呢?

通过中断

操作系统给上层应用提供了int 0x80号中断,可以进入对应的中断处理程序,而这也是唯一的进入内核的方法

前提准备

为了保证用户态代码可以正常执行中断指令,提供以下规范:

  1. 中断处理程序的目标区域的DPL被设置为3,这样就可以保证用户态也可以正常访问。

  2. 中断信息存储在IDT表中,下图给出了IDT表的结构与宏代码:

    而int 0x80中断的描述符设置代码:set_gate(&idt[80],15,3,&system_call)
    提供查看此代码可知:其DPL为3,所存储的偏移地址为system_call函数,段描述符为0x0008

执行流程

  1. 用户使用0x80中断,通过查询IDT表获取中断代码。(CPL=DPL=3
  2. IDT表中存储段描述符段偏移符,即可确定CS:IP以定位中断函数位置。(因为CS为0x0008,所以此时CPL变为0即内核特权级)
  3. 根据CS:IP确定位置后即可执行system_call,进入内核,使用系统调用。

printf的完整故事

printf函数是一个常用的输出程序,但是其实现的流程绝对是一波三折,下面我们具体介绍。

库函数处理

第一个阶段会由库函数完成,主要实现以下功能:

  • 对格式化输出中的格式进行处理
  • 调用write系统调用在屏幕上输出

C函数库会将printf("hello world!");变成如下代码:

注:这个阶段只是系统调用前的准备工作,为真正的系统调用准备参数等、

系统调用

主要涉及到write函数的具体实现

将write宏展开为一段包含int 0x80的代码,具体如下:

:正常用户态代码是无法使用系统调用的,只有使用中断(0x80),利用宏展开的方式将C代码转换成了包含中断的代码。

中断执行

有了代码就要执行,不同的是这里要执行的是中断。

int 0x80的执行过程如下:

  1. 通过查找IDT表中的0x80表项,获取地址和特权级。
  2. 跳转到sys_call函数执行系统调用

上图给出了system_call函数的代码,这个函数一共做了下面几件事:

  1. DS、ES、FS寄存器中的值入栈以便于恢复,同时更新寄存器值为内核区域段选择子。(内核代码段0x08,内核数据段0x10
  2. 跳转到sts_call_table中的第4个系统调用即sys_write.至于为什么会选择第4个系统调用,因为在实现printf的库函数中定义了# define __NR_write=4,所以在宏展开的汇编代码中指定eax=4,从而当做参数传递到sys_call_table.
  3. 在执行sys_write这个内核函数之前,需要告知其参数,这里使用内嵌汇编进行,将参数信息先保存在ebx\ecx\edx寄存器中,而后入栈即可。
  4. 在上图代码中有一个细节:%fs=0x17,这个属于段选择符,其后三位为111,即可说明其CPL为3指向LDT表。(通过LDT表即可找到调用系统调用的用户态进程,进而完成数据交换以实现输出操作)

写在最后:这一节主要介绍了系统调用的原理与实现。

  • 系统调用就是接口,也是用户进入操作系统内核的唯一途径。
  • 系统调用的实现需要借助中断来执行。
  • 通过特权级来实现访问控制,借助CPL和DPL比较来实现。