Skip to content

浙江大学实验报告

:material-circle-edit-outline: 约 2591 个字 :fontawesome-solid-code: 183 行代码 :material-clock-time-two-outline: 预计阅读时间 11 分钟

课程名称:操作系统 实验类型:综合型/设计型

实验项目名称:实验0 RV64 环境搭建和内核编译

学生姓名:俞仲炜 专业:计算机科学与技术 学号:3220104929

电子邮件地址:zhongweiy@zju.edu.cn 实验日期:2024.9.16

一、实验内容或步骤

包括思考题回答

实验内容

RV64 内核引导

完善 Makefile 脚本

注意到 lib 目录的 Makefile 功能是编译该目录下的 .c文件 ,参照隔壁 init 目录里同样功能的 Makefile 文件完成:

# set variable
C_SRC       = $(sort $(wildcard *.c))   # wildcard is to get all the .c of the directotry
OBJ         = $(patsubst %.c,%.o,$(C_SRC))  # patsubst is to replace the name

all:$(OBJ)

%.o:%.c
    ${GCC} ${CFLAG} -c $<   # $< the first dependent file
clean:
    $(shell rm *.o 2>/dev/null) 
编写 head.S

计算机上电后,首先硬件进行一些基础的初始化后,将 CPU 的 Program Counter 移动到内存中 bootloader 的起始地址

Bootloader 是操作系统内核运行之前,用于初始化硬件,加载操作系统内核

在 RISC-V 架构里,bootloader 运行在 M 模式下,运行完毕后就会把当前模式切换到 S 模式下,机器随后开始运行 kernel

   Hardware             RISC-V M Mode           RISC-V S Mode 
+------------+         +--------------+         +----------+
|  Power On  |  ---->  |  Bootloader  |  ---->  |  Kernel  |
+------------+         +--------------+         +----------+

head.S 负责为内核主程序 start_kernel 分配程序栈并跳转到目标函数

    .extern start_kernel
    .section .text.init
    .globl _start
_start:
# 
    la sp,boot_stack_top # initiate
    call start_kernel # goto start_kernel

    .section .bss.stack
    .globl boot_stack
boot_stack:
    .space 4096 # leave enough stack space for start_kernel

    .globl boot_stack_top
boot_stack_top:
补充 sbi.c

SBI(Supervisor Binary Interface)是 S-mode 的 Kernel 和 M-mode 执行环境之间的接口规范,而 \(OpenSBI\) 是一个 RISC-V SBI 规范的开源实现,提出了一系列规范对 M-mode 下的硬件进行了统一定义,运行在 S-mode 下的内核可以按照这些规范对不同硬件进行操作

我们选择 \(OpenSBI\) 作为 bootloader 来完成机器启动时 M-mode 下的硬件初始化与寄存器设置,并使用 \(OpenSBI\) 所提供的接口完成诸如字符打印的操作

QEMU 已经内置了 \(OpenSBI\) 作为 bootloader,QEMU 会将 \(OpenSBI\) 代码加载到 0x80000000 起始处

\(OpenSBI\) 初始化完成后,会跳转到 0x80200000 处(也就是 kernel 的起始地址)。因此,我们所编译的代码需要放到 0x80200000

调用 \(OpenSBI\) 接口的过程被封装在 sbi_call,在调用接口前,需要先将寄存器 a[0-7] 的值修改为传入参数的值,在从 \(OpenSBI\) 返回后,还需要从寄存器内取回接口的返回值

struct sbiret sbi_ecall(int ext, int fid, uint64 arg0,
                        uint64 arg1, uint64 arg2,
                        uint64 arg3, uint64 arg4,
                        uint64 arg5) 
{
    struct sbiret res;
    uint64 error, value;
    // unimplemented     
    asm volatile (
        "mv a7, %[ext]\n"
        "mv a6, %[fid]\n"
        "mv a0, %[arg0]\n"
        "mv a1, %[arg1]\n"
        "mv a2, %[arg2]\n"
        "mv a3, %[arg3]\n"
        "mv a4, %[arg4]\n"
        "mv a5, %[arg5]\n"
        "ecall\n" // environment call, 转移控制权
        "mv %[error], a0\n"
        "mv %[value], a1\n"
        : [error] "=r" (error), [value] "=r" (value)
        : [ext] "r" (ext), [fid] "r" (fid), [arg0] "r" (arg0), [arg1] "r" (arg1), [arg2] "r" (arg2), [arg3] "r" (arg3), [arg4] "r" (arg4), [arg5] "r" (arg5)
        : "memory"
    );
    res.error = error;
    res.value = value;

    return res;
}

