操作系统学习笔记2 | 操作系统接口

这部分将讲解上层应用软件如何与操作系统交互,理解操作系统到底发生了什么事情,理解操作系统工作原理,为以后扩充操作系统、设计操作系统铺垫。


参考资料:

0815这部分听的比较折磨,反复听了几次,终于基本理解了整个过程。


1. 接口

  • 生活中的接口有:电源插座、油门阀......
  • 总结一下,
    • 连接两个东西;
    • 进行信号转换、屏蔽细节;
    • 特点:上层使用接口非常方便,不必在意接口背后做了什么;而接口内部需要进行转化。

学习操作系统接口,不仅要关注如何调用接口,还要理解接口内部的工作原理。

2. 操作系统接口

正如生活中的接口,对于上层来讲,接口的存在是十分自然的,当我们有某项需求,才会使用响应接口

如使用电的需求,才会用到插座

我们如何使用操作系统呢?

-- 比如

  1. 我们终端键入一个命令

  2. 操作系统内部进行处理

  3. 屏幕上就显示出来相应内容

    也不一定都是命令

  • 操作系统接口大致有3种

    • 命令行、图形按钮、应用程序

      image.png

2.1 命令行

命令行是什么?即输入命令后发生了什么?

  • 命令就是一段程序

  • 举个例子,程序编译后变成可执行程序,就可以在命令行以命令的方式执行(如下图),这些程序中包含一些语句,就是对操作系统接口的调用

  • 操作系统启动到最后,打开一个桌面 / shell,打开桌面和shell是一回事

    现在我们常见的是打开桌面。而一些服务器启动后就是shell,没有桌面。

    shell也是一段程序,在main.c中一系列的初始化之后,会执行/bin/sh,这个文件可以自己写。

  • shell 程序的主体:

    int main(int argc,char *argv[]){
    	char cmd[20];
        while(1){
            scanf("%s",cmd);
            if(!fork()){exec(cmd);}
            else{wait();}
        }//while(1)
    }
    

    可见shell 是一段死循环,会用if(!fork()){exec(cmd);} 来执行用户输入的命令。

    其中fork和exec是真正的操作系统接口,这涉及进程管理(CPU管理)。

  • 现在回头看一下上图的过程:

    1. 系统启动到最后执行shell,如上面程序

    2. shell 调用scanf 打出cst:/home/lizhijun#

      正好20个字符

    3. 通过 fork()以及 wait() 申请CPU,让其执行左上角的代码

    4. 通过printf() 打出 ECHO:hello

      除了 fork() wait() 调用CPU 以外:

      • scanf也是真正的操作系统接口,可以实现从键盘读入信息,调用了键盘输入
      • 另外printf也是,可以调用显示器
    5. 可见命令行就是一些程序,通过一些函数实现对计算机硬件的使用。

2.2 图形按钮

图形按钮基于一套消息机制。

说明:

  • linux0.11只有命令行,而没有图形界面
  • linux 有图形界面是比较新的版本如ubuntu
  • Windows也有
  • 可以尝试在linux0.11上实现图形界面

image.png

如何实现?

  • 当鼠标点击、键盘按下后,通过中断,这一事件被放到消息队列中

  • 而应用程序需要写一个系统调用getmessage(),从操作系统内核中把消息队列中的消息取出

    而应用程序是一个不断从消息队列中取消息的循环,这就是消息机制

  • 根据拿出的消息执行对应的函数

  • 以上图程序为例,做了一件什么事情:

    1. 硬件输入

    2. 放入消息队列

    3. 应用程序的消息循环取出消息

      这里是应用程序调用了操作系统的接口

    4. 判断消息类型(右侧函数)

    5. 下方函数中,打开一个文件,写入字符串

      这里使用了调用磁盘的函数

应用程序接口先不讲。

2.3 总结

从上面可以知道,命令行和图形按钮都是一些程序,就是普通的C程序,只是在C的基础上使用了一些重要的函数,这些函数可以进入操作系统、使用硬件

