操作系统学习笔记2 | 操作系统接口
这部分将讲解上层应用软件如何与操作系统交互,理解操作系统到底发生了什么事情,理解操作系统工作原理,为以后扩充操作系统、设计操作系统铺垫。
参考资料:
- 课程:哈工大操作系统(本部分对应 L4 && L5)
- 实验:操作系统原理与实践_Linux - 蓝桥云课 (lanqiao.cn)
- 笔记:操作系统学习导引 · 语雀 (yuque.com)
0815这部分听的比较折磨,反复听了几次,终于基本理解了整个过程。
1. 接口
- 生活中的接口有:电源插座、油门阀......
- 总结一下,
- 连接两个东西;
- 进行信号转换、屏蔽细节;
- 特点:上层使用接口非常方便,不必在意接口背后做了什么;而接口内部需要进行转化。
学习操作系统接口,不仅要关注如何调用接口,还要理解接口内部的工作原理。
2. 操作系统接口
正如生活中的接口,对于上层来讲,接口的存在是十分自然的,当我们有某项需求,才会使用响应接口
如使用电的需求,才会用到插座
我们如何使用操作系统呢?
-- 比如
-
我们终端键入一个命令
-
操作系统内部进行处理
-
屏幕上就显示出来相应内容
也不一定都是命令
-
操作系统接口大致有3种
-
-
命令行、图形按钮、应用程序
-
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管理)。
-
现在回头看一下上图的过程:
-
系统启动到最后执行shell,如上面程序
-
shell 调用scanf 打出
cst:/home/lizhijun#
正好20个字符
-
通过
fork()
以及wait()
申请CPU,让其执行左上角的代码 -
通过
printf()
打出ECHO:hello
除了
fork()
和wait()
调用CPU 以外:scanf
也是真正的操作系统接口,可以实现从键盘读入信息,调用了键盘输入- 另外
printf
也是,可以调用显示器
-
可见命令行就是一些程序,通过一些函数实现对计算机硬件的使用。
-
2.2 图形按钮
图形按钮基于一套消息机制。
说明:
- linux0.11只有命令行,而没有图形界面
- linux 有图形界面是比较新的版本如ubuntu
- Windows也有
- 可以尝试在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()
为例:
main(){
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基础中的宏定义,这个宏能够替代一段程序。
这个宏做了什么事情?
-
如上图代码:
//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 使用什么中断程序来处理呢?系统也帮我们做好了。
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,它是如何做的呢?
关键代码:
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个字节
- 一个内存地址对应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)
将字符串打出来。
实验二可以做了。