本篇文章开始记录一些ucore的学习例程,以nku的2024实验作业为例

myblog:https://moyingxing.github.io/

参考链接

OpenSBI,bin,ELF · GitBook (mobisys.cc)

https://riscv.org/wp-content/uploads/2017/05/riscv-privileged-v1.10.pdf

https://tinylab.org/riscv-uefi-part1/

OpenSBI,bin,ELF · GitBook (mobisys.cc)

环境配置

os:wsl ubuntu22.04

gcc(交叉编译器手动编译):13.2.0

qemu:QEMU emulator version 6.2.0 (Debian 1:6.2+dfsg-2ubuntu6.22)

代码资源:OS Labs (mobisys.cc)

前置基础

因为本实验是以riscv-64位基础架构开发的os,因此需要理解此架构提供的一些硬件资源(如寄存器等等)和一些资源管理模式(第一感觉类似于x86架构下的GDT,LDT,IDT之类),因为是os级别的开发,所以只涉及到特权架构。

RISC-V 特权软件栈

描述在 RISC-V 处理器上运行的特权模式下的软件架构、模式和组件。十分的接口化和模块化,这块感觉和amd64相比是比较规范的,amd64基本上没有这么的考虑接口这些个东西。

image-20240831162403318

本图从总体上描述了riscv上运行的软件的运行模式,任意两层之间的交互全都是通过二进制接口进行的,如中间的图像,os的实现可以调用下一层提供的Supervisor二进制接口来进行实现(这大概能保证os的移植性吧不知道,没体会到过),而SEE则是二进制接口的执行环境,对于os来说这大概就是在装载os之前实现的吧。最后一幅图主要讲的是虚拟机的实现方式,不是本次实验的重点,主要理解中间这副图即可。

特权等级

接触过内核的都知道,指令是有权限的,比如一个用户级别的指令可以影响os级别的mmu的映射方式,那么一些恶意软件只要控制了一个用户级别的程序则可以控制你的电脑,都没有LPE什么事了,这岂不是乱套了?

image-20240831163159340

可以看出riscv的架构分别有三种安全模式,分别为U,S,M,安全模式相关的信息存储在CSR寄存器中,因此当cpu进行指令执行时,会先检查CSR寄存器来判断是否有权限来执行此指令。越权访问将导致异常,会陷入异常处理程序。若使用riscv架构,那么硬件方面必须实现M特权,而其他特权是可选的。不同的特权有着不同的指令集扩展,需要深入学习。

关于qemu在装载os前做的准备

riscv架构下,os的装载基址在物理地址0x80000000(此时未启动mmu),在执行os代码前,会先进行一些硬件环境初始化工作,从上面的介绍可以看出来,下面将一一介绍和学习。

BIOS,UEFI和U-BOOT此三类程序才是一个计算机启动时首先运行的,但是BIOS经过一些列的标准化等等发展成了UEFI,而U-BOOT主要是用于嵌入式系统,经过一些列的初始化装载操作会执行一个叫做bootloader的东西,这个是os的装载的核心部分。可以看出bootloader在运行之前也是做了一些准备的,具体是啥不用关系,对学os没什么阻碍。至于实验文档上说的QEMU模拟的这款riscv处理器的复位地址是0x1000,而不是0x80000000估计也是这个原因吧。而在riscv架构下的bootloader则是OpenSBI。

关于opensbi的源代码可以通过下述命令来获取git clone https://gitee.com/tinylab/qemu-opensbi.git,在其中找到firmware文件夹,可以通过连接控制脚本来找到程序的入口点为__start,代码比较长就复制一下前几部分:

_start:
/* Find preferred boot HART id */
MOV_3R s0, a0, s1, a1, s2, a2
call fw_boot_hart
add a6, a0, zero
MOV_3R a0, s0, a1, s1, a2, s2
li a7, -1
beq a6, a7, _try_lottery
/* Jump to relocation wait loop if we are not boot hart */
bne a0, a6, _wait_relocate_copy_done
_try_lottery:
/* Jump to relocation wait loop if we don't get relocation lottery */
lla a6, _relocate_lottery
li a7, 1
amoadd.w a6, a7, (a6)
bnez a6, _wait_relocate_copy_done

