批处理系统
当计算机执行完一条指令的时候, 就自动执行下一条指令. 类似的, 我们能不能让管理员事先准备好一组程序, 让计算机执行完一个程序之后, 就自动执行下一个程序呢?
这就是批处理系统的思想
最简单的操作系统
Nanos-lite是运行在AM之上, AM的API在Nanos-lite中都是可用的. 虽然操作系统对我们来说是一个特殊的概念, 但在AM看来, 它只是一个调用AM API的普通C程序而已, 和超级玛丽没什么区别,我们可以将操作系统看为一个C程序,该C程序能够对不同的程序进行调用顺序的排列,能够提供一些系统调用。
要实现一个最简单的操作系统, 就要实现以下两点功能:
- 用户程序执行结束之后, 可以跳转到操作系统的代码继续执行
- 操作系统可以加载一个新的用户程序来执行
穿越时空的旅程
为了实现最简单的操作系统, 硬件还需要提供一种可以限制入口的执行流切换方式. 这种方式就是自陷指令, 程序执行自陷指令之后,就会陷入到操作系统预先设置好的跳转目标.
riscv32 自陷指令
riscv32提供ecall指令作为自陷指令, 并提供一个mtvec寄存器来存放异常入口地址. riscv32通过mret指令从异常处理过程中返回, 它将根据mepc寄存器恢复PC
CTE定义了名为"事件"的如下数据结构
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;
描述上下文的结构体类型
struct Context {
// TODO: fix the order of these members to match trap.S
uintptr_t gpr[32];
uintptr_t mcause, mstatus, mepc;
void *pdir;
uintptr_t np;
};
gpr为通用寄存器,mcause为csr异常号存储寄存器,mstatus为csr状态寄存器,mepc为异常地址寄存器
Context结构中的成员. CTE也提供了一些的接口, 来让操作系统在必要的时候访问它们, 从而保证操作系统的相关代码与架构无关.最后还有另外两个统一的API:
- bool cte_init(Context* (*handler)(Event ev, Context *ctx))用于进行CTE相关的初始化操作. 其中它还接受一个来自操作系统的事件处理回调函数的指针, 当发生事件时, CTE将会把事件和相关的上下文作为参数, 来调用这个回调函数, 交由操作系统进行后续处理.
- void yield()用于进行自陷操作, 会触发一个编号为EVENT_YIELD事件. 不同的ISA会使用不同的自陷指令来触发自陷操作, 具体实现请RTFSC.
isa_raise_intr()实现 思路
SR[mepc] <- PC
SR[mcause] <- 一个描述失败原因的号码
PC <- SR[mtvec]
在进行指令调用时记录下失败的原因,当前的PC(与x86不同,所以在mret时需要软件进行+4操作)
通过阅读cte_init()发现入口为**__am_asm_trap**函数,该函数定义如下:(在trap.s的文件中)
#define concat_temp(x, y) x ## y
#define concat(x, y) concat_temp(x, y)
#define MAP(c, f) c(f)
#if __riscv_xlen == 32
#define LOAD lw
#define STORE sw
#define XLEN 4
#else
#define LOAD ld
#define STORE sd
#define XLEN 8
#endif
#define REGS(f) \
f( 1) f( 3) f( 4) f( 5) f( 6) f( 7) f( 8) f( 9) \
f(10) f(11) f(12) f(13) f(14) f(15) f(16) f(17) f(18) f(19) \
f(20) f(21) f(22) f(23) f(24) f(25) f(26) f(27) f(28) f(29) \
f(30) f(31)
#define PUSH(n) STORE concat(x, n), (n * XLEN)(sp);
#define POP(n) LOAD concat(x, n), (n * XLEN)(sp);
#define CONTEXT_SIZE ((32 + 3 + 1) * XLEN)
#define OFFSET_SP ( 2 * XLEN)
#define OFFSET_CAUSE (32 * XLEN)
#define OFFSET_STATUS (33 * XLEN)
#define OFFSET_EPC (34 * XLEN)
.align 3
.globl __am_asm_trap
__am_asm_trap:
addi sp, sp, -CONTEXT_SIZE
MAP(REGS, PUSH)
csrr t0, mcause
csrr t1, mstatus
csrr t2, mepc
STORE t0, OFFSET_CAUSE(sp)
STORE t1, OFFSET_STATUS(sp)
STORE t2, OFFSET_EPC(sp)
# set mstatus.MPRV to pass difftest
li a0, (1 << 17)
or t1, t1, a0
csrw mstatus, t1
mv a0, sp
jal __am_irq_handle
LOAD t1, OFFSET_STATUS(sp)
LOAD t2, OFFSET_EPC(sp)
csrw mstatus, t1
csrw mepc, t2
MAP(REGS, POP)
addi sp, sp, CONTEXT_SIZE
mret
通过store指令以及load指令对寄存器状态进行存储,随后在trap.s指令jal __am_irq_handle,中进行异常处理。
重新组织Context
结构体 思路
通过观察trap.s中我们发现上下文存储的方式为先存储32个通用寄存器随后存储mcause mstatus mepc,具体上面给出
事件分发
__am_irq_handle()的代码会把执行流切换的原因打包成事件, 然后调用在cte_init()中注册的事件处理回调函数, 将事件交给Nanos-lite来处理. 在Nanos-lite中, 这一回调函数是nanos-lite/src/irq.c中的do_event()函数. do_event()函数会根据事件类型再次进行分发. 不过我们在这里会触发一个未处理的4号事件:
实现正确的事件分发 思路
通过指令ecall进行异常号赋值,随后在__am_irq_handle中通过异常号,对event进行赋值,从而在do_event()中进行相应的操作
恢复上下文
因为进入了异常中我们对寄存器进行了一些操作,但是在异常处理完毕之后,我们要对上下文的内容进行恢复,在trap.s代码中。为了实现软件+4 我们需要在识别到事件时,对上下文中mepc进行+4即可,最后trap.s中的mret就会跳转到这个pc实现上下文的恢复。
异常处理的踪迹 - etrace 思路
在event处理时,通过printf实现记录当下的事件以及上下文状态的输出即可
用户程序和系统调用
在操作系统中, 加载用户程序是由 loader(加载器) 模块负责的。加载的过程就是把可执行文件中的代码和数据放置在正确的内存位置, 然后跳转到程序入口, 程序就开始执行了。简单来说,实现 loader 的最大困难就在于如何解析 ELF 文件。弄懂了这点,实现 loader 就没什么困难了。
让我们先来梳理一下实现 loader 需要解决的问题:
1. 可执行文件在哪里?
2. 代码和数据在可执行文件的哪个位置?
3. 代码和数据有多少?
4. “正确的内存位置”在哪里?
segment位于程序头表中,其中记录了类型, 虚拟地址, 标志, 对齐方式, 以及文件内偏移量和 segment 大小。
具体的可以通过 man elf进行查看
系统调用
在GNU/linux中,用户程序通过自陷指令来触发系统调用。我们能够通过yield 触发自陷指令实现系统调用,由于我们之前存储了上下文信息,上下文信息中包括寄存器,所以阔以通过指定的寄存器进行读取系统调用所需要的参数。
intptr_t _syscall_(intptr_t type, intptr_t a0, intptr_t a1, intptr_t a2) {
// ...
asm volatile (SYSCALL : "=r" (ret) : "r"(_gpr1), "r"(_gpr2), "r"(_gpr3), "r"(_gpr4));
return ret;
}
通过在syscall.c文件中我们阔以看到不同的指令集对应的寄存器使用不同:
#if defined(__ISA_X86__)
# define ARGS_ARRAY ("int $0x80", "eax", "ebx", "ecx", "edx", "eax")
#elif defined(__ISA_MIPS32__)
# define ARGS_ARRAY ("syscall", "v0", "a0", "a1", "a2", "v0")
#elif defined(__ISA_RISCV32__) || defined(__ISA_RISCV64__)
# define ARGS_ARRAY ("ecall", "a7", "a0", "a1", "a2", "a0")
#elif defined(__ISA_AM_NATIVE__)
# define ARGS_ARRAY ("call *0x100000", "rdi", "rsi", "rdx", "rcx", "rax")
#elif defined(__ISA_X86_64__)
# define ARGS_ARRAY ("int $0x80", "rdi", "rsi", "rdx", "rcx", "rax")
#elif defined(__ISA_LOONGARCH32R__)
# define ARGS_ARRAY ("syscall 0", "a7", "a0", "a1", "a2", "a0")
#else
#error _syscall_ is not implemented
#endif
实现SYS_yield系统调用 思路
通过上述指令集对应我们发现,
#define SYSCALL _args(0, ARGS_ARRAY)
#define GPR1 _args(1, ARGS_ARRAY)
#define GPR2 _args(2, ARGS_ARRAY)
#define GPR3 _args(3, ARGS_ARRAY)
#define GPR4 _args(4, ARGS_ARRAY)
#define GPRx _args(5, ARGS_ARRAY)
所以GPR1 -4 x 分别对应 “a7”, “a0”, “a1”, “a2”, “a0”
系统调用的踪迹 - strace 思路
通过在system.c中通过printf输出相应的系统调用名称与参数即可
在Nanos-lite上运行Hello world
write会进行系统调用,在系统调用中将相应的buf输出即可
实现堆区管理 思路
write的test代码如下,
#include <unistd.h>
#include <stdio.h>
int main() {
write(1, "Hello World!\n", 13);
int i = 2;
volatile int j = 0;
while (1) {
j ++;
if (j == 10000) {
printf("Hello World from Navy-apps for the %dth time!\n", i ++);
j = 0;
}
}
return 0;
}
我们在PA1的时候提示过大家, 使用printf()调试时需要添加\n, 现在终于可以解释为什么了: fwrite()的实现中有缓冲区, printf()打印的字符不一定会马上通过write()系统调用输出, 但遇到\n时可以强行将缓冲区中的内容进行输出