NJU计算机课程基础实验 PA3笔记(一)2022-08-26
穿越时空的旅程,批处理系统,基本的异常与syscall实现
写在前面:感谢pony、杨婆婆等群友的讨论和帮助,让我对PA有了更深入的理解。
关于问题部分:请遵守学术诚信,有关提问都要自己思考(笔记也不会涉及到任何直接给出代码的部分,有关自己的心得体会很可能是错的,请自己思考。遵 守以下协议:
1、最简单的操作系统
Nanos-lite是运行在AM之上, AM的API在Nanos-lite中都是可用的. 虽然操作系统对我们来说是一个特殊的概念, 但在AM看来, 它只是一个调用AM API的普通C程序而已,
Nanos-lite目前的行为:
打印Project-N的logo, 并通过
Log()
输出hello信息和编译时间. 在Nanos-lite中,Log()
宏通过你在klib
中编写的printf()
输出, 最终会调用TRM的putch()
.调用
init_device()
对设备进行一些初始化操作. 目前init_device()
会直接调用ioe_init()
.在NEMU中对磁盘进行模拟是一个略显复杂工作, 先让Nanos-lite把其中的一段内存作为磁盘来使用. 这样的磁盘有一个专门的名字, 叫ramdisk.
init_fs()
和init_proc()
, 分别用于初始化文件系统和创建进程, 目前它们均未进行有意义的操作, 可以忽略它们.调用
panic()
结束Nanos-lite的运行.
回顾历史, 要实现一个最简单的操作系统, 就要实现以下两点功能:
-
用户程序执行结束之后, 可以跳转到操作系统的代码继续执行
-
操作系统可以加载一个新的用户程序来执行
(没错,就是fork与execve)
为了操作系统的稳定性,我们所希望是一种可以限制入口的执行流切换方式, 而不像用户程序那样可以随意切换PC跳转:
i386:保护模式(protected mode)和特权级(privilege level)
mips32处理器:内核模式和用户模式
riscv32:机器模式(M-mode), 监控者模式(S-mode)和用户模式(U-mode)
最厉害的是硬件检查:如mips32都是通过硬件检查,只要软件运行在硬件上面, 都无法逃出这一天网. 硬件保护机制使得恶意程序永远无法全身而退,
在硬件中加入一些与特权级检查相关的门电路(例如比较器电路), 如果发现了非法操作, 就会抛出一个异常信号, 让CPU跳转到一个约定好的目标位置, 并进行后续处理.
直接看看效果:
此时会出现异常,因为我们还没实现csrw伪指令,但要注意的是这里中文文档有个错误,从csrwi的可以推测这里应该是csrrw,也可以直接看英文文档的说明
这里我们发现了一个名词“控制状态寄存器”,应该如何理解呢?我们看看下文中对控制状态寄存器的解释。
2、穿越时空的旅程
硬件需要提供一种可以限制入口的执行流切换方式. 这种方式就是自陷指令,
程序执行自陷指令之后, 就会陷入到操作系统预先设置好的跳转目标. 这个跳转目标也称为异常 入口地址.
非法指令可以定义成"不属于ISA手册描述范围的指令", 而自陷指令可以认为是一种特殊的无条件失败.
riscv32提供
ecall
指令作为自陷指令, 并提供一个mtvec寄存器来存放异常入口地址. 为了保存程序当前的状态, riscv32提供了一些特殊的系统寄存器, 叫控制状态寄存器(CSR寄存器). 在PA中, 我们只使用如下3个CSR寄存器:
**mepc寄存器 **- 存放触发异常的PC
mstatus寄存器 - 存放处理器的状态
mcause寄存器 - 存放触发异常的原因
riscv32触发异常后硬件的响应过程如下:
将当前PC值保存到mepc寄存器
在mcause寄存器中设置异常号
从mtvec寄存器中取出异常入口地址
跳转到异常入口地址
上述保存程序状态以及跳转到异常入口地址的工作, 都是硬件自动完成的
所以我们知道,如果要补全csrw指令,首先要能区分不同的控制状态寄存器,具体怎么区分呢,
首先修复一下对应的指令,此时要注意的是CSR寄存器的实现方法。
注意到:The standard RISC-V ISA sets aside a 12-bit encoding space (csr[11:0]) for up to 4,096 CSRs.
此时需要查阅的是新手大礼包(具体是哪个需要自己RTFM)之Control and Status Registers——CSR Listing的Currently allocated RISC-V machine-level CSR addresses
因为此时立即数位置对应的就是CSR地址,打印出来后发现是0x305查手册后发现也确实是mtvec——Machine trap-handler base address.
根据手册的地址进行匹配并存入寄存器。
状态机视角下的异常响应机制
SR[mepc] <- PC (发生异常的pc)
SR[mcause] <- 一个描述失败原因的号码
PC <- SR[mtvec] (异常入口地址)
fex: S -> {0, 1}
, 给定状态机的任意状态S
,fex(S)
都可以唯一表示当前PC指向的指令是否可以成功执行
将上下文管理抽象成CTE
程序的状态, 在操作系统中有一个等价的术语, 叫"上下文".AM的一类新的API中名字叫CTE(ConText Extension).
与IOE一样, 上下文管理的具体实现也是架构相关的: 例如上文提到, x86/mips32/riscv32中分别通过
int
/syscall
/ecall
指令来进行自陷,native
中甚至可以通过一些神奇的库函数来模拟相应的功能操作系统的处理过程其实需要哪些信息:
引发这次执行流切换的原因, 是程序除0, 非法指令, 还是触发断点, 又或者是程序自愿陷入操作系统?
程序的上下文了(寄存器)
把这两点信息抽象成一种统一的表示方式, 就可以定义出CTE的API.
对于切换原因, 我们只需要定 义一种统一的描述方式即可. CTE定义了名为"事件"的数据结构(见
abstract-machine/am/include/am.h
)其中
event
表示事件编号,cause
和ref
是一些描述事件的补充信息,msg
是事件信息字符串, 我们在PA中只会用到event
. 然后, 我们只要定义一些统一的事件编号(上述枚举常量), 让每个架构在实现各自的CTE API时, 都统一通过上述结构体来描述执行流切换的原因,对于上下文, 我们只能将描述上下文的结构体类型名统一成
Context
至于其中的具体内容, 就无法进一步进行抽象了. 这主要是因为不同架构之间上下文信息的差异过大(比如各种寄存器),在AM中,Context
的具体成员也是由不同的架构自己定义的, 比如x86-nemu
的Context
结构体在abstract-machine/am/include/arch/x86-nemu.h
不过大多数情况下, 操作系统并不需要单独访问
Context
结构中的成员. CTE也提供了一些的接口, 来让操作系统在必要的时候访问它们, 从而保证操作系统的相关代码与架构无关.最后还有另外两个统一的API:
bool cte_init(Context* (*handler)(Event ev, Context *ctx))
用于进行CTE相关的初始化操作. 其中它还接受一个来自操作系统的事件处理回调函数的指针, 当发生事件时,** CTE将会把事件和相关的上下文作为参数, 来调用这个回调函数, 交由操作系统进行后续处理**.
void yield()
用于进行自陷操作, 会触发一个编号为EVENT_YIELD
事件.** 不同的ISA会使用不同的自陷指令来触发自陷操作**,
Q:AM究竟给程序提供了多大的栈空间呢? 事实上, 如果你在PA2的时候尝试努力了解每一处细节, 你 已经知道这个问题的答案了;
A:
_stack_top = ALIGN(0x1000);
. = _stack_top + 0x8000;
_stack_pointer = .;
end = .;
_end = .;
_heap_start = ALIGN(0x1000);
注意区别:
/* Minimum stack size for a signal handler. */
#define MINSIGSTKSZ 2048
/* System default stack size. */
#define SIGSTKSZ 8192
插播一个潜在大坑
【发现者为jyyos群的杨婆婆,也是一位超神的大佬】:
涉及到ra寄存器相关跳转的顺序问题,要严格按照这个顺序操作
为什么呢?原因是如果我们的源操作数和目的操作数寄存器刚好是同一个的时候(比如ra),重复写入会直接覆盖值,如果我们先用pc+4赋值,很可能再次使用的时候就会出大问题,值被提前覆盖了!!!
尝试在Nanos-lite中触发一次自陷操作
设置异常入口地址
按照ISA的约定来设置异常入口地址, 将来切换执行流时才能跳转到正确的异常入口. 这显然是架构相关的行为, 因此我们把这一行为放入CTE中, 而不是让Nanos-lite直接来设置异常入口地址.
你需要在
nanos-lite/include/common.h
中定义宏HAS_CTE
, 这样以后, Nanos-lite会多进行一项初始化工作: 调用init_irq()
函数, 这最终会调用位于abstract-machine/am/src/nemu/isa/$ISA/cte.c
中的cte_init()
函数.cte_init()
函数会做两件事情, 第一件就是设置异常入口地址
cte_init()
函数做的第二件事是注册一个事件处理回调函数, 这个回调函数由Nanos-lite提供,
- 对于riscv32来说, 直接将异常入口地址设置到mtvec寄存器中即可.
实现完各种指令框架后我们能看到
这时还没完全实现ecall,进入实现过程:
你需要实现上文提到的新指令, 并实现
isa_raise_intr()
函数. 然后阅读cte_init()
的代码, 找出相应的异常入口地址.实现后, 重新运行Nanos-lite, 如果你发现NEMU确实跳转到你找到的异常入口地址, 说明你的实现正确
你需要在自陷指令的辅助函数中调用
isa_raise_intr()
此时先实现isa_raise_intr
,模拟上文提到的异常响应机制:
会发现我们需要找到一个NO,而这个异常NO可以在yield的代码中“领略到怎么获取”,我们直接看他的汇编代码asm volatile("li a7, -1; ecall")
; 可以猜想是不是和a7有关呢?这时候可以拿出a7看看。至于怎么拿出,这个就是基础操作了。(注意:这里其实是错误的,之后会说为什么)
同时我们再看到asm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap));
这是什么意思呢,首先我们能知道应该是把寄存器的值写入控制状态寄存器(有理由猜测这就是异常地址),那后面的"r"是什么意思呢?
我们可以对照一下知道了那是输入操作数,我们有理由猜测这就是为了把trap相关的地址存到mtvec,在另外一个文件可以看到类似的东西
此时大胆猜测这就是异常相关入口。
一顿操作猛如虎实现了ecall调用,找了个地方加入了异常处理的寄存器:
开diff检查发现了问题:
为啥是b呢?我们发现这其实是mcause的返回值,难道不是前面的li a7 -1吗???
经过搜索手册可以查到那其实表示一种environment call。
经过查阅资料:
RISC-V中系统调用通过(environment call)来实现。在U-mode、S-mode、M-mode下执行
ecall
分别会触发environment-call-from-U-mode异常、environment-call-from-S-mode异常、environment-call-from-M-mode异常。在系统调用的实现中,我们通过在U-mode下执行ecall
触发environment-call-from-U-mode异常,并由S-mode中运行的内核处理这个异常。在head.S中内核boot阶段时,设置 medeleg 寄存器为用户模式系统调用添加异常委托。 在没有设置异常委托的情况下,
ecall
指令产生的异常由M-mode来处理,而不是交由内核所在的S-mode进行处理。通过 medeleg 中设置相应的位,可以将environment-call-from-U-mode异常直接交由S-mode处理。链接:https://www.zhihu.com/question/24960401/answer/2308952497
来源:知乎
那么里面的U,S,M是什么意思呢?以支持现代操作系统的RISC-V处理器为例, 它们存在M, S, U三个特权模式, 分别代表机器模式, 监管者模式和用户模式. M模式特权级最高, U模式特权级最低。所以我们知道了到底是什么调用。
华中科技大学操作系统团队:https://gitee.com/hustos/pke-doc/blob/master/chapter1_riscv.md
系统发生中断(我们用中文的“中断”这个名词来指代广义的中断,并非以上的interrupt)时执行的这段程序,往往被称为中断例程(interrupt routine)。因为事件的多样性,系统可能有多个这样的中断例程,通常的做法是把这些例程的入口放在一张表中,而这张表一般称为中断向量表(interrupt table)。RV64G处理器在发生中断后,会将发生的中断类型、编号自动记录(硬件完成)到目标模式的CSR中。假设发生中断的目标模式为M模式,则中断的这些信息会记录到mcause寄存器。 表1.7列出了mcause的可能取值以及对应的中断信息
那么a7到底是什么呢,从man syscall可以查到, 原来只是用于存放syscall调用号的地方,并不是真正的mcause,只是为了触发自陷操作,不同ISA的实现不同的(可以全局搜索yield)
(至于为什么是-1,和小伙伴讨论还是个迷)
接下来目的就是找到正确的mcause,什么才是正确的呢?这里就需要你查手册理解PA有几种模式了,以及理解为什么说在后面我们会再次用到isa_raise_intr()
函数。(想想isa_raise_intr()
的翻译是什么?并不是异常指令)
你可以用diff检查自己实现的对不对。
通过diff检查:
保存上下文
现在的通用寄存器, 里面存放的都是执行流切换之前的内容. 这些内容也是上下文的一部分, 如果不保存就覆盖它们, 将来就无法恢复这一上下文了. 但通常硬件并不负责保存它们, 因此需要通过软件代码来保存它们的值. riscv32则通过
sw
指令将各个通用寄存器依次压栈.除了通用寄存器之外, 上下文还包括:
**触发异常时的PC和处理器状态. **epc/mepc和status/mstatus寄存器, 异常响应机制把它们保存在相应的系统寄存器中, 我们还需要将它们从系统寄存器中读出, 然后保存在堆栈上.
**异常号. **异常号已经由硬件保存在cause/mcause寄存器中, 我们还需要将其保存在堆栈上.
地址空间. 这是为PA4准备的,将地 址空间信息与0号寄存器共用存储空间, 反正0号寄存器的值总是0, 也不需要保存和恢复. 我们暂时不使用地址空间信息, 你目前可以忽略它们的含义.
问:异常号的保存
Q
通过软件来保存异常号, 没有类似cause的寄存器. mips32和riscv32也可以这样吗? 为什么?A:我认为可以,因为异常地址之类的都能保存到上下文信息,那么riscv也可以有。
重新组织Context结构体
你的任务如下:
实现这一过程中的新指令, 详情请RTFM.
理解上下文形成的过程并RTFSC, 然后重新组织
abstract-machine/am/include/arch/$ISA-nemu.h
中定义的Context
结构体的成员, 使得这些成员的定义顺序和abstract-machine/am/src/$ISA/nemu/trap.S
中构造的上下文保持一致.需要注意的是, 虽然我们目前暂时不使用上文提到的地址空间信息, 但你在重新组织
Context
结构体时仍然需要正确地处理地址空间信息的位置, 否则你可能会在PA4中遇到难以理解的错误.实现之后, 你可以在
__am_irq_handle()
中通过printf
输出上下文c
的内容, 然后通过简易调试器观察触发自陷时的寄存器状态, 从而检查你的Context
实现是否正确.
这部分关键是 理解结构体和汇编语言的关系,让我们看一段示例代码:
struct test
{
char ch;
short s;
int i;
long l;
};
int fun(void)
{
int ret;
struct test tmp;
ret=0;
tmp.ch='a';
tmp.s=123;
tmp.i=1234;
tmp.l=12345;
return ret;
}
汇编代码:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
//ret=0;
movl $0, -16(%ebp)
//tmp.ch='a';
movb $97, -12(%ebp)
//tmp.s=123;
movw $123, -10(%ebp)
//tmp.i=1234;
movl $1234, -8(%ebp)
//tmp.l=12345;
movl $12345, -4(%ebp)
//返回值
movl -16(%ebp), %eax
leave
ret
从顺序上我们就知道根据压入栈该如何组织结构体了,同时我们也可以在handler处理后看到结果。
问:必答题(需要在实验报告中回答) - 理解上下文结构体的前世今生
你会在
__am_irq_handle()
中看到有一个上下文结构指针c
,c
指向的上下文结构究竟在哪里? 这个上下文结构又是怎么来的? 具体地, 这个上下文结构有很多成员, 每一个成员究竟在哪里赋值的?$ISA-nemu.h
,trap.S
, 上述讲义文字, 以及你刚刚在NEMU中实现的新指令, 这四部分内容又有什么联系?
答:本质是Context* (user_handler)(Event, Context)的结构体指针。其中定义在am.h,赋值在cte_init阶段初始化
(怀疑就是为了软件存储上下文信息。所谓的保存到堆栈(S的过程))
具体的Context是怎么被加载的?
首先我们能在S中找到.globl __am_asm_trap,其中.globl关键字作用是
能被链接后,就会和extern相对应,所以我们确定__am_irq_trap 是可以在链接过程打包到可执行文件的
再回顾S代码,我们发现这确实就是结构体的push过程。最开始是寄存器,最后是mepc。(我们可以通过打印结构体指针的讯息来看看是不是对应的位置(比如mepc)。)同时我们可以注意到jal __am_irq_handle,这里就真正跳转到了框架代码。但这个明明是Context ,哪来的Context?
可以合理猜测应该是mv a0,sp 中传入sp得到了context的指针信息Context *c,而结构体指针的第一个成员的地址也即是结构体指针的地址。
事件分发
__am_irq_handle()
的代码会把执行流切换的原因打包成事件, 然后调用在cte_init()
中注册的事件处理回调函数(user_handler), 将事件交给Nanos-lite来处理
实现完成后发现卡在自现异常的死循环,之后会解决这个问题
恢复上下文
代码将会一路返回到
trap.S
的__am_asm_trap()
中, 接下来的事情就是恢复程序的上下文.__am_asm_trap()
将根据之前保存的上下文内容, 恢复程序的状态, 最后执行"异常返回指令"返回到程序触发异常之前的状态.需要注意之前自陷指令保存的PCr。iscv32的
ecall
, 保存的是自陷指令的PC, 因此需要在适当的地方对保存的PC加上4, 使得将来返回到自陷指令的下一条指令.
什么叫做合适的地方?我们该想想什么叫做恢复上下文——用什么恢复?
所以我们应该在恢复的地方将pc+4。
问:从加4操作看CISC和RISC
事实上, 自陷只是其中一种异常类型. 有一种故障类异常, 它们返回的PC和触发异常的PC是同一个, 例如缺页异常, 在系统将故障排除后, 将会重新执行相同的指令进行重试, 因此异常返回的PC无需加4. 所以根据异常类型的不同, 有时候需要加4, 有时候则不需要加.
这时候, 我们就可以考虑这样的一个问题了: 决定要不要加4的, 是硬件还是软件呢? CISC和RISC的做法正好相反, CISC都交给硬件来做, 而RISC则交给软件来做. 思考一下, 这两种方案各有什么取舍? 你认为哪种更合理呢? 为什么?
答:riscv不支持用硬件,只有一个,是因为pc要存在mepc里,mepc只有1个,x86执行到int指令后会直接把flags,cs,ip压栈。x86是硬件做的 (by pony)
而且我倾向于x86放在硬件主要是指令太多,放在软件会拖慢速度而且可能会导致奇奇怪怪的问题。