/* Save load address */
lla t0, _load_start
lla t1, _fw_start
REG_S t1, 0(t0)

调试(研究os装载例程)

有了上述基础那么就可以开始调试环节了,利用make qemu来编译源代码,make gdb来调试,将makefile中的make qemu更改一下不然会有问题(至少我的有….)

......................
.PHONY: qemu
qemu: $(UCOREIMG) $(SWAPIMG) $(SFSIMG)
# $(V)$(QEMU) -kernel $(UCOREIMG) -nographic
$(V)$(QEMU) \
-machine virt \
-nographic \
-bios default \
-S \ ##启动时暂停便于调试
-gdb tcp::12345 \ ## gdb监听端口
-kernel $(UCOREIMG) ## 新增
## -device loader,file=$(UCOREIMG),addr=0x80200000 ## 注释掉此部分
.............................

qemu的路径和gdb的一些路径改一下,改成你安装的bin文件中的可执行程序的文件名。gdb我没有安装riscv版本的,直接用的gdb-multiach,可供参考。

可观察到复位地址以及阻塞地址,和上面讲的都是一样的

image-20240831175014062

image-20240831175046980

opensbi

将断点打到0x80000000,然后continue,观察一下opensbi的指令看看是否和先前分析的一样。

image-20240831175528255

_start:
/* Find preferred boot HART id */
MOV_3R s0, a0, s1, a1, s2, a2
call fw_boot_hart
add a6, a0, zero
MOV_3R a0, s0, a1, s1, a2, s2
li a7, -1
beq a6, a7, _try_lottery
/* Jump to relocation wait loop if we are not boot hart */
bne a0, a6, _wait_relocate_copy_done

tql,确实是一样的,之前分析的没有一点问题。具体函数功能就不做分析了,整个opensbi估计够我学好长时间了,以后有机会的话再学习吧。

os加载

opensbi就是实现see为os提供sbi的接口,因此opensbi是运作在m态之上的。接下来解释os的加载了,os的入口点也是在链接控制脚本中指定,这是本实验的连接控制脚本所指定的入口点,至于段合并之类的就不展示了,本质就是手动控制一些program segment的合并,精确控制每一个segment的基址,毕竟os之前没有加载器之类的东西,默认链接脚本生成的可执行文件现在的机器是不认识的。接下来就是找入口点地址,利用p指令可以打印出来,这里面还有一个坑,就是此内核的默认make是去掉符号表的,因此手动改一下吧:手动搜索strip,把这个删掉就可以了,gdb也要执行file bin/kernel才行,不知道为什么。

/* Simple linker script for the ucore kernel.
See the GNU ld 'info' manual ("info ld") to learn the syntax. */

OUTPUT_ARCH(riscv)
ENTRY(kern_entry)
...............

image-20240831181055849

可以看见入口点就做两件事,调整sp到bootstacktop并加载正真的kernel的初始函数。

image-20240831181457737

os运行

继续打断点,然后运行,这就是我们实验上写的内核的初始函数喽,调用sbi接口进行打印,然后坠入死循环(最后一条指令),因此qemu就直接变砖了哈哈哈,只能强制关闭shell才行,终端都相应不了了,这也许就是下一次实验实现中断的原因吧。

image-20240831182044836

可以观察一下qemu的执行结果,会打印出咱们想要的

image-20240831182427141

可以看出程序已经跟死了没啥差别了。。。。

image-20240831182501902

中断功能加入

在做此实验之前需要一些前置知识

特权指令