三个 : 将汇编部分分成了四部分:

  • 第一部分是汇编指令,指令末尾需要添加 \n
  • 第二部分是输出操作数部分;
  • 第三部分是输入操作数部分;
  • 第四部分是可能影响的寄存器或存储器,用于告知编译器当前内联汇编语句可能会对某些寄存器或内存进行修改,使得编译器在优化时将其因素考虑进去。

这四部分中后三部分不是必须的

修改 defs

参考同文件下的 csr_write,可知待补全宏用于读取 csr ,使用 csrr 伪指令即可

#define csr_read(csr)                       \
({                                          \
    register uint64 __v;                    \
    /* unimplemented */                     \
    asm volatile ("csrr %0, " #csr           \
                  : "=r" (__v)              \
                  :                         \
                  : "memory");              \
    __v;                                    \
})

GNU ld 即链接器,用于将 *.o 文件(和库文件)链接成可执行文件

在操作系统开发中,为了指定程序的内存布局,ld 使用链接脚本(Linker Script)来控制,在 Linux kernel 中链接脚本被命名为 vmlinux.lds

vmlinux 通常指 Linux kernel 编译出的可执行文件(Executable and Linkable Format,ELF),特点是未压缩的,带调试信息和符号表的

在整套 OS 实验中,vmlinux 通常指将你的代码进行编译,链接后生成的可供 QEMU 运行的 RV64 架构程序

RV64 时钟中断处理

我们已经成功启动了一个最简单的 OS,但还没有办法与之交互

操作系统启动之后由事件event)驱动,我们引入事件 trap

trap 给了 OS 与硬件、软件交互的能力,在 boot 阶段,OpenSBI 已经将 M 态的 trap 处理进行了初始化,后续重点关注 S 态的 trap 处理

We use the term trap to refer to the transfer of control to a trap handler caused by either an exception or an interrupt

开启 trap 处理

trap 描述的是一种控制转移的过程,这个过程是由 interrupt 或者 exception 引起的,我们在这里约定 trap 为 interrput 与 exception 的总称

相关寄存器

除了 32 个通用寄存器之外,RISC-V 架构还有大量的控制状态寄存器(Control and Status Registers,CSRs),下面将介绍几个和 trap 机制相关的重要寄存器。

Supervisor Mode 下 trap 相关寄存器 :

  • sstatus(Supervisor Status Register)中存在一个 SIE(Supervisor Interrupt Enable)比特位,当该比特位设置为 1 时,会响应所有的 S 态 trap,否则将会禁用所有 S 态 trap。
  • sie(Supervisor Interrupt Enable Register),在 RISC-V 中,interrupt 被划分为三类 software interrupt、timer interrupt、external interrupt。在开启了 sstatus[SIE] 之后,系统会根据 sie 中的相关比特位来决定是否对该 interrupt 进行处理
  • stvec(Supervisor Trap Vector Base Address Register)即所谓的“中断向量表基址”。stvec有两种模式:
    • Direct 模式,适用于系统中只有一个中断处理程序,其指向中断处理入口函数(本次实验中我们所用的模式)。
    • Vectored 模式,指向中断向量表,适用于系统中有多个中断处理程序(该模式可以参考 RISC-V 内核源码)。
  • scause(Supervisor Cause Register),会记录 trap 发生的原因,还会记录该 trap 是 interrupt 还是 exception。
  • sepc(Supervisor Exception Program Counter),会记录触发 exception 的那条指令的地址。
