进程、系统调用和进程调度
进程是操作系统最重要的概念之一,实际上,实现进程之前的部分都不能被称为操作系统。进程的切换和调度等内容是和保护模式的相关技术紧密相连的,而这也是本书作者要花大量篇幅先介绍保护模式的原因,但是,对于一个只学过微机原理和C语言的小白(比如我)来说,理解起来还是太难了。为此,我特意先学习了保护模式相关的知识,具体书籍和路径前面已经介绍过。
“对于进程的概念,只有在有了基于具体平台的感性认识之后,才有可能对形而上的理论有更踏实的理解”,对作者的这句话我深以为然。其实不单单是进程的概念,对于计算机领域的无数理论概念来说都一样,各种抽象的理论和概念没有落脚点,朦胧的理解就会很快被遗忘,而这个落脚点其实就是具体的实现,这和那句著名的“源码之下,了无秘密”的道理其实是一致的。“有了一种机型的经验,不但有利于在学习理论时形成形象思维,更有触类旁通的能力,面对任何类型的机器和操作系统都能够胸有成竹。”
进程介绍
作者的这段关于进程的讲解也十分符合他的理念,将进程涉及的代码数据和堆栈以及对进程的调度这些抽象的概念与具体的人做工作的行为对应起来,给抽象的概念以具体的例子支撑。看过很多对进程的讲解都是为了举例子而举例子,看完还是无法将进程理解清楚。
多进程的必要准备
多进程下,处于内存中的进程数总是大于CPU数的
graph LR
进程数大于CPU数-->进程有运行和挂起等不同状态
进程有运行和挂起等不同状态-->内核要对各个进程进行调度在不同状态间切换
内核要对各个进程进行调度在不同状态间切换 -->切换时需要保存和恢复进程运行状态
切换时需要保存和恢复进程运行状态--> 采用数据结构来保存进程状态
几个设计点明确
- 进程和进程调度(进程调度模块)是运行在不同的特权级上的,为了简单起见,让进程运行在ring1,进程调度模块运行在ring0;实际上linux里也只用了两个层级ring0和ring3
- 调度算法,也就是什么时候进行切换,按照什么样的规则进行调度。这里要展开就有很多复杂的算法了,这也是大多数操作系统书籍已经着重讨论的。本着简单原则,这里就通过时钟中断进行切换,需要注意的是,实际上并不是每次时钟中断都会进行切换,这里只是暂时让每次非重入的中断都切换一次进程。
- 本书的进程管理部分实际上是对Minix的简化,所以这里的简单实现可以加速我们理解Minix。
最简单的进程(包含涉及的关键技术)
进程调度过程
- 进程A运行
- 时钟中断发生,ring1->ring0,进入中断处理程序
- 调用进程调度模块,获取切换的目标进程B
- 切换到(恢复)进程B,ring0->ring1
- 进程B运行
关键技术
- 哪些进程状态需要保存:CPU寄存器内容
- 进程状态何时如何被保存:中断处理程序的最顶端,pushad一键保存所有寄存器
- 如何切换(恢复)到进程B的状态:popad后iretd
- 进程的状态:前面说过,任务的状态要用单独的数据结构保存。但是这里其实有两个结构和任务状态有关,一个是
TSS,一个是PCB。TSS是存在进程里的,里面保存的主要是各个寄存器的值,以及各个特权级栈的SS和ESP;PCB是独立于各个进程本身的,由内核进行管理,TCB可以说是进程的提纲,内容包含了对应进程TSS的选择子基地址等索引信息,也包括了其他一些代表进程的信息和状态。它和TSS的信息有重复也有不同,PCB的信息更全面一些比如还包含了进程的LDT信息,而TSS里则主要就是各个寄存器的具体值。总结来说,TSS是决定好切换到进程B后真正切换过去的时候用的,直接提取放入对应的寄存器即可,而PCB则是创建/销毁进程还有决定切换到哪个进程时要用到的进程状态和信息,这些信息可以不是直接的具体的,因为可以通过里面的索引间接获取,但是要够全面,包含了进程相关的所有信息。所以,PCB是最能代表进程的结构。 - 进程栈到内核栈的切换
写代码的时候要注意当前用的哪个堆栈,以免忘记切换而造成数据被破坏。
- ring1->ring0:从LDT找TSS的选择子,再从TSS中取得目标代码的ss和esp以及别的信息并切换
- ring0->ring1:第一次启动一个ring1用户程序时,假装经过调度然后用iretd实现转移
先进行ring0->ring1——启动第一个用户进程
准备工作有:进程表,进程体,GDT和TSS
- 进程表和GDT的创建和设置:这里因为LDT也是进程的一部分,所以方便起见也把LDT放在了进程表PCB里,同时还放了LDT的选择子。然后在GDT里放置进程的LDT描述符。
- 进程表和进程。进程表是进程的描述,储存了进程的各个寄存器的值,暂时为了简单起见,只放必须的,比如进程的入口地址,另外,由于堆栈是由内核设置的,不受程序本身控制,所以还需要指定esp。
- GDT和TSS。在GDT里创建进程对应TSS的描述符。
- 进程体:一个循环显示字符和数字的简单函数TestA()位于main.c中。之前对于内核是完成显示任务后hlt等待中断,现在应该是运行内核的主函数kernel_main(),并在里面调用进程启动等相关操作函数。用到的延时函数等功能函数统一放在lib/klib里
- 初始化进程表:在proc.h中定义好结构体,主要内容有各个寄存器的值、ldt选择子、进程pid、进程名称、LDT等。然后在global.c中声明一个进程表proc_table[NR_TASKS],为了多进程做准备,定义为数组,暂时数组元素NR_TASKS进程数为1. 然后在kernel_main()里初始化进程表;还有就是在GDT里建立LDT描述符,使用init_descriptor
- 准备GDT和TSS。先定义好TSS结构体,定义一个TSS,然后调用init_descriptor创建其描述符,最后通过TSS的选择子加载TR寄存器ltr
- 最后,进程表已经设置完毕,通过restart()函数,加载LDT,并将进程表(TSS)的的寄存器信息pop进相应寄存器后iretd转移到用户进程执行.
注意点
一定要记得以下几点:
- 添加新的头文件后makefile里更新、相应的源文件的包含
- 添加新的供别的文件中函数调用的函数后,要记得在头文件里声明
- 汇编中,调用C的函数引用C的全局变量,记得extern声明导入;要让C函数调用汇编的函数,记得global导出,对应的C文件里要声明汇编语言的函数
- 另外,其实前面并没有开启时钟中断,只是模仿了中断返回的过程,实现ring0->ring1的转移。而且即使打开了,也只会发生一次,因为需要在一次中断响应结束的时候向8259A写EOI。
进程启动过程总结
第二步,开始使用时钟中断——通过中断回到内核ring0
- 先打开时钟中断:init8259.c中out_byte写控制字(注意,这里虽然打开了,但是在当前中断处理程序中,中断是被关闭的,需要sti开启)
- 设置EOI,使时钟中断不停发生,然后简单地改变一下屏幕显示字符测试一下中断是否不停发生。
- 现场的保护和恢复:使用进程表是为了保存进程状态,以便恢复现场。所以在中断处理程序中,应当先保存push各个寄存器,这时的esp是指向进程表的(会自动修改esp为TSS中预设好的ring0下的esp值),所以寄存器的值都会保存在进程表中。中断程序的最后iretd之前,再pop恢复原来的寄存器值。
- 这背后伴随着堆栈的切换:ring0->ring1,堆栈切换在iretd时完成,目标代码段的cs\eip\ss\esp从堆栈中得到。ring1到ring0就需要用到TSS了,到目前为止,TSS对我们的用处只是保存ring0程序的堆栈信息即ss和esp。ss的设置是TSS初始化中完成的,而tss.esp0的设置则应当是在iretd返回之前完成,因为tss.esp0本来就是为了进入中断处理程序ring0时用的。后面如果有多个进程时,在进程B和进程C将要获得CPU之前,tss.esp0的值会被修改成进程表B或者C中相应的地址。
- 时钟中断处理程序中,esp是指向用户进程的进程表的,但是这时已经指向内核态ring0了,所以为了不在后面用到堆栈时影响进程表,需要将堆栈切换成内核栈。这样子中断例程就可以放手实现复杂的功能了。
- 中断重入:是否应该允许中断嵌套?显然应该允许,否则时钟中断(进程调度)的时候键盘就无响应了,但是嵌套也需要规范,比如上一次时钟中断还没处理完就来了下一次时钟中断,这样就会进入死循环,永远无法中断返回,直到爆栈。为了解决这个矛盾的问题,避免这种嵌套现象的发生,我们必须想办法让一个中断处理程序能够知道自己是否在嵌套执行,嵌套的情况就直接调到中断程序结尾直接返回,方法也简单,用全局变量就可以。
多进程
前面已经为多进程打好了基础,可以从内核跳转到第一个进程运行,运行的进程可以被中断,中断完成后还能返回到原来的进程恢复运行。
添加新的进程体
在main.c函数里加一个TestB()函数。
实现批量准备进程4要素的相关变量和宏
-
在初始化设置进程相关的进程表、GDT和TSS等内容时,之前是在kernel_main里直接写一个进程的初始化代码,然后restart()运行进程。但是这时候多进程的话总不能来一个进程就复制一份初始化代码,应该写成循环实现自动化。
-
初始化用到的多个进程的开始地址、堆栈等内容可以用一个结构体数组来表示,初始化的时候就能for循环每次读取一个数组元素,把里面的内容填入相应表项即可。
进程表初始化代码扩充(用for批量处理)
每次循环从TASK中读取不同任务的入口地址、堆栈栈顶和进程名(这里进程和任务等价)
两个注意点:
- 每个进程的堆栈的空间分配记得是高地址往低地址
- 每个进程都会在GDT里有一个其LDT的描述符,但是在protect.h里只有一个SELECTOR_LDT_FIRST,它用来表示GDT中用户程序LDT描述符的位置。
- 另外,这里的pid和进程名其实没有什么实际用处
批量生成进程的LDT
在init_prot()里用for循环批量init_descriptor
- 注意,进程切换的时候要重新加载ldtr
修改中断处理程序——添加进程切换代码
想要在中断处理程序中恢复不同的进程只需要将esp指向不同的进程表即可。
mov esp, [p_proc_ready]
就是设置切换的目标进程。p_proc_ready是指向进程表的指针,所以我们只需在这条语句前面通过进程调度算法,得到调度的目标进程,然后将p_proc_ready指向目标进程的进程表即可。
- 进程调度部分写在一个新的文件clock.c中,为clock_handler()函数,然后在中断处理程序中调用它。暂时就是简单的循环顺序调度。
添加任务步骤总结
- 在结构体数组task_table中增加一项,表示添加一个进程的主要信息(用来初始化proc_table用的)
- NR_TASKS加1(proc.h)
- 定义任务堆栈(proc.h)准确说是开辟任务堆栈,总的堆栈空间已经在global.c里定义了task_stack
- 修改STACK_SIZE_TOTAL(proc.h)
- 添加新的任务执行体的函数声明(proto.h)
一个值得注意的点
中断重入的处理。之所以允许中断嵌套,是为了在进程调度的时候允许其他的一些需要及时响应的中断比如键盘中断等,而一旦允许了中断嵌套,时钟中断也就有可能嵌套本身,这可能造成时钟中断的无限嵌套,为了解决这个问题,我们才使用全局变量来识别当前是否是重入的时钟中断,如果是则直接返回。而对于其他类型的中断则没有影响。
后面仿照minix的中断处理整理代码
总结来说,minx的中断处理和我们前面的实现总体是一致的,只不过minx更加模块化,代码的耦合性更低,并将restart()部分和中断处理程序后半部分这两个一致的部分合并成一个,让代码更简洁。
- 为什么要用jmp来从save返回,而不是直接ret?因为非重入的情况会切换堆栈到内核栈,而重入的情况不用,所以两种情况下堆栈都不一样了,我们知道ret是会自动pop的,要保证堆栈不变,因此用ret返回是不现实的。
最后,再将这个中断处理格式写成多行宏的形式,拓展到每一个中断通用的格式,并将其中原来的clock_handler也用函数指针数组来代替,根据中断向量号来调用不同的中断处理程序。至此,进程调度的框架已经搭好,甚至同时为整个中断系统都搭好了基本框架,这个框架解决了中断重入问题,可以保证中断处理程序部分才能被重入,而且不能被当前中断程序本身嵌套,只能被别的中断嵌套。
整理代码总结
- 整理后的代码虽然和整理前功能差不多,但是明显更有条理和层次,扩展性也更强,因为提供了一套方便扩展的中断处理接口(现在如果想要添加某个中断处理模块,只要将完成中断处理的函数入口地址赋给irq_table中相应的元素即可。而且相应的接口函数put_irq_handler也已经提供。
- 这一小结最核心的也最难的就是“中断处理程序围绕进程表项进行进程切换的过程”
目前为止整个系统运行过程:
系统调用
系统调用是应用程序和操作系统的桥梁,运行在低特权级的应用程序能够通过它来间接地安装操作系统的规范来使用其不能直接使用的指令或者内存区域。
这章主要就是在实现一个简单的系统调用的同时也搭建好创建系统调用的框架,这样后面自己添加新的系统调用的时候就很方便了。
实现一个简单的系统调用get_ticks
ticks全局变量代表发生时钟中断的次数,get_ticks就是获取当前发生时钟中断的次数。
我们的目标是实现进程能够随时通过系统调用来获取ticks值,既然是要能够随时,那自然就想到使用中断来实现。
- 总体的调用过程:
- 首先是在新的.asm文件syscall.asm里写系统调用在用户程序中的接口函数get_ticks,通过用户进程比如TestA()调用它get_ticks()
- get_ticks函数中,先将该系统调用对应的标识号_NR_get_ticks(=0)也就是索引号,类似于irq通过eax传递,然后通过int指令加系统调用的中断向量号引发系统调用软中断
- 在相应的系统调用中断处理程序中,也是类似于前面的硬件中断,通过eax里的索引号,调用系统调用函数指针数组里的相应系统调用函数sys_get_ticks(类似于clock_handler),所以其实,我们直接调用的那个系统调用函数只是个接口,它最终也还是要通过中断来间接调用真正的系统调用程序的。
- 发生中断的时候,处理程序从何得知调用的是哪个系统调用函数以及其参数呢?首先自然想到可以用堆栈,因为ring0是可以直接读取ring1的堆栈的,也无需切换堆栈。这是可以的,但是这里暂时无需传入参数,只要传入一个标号来标识调用的是哪个系统调用即可,所有系统调用只占用一个中断向量号,各个系统调用的函数指针组成一个函数指针数组sys_call_table[]类似irq_table。后面一旦要实现有很多参数的复杂系统调用,用寄存器来传递的话就不现实了,还是得用堆栈,这里暂且先这样。
- 当然,要能够通过int + 中断向量号来触发种中断处理程序也是需要在idt中初始化相应的中断门(描述符)才行
- 类似hwint_master宏来实现sys_call,先save保存现场,然后调用相应的函数sys_get_ticks(这才是真正的系统调用函数)。但是这里有个问题就是,原来的eax是没有用来传东西的,所以save里用了eax,这里把它换成esi就可以解决。这样save就可以直接用了,无需另外再写。
- 实际的系统调用函数sys_get_ticks()放在哪里? 由于ticks看上去是和进程相关的东西,所以这里就单独建一个文件proc.c来放它(主要是因为后面ticks在进程调度里有很多应用)
- 实现正确的调用系统调用后,就要实现sys_get_ticks的实际功能:也就是设置全局变量ticks,并在clock_handler里更新它,最后在sys_get_ticks里获取并返回它。
get_ticks的应用——更精准的延时函数
利用相对精准的时钟中断周期来实现更精准的延时函数
8253/8254 PIT可编程周期计数器/定时器
这部分其实算是复习了,因为之前微机原理学习过。8254就是比8253功能强一些。
8253有3个计数器(Counter)它们都是16位的。
计数器 | 作用 |
---|---|
Counter 0 | 输出到IRQ0, 以便每隔一段时间让系统产生一次时钟中断 |
Counter 1 | 通常被设为18,以便每15us做一次RAM刷新 |
Counter 2 | 连接PC喇叭 |
- 改计数初值,就是向相应端口写,端口是从40h到43h分别是从计数器0到2以及模式控制寄存器
- 模式控制寄存器,有计数器模式位,由控制一共6种模式,minix和linux就是用的不同模式;读/写/锁(Latch)位,锁存后读取,或者只读写高或低字节;计数器选择位。3\3\2一共8位组成一个控制字节。
- 8253有关宏定义在const.h里,利用out_byte函数向端口写控制字
- 原来默认中断频率为18.2Hz,通过设置,变成10ms周期,100Hz。就可以写一个精确到10ms的延时函数。
- 从运行结果可以看出,有很多!说明发生了很多次中断重入。由于现在进入内核态只有两种可能,一是发生了时钟中断,二就是调用了系统调用get_ticks。显然中断重入的情况只可能是调用get_ticks的时候发生了时钟中断。
- 通过计算,相邻两次打印A之间的间隔和理论标准误差时大时小,为10ms级别。产生误差的原因主要有:多进程运行,可能刚满足延时条件的时候CPU控制权可能恰好交给了其他进程,其他进程浪费了几个ticks;另一种是打印字符和数字耗费的时间。
- 现在可以只让一个进程运行,且去掉打印字符数字,看看是否准确。
- 和Linux以及Minix中延时函数的对比:Linux中的udelay是通过熟悉的计算循环次数和时间的关系来延时的,只不过它这个计算得相对比较精确;Minix中的milli_delay和我们的相似,也是通过读取8253的计数值来得到较为精确的延时,但是它只能工作在内核态。而我们的则是运行在用户态(但是也调用了系统调用,只不过这里多了一层间接性,精确度下降),而且足够简单,使用方便。
进程调度
这一小节主要是利用刚刚的ticks来让进程运行的时间不一,从而在某种意义上实现优先级的概念。但是这里的调度算法比较简单和操作系统理论书里的很多调度算法不一样。不过,本着简洁高效的原则,只要能够体现一定的优先级,并对不同优先级的进程实现不同的调度即可。
初始调度策略
- 先给进程表添加一个ticks项,和一个priority项,前者表示该进程每被调度一次就减一,后者则是ticks的初值。
- 调度就是选择当前ticks值最大的进程。一旦所有进程的ticks都已经为0,那就将它们的ticks重置为各自的初
- 这种调度策略:先是ticks值最大的进程一直被调度,然后直到和另一个进程的ticks相同,这两者交替被调度,然后以此类推,三个进程一起调度,最后三个进程的ticks都变成0,那就重新恢复起始值。这种虽然可以保证优先级高的进程被更多调度,但是无法直观地了解各个进程被调度的比例,要计算才能得到。
改进的调度策略
就是一直调度priority值最大的进程,直到其ticks为0,再调度下一个priority第二的进程。三个进程都结束后,再重置它的ticks为priority初值。这样就能够实现按照优先级,优先级值高的进程先被调度,而且执行每次调度执行时间也和优先级成正比。
可以看到,运行结果如图,打印出的字符的比例和优先级比例15:5:3很接近
总结
最后,作者提到,这个最后的调度策略其实是从linux中借鉴来的,“轻重缓急”四个字可以概况这个策略,优先级高的进程"更早地处理"也被处理“更多的时间”。
Minix中,进程被分成Task、Server和User(任务、服务和用户进程)三种,进程调度也为此设置了三个不同的优先级队列来分别调度。