美文网首页
操作系统学习笔记2 | 操作系统接口

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

作者: 优雅程序员阿鑫 | 来源:发表于2022-08-25 14:27 被阅读0次

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

    参考资料:

    课程:哈工大操作系统(本部分对应 L4 && L5)

    实验:操作系统原理与实践_Linux - 蓝桥云课 (lanqiao.cn)

    笔记:操作系统学习导引 · 语雀 (yuque.com)

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

    1. 接口

    生活中的接口有:电源插座、油门阀......

    总结一下,连接两个东西;进行信号转换、屏蔽细节;特点:上层使用接口非常方便,不必在意接口背后做了什么;而接口内部需要进行转化。

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

    2. 操作系统接口

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

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

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

    -- 比如

    我们终端键入一个命令

    操作系统内部进行处理

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

    也不一定都是命令

    操作系统接口大致有3种

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

    2.1 命令行

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

    命令就是一段程序

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

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

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

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

    shell 程序的主体:

    hide codeint 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管理)。

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

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

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

    正好20个字符

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

    通过printf() 打出 ECHO:hello

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

    scanf也是真正的操作系统接口,可以实现从键盘读入信息,调用了键盘输入

    另外printf也是,可以调用显示器

    可见命令行就是一些程序,通过一些函数实现对计算机硬件的使用。

    2.2 图形按钮

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

    说明:

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

    如何实现?

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

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

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

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

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

    硬件输入

    放入消息队列

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

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

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

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

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

    应用程序接口先不讲。

    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。操作系统现在基本不依靠段来进行权限检查 以页保护为主进行权限检查

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

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

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

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

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

    还是以whoami()为例:

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

    系统调用的核心:

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

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

    进入内核。

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

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

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

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

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

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

    具体见下图右侧代码。

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

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

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

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

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

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

    见右侧代码第二个框。

    3.4 write 的完整理解

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

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

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

    这个宏做了什么事情?

    如上图代码:

    hide code//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还没有细说,现在看看这个指令是如何工作的。

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

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

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

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

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

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

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

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

    hide codevoidsched_init(void){    set_system_gate(0x80,&system_call);}---------------------------------------------//linux/include/asm/system.h中#defineset_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,它是如何做的呢?

    关键代码:

    hide codemov1 $0x10,%edx mov %dx,%dsmov %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个字节

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

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

    3.7 sys_call_table/sys_write理解

    从上面代码得知,_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 系统调用总结

    如上图所示的链条:

    用户调用 printf;

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

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

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

    system_call 中断处理;

    查表 _sys_call_table;

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

    调用 sys_write;

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

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

    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)将字符串打出来。

    相关文章

      网友评论

          本文标题:操作系统学习笔记2 | 操作系统接口

          本文链接:https://www.haomeiwen.com/subject/ixkzgrtx.html