相关特权指令
  • ecall(Environment Call):
    • 当我们在 S 态执行这条指令时,会触发一个 ecall-from-s-mode-exception,从而进入 M Mode 下的处理流程(如设置定时器等)
    • 当我们在 U 态执行这条指令时,会触发一个 ecall-from-u-mode-exception,从而进入 S Mode 下的处理流程(常用来进行系统调用);
  • sret 用于 S 态 trap 返回,通过 sepc 来设置 pc 的值,返回到之前程序继续运行。
    .extern start_kernel
    .section .text.init
    .globl _start
_start:
    # (previous) initialize stack
    la sp,boot_stack_top # initial the sp

    # set stvec = _traps
    la t0, _traps
    csrw stvec, t0 # csrrw is reg2reg, while _traps is a label containing a mem_addr

    # set sie[STIE] = 1,开启时钟中断
    li t0, 1<<5 # STIE is the 5_bit of sie
    csrs sie, t0 # csrs = csrs or t0

    # set first time interrupt
    rdtime a0
    li a7, 0 # eid
    li a6, 0 # fid
    li t0, 10000000 # 1s
    add a0, a0, t0

    ecall # call sbi_set_timer



    # set sstatus[SIE] = 1
    # li t0, 1<<1  # the 1_bit
    csrsi sstatus, 1<<1

    # (previous) jump to start_kernel
    call start_kernel # goto start_kernel

    .section .bss.stack
    .globl boot_stack
boot_stack:
    .space 4096 # leave enough stack space for start_kernel

    .globl boot_stack_top
boot_stack_top:
实现上下文切换

在处理 trap 时,有可能会改变系统的状态。所以在真正处理 trap 之前,我们有必要对系统的当前状态进行保存,在处理完成之后,我们再将系统恢复至原先的状态,就可以确保之前的程序继续正常运行

这里的系统状态通常是指寄存器,这些寄存器也叫做 CPU 的上下文(Context)

    .extern trap_handler
    .section .text.entry
    .align 2
    .globl _traps 
_traps:

    # 1. save 32 registers and sepc to stack
    addi sp, sp, -264
    sd x0, 0(sp)
    sd x1, 8(sp)
    sd x2, 16(sp)
    # 报告里省略,详见源代码
    sd x31, 248(sp)
    csrr t0, sepc
    sd t0, 256(sp)

    # 2. call trap_handler
    csrr a0, scause
    csrr a1, sepc
    call trap_handler


    # 3. restore sepc and 32 registers (x2(sp) should be restore last) from stack
    ld t0, 256(sp)
    csrw sepc, t0
    ld x0, 0(sp)
    ld x1, 8(sp)
    ld x2, 16(sp)
    # 报告里省略,详见源代码
    ld x31, 248(sp)
    addi sp, sp, 264

    # 4. return from trap
    sret
实现 trap 处理函数

trap 处理程序根据 scause 的值,进入不同的处理逻辑,在本次试验中我们需要关心的只有 Superviosr Timer Interrupt。

#include "stdint.h"
#include "printk.h"

void trap_handler(uint64_t scause, uint64_t sepc) {
    // 通过 `scause` 判断 trap 类型
    // 如果是 interrupt 判断是否是 timer interrupt
    // 如果是 timer interrupt 则打印输出相关信息,并通过 `clock_set_next_event()` 设置下一次时钟中断
    // `clock_set_next_event()` 见 4.3.4 节
    // 其他 interrupt / exception 可以直接忽略,推荐打印出来供以后调试

    uint64_t itrpt = scause & (1UL << 63);
    uint64_t code = scause & -(1UL << 63);

    if(itrpt && (code == 5)){
        printk("Supervisor Mode Timer Interrupt\n");
        clock_set_next_event();
    }
}
实现时钟中断相关函数

时钟中断需要 CPU 硬件的支持,CPU 以“时钟周期”为工作的基本时间单位,对逻辑门的时序电路进行同步,而时钟中断实际上就是“每隔若干个时钟周期执行一次的程序”

