寒假不知不觉就到了尾声, 相信大家基本都开学了, 先祝大家开学快乐(doge). 在开学后我仍然会抽时间继续完成PA, 并且将在这些文章中继续从概念的角度继续介绍PA中涉及的概念. 可能更新速度会稍微变慢一些, 但是我想大概也没人care吧 希望大家谅解.
最简单的操作系统
上一次PA中, 我们完成了事件处理系统. 在我们的计算机中可能发生诸多事件, 例如错误, 异常等. 这些事件发生后, 我们的计算机会记忆事件发生时的状态(如通用寄存器(General Registers, GR)的内容等), 并从中分析出事件的类型以及事件处理所需要的参数.
Recall: 事件处理流程
在RiscV架构中, 有一套事件处理专用的系统寄存器(System Registers, SR), 其中有用于存储事件原因的系统寄存器mcause, 有用于存储事件处理函数的位置的系统寄存器mtvec, 有用于存储事件发生时PC值的系统寄存器mepc……
同时, 在事件时, 操作系统可能需要一些参数才能正确处理事件, 因此, RiscV中规定了一些通用寄存器用于存储事件相关的参数以及返回值.
有了这样的事件处理流程之后, 我们的应用程序就可以与操作系统交流, 以及从操作系统中获取信息啦!
因此, 我们就要引入一个最简单的操作系统: Nanos-lite. 我们之前所描述的程序运行过程都是在裸机上的, 他们利用了NEMU硬件系统的所有资源(现在的Nanos-lite也是一个运行在裸机上的程序). 而如今引入了操作系统, 我们的程序就变为运行在操作系统之上的了, 只能利用由操作系统分配到的部分资源, 因此, 程序运行的原理也要相应发生变化.
在我们的操作系统中, 我们的应用程序以二进制可执行程序的形式存储(如Windows中的.exe
文件, Linux中的.elf
文件), 这些可执行文件在被运行时, 其中的信息(指令, 数据等)会被加载到由操作系统指定的某个内存空间中, 并且将PC指向这个文件中的第一条指令.
这里看起来好像和原本没什么不同, 但是在计算机的历史中确实一个划时代的变化. 我们之前的运行方式, 好比将写有程序的程序纸带(好有年代感)直接放入机器中运行; 而现在的运行方式则支持批处理系统, 能够将多个程序连续运行. 在后续加入虚拟化和并发之后, 就能得到一个与现代操作系统相似的系统啦!
ELF文件提供了两个视角来组织一个可执行文件, 一个是面向链接过程的section视角, 这个视角提供了用于链接与重定位的信息(例如符号表); 另一个是面向执行的segment视角, 这个视角提供了用于加载可执行文件的信息. 通过readelf
命令, 我们还可以看到section和segment之间的映射关系: 一个segment可能由0个或多个section组成, 但一个section可能不被包含于任何segment中.
我们现在关心的是如何加载程序, 因此我们重点关注segment的视角. ELF中采用program header table来管理segment, program header table的一个表项描述了一个segment的所有属性, 包括类型, 虚拟地址, 标志, 对齐方式, 以及文件内偏移量和segment大小. 根据这些信息, 我们就可以知道需要加载可执行文件的哪些字节了, 同时我们也可以看到, 加载一个可执行文件并不是加载它所包含的所有内容, 只要加载那些与运行时刻相关的内容就可以了, 例如调试信息和符号表就不必加载. 我们可以通过判断segment的Type
属性是否为PT_LOAD
来判断一个segment是否需要加载.
+-------+---------------+-----------------------+
| |...............| |
| |...............| | ELF file
| |...............| |
+-------+---------------+-----------------------+
0 ^ |
|<------+------>|
| | |
| |
| +----------------------------+
| |
Type | Offset VirtAddr PhysAddr |FileSiz MemSiz Flg Align
LOAD +-- 0x001000 0x03000000 0x03000000 +0x1d600 0x27240 RWE 0x1000
| | |
| +-------------------+ |
| | |
| | | | |
| | | | |
| | +-----------+ --- |
| | |00000000000| ^ |
| | --- |00000000000| | |
| | ^ |...........| | |
| | | |...........| +------+
| +--+ |...........| |
| | |...........| |
| v |...........| v
+-------> +-----------+ ---
| |
| |
Memory
系统调用
运行时环境的部分功能是需要使用资源的, 比如申请内存需要使用物理内存, 更新屏幕需要使用帧缓冲. 在PA2中, 我们的计算机系统是被一个程序独占的, 它可以想怎么玩就怎么玩, 玩坏了也是它一个程序的事情. 而在现代的计算机系统中, 可能会有多个程序并发甚至同时使用计算机系统中的资源. 如果每个程序都直接使用这些资源, 各自都不知道对方的使用情况, 很快整个系统就会乱套了: 比如我覆盖了你的画面, 你覆盖了我的内存空间…
所以需要有一个角色来对系统中的资源进行统一的管理: 程序不能擅自使用资源了, 使用的时候需要向资源管理者提出申请. 既然操作系统位于高特权级, 享受着至高无上的权利, 自然地它也需要履行相应的义务: 作为资源管理者管理着系统中的所有资源, 操作系统还需要为用户程序提供相应的服务. 这些服务需要以一种统一的接口来呈现, 用户程序也只能通过这一接口来请求服务.
这一接口就是系统调用. 系统调用把整个运行时环境分成两部分, 一部分是操作系统内核区, 另一部分是用户区. 那些会访问系统资源的功能会放到内核区中实现, 而用户区则保留一些无需使用系统资源的功能, 以及用于请求系统资源相关服务的系统调用接口.
在这个模型之下, 用户程序只能在用户区安分守己地”计算”, 任何超越纯粹计算能力之外的任务, 都需要通过系统调用向操作系统请求服务. 如果用户程序尝试进行任何非法操作, CPU就会向操作系统抛出一个异常信号, 让非法操作的指令执行”失败”, 并交由操作系统进行处理.
系统调用的过程
系统调用是应用程序与操作系统沟通的方式, 也是一种特殊的事件. 应用程序触发系统调用事件时, 会将系统调用的参数存入上文说过的通用寄存器中. 在事件响应的过程中, 会调用操作系统所定义的事件响应函数, 并在其中调用系统调用处理函数. 系统调用函数会根据通用寄存器中存储的参数向应用程序提供相应的功能, 从而实现向应用程序提供服务的目的.
To be continued…