Skip to content

浙江大学实验报告

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

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

实验项目名称:实验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

head.S 负责对内核主程序 start_kernel 进行调用并对其分配程序栈,我们分配 4KiB 的空间

#略
_start:
    la sp,boot_stack_top # initiate
    call start_kernel # goto start_kernel

#略
boot_stack:
    .space 4096 # leave enough stack space for start_kernel

#略
补充 sbi.c

为了方便调用,我们将调用 \(OpenSBI\) 接口的过程封装在 sbi_call

调用时先将传入的参数赋予寄存器 a[0-7] ,供 \(OpenSBI\) 使用,并且将 \(OpenSBI\) 的返回值 从寄存器内取出,由于涉及到不同特权模式的处理流程,所以传入参数后需要执行 ecall ,叫 \(M\) 态去处理一下

下面为 sbi_ecall 的具体实现

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" 
        "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)
        : "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7"
    );
    res.error = error;
    res.value = value;

    return res;
}
修改 defs

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

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

完成上述步骤后,编译运行的结果如下:

做完发现忘记截这里的图了,用了找助教问问题时截的图,正好包含这里的结果,有点难看,万分抱歉

image-20241008153325549

RV64 时钟中断处理

先修改 vmlinux.lds 以及 head.S

开启 trap 处理

下为 head.S 文件代码,按照实验文档提供的框架进行具体实现:

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

    # 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
    csrs sstatus, t0
  • set stvec = _traps
    • 使用 la 获取 _traps 地址,再用 csrw 进行赋值
  • set sie[STIE] = 1
    • STIEsie 寄存器的第五位,设置为 1 以响应所有的 S 态 trap
  • set first time interrupt
    • 首先通过 rdtime 伪指令获得当前的时间,并暂存在通用寄存器 a0
    • 然后将当前时间与设置的中断间隔 10000000 相加,得到下一次触发时钟中断的时间
    • 最后利用 ecall 指令调用 \(OpenSBI\) 的接口来修改 mtimecmp
  • set sstatus[SIE] = 1
    • SIEsstatus 寄存器的第一位,设置为 1 以启用 S 模式的时钟中断,具体实现与 set sie[STIE] = 1 一致
实现上下文切换

下为 entry.S 文件代码,按照实验文档提供的框架进行具体实现:

#前略
_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 x3, 24(sp)
    ... # 报告里省略,详见源代码
    ld x31, 248(sp)
    ld x2, 16(sp)
    addi sp, sp, 264

    # 4. return from trap
    sret
  • save 32 registers and sepc to stack
    • 通过移动 sp 为保护现场预留空间,使用 sd 进行保存,注意栈是高位往低位扩张
    • 注意 sepc 作为返回地址也应保存
  • call trap_handler
    • 根据实验文档给的定义,为 trap_handler 提供 scausesepc 参数
  • restore sepc and 32 registers (x2(sp) should be restore last) from stack
    • 使用 ld 进行现场恢复
  • return from trap
    • 使用 sret 从 S 态 trap 返回
实现 trap 处理函数

下为 trap_handler 模块的具体实现:

void trap_handler(uint64_t scause, uint64_t sepc) {
    uint64_t i = 1ULL << 63;
    uint64_t itrpt = scause & i;
    i = ~i;
    uint64_t exc = scause & i;

    if(itrpt && (exc == 5)){
        printk("Supervisor Mode Timer Interrupt\n");
        clock_set_next_event();
    }
}

参考 RISC-V 对 scause 寄存器值的定义,其最高位(即 Interrupt 字段)为 1 时代表陷入原因为中断,Exception Code 为 5 时代表中断由时钟产生,此时输出一条中断日志,并调用 clock_set_next_event 函数更新下一次时钟中断时间

image-20241010103201945

实现时钟中断相关函数

下为 clock.c 文件代码:

uint64_t TIMECLOCK = 10000000;

uint64_t get_cycles() {
    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_ecall(0x54494d45, 0, next, 0, 0, 0, 0, 0);
}
  • get_cycles 通过 rdtime 指令获取当前时间
  • clock_set_next_event 计算下一次中断时间并执行下一次的中断