下面介绍与时钟中断相关的寄存器以及如何产生时钟中断

  • mtimemtimecmp(Machine Timer Register):
    • mtime 是一个实时计时器,由硬件以恒定的频率自增
    • mtimecmp 中保存着下一次时钟中断发生的时间点,当 mtime 的值大于或等于 mtimecmp 的值,系统就会触发一次时钟中断
    • 因此我们只需要更新 mtimecmp 中的值,就可以设置下一次时钟中断的触发点。\(OpenSBI\) 已经为我们提供了更新 mtimecmp 的接口 sbi_set_timer
  • mcounteren(Counter-Enable Registers):
    • 由于 mtime 是属于 M 态的寄存器,我们在 S 态无法直接对其读写
    • 幸运的是 \(OpenSBI\) 在 M 态已经通过设置 mcounteren 寄存器的 TM 比特位,让我们可以在 S 态中可以通过 time 这个只读寄存器读取到 mtime 的当前值,相关汇编指令是 rdtime
#include "stdint.h"

#include "sbi.h"

// QEMU 中时钟的频率是 10MHz,也就是 1 秒钟相当于 10000000 个时钟周期
uint64_t TIMECLOCK = 10000000;

uint64_t get_cycles() {
    // 编写内联汇编,使用 rdtime 获取 time 寄存器中(也就是 mtime 寄存器)的值并返回
    uint64_t cycles;
    asm volatile(
        "rdtime %0\n" 
        : "=r"(cycles)
        :
        : "memory"
    );
    return cycles;
}

void clock_set_next_event() {
    // 下一次时钟中断的时间点
    uint64_t next = get_cycles() + TIMECLOCK;

    // 使用 sbi_set_timer 来完成对下一次时钟中断的设置
    sbi_ecall(0, 0, next, 0, 0, 0, 0, 0);
}

思考题

请总结一下 RISC-V 的 calling convention,并解释 Caller / Callee Saved Register 有什么区别?

传递参数时,会尽可能多的使用寄存器传递。使用a0-a7 八个整形寄存器,以及fa0-fa7 八个浮点寄存器来实现。更多放不下的参数会通过栈传递。

具体而言,如果传递的第i个参数是float数据类型,并且参数个数小于8,那么就用第i个浮点寄存器fai来传递,其他的数据类型用第i个整形寄存器ai来传递。

返回值从整数寄存器a0a1以及浮点寄存器fa0fa1返回。

Caller / Callee Saved Register 区别
寄存器类型 含义 谁负责保存
Caller Saved 调用者调用函数前需进行保存,以备不时之需 调用者
Callee Saved 被调用者在修改这些寄存器前需进行保存,以免破坏数据 被调用者

编译之后,通过 System.map 查看 vmlinux.lds 中自定义符号的值并截图。

对于计算机而言是没有符号这个概念的,只有0 和 1,但是我们比较容易理解的是函数名、变量名这样的符号。

在Linux 内核中用 System.map 来记录Linux 内核中的符号信息,称为内核的符号表,该文件会在每次内核编译的时候,都会产生一个新的对应的 System.map 文件。

在内核运行出错时,通过 System.map 中的符号表解析,就可以查到一个地址值对应的变量名、函数名。

System.map 文件是通过 nm vmlinux 命令重定向到文件中产生的。

nm 是一个用于显示目标文件中的符号信息的工具,而 vmlinux 则是 Linux 内核编译生成的原始可执行文件。

csr_read 宏读取 sstatus 寄存器的值,对照 RISC-V 手册解释其含义并截图。

csr_write 宏向 sscratch 寄存器写入数据,并验证是否写入成功并截图。

详细描述你可以通过什么步骤来得到 arch/arm64/kernel/sys.i,给出过程以及截图。

寻找 Linux v6.0 中 ARM32 RV32 RV64 x86_64 架构的系统调用表;请列出源代码文件,展示完整的系统调用表(宏展开后),每一步都需要截图。

阐述什么是 ELF 文件?尝试使用 readelf 和 objdump 来查看 ELF 文件,并给出解释和截图。运行一个 ELF 文件,然后通过 cat /proc/PID/maps 来给出其内存布局并截图。

通过查看 RISC-V Privileged Spec 中的 medelegmideleg 部分,解释上面 MIDELEGMEDELEG 值的含义。

二、讨论、心得

实验中遇到的问题和解决方案