可见,这些函数就是电源插座,就是操作系统的接口

  • 操作系统提供了这样的重要函数,这就是系统接口。
  • 接口表现为函数调用,又由系统提供,称为系统调用 System Call。

有哪些具体的系统接口呢?

  • printf,实际printf在库中调用了write,后者是真正的接口
  • fork,创建一个进程
  • 系统调用太多了,但应当知道哪里是系统调用,到哪里去查系统调用。
  • 系统调用接口需要有统一的规范,以适配不同的实现
    • POSIX:Portable Operating System Interface of Unix;
    • 这是一个手册,可以在这里查系统调用;也可以从这里得知设计一个操作系统应当提供的基本接口,这样在Linux上、Windows上跑的应用也可以在我的系统上跑。
    • 这就为上层应用程序跨操作系统提供了可能,因为调用接口一样。

3. 系统调用的实现

那么,上面提到的重要函数是如何实现的呢?

  • 从一个直观的例子开始--whoami()

    • whoami()是一个系统调用,进入操作系统拿到当前用户名并打印

      用户名这个字符串是在内核中,所以这个函数进入内核了。

3.1 为什么不能直接访问内核

这里解释一个事情:

  • 应用程序main()在内存中,操作系统whoami()也在内存中,为什么不能直接访问存放用户名这个字符串的内存呢?

    在我的例子中,这个字符串放在100这个地方。

    能不能直接找到用户名所在的内存,然后打印呢?例如汇编中的mov指令。

  • 不能!不能随意调用数据,不能在指令层面随意jmp;不能也不应该。

  • 如果上面的事情被允许,上层应用程序(可能来自于网络),就可以得知你的root用户名和密码,可以修改它。

  • 此外,任何一种输出数据到外设的系统调用,在某个时刻,这些数据会在操作系统内核的缓冲区中,这个时候就可能被泄漏,比如可以通过缓冲区或者显存看到word软件里面的内容;

  • 所以操作系统阻止直接访问的发生。

  • 这是怎么做到的呢?

3.2 如何实现内核态和用户态隔离

处理器的硬件设计做到的,从硬件层面保证了这个机制生效。

处理器硬件将内存访问权力(主要)分为了用户态和核心态。对应的实际区域即用户段和内核段。指令在两段之间不能随意跳转。

内核态和用户态隔离的具体实现:

几个名词概念:DPL、CPL、RPL,是基于硬件实现的。

  • 下图左下角

  • DPL ≥ CPL这一句(DPL ≥ RPL有兴趣自己查看)

    RPL说明的是进程对段访问的请求权限(Request Privilege Level)

    • DPL意思是目标内存段的特权级,destination privilege level也称 descriptor privilege level,之所以称为目标,是因为它描述程序将要跳往的地方的特权级;

    • CPL意思是当前内存段的特权级,current privilege level,CS:IP指向当前要执行的指令地址,当前程序处于内核态还是用户态(CPL),用CS:IP的最低两位来表示。

      • 特权级,特权级有一个数字,数字的含义可见下图处理器保护环;数字越小,越接近内核。
      • 特权级是在操作系统初始化时就设置好了的;DPL就在GDT表中,GDT表中第45、46位就是DPL。head.s 初始化时全为0。在系统最后启动用户应用程序时,跳转后cs中的CPL就置为3了。
      • 0是内核态,3是用户态。
      • 用户态不可以访问内核数据,内核态可以访问所有层次数据
    • 重点:CPL就是CS的最低两位,DPL可以从GDT表中查到,在保护模式下指令地址的翻译是查GDT表,那么这个时候就可以查到目标指令的DPL,和当前态的特权级CPL比较,如果DPL>=CPL,那就说明当前态的特权级足以执行目标指令,否则就不允许执行

    • 回到例子。所以例子中的main()程序CPL=3,而目标whoami()DPL为0,所以不能跳转,也即不能从用户态直接访问内核。

      参考资料:CPL\DPL\RPL

      更多可查:特权环、保护环。

      whoami在内核态加载,main在用户态加载,main调用whoami相当于用户态jmp到内核态

  • 疑问:1和2表示什么?

    答:操作系统——特权级

  • 当然,上面的举例基于linux0.11。操作系统现在基本不依靠段来进行权限检查 以页保护为主进行权限检查