实验结果

image-20241010181436135

思考题

请总结一下 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 中自定义符号的值并截图。

使用指令 nm vmlinux

nm 是一个用于显示目标文件中的符号信息的工具

第一列是符号在内存中的地址,第二列是符号类型,第三列是符号本身

image-20241010112012447

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

修改 main.c ,添加代码如下:

#include "defs.h"

int start_kernel() {
    printk("sstatus = = 0x%llx\n", csr_read(sstatus));

image-20241010112748118

可见 sstatus 值为 0x8000 0002 0000 6002 , 查阅手册:

image-20241010113049416

  • SD=1
    • 脏状态,此处为 1,表示 FSVSXS 中至少一个字段的状态为 Dirty。
  • UXL=2
    • U 模式下的 XLEN,此处值为 2,表示用户模式下使用 64 位指令集。
  • MXR=SUM=XS=VS=SPP=UBE=SPIE=0
    • MXR:允许读可执行页,此处为 0,表示不允许从标记为可执行的内存页加载数据。
    • SUM:允许访问标记为 U 模式的内存,此处为 0,表示 S 模式下无法访问处于 U 模式的内存。
    • XS:扩展状态,此处值为 0,表示处理器不支持任何扩展指令集。
    • VS:向量状态,此处值为 0,表示向量状态为 Off。
    • SPP:中断前所处的特权级。
    • UBEU 模式采用大端编码,此处为 0,表示 U 模式下使用小端编码。
    • SPIE:进入 S 模式前是否启用了中断,用于在处理中断时临时记录 SIE 的值。
  • FS=3
    • FS:浮点状态。此处值为 3,未找到相关说明,意义不明。
  • SIE=1
    • 启用 S 模式中断。此处为 0,表示被启用。
  • WPRI=0
    • Write Protect, Read Ignore,即写保护,读忽略。

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

修改 main.c ,添加代码如下:

int start_kernel() {
    csr_write(sscratch, 0x11451419);
    printk("sstatus = = 0x%llx\n", csr_read(sscratch)); #打印修改后的sscratch

结果如下,可见已被成功修改:

printk 里面的文本忘记修改了,这里修改和读取的不是 sstatus 而是 sscratch

image-20241010115521565

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

sys.i 文件通常是符号表(symbol table)的一种形式,用于记录内核中定义的符号(函数、变量等)的信息

类似编译linux内核,在 linux-6.11-rc7/ 目录下,执行下述指令

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfig

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- arch/arm64/kernel/sys.i

由此编译得到 sys.i

image-20241018223603361

进行 cat ,其内容过多无法全部截图

image-20241018223617251

image-20241018224026888

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

ARM32 与 x86_64 的系统调用表路径分别为:

  • ARM32: arch/arm/tools/syscall.tbl
  • x86_64: arch/x86/entry/syscalls/syscall_64.tbl

ARM32

image-20241010145850625

x86_64

image-20241010150140640

RV64

查资料得知riscv的系统调用表在 arch/riscv/kernel/syscall_table.c ,为下图中的 sys_call_table 这个数组

image-20241018213016107

其中的 asm/syscall_table.h 实际上是 syscall_table_32.hsyscall_table_64.h,下图为 syscall_table.h

image-20241018212910369

这两头文件都在 arch/riscv/include/generated/asm/下,或者说就在 arch/riscv/include/

将系统调用表的代码与相关宏定义提取,并移入新建文件出来以便展开后阅读

image-20241018213924143

借助指令 cpp syscall_tobeexc.c -I include -I include/generated > final.c 进行宏展开,在所得文件中得到 RV64 宏展开后的系统调用表

image-20241018214102803

RV32

修改提取的代码,系统调用表中的引用改成:

#include <iasm/syscall_table_32.h>

得到 RV32 的系统调用表

image-20241018214549354

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

ELF(Executable and Linkable Format)是一种用于可执行文件、目标代码、共享库和核心转储(core dump)的标准文件格式,其实就是一种二进制文件格式,存储了程序的代码和数据,计算机能够直接执行。

一个 ELF 文件主要由以下几个部分组成:

  • ELF 头部: 包含了整个 ELF 文件的描述信息,比如文件类型(可执行文件、目标文件等)、机器架构、节区数量等。
  • 程序头部表: 描述了程序的各个段(segment),例如代码段、数据段等。
  • 节区头部表: 描述了文件的各个节(section),例如 .text(代码节)、.data(数据节)、.bss(未初始化数据节)等。
  • .text 节: 存储程序的机器指令代码。
  • .data 节: 存储程序的初始化全局变量和静态变量。
  • .bss 节: 存储未初始化的全局变量和静态变量。
  • .rodata 节: 存储只读数据,例如字符串常量。
  • 其他节: 还有许多其他节,用于存储调试信息、符号表等。
readelf

image-20241010124237248

用来查看 ELF 文件内部结构的工具,能查看其头信息、节区信息、符号表,等等。

objdump

使用 -x 参数可打印所有头信息。

image-20241010124917974

objdump 与 readelf 类似,不过后者专门用来查阅 ELF 文件,而前者查阅的是各种文件,包括可执行文件、目标文件、共享库等二进制文件。

内存布局

通过 ps aux 查询 PID,并进一步查询目标进程的内存布局,可见辨认程序与动态链接库的代码段、数据段、堆空间、栈空间。

image-20241010143028518

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

Boot HART MIDELEG : 0x0000000000000222

Boot HART MEDELEG : 0x000000000000b109

这两个寄存器属于 Machine Trap Delegation Registers。默认情况下,RISC-V 架构下任意特权级的任何 trap 均在 M 模式下处理。通过配置 medeleg 寄存器与 mideleg 寄存器可以将 trap 交给较低的特权级即 S 模式下处理 trap。具体而言,medeleg 寄存器控制异常处理的委派, mideleg 寄存器控制中断处理委派。

mideleg 寄存器中的 0~3 位 bit 控制软件中断,4~7 位控制时钟中断,8~11 位控制外部中断,其值为 0x222,即 2‘001000100010 ,其含义为将 S 模式的三种中断委派给 S 模式处理。

medeleg 寄存器的值为 0xb109,即 2’b000100001001,其含义为指令地址未对齐、断点、S 模式的环境调用、指令页缺失、 读写引起的页缺失等异常委派给 S 模式处理。

二、讨论、心得

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

本次实验量大管饱,全都是之前没怎么学过的东西,光是学习 RISCV 手册就花了几天,所以难度较大,疑似从0到1的实验。

遇到的一个问题是环境配置的,因个人没有备份注册表的习惯,在一次胡乱修改注册表后导致大量 windows 服务无法联网,其中包括我使用的 wsl,无论是自动升级还是本地手动升级均无法使用 Ubuntu24.04。在联系微软工程师依旧没能解决并被吐槽不备份注册表后,决定重装系统并解决了问题,可见注册表不能随意修改,且需养成备份习惯。

image-20241012144045811

重装系统前后注册表体积变化

在实验里,遇到的问题。。。说来惭愧,由于思考题写太久了,跨度接近一个月,我已经忘记做实验时哪里有卡住了。唯一一个问题时根据聊天记录回忆起来的。在RV64内核引导部分,我遇到了系统调用无返回值的问题,经过排查与询问,发现是 sbi.c 里的 shi_call 函数,我在使用内联汇编时,在 memory 一栏只置 "momory",将这一栏补充为已使用寄存器后恢复正常:

        : "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7"
    );

因实验文档里关于内联汇编的讲解中有提到这一行用于优化的,是非必要的,我就忽略了,没想到是这里出了问题。

这一行用于指定内联汇编代码中使用的寄存器,编译器需要在使用这些寄存器之前保存它们的值,并在使用之后恢复它们的值。只写 "memory",这只告诉编译器内存可能被修改,但没有指定哪些寄存器被修改。经过补全后,编译器会正确处理这些寄存器的保存和恢复,从而确保函数能够正确返回值。

上面一段来自资料,但是没理解为什么寄存器上下文会影响这个函数的返回值。。。