上次结束的时候, 我们已经能在这台机器上运行各种各样的程序了! 但是运行程序的方式还是有点怪怪的… 为了更清楚地讲解我们现在运行程序的过程, 我将称我目前正在使用的有物理实体的计算机为”Host”; 而称从CPU到运行时环境都是我自己用软件实现的计算机为”Client”.
要想在Client上运行一个程序, 我必须要:
- 在Host中编写程序: 目前我的Client并没有存储功能, 因此任何要运行的程序只能存储在Host中.
- 在Host中编译: 同时, 也是由于存储功能的缺失, 我们必须在Host中编译能在Client上运行的二进制文件.
- 在Host中运行Client, 同时将程序的二进制文件写入Client的内存: 目前, 我们的Client会从启动后自动从内存中读取指令依次执行, 而程序结束时Client也会结束. (这里详细地介绍了编译到运行的过程)
这里可以看出Client的本质: Client是一个程序, 但是是一个可以运行其他程序的程序!
相信聪明的同学们已经发现了非常不方便的点了: 我们每次开机时只能运行一个程序!
事实上, 早期的计算机就是这样工作的: 系统管理员给计算机加载一个特定的程序(其实是上古时期的打孔卡片), 计算机就会一直执行这个程序, 直到程序结束或者是管理员手动终止, 然后再由管理员来手动加载下一个程序.
怎么解决这个问题呢? 回想起冯诺依曼计算机的工作方式, 它的其中一个特点就是: 当计算机执行完一条指令的时候, 就自动执行下一条指令. 类似的, 我们能不能让管理员事先准备好一组程序, 让计算机执行完一个程序之后, 就自动执行下一个程序呢?
批处理系统就是能够将软件程序按批运行的方法. 在用户将需要处理的多个任务交给批处理系统后, 批处理系统就会运行在后台, 并将这些任务按顺序运行. 这样一个在后台运行的软件, 其实就是一个最简单的操作系统.
因此, 我们现在的任务就是从批处理系统开始, 逐渐搭建一个完善的操作系统.
来自操作系统的新要求
仔细思考, 我们就会发现, 上述两点功能中其实蕴含着一个新的需求: 程序之间的执行流切换. 我们知道函数调用一般是在一个程序内部发生的(动态链接库除外), 属于程序内部的执行流切换, 使用jal(跳转)指令即可实现. 而上述两点需求需要在操作系统和用户程序之间进行执行流的切换. 不过, 执行流切换的本质, 也只是把PC从一个值修改成另一个值而已(黑客眼中就是这么理解的). 那么, 我们能否也使用jal指令来实现程序之间的执行流切换呢?
也许在GM-NAA I/O诞生的那个年代, 大家说不定还真是这样做的: 操作系统就是一个库函数, 用户程序退出的时候, 调用一下这个特殊的库函数就可以了. 不过, 后来人们逐渐认识到, 操作系统和其它用户程序还是不太一样的: 一个用户程序出错了, 操作系统可以运行下一个用户程序; 但如果操作系统崩溃了, 整个计算机系统都将无法工作. 所以, 人们还是希望能把操作系统保护起来, 尽量保证它可以正确工作.
在这个需求面前, 用jal指令来进行操作系统和用户进程之间的切换就显得太随意了. 操作系统的本质也是一个程序, 也是由函数构成的, 但无论用户程序是无意还是有心, 我们都不希望它可以把执行流切换到操作系统中的任意函数. 我们所希望的, 是一种可以限制入口的执行流切换方式, 显然, 这种方式是无法通过程序代码来实现的.
事件响应机制
为了阻止程序将执行流切换到操作系统的任意位置, 除了事件处理机制, 硬件中还有许多其他保护机制相关的功能. 这里可以阅读更多相关信息.
有了强大的硬件保护机制, 用户程序将无法把执行流切换到操作系统的任意代码了. 但为了实现最简单的操作系统, 硬件还需要提供一种可以限制入口的执行流切换方式. 这种方式就是自陷指令, 程序执行自陷指令之后, 就会陷入到操作系统预先设置好的跳转目标. 这个跳转目标也称为事件入口地址.
同时, CPU在执行程序的过程中可能由于各种原因发生事件, 也需要通过某种统一入口交由操作系统处理. ISA手册中通常不区分自陷和CPU事件, 因此我们这里也将它们统称为”事件”.
当事件发生时:
- 我们需要存储: 事件发生的原因(用于处理事件), 事件发生的位置(用于在事件处理后恢复程序的运行), 以及事件发生时CPU的状态;
- 我们将CPU的PC(指示指令执行位置的某个寄存器)设定为某个约定好的事件处理程序的入口;
- CPU继续依次执行预先设置好的事件处理程序的指令;
- 在事件处理结束后, 将PC调回事件发生的位置, 并将CPU状态设置为事件发生前的状态, 使程序继续正常执行.
此后, 我们的机器就会继续正常执行程序啦! 在它看来, 这次事件甚至从来没有发生过(awww, 多么可爱的小家伙!)
This ends PA3.1