SYSTEM操作码在RISC-V指令集中承担着编码所有特权指令的角色。特权指令是那些只能由具有特权的处理器模式(例如内核模式)执行的指令。这些指令分为两大类:

  1. 原子性读-修改-写CSRs的指令:这些指令是对控制和状态寄存器(CSRs)进行操作的指令。CSRs是RISC-V处理器中的特殊寄存器,用于存储与处理器的控制和状态相关的信息,例如中断处理、异常状态、性能计数器等。原子性意味着这些指令可以确保在进行读取、修改和写入操作时,不会被其他指令打断,从而保证操作的完整性。
  2. 其他特权指令:除了操作CSRs的指令,其他特权指令还包括许多与处理器的特权模式相关的操作,例如切换处理器模式、管理虚拟内存、处理异常和中断等。这些指令通常和处理器的管理功能紧密相关。

CSRs

RISC-V为CSR预留了一个12位的地址空间,即每个CSR的地址由12位组成。这12位地址中的高4位(csr[11:8])用于定义CSR的访问权限:

  1. **csr[11:10]**:这两位用于指示该CSR是可读写还是只读。不同的组合表示不同的读写权限:
    • 000110 表示该CSR是可读写的。
    • 11 表示该CSR是只读的。
  2. **csr[9:8]**:这两位用于定义最低能够访问该CSR的特权级别。RISC-V有多个特权级别,如用户模式、监督模式和机器模式,csr[9:8]的值决定了哪一个特权级别的代码可以访问这个寄存器。

image-20240831214926417

CSR字段规范(filed specification)

WIRI(保留写入忽略,读取忽略值)

  • 这些字段是保留供将来使用的。写入这些字段时,硬件会忽略写入的值,读取时返回的值无意义。此标签主要用于那些在当前没有明确用途的CSR字段,以避免未来使用中的冲突。

WPRI(保留写入保留值,读取忽略值)

  • 当你写入一个寄存器时,如果该寄存器包含WPRI字段,硬件会保留这些字段的现有值,而忽略你试图写入的新值。这确保了当将来这些字段有实际用途时,它们的值不会因先前的操作而被破坏。

WRL(写入/读取仅合法值)

  • 对这些字段的写入和读取仅支持特定的合法值。如果你写入一个不合法的值,硬件可能不会引发异常,但读取的结果可能是未定义的。通常,硬件会返回最后一个合法值,或根据某些硬件状态位返回其他值。

WARL(写入任意值,读取合法值)

  • 这些字段允许你写入任何值,但硬件会自动确保读取时返回合法的值。这个机制使得即使软件写入了非标准值,硬件也能保证系统的稳定性和一致性。

Supervisor-Level ISA

因为是操作系统实验,故跳过了m态的部分,其依赖于硬件提供的sbi接口

Supervisor Status Register (sstatus)

image-20240831215741967

SPP(Supervisor Previous Privilege):

  • 位置: 在 RV32 中位于第 8 位,在 RV64 和 RV128 中位于第 20 位。

  • 功能

    : 该位用于指示陷入监督模式之前处理器的特权级别。

    • 如果陷入(trap)之前处理器是在用户模式(User Mode)下运行,则该位为0。
    • 如果是在监督模式(Supervisor Mode)下运行,则该位为1。
  • 使用场景: 当使用 SRET 指令从陷阱(trap)返回时,处理器会根据该位决定返回到用户模式或监督模式。

SIE(Supervisor Interrupt Enable):

  • 位置: 在 RV32 中位于第 1 位,在 RV64 和 RV128 中位于第 1 位。

  • 功能

    : 该位控制监督模式下的全局中断使能。

    • 当 SIE 为 1 时,监督模式中断被使能。
    • 当 SIE 为 0 时,所有监督模式下的中断被禁用。
  • 使用场景: 在处理器需要临时屏蔽中断时,OS 可以清除 SIE 位,以确保中断不会打断关键的代码执行。

SPIE(Supervisor Previous Interrupt Enable):

  • 位置: 在 RV32 中位于第 5 位,在 RV64 和 RV128 中位于第 5 位。

  • 功能

    : 该位保存了陷入监督模式前 SIE 位的值。

    • 当陷阱发生时,SIE 位的值会被保存到 SPIE 中,并且 SIE 位被清除(禁用中断)。
    • 当执行 SRET 指令返回时,SIE 位将被恢复为 SPIE 的值。
  • 使用场景: 这确保了中断状态在陷入和返回过程中的一致性。