image.png

3.3 系统调用如何实现跨越特权级访问

前面提到过了不能直接访问内核,不应该直接访问内核,计算机是如何做到这种隔离的,下面就来看看在这种隔离下,系统调用如何实现跨越特权级的访问。

同样,也是硬件提供了 "主动进入内核的方法":

对于 Intel ×86 来说,进入内核的 唯一方法中断指令int,其他如jmp和mov都不行。

特意设计了一些特殊中断,可以进入内核。

还是以whoami()为例:

main(){
    whoami();
}
//用户程序,CPL为3,运行到whoami()时检测到DPL为0
----------------------
whoami(){
    printf(100,8);
}
//系统程序
----------------------
100:"lizhijun"
//存放用户名的内存和字符串

系统调用的核心:

  • 用户程序(上图中的main程序)中包含了一段包含 int 指令的代码

    表面上是open()函数,展开后是由包含int指令的C语言库函数做的。

    进入内核。

  • 操作系统写中断处理,获取想调程序的编号

  • 操作系统根据编号执行相应代码

问:为什么不能在普通代码里直接使用这个特殊中断进入内核?

答:不使用封装的库函数,直接写int中断编译不通过(可能是编译器的设计)。

以C代码库编写的系统调用,在用户程序调用后,会首先进入C代码库函数,然后用汇编代码在约定的位置(栈或者寄存器)设置参数和系统调用编号,最后执行int指令

关于特殊中断,操作系统也规定好了:int 0x80 中断指令

具体见下图右侧代码。

image.png

所以举例whoami() 中的printf()很复杂,它的实现在软件层面跨越了三个层次:

  1. 应用程序,也就是我们常见的C语言,printf() 调用

  2. C函数库 中printf()执行具体代码,调用库函数write()

    所说的 write()见右侧代码第一个框。

    之所以这么做(中间隔了一层),是因为printf()格式化输出和write()的参数不很协调,所以加了一层。

  3. 在库函数write()中展开为一个包含0x80的中断代码,通过系统调用进入操作系统

    见右侧代码第二个框。

3.4 write 的完整理解

将关于write的故事完整的讲完,看看int 0x80 到底做了什么事情,以及是如何做到的。

对库函数write()来说,内嵌了一个宏:_systemcall3展开为包含int0x80的汇编代码。

宏展开:C语言中的宏展开 ,可以简单理解为文本替换,相比于C基础中的宏定义,这个宏能够替代一段程序。

