作者:南京航空航天大学 计算机科学与技术学院 金航 未经作者允许,禁止转载
回想我们之前的运行环境由谁来提供?AM,但是AM并不是操作系统,其没有中断和文件的概念。
OS:提供文件管理服务,我们的程序都是在OS下运行的。现在我们要实现一个简单的操作系统支持dummy
的运行。
可以认为是我们运行在NEMU/AM之上的一个OS
- 输出两条log
- 初始化
ramdisk
:即内存模拟磁盘 - 初始化设备:调用AM的初始化函数
- 加载用户程序:通过
loader
将用户程序加载到指定位置(第一个任务) - 跳转到用户程序入口点:即调用
entry
函数
- 先将待运行的用户程序放于
ramdisk
的映像中(编译阶段完成) - 开始运行项目,首先加载
ramdisk
- 从
ramdisk
中将程序读到指定内存处(运行NANOS-LITE后由loader
完成) - 调用放置好的程序
- 按讲义要求编译出
dummy-x86
- 执行
make update
更新ramdisk
这样以后,可执行文件就进入了ramdisk.img
这个映像文件中。
注意:每需要更换ramdisk
的内容都需要更新ramdisk。
- 目前,我们只需要直接将程序读到
0x4000000
(即框架自带的DEFAULT_ENTRY
宏定义)这个位置就可以了。使用ramdisk.c
中提供的函数。 - 可执行程序在哪里?
ramdisk
偏移为0
的地方。 - 可执行程序有多大?整个
ramdisk
那么大 - 在哪里实现
loader
?理所当然:loader.c
。 - 很简单,1行代码就能实现。
- 正常实现后,NEMU会报出未实现指令的错误(0xcd),这就是
INT
指令。请见后文。
注意:虽然说整个ramdisk
中只有dummy-x86
一个可执行文件,但是所有的库都已经被编译到这个可执行文件中了,这和我们平时写的程序的库函数调用过程不太一样(真正x86平台的程序通过动态链接库调用)。
一般来说,用户程序运行在第3级
,而操作系统运行在第0级
。保证了一般用户程序拿不到操作系统的部分特权。
如果用户程序试图去执行一条他的权限不够的特权级指令,CPU就会抛出一个异常。因此我们本章的内容就是异常控制流。
这个特权级的检查本来是由分段机制负责控制的,但是我们的NEMU为了简单,不包含分段的保护机制。(理论课上,分段是个考试重点,这里我们不讲,需要的时候简单讲)
最典型的特权保护机制:银行取钱。
注意:我们PA中所有的程序相当于直接运行在和操作系统位于同一级的第0级
上。
我们的操作系统有义务将部分特权安全地给客户程序使用,这就有了系统调用的存在。虽然我们的程序有权利能够执行这些特权指令,但是为了后续实验,我们仍要实现系统调用这个模块。
提问:回想我们C语言程序调用一个函数如何传参?传参的过程在机器级层面上是个什么样的过程?(答案:我们在C语言中函数的传参通常先将各个参数压栈,然后通过CALL
指令跳转到函数体的入口点。这只是参数传递的方式之一,最简单的参数传递是寄存器传递,如果后面学微机原理,就会知道用寄存器传参是最好写的)
那么,我们系统调用不使用CALL
指令,因为CALL
指令只能调用用户态的函数,并不能完成特权级转换。这里我们引入了INT
中断指令。再特殊一点,我们这里用到的是int 0x80
的系统调用来陷入内核态。
通过寄存器传参。系统调用参数的顺序一定要记住:EAX, EBX, ECX, EDX。
C语言函数的返回值是如何返回的?(答案:EAX
寄存器带回)
那么,系统调用和函数调用一样,也是将返回值放入EAX
之中带回。
在Navy-app
中,nanos.c
的_syscall_()
函数。通过调用这个函数,就可以便利地帮我们编译出上述的五条指令(把参数值依次放入对应寄存器中并进行系统调用)以及带回返回值。
通过门描述符
来找,门描述符
的格式见讲义。这里特别指出:我们只需要关心两个用来被拼接在一起的地址字段
和P位
。
将每个中断门描述符
看作一个结构体
,那么内存
中有一段区域
,这个区域存放着门描述符数组
,这个数组
就是IDT,即中断描述符表。
这个表中存放着每一个中断
的入口地址
,我们通过中断号
作为索引在IDT中找到其对应的描述符,然后将该描述符中的地址域组合,得到中断服务程序的入口地址。
我们认为,中断的服务程序都是已经加载到内存中了,需要中断调用的时候直接经过一定手续,让EIP
指向中断服务程序的入口点(中断服务程序的第一个指令)即可
我们如何能够找到IDT在内存中的哪个位置?那就是IDTR
寄存器,它始终指向IDT表的首地址。
我们说,执行一次指令INT 0x??
就是一次中断调用,而当
0x?? = 0x80
时,这个中断为系统调用。因此,触发系统调用就是触发一个特殊的异常。
我们说系统调用是一个特殊的中断调用,所以系统调用肯定需要经过中断调用的流程。详细流程见i386手册
和讲义。这里简述如下:
- 保护现场(将EFLAGS, EIP, CS压栈)。为什么要保护现场?为了让程序返回调用点后仍然能保持原先的状态。
- 从IDTR中得到IDT首地址,并通过中断号在IDT中寻址,找到一个门描述符。
- 得到门描述符后,首先检验门描述符的
P位
,若这个门描述符无效,请直接结束程序的运行并进行调试。 - 将门描述符的地址字段相拼接,成为跳转地址。然后设置跳转即可。
- 在CPU结构体中添加
IDTR
寄存器(自行参考IDTR
的结构),并实现LIDT
指令(2行代码能够实现),定义宏HAS_ASYE
。实现以后,项目就会在启动后对IDT
进行初始化,初始化内容包括填IDT表
和注册处理函数
。(这一步是加入AM
为我们提供的ASYE
,有兴趣自己看一下,很简单) - 实现
INT
指令(1行代码能够实现),但是我们不要把处理过程写在INT
里面,而是用INT
去调用raise_intr
函数。这个函数实现的内容即上述中断调用的过程(20行代码以内可以实现)。其中,门描述符GateDesc
结构体框架已为我们写好,直接用就行。注意:这里讲义上有几个注意事项,不要忘记实现。 - 重新运行
dummy
程序,若出现了讲义说的未实现指令(0x60,PUSHA
),说明正常实现。 - 实现
PUSHA
指令,用于将所有一般寄存器压栈,以保存现场,即程序在进行中断调用前的状态。 - 注意重新组织
_RegSet
的顺序,使得其内的寄存器指针或错误号等项目和陷阱帧一致。 - 重新运行
dummy
程序,在irq_handle
中出现了HIT BAD TRAP
,从提示消息知是8号事件(注意:一定是8号事件)未被处理,则说明正常实现。
中断调用经过其自己通用的流程过后,将异常封装成了一个个事件,现在开始针对不同的中断类型进行处理,这就是事件分发,即对应我们的do_event
函数。注意:我们并不是直接调用do_event
函数,而是在初始化的时候把do_event
函数注册为了事件分发函数,所以此时会调用这个函数。
我们前文提到的未处理的8号事件
,可以去am.h
中查一下,其实这就是一个_EVENT_SYSCALL
,即系统调用事件
。因此我们需要调用系统调用事件的处理函数去处理这个事件。
而我们的系统调用处理函数就是do_syscall
,目前,我们这个阶段只需要处理2个类型的系统调用即可,即SYS_none
和SYS_exit
。具体来说,这个函数中做的事情就是得到四个参数(有可能有没用上的),然后根据中断调用类型选择合适的后续处理函数(本阶段暂不涉及,下阶段文件调用时会涉及)。处理完毕后,设置返回值到约定的地方(忘了吗?请看前文)即可。
- 在
do_event
中识别系统调用并调用do_syscall
- 正确实现
arch.h
中的几个可以从现场寄存器中获取参数的寄存器的宏 - 完成并实现
SYS_none
和SYS_exit
这两个系统调用 - 设置返回值到寄存器中
- 实现
POPA
(0x61)和IRET
(0xcf)指令(调用的时候保护现场,调用完成后当然要恢复现场啦) - 成功运行
dummy
项目并HIT GOOD TRAP
注意:省去了初始化时候的部分调用过程以及NEMU中部分调用过程,仅写出和系统调用相关的:
次序 | 函数名 | 所在项目 | 所在子项目 | 所在代码文件 |
---|---|---|---|---|
1 | main | navy-apps | dummy | dummy.c |
2 | _syscall_ | navy-apps | libos | nanos.c |
3 | make_EHelper(int) | nemu | - | system.c |
4 | raise_intr | nemu | - | intr.c |
5 | vecsys | am | x86-nemu | tarp.S |
6 | asm_trap | am | x86-nemu | tarp.S |
7 | irq_handle | am | x86-nemu | asye.c |
8 | H(即do_event) | nanos-lite | - | irq.c |
9 | do_syscall | nanos-lite | - | syscall.c |