UPIE(User Previous Interrupt Enable):

  • 位置: 在 RV32 中位于第 4 位,在 RV64 和 RV128 中位于第 4 位。

  • 功能

    : 该位保存了陷入用户模式前的用户模式中断使能状态。

    • 类似于 SPIE,当处理器从用户模式陷入监督模式时,UPIE 保存用户模式的中断使能状态。
    • URET 指令被执行时,UIE 位会被恢复为 UPIE 的值。
  • 使用场景: 用于管理用户模式的中断状态,确保当用户模式任务恢复执行时,其中断状态与陷入前一致。

UIE(User Interrupt Enable):

  • 位置: 在 RV32 中位于第 0 位,在 RV64 和 RV128 中位于第 0 位。

  • 功能

    : 该位控制用户模式下的全局中断使能。

    • 当 UIE 为 1 时,用户模式中断被使能。
    • 当 UIE 为 0 时,用户模式中断被禁用。
  • 使用场景: 在用户模式任务需要屏蔽中断时,OS 可以操作 UIE 位。

Supervisor Trap Vector Base Address Register (stvec)

image-20240831221145005

关于mode

image-20240831221210957

Supervisor Interrupt Registers (sip and sie)

分别代表着中断挂起(pending)和中断使能(enable)是对每种类型中断的精确控制,主要分为这三种:software interrupts, timer interrupts, and external interrupts,软件中断又分为u型和s型

Supervisor Timers and Performance Counters

scounteren 是一个用于控制用户模式(U-mode)下硬件性能监控计数器(Hardware Performance Monitoring Counters)可用性的寄存器。通过设置或清除 scounteren 寄存器中的各个位,监督模式(S-mode)可以控制用户模式下对特定计数器的访问权限。

Supervisor Scratch Register (sscratch)

sscratch 寄存器是一个 XLEN 位的读/写寄存器,专门供监督模式(Supervisor Mode)使用。这个寄存器通常用来保存当前硬件线程(hart)在执行用户代码时监督模式的上下文指针。

典型用途

  • 存储指针sscratch 寄存器通常用于存储一个指向 hart 本地监督模式上下文的指针。当处理器在用户模式下运行时,sscratch 中会保存与监督模式相关的关键信息,比如栈指针或数据结构的地址。
  • 陷阱处理程序初始化:在发生陷阱(trap)时,处理器会切换到监督模式,sscratch 寄存器的内容可以与某个用户寄存器的内容交换。这种交换机制允许陷阱处理程序快速获得一个可用的工作寄存器,以便开始处理陷阱。例如,陷阱处理程序可以将 sscratch 的值加载到一个通用寄存器中,以便使用监督模式的栈指针。

Supervisor Exception Program Counter (sepc)

sepc 是一个 XLEN 位的读/写寄存器,用于存储发生异常时的程序计数器(PC)值。这个寄存器在监督模式(S-mode)下使用,通常用于记录导致异常的指令地址,以便在处理完异常后能够恢复程序执行。

寄存器结构

  • 最低位 sepc[0]: 永远为零。这是因为在RISC-V中,指令的对齐要求至少为16位或32位,因此程序计数器的最低一位不可能为1。
  • 最低两位 sepc[1:0]: 对于不支持16位指令对齐的实现,这两位始终为零。这确保了程序计数器指向的是一个有效的指令地址。

Supervisor Cause Register (scause)

指示引发中断的原因

Supervisor Trap Value (stval) Register

stval 寄存器 是一个 XLEN 位的可读写寄存器(XLEN 是架构的字长,如 32 位或 64 位)。当系统进入 S 模式(Supervisor mode)并处理陷阱(异常)时,stval 会被写入与该异常相关的特定信息,以便帮助软件处理该异常。

Supervisor Address Translation and Protection (satp) Register