这个宏做了什么事情?

  • 如上图代码:

    //linux/include/unistd.h
    #define _syscall3(type,name,atype,a,btype,b,ctype,c)
    //参数就对应上面3.3 最后图的int,write,int ,fd,const,char*buf,off_t,count
    type name(atype a, bytpe b,ctype c)
    {
    	long __res;
        __asm__ volatile("int 0x80":"=a"(__res):""(__NR_##name),"b"((long)(a)),"c"((long)(b)),"d"((long)(c)));
    	if(__res>=0)return (type)__res;
    	errno=-__res;
    	return -1l
    }
    

    这里给大家解释一下type,这被用来定义宏参数,也就说参数类型可以被替换,这样就使得宏函数的定义变得非常灵活,这算是linus早期编程时使用的一个trick

  • 这是一段C语言内嵌汇编代码

    • 内嵌汇编共四个部分:汇编语句模板:输出部分:输入部分:破坏描述部分;
    • 各部分使用":"格开,汇编语句模板必不可少,其他三部分可选;
    • 使用了后面的部分而前面部分为空,也需要用":"格开,相应部分内容为空
    • 进阶学习:内嵌汇编 - 阿加 - 博客园 (cnblogs.com)
  • 核心代码就是0x80,"=a"(__res)时将a置给eax,后面引号为空,默认还是将NR_name置给eax....后面以此类推,

  • 这段代码的意思就是,获取__NR_write,这是write的系统调用编号,将它放在%eax寄存器中方便后续系统使用。

    后面的b、c、d参数放在ebx、ecx、edx中,接着执行最前面的int 0x80

    __NR_write 是系统调用号,区分使用int 0x80 的函数,比如 open()write()。write对应的是4。

  • 执行int 0x80指令,这个中断执行后,执行"=a"(__res),会把 eax 寄存器置给res,最后return res,就在C语言层面返回了write 对应的系统调用号。

    这里划重点,这里只讲了 "执行 int 0x80指令",而没有讲如何执行,下一部分就会再详细说这个。

上面的_syscall3的3的意思就是:有三个参数。只要都是3个参数都可以使用这部分代码的套路。

初始化一个描述 int 0x80 中断的门描述符,并添加到IDT表,门描述符中的段选择符是0x0008,可以定位到GDT表的第二个表项,即内核代码段

3.5 int 0x80 执行理解

上部分大致讲了write库函数的展开与实现过程,其中int 0x80还没有细说,现在看看这个指令是如何工作的。

image.png

这部分老师讲的很多,如果基础不牢,会感觉很晕,先捋一下思路:

  • 3.3中题到系统调用的核心三步,进入系统的唯一方法就是 0x80,而库函数write()通过一个宏,内嵌了一段包含核心0x80的汇编代码

  • 然后需要再用一个寄存器 (eax) 保存是因为什么到达 0x80 这个入口的,方便操作系统来进行对应的操作。比方说这里是 write 使用了0x80.。

  • 寄存器会通过保存系统调用号的方式来做上面的记录,write对应的系统调用号就是4。

  • C程序中 int 0x80 这句话,int指令需要查idt表,取出中断处理函数来确定int 0x80到哪个地方执行。

    处理结束后再返回,这时0x80就已经完成,接着进行"=a"(__res)把 eax 赋给 res

    中断:计算机科学很伟大的发明,停下来跳到另外一个地方去执行。

  • 那么,int0x80 使用什么中断程序来处理呢?系统也帮我们做好了。

image.png

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

//linux/include/asm/system.h中
#define set_system_gate(n,addr)
//n为中断处理号,addr是中断处理号。
_set_gate(&idt[n],15,3,addr);
//idt是中断向量表基址,传向gate_addr,15传向type,3传向dpl
//到这里应当明白dpl的设置过程,在这里目标态被设为了用户态
#define _set_gate(gate_addr, type, dpl, addr)
//又是一段C内嵌汇编的代码
__asm__("movw %%dx,%%ax\n\\t""movw %0,%%dx\n\t"
       "mov1 %%eax,%1\n\t""mov1 %%edx %2":
       :"i"((short)(ox8000+(dpl<<13)+type<<8))),"o"(*((char*)(gate_addr))),"o"(*(4+(char*)(gate_addr))),
"d"((char*)(addr),"a"(0x00080000));
//意思就是将表中的高四位和第四位分别贴到edx和eax

从上面的init()函数可知,系统初始化时就已经做了:int 0x80 通过system_call 来进行处理.

  • 设置 set_system_gate 中断处理门来实现从 0x80 到 system_call 的连接。

    实际上每个表项都是中断处理门,set_system_gate 这个函数核心就是设置IDT表,遇到80中断,就从表中取出相应中断处理函数,跳转执行。

  • 上面C程序的功能对应就是填充下面的表格:

    • addr 填充 处理函数入口点偏移

    • 3 放到表中 DPL

    • 把上面程序"a"(0x00080000)中 的 0008 (16位)放到 段选择符

      即段选择符为8

    • 另外一点,PPT中的type域不对,应为01111


再来详细说说DPL=3的操作。

  • 回忆一下例子:

  • 在main中 CPL为3,而当whoami()展开、执行 int 0x80 时,需要查IDT表时来进行,IDT表中的 DPL 特意设置为3

    相当于特意为这次调用开了个后门

  • 这样 CPL = DPL,能够跳到80号中断.


如何跳到80号中断?

  • 上面的IDT表中已经有了偏移,还需要基址才能组成PC

  • cs=0x0008, ip=&system_call

    回忆一下,上一讲中提到的jmpi 0,8,两个8完全一样。

    这样我们以相似的方式,找到表项,再找到system_call 的地址,就实现了跳转。

  • 跳转之后,cs的最后两位,就==0,这就正是CPL=0;意味着特权级置为0.意味着最高权限什么事情都可以做了。

    这样,在内核中执行时,特权级就是0.

  • 中断再返回的时候,会再执行一条指令,cs 最后两位就又变成了3。

3.6 system_call 理解

上面讲到使用system_call 来处理 int 0x80,它是如何做的呢?

image.png

关键代码:

mov1 $0x10,%edx 
mov %dx,%ds
mov %dx,%es
## 内核数据
###ds=es=0x10
###8是内核代码段,16(十进制)是内核数据段
###意味着从现在开始真正执行内核代码
###这里有疑问,老师说,既是内核代码段也是数据段?

## 跳到一个表里取执行内核代码
call _sys_call_table(,%eax,4)#a(,%eax,4)=a+4*eax
### eax正是前面的__NR_write,系统调用号
### _sys_call_table+4*%eax就是响应的调用处理函数入口

为什么要乘4呢?

eax是4(表示write的系统调用号为4,为第四系统调用)

而前面的4是,每个系统调用占4个字节。

再具体一点说,每个系统调用函数指针是4个字节

  1. 一个内存地址对应8位就是一个内存地址存1个字节,所以*4就是找4个字节
  2. 中断号为4,那从中断向量表里找中断服务函数入口的时候就是4*4,
  3. 即从表的初始地址往下加16个地址,就正好是16个字节,每四个字节一个入口

可以理解/推测 sys_call_table,就是一个函数表

3.7 sys_call_table/sys_write理解

image.png

从上面代码得知,_sys_call_table果然是一个函数指针数组,第4个位置上放的是sys_write。

从0开始数。

根据上面的4*4,最终计算得到的入口是sys_write,所以3.6图中的call _sys_call_table(,%eax,4),实际上就是call sys_write

sys_write 要做什么呢?

  • 就要实现向显存写的功能
  • 至于sys_write的内部实现,要等到讲了文件读写、IO驱动后再来看

3.8 系统调用总结

如上图所示的链条:

  1. 用户调用 printf;

  2. printf 在库函数中展开为 包含 int 0x80 的代码;

  3. --------------用户态结束,内核态开始------------------

    这里用一种特殊的方式开启了后门(IDT表 将 DPL 置为3)

  4. system_call 中断处理;

  5. 查表 _sys_call_table;

  6. 根据__NR_write=4拿到对应函数;

  7. 调用 sys_write;

我们已经推进到了sys_write,也就是接口的边界,再向内部,才能解释sys_write 最后发生了什么。


回到最开始whoami的例子,参考上面write的过程:

image.png

  • eax=72,表示whoami系统调用编号为72;

  • 通过 int 0x80 指令进入中断处理函数_system_call

    这里经历了CPL和DPL的变化;

  • _sys_call_table找到第72项,是_sys_whoami(应该需要修改操作系统初始化的代码,在_sys_call_table中加入sys_whoami表项)

  • 最终执行的就是sys_whoami的函数体,现在就有权限访问内核段了

    在内核中,使用printk(100,8)将字符串打出来。

实验二可以做了。

posted @ 2022-08-15 19:49  climerecho  阅读(896)  评论(0编辑  收藏  举报