这部分将讲解上层应用软件如何与操作系统交互,理解操作系统到底发生了什么事情,理解操作系统工作原理,为以后扩充操作系统、设计操作系统铺垫。
参考资料:
课程:哈工大操作系统(本部分对应 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)将字符串打出来。
网友评论