和地址转换相关

lab相关

知道了部分基础就可以进行试验了,实验基本上处理的是操作系统层级的异常和中断。

1.1初始化

这一部分是初始化那些个异常寄存器,让中断以及异常发生时能挑战到相应的入口点。接下来学习此部分代码

代码如下:这里有一个问题,那就是在EBREAK之前并没有设置SIE,为什么还是能触发中断。(得到的答案是SIE只能禁止中断,而不能进制异步中断(异常))。

idt_init(); 用来初始化vector table,也就是设置中断处理的entry,主要改变的是stvec这个寄存器,通过定义在riscv.h中的一个宏来实现。

#define write_csr(reg, val) ({ \
if (__builtin_constant_p(val) && (unsigned long)(val) < 32) \
asm volatile ("csrw " #reg ", %0" :: "i"(val)); \
else \
asm volatile ("csrw " #reg ", %0" :: "r"(val)); })

本实验中需要时钟中断的触发,这需要了解sie这个寄存器,这个寄存器为os开发提供了细粒度的异常控制,具体可以控制需要触发哪个异常。代码:set_csr(sie, MIP_STIP);具体参照:

A supervisor-level timer interrupt is pending if the STIP bit in the sip register is set. Supervisorlevel timer interrupts are disabled when the STIE bit in the sie register is clear. An SBI call to the SEE may be used to clear the pending timer interrupt.

其中interrupt被分为三类,一类为soft,一类为time,一类为extern,为别对应三种细粒度的控制

最后的初始化是异常使能位的初始化,主要涉及到sstatus这个寄存器,这个寄存器控制所有中断是否能触发,若此位为0那么细粒度的控制无效。

总结来说中断的触发需要三个条件,先挂起再使能,也要保证全局的可触发性:

**sip**:相应位为1。

**sie**:相应位为1。

sstatus 中的 SIE:相应位为1;

或者异步中断(异常)(Interrupt为0)可以无条件触发。

yi

int kern_init(void) {
..................
idt_init(); // init interrupt descriptor table
//void (*invalid_function)() = (void (*)())0x80200003;
//invalid_function();
__asm__ volatile (
"EBREAK\n"
);
// rdtime in mbare mode crashes
clock_init(); // init clock interrupt

intr_enable(); // enable irq interrupt

while (1)
;
}

1.2 中断逻辑的实现之状态保存与恢复

在第一步的时候已经将地址绑定到了alltraps,write_csr(stvec, &__alltraps);,因此异常处理的实际入口在此,此函数使用汇编实现,定义在trapentry.S中,主要逻辑如下:

保存通用寄存器的状态和一些csr寄存器的状态以便于异常处理原因查找和执行流的恢复。大多数csr不用手动改变,涉及到特权模式的切换csr也会自动修改,基本上就是记录一下异常发生的位置以及异常发生的原因,最主要的还是状态的保存与切换,这就得了解sscratch的功能了,具体代码如下:

```



### 1.3 中断逻辑的实现之选择性处理

> 具体什么中断该用什么代码来处理,此程序设计十分巧妙,具体的逻辑在trap函数中,实现并不复杂,但是巧妙在你如何传递异常信息。传递信息的逻辑为将中断信息保存在栈中,并将栈指针传递给trap函数作为第一个参数。那我以后总不能以栈指针交互吧,这太麻烦了,所以就用c语言定义了结构体,这也是一种汇编和c交互的方法吧,如下:
>
> ```c
> struct trapframe {
> struct pushregs gpr;
> uintptr_t status;
> uintptr_t epc;
> uintptr_t badvaddr;
> uintptr_t cause;
> };
> __alltraps:
> SAVE_ALL
>
> move a0, sp
> jal trap
> # sp should be the same as before "jal trap"
>
> .globl __trapret
> __trapret:
> RESTORE_ALL
> # return from supervisor call
> sret

那么就可以完美传递中断信息,具体中断处理代码的实现就是简单的了

1.4 中断处理逻辑实现与检测