ysyx:pa3.1批处理系统
批处理系统
为了让管理员事先准备好一组程序, 让计算机执行完一个程序之后, 就自动执行下一个程序,提出了批处理系统的思想。处理系统的关键, 就是要有一个后台程序, 当一个前台程序执行结束的时候, 后台程序就会自动加载一个新的前台程序来执行,这样的一个后台程序, 其实就是操作系统。
我们希望操作系统和用户进程之间的切换是一种可以限制入口的执行流切换方式,这种方式是无法通过程序代码来实现的.
异常响应机制
为了实现最简单的操作系统, 硬件还需要提供一种可以限制入口的执行流切换方式. 这种方式就是自陷指令, 程序执行自陷指令之后, 就会陷入到操作系统预先设置好的跳转目标. 这个跳转目标也称为异常入口地址.
riscv32提供ecall指令作为自陷指令, 并提供一个mtvec寄存器来存放异常入口地址. 为了保存程序当前的状态, riscv32提供了一些特殊的系统寄存器, 叫控制状态寄存器(CSR寄存器).
PA这里需要用到三个状态寄存器,分别为
mepc寄存器 - 存放触发异常的PC
mstatus寄存器 - 存放处理器的状态
mcause寄存器 - 存放触发异常的原因
riscv32触发异常后硬件的响应过程如下:
将当前PC值保存到mepc寄存器
在mcause寄存器中设置异常号
从mtvec寄存器中取出异常入口地址
跳转到异常入口地址
若决定无需杀死当前程序, 等到异常处理结束之后, 就根据之前保存的信息恢复程序的状态, 并从异常处理过程中返回到程序触发异常之前的状态. 具体地:
riscv32通过mret指令从异常处理过程中返回, 它将根据mepc寄存器恢复PC.
我们是以状态机的视角看待处理器的,前面在TRM
和IOE
中,我们说程序是个S = <R, M>的状态机,现在为了给机器添加状态响应机制,我们需要给这个机器的R
添加功能,将R增加了R = {GPR, PC, SR}。其中GPR为通用寄存器堆,PC为程序计数器,SR为状态控制寄存器。也就是前面说的三个mepc mcause mtvec
。
添加异常响应机制之后, 我们允许一条指令的执行会"失败". 为了描述指令执行失败的行为, 我们可以假设CPU有一条虚构的指令raise_intr, 执行这条虚构指令的行为就是上文提到的异常响应过程。
SR[mepc] <- PC
SR[mcause] <- 一个描述失败原因的号码
PC <- SR[mtvec]
至于这个号码需要你自行RTFM了。
like this
那么, "一条指令的执行是否会失败"这件事是不是确定性的呢? 显然这取决于"失败"的定义, 例如除0就是"除法指令的第二个操作数为0", 非法指令可以定义成"不属于ISA手册描述范围的指令", 而自陷指令可以认为是一种特殊的无条件失败. 不同的ISA手册都有各自对"失败"的定义, 例如RISC-V手册就不认为除0是一种失败, 因此即使除数为0, 在RISC-V处理器中这条指令也会按照指令手册的描述来执行.
事实上, 我们可以把这些失败的条件表示成一个函数fex: S -> {0, 1}
, 给定状态机的任意状态S, fex(S)都可以唯一表示当前PC指向的指令是否可以成功执行,若fex(S)=0,那就
异常响应机制的加入还伴随着一些系统指令的添加,如csrrw,csrrs,ecall,mret
,具体含义请RTFM。
将上下文管理抽象成CTE
我们刚才提到了程序的状态, 在操作系统中有一个等价的术语, 叫"上下文". 因此, 硬件提供的上述在操作系统和用户程序之间切换执行流的功能, 在操作系统看来, 都可以划入上下文管理的一部分。
上下文(context)在操作系统里指的是“使程序能够从中断点恢复并继续执行的全部状态”。
为什么需要上下文?
上下文保存使得操作系统可以暂停一个进程(或线程),去运行另一个,然后再把之前的执行恢复到完全相同状态(时间片切换、阻塞/唤醒、异常/中断返回等)。
典型的上下文切换步骤(简化):
触发:定时中断 / 系统调用 / 异常 / 阻塞事件。
保存:把当前 CPU 寄存器、PC、状态寄存器保存到当前 PCB(或内核栈)。
切换地址空间:切换页表基址等 MMU 设置(如果需要)。
恢复:从下一个进程的 PCB 恢复寄存器、PC、状态寄存器,返回用户态继续执行。
操作系统处理上下文需要机器状态异常的原因
和程序的上下文
,关于上下文,在处理过程中, 操作系统可能会读出上下文中的一些寄存器, 根据它们的信息来进行进一步的处理. 例如操作系统读出PC所指向的非法指令, 看看其是否能被模拟执行.
typedef struct {enum {EVENT_NULL = 0,EVENT_YIELD, EVENT_SYSCALL, EVENT_PAGEFAULT, EVENT_ERROR,EVENT_IRQ_TIMER, EVENT_IRQ_IODEV,} event;uintptr_t cause, ref;const char *msg;
} Event;
因此CTE定义了名为"事件"的如上数据结构。
其中event表示事件编号, cause和ref是一些描述事件的补充信息, msg是事件信息字符串, 我们在PA中只会用到event
此外还用另外两个统一的API
bool cte_init(Context* (*handler)(Event ev, Context *ctx))用于进行CTE相关的初始化操作. 其中它还接受一个来自操作系统的事件处理回调函数的指针, 当发生事件时, CTE将会把事件和相关的上下文作为参数, 来调用这个回调函数, 交由操作系统进行后续处理.
void yield()用于进行自陷操作, 会触发一个编号为EVENT_YIELD事件. 不同的ISA会使用不同的自陷指令来触发自陷操作, 具体实现请RTFSC.
触发第一个异常
通过AM中的am-test
中的hello_intr
测试用例来触发一次自陷。
#include <amtest.h>void (*entry)() = NULL; // mp entrystatic const char *tests[256] = {['h'] = "hello",['H'] = "display this help message",['i'] = "interrupt/yield test",['d'] = "scan devices",['m'] = "multiprocessor test",['t'] = "real-time clock test",['k'] = "readkey test",['v'] = "display test",['a'] = "audio test",['p'] = "x86 virtual memory test",
};int main(const char *args) {switch (args[0]) {CASE('h', hello);CASE('i', hello_intr, IOE, CTE(simple_trap));CASE('d', devscan, IOE);CASE('m', mp_print, MPE);CASE('t', rtc_test, IOE);CASE('k', keyboard_test, IOE);CASE('v', video_test, IOE);CASE('a', audio_test, IOE);CASE('p', vm_test, CTE(vm_handler), VME(simple_pgalloc, simple_pgfree));case 'H':default:printf("Usage: make run mainargs=*\n");for (int ch = 0; ch < 256; ch++) {if (tests[ch]) {printf(" %c: %s\n", ch, tests[ch]);}}}return 0;
}
在make ARCH=riscv32-nemu minargs=i run
的时候会执行
CASE('i', hello_intr, IOE, CTE(simple_trap));
这一句。
首先申明hello_intr()
并设置为入口,并且初始化IOE,这里对这个不做要求,然后就申明了一个Context
类型的函数指针名为simple_trap
,这个函数指针需要Event和Context结构体的变量。simple_trap
代码如下。
Context *simple_trap(Event ev, Context *ctx) {switch(ev.event) {case EVENT_IRQ_TIMER:putch('t'); break;case EVENT_IRQ_IODEV:putch('d'); break;case EVENT_YIELD:putch('y'); break;default:panic("Unhandled event"); break;}return ctx;
}
随后执行cte_init
的函数,其变量是刚刚的simple_trap
。
cte_init
代码如下:
bool cte_init(Context*(*handler)(Event, Context*)) {// initialize exception entryasm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap)); //把amasmtrap的地址传给mtvec// register event handleruser_handler = handler;return true;
}
cte_init()函数会做两件事情, 第一件就是设置异常入口地址:也就是把__am_asm_trap
函数的地址直接传给mtvec寄存器,
第二件事是注册一个事件处理回调函数:user_handler = handler;
在这里赋值。
那现在函数从cte_init()
继续执行,下一步就是到执行hello_intr()
void hello_intr() {printf("Hello, AM World @ " __ISA__ "\n");printf(" t = timer, d = device, y = yield\n");io_read(AM_INPUT_CONFIG);iset(1);while (1) {for (volatile int i = 0; i < 1000000; i++) ;yield();}
}
首先打印一点东西出来,然后iset(1)
打开中断但是现在没有实现所以可以当做这个函数没有作用所以继续向后看,终于跳到了yield()
函数中。
void yield() {
#ifdef __riscv_easm volatile("li a5, -1; ecall");
#elseasm volatile("li a7, -1; ecall");#endif
}
进来就是一个内联汇编,首先
li a7,- 1
然后再 ecall
,ecall
我们知道是用来通过引发环境调用异常来请求执行环境。那这个li a7,- 1
是用来干嘛呢?
yield的异常号是11,系统调用号是-1。
约定了GPR1
是gpr[17]也就是a7。
Context* __am_irq_handle(Context *c) {if (user_handler) {Event ev = {0};switch (c->mcause) {case 11:ev.event=EVENT_YIELD;if(c->GPR1!=-1)ev.event = EVENT_SYSCALL;c->mepc += 4;break;default: ev.event = EVENT_ERROR; break;}//printf("mcause = %s\n",c->mcause);c = user_handler(ev, c); //调用之前注册的handlerassert(c != NULL);}return c;
}
struct Context {// TODO: fix the order of these members to match trap.Suintptr_t gpr[NR_REGS], mcause, mstatus, mepc;void *pdir;
};#ifdef __riscv_e
#define GPR1 gpr[15] // a5
#else
#define GPR1 gpr[17] // a7
#endif#define GPR2 gpr[10] // a0
#define GPR3 gpr[11] // a1
#define GPR4 gpr[12] // a2
#define GPRx gpr[10] // a0
然后调用了ecall
指令,那此时我们就需要用到讲义提到的isa_raise_intr()
函数。
INSTPAT("0000000 00000 00000 000 00000 11100 11", ecall , I, s->dnpc = isa_raise_intr(11,s->pc);etrace());
由于是ecall
,那我们这里写他的异常号是11,于是现在跳到了isa_raise_intr()
函数中,看下它的源码。
word_t isa_raise_intr(word_t NO, vaddr_t epc) {/* TODO: Trigger an interrupt/exception with ``NO''. 待办事项:使用“NO”触发中断/异常。* Then return the address of the interrupt/exception vector. 然后返回中断/异常向量的地址*/cpu.mstatus = 0x00001800; cpu.mepc = epc; cpu.mcause = NO;return cpu.mtvec;
}
mstatus是0x1800是因为要通过difftest,mtvec存放触发异常的PC,也就是前面提到的__am_asm_trap
,mcause存放出发状态异常的异常号,也就是调用这个函数用的11,mepc保存当前pc值,以便待会跳回来找到下一条指令。
执行完这个isa_raise_intr
函数之后,我们就会跳到mtvec所指的地址,也就是__am_asm_trap
,代码贴出如下。
__am_asm_trap:addi sp, sp, -CONTEXT_SIZEMAP(REGS, PUSH)csrr t0, mcausecsrr t1, mstatuscsrr t2, mepcSTORE t0, OFFSET_CAUSE(sp)STORE t1, OFFSET_STATUS(sp)STORE t2, OFFSET_EPC(sp)# set mstatus.MPRV to pass difftestli a0, (1 << 17)or t1, t1, a0csrw mstatus, t1mv a0, spcall __am_irq_handlemv sp, a0LOAD t1, OFFSET_STATUS(sp)LOAD t2, OFFSET_EPC(sp)csrw mstatus, t1csrw mepc, t2MAP(REGS, POP)addi sp, sp, CONTEXT_SIZEmret
首先把栈指针下移,腾出((NR_REGS + 3) * XLEN)
的空间用于保存上下文。
然后MAP(REGS, PUSH)
是把32个寄存器压入栈中,保存通用寄存器。
然后读取并保存CSR寄存器。
把mstatus的第十七位(MPRV)置为1。
然后把栈寄存器存放到a0中,并进入__am_irq_handle()
函数中。
Context* __am_irq_handle(Context *c) {if (user_handler) {Event ev = {0};switch (c->mcause) {case 11:ev.event=EVENT_YIELD;if(c->GPR1!=-1)ev.event = EVENT_SYSCALL;c->mepc += 4;break;default: ev.event = EVENT_ERROR; break;}//printf("mcause = %s\n",c->mcause);c = user_handler(ev, c); //调用之前注册的handlerassert(c != NULL);}return c;
}
mcause=11于是ev.event被赋值为EVENT_YIELD
,且mepc += 4,用于跑到错误的下一条指令去了。然后调用之前注册的回调函数,也就是
Context *simple_trap(Event ev, Context *ctx) {switch(ev.event) {case EVENT_IRQ_TIMER:putch('t'); break;case EVENT_IRQ_IODEV:putch('d'); break;case EVENT_YIELD:putch('y'); break;default:panic("Unhandled event"); break;}return ctx;
}
由于event是EVENT_YIELD
,于是打印出了y。
然后保存现场,把刚改变的东西都还原回去,随后执行mret
指令。
看下mret指令要我们干什么
INSTPAT("0011000 00010 00000 000 0000 011100 11", mret , R, s->dnpc = cpu.mepc);
跳回到刚发生异常的下一个函数去了,也就是进入下一个while(1)中了,于是这样反复循环。