浙江大学实验报告
课程名称:操作系统 实验类型:综合型/设计型
实验项目名称:实验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; \
})
完成上述步骤后,编译运行的结果如下:
做完发现忘记截这里的图了,用了找助教问问题时截的图,正好包含这里的结果,有点难看,万分抱歉
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
STIE
是sie
寄存器的第五位,设置为1
以响应所有的 S 态 trap
- set first time interrupt
- 首先通过
rdtime
伪指令获得当前的时间,并暂存在通用寄存器 a0 - 然后将当前时间与设置的中断间隔
10000000
相加,得到下一次触发时钟中断的时间 - 最后利用
ecall
指令调用 \(OpenSBI\) 的接口来修改mtimecmp
- 首先通过
- set sstatus[SIE] = 1
SIE
是sstatus
寄存器的第一位,设置为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
提供scause
和sepc
参数
- 根据实验文档给的定义,为
- 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
函数更新下一次时钟中断时间
实现时钟中断相关函数
下为 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
计算下一次中断时间并执行下一次的中断
实验结果
思考题
请总结一下 RISC-V 的 calling convention,并解释 Caller / Callee Saved Register 有什么区别?
传递参数时,会尽可能多的使用寄存器传递。使用a0
-a7
八个整形寄存器,以及fa0
-fa7
八个浮点寄存器来实现。更多放不下的参数会通过栈传递。
具体而言,如果传递的第i
个参数是float数据类型,并且参数个数小于8,那么就用第i
个浮点寄存器fai
来传递,其他的数据类型用第i
个整形寄存器ai
来传递。
返回值从整数寄存器a0
和a1
以及浮点寄存器fa0
和fa1
返回。
Caller / Callee Saved Register 区别
寄存器类型 | 含义 | 谁负责保存 |
---|---|---|
Caller Saved | 调用者调用函数前需进行保存,以备不时之需 | 调用者 |
Callee Saved | 被调用者在修改这些寄存器前需进行保存,以免破坏数据 | 被调用者 |
编译之后,通过 System.map 查看 vmlinux.lds 中自定义符号的值并截图。
使用指令 nm vmlinux
nm
是一个用于显示目标文件中的符号信息的工具第一列是符号在内存中的地址,第二列是符号类型,第三列是符号本身
用 csr_read
宏读取 sstatus
寄存器的值,对照 RISC-V 手册解释其含义并截图
修改 main.c
,添加代码如下:
可见 sstatus
值为 0x8000 0002 0000 6002
, 查阅手册:
SD=1
- 脏状态,此处为
1
,表示FS
、VS
、XS
中至少一个字段的状态为 Dirty。
- 脏状态,此处为
UXL=2
- U 模式下的 XLEN,此处值为
2
,表示用户模式下使用 64 位指令集。
- U 模式下的 XLEN,此处值为
MXR=SUM=XS=VS=SPP=UBE=SPIE=0
MXR
:允许读可执行页,此处为0
,表示不允许从标记为可执行的内存页加载数据。SUM
:允许访问标记为U
模式的内存,此处为0
,表示 S 模式下无法访问处于 U 模式的内存。XS
:扩展状态,此处值为0
,表示处理器不支持任何扩展指令集。VS
:向量状态,此处值为0
,表示向量状态为 Off。SPP
:中断前所处的特权级。UBE
:U
模式采用大端编码,此处为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
详细描述你可以通过什么步骤来得到 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
进行 cat
,其内容过多无法全部截图
寻找 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
x86_64
RV64
查资料得知riscv的系统调用表在 arch/riscv/kernel/syscall_table.c
,为下图中的 sys_call_table
这个数组
其中的 asm/syscall_table.h
实际上是 syscall_table_32.h
或 syscall_table_64.h
,下图为 syscall_table.h
这两头文件都在 arch/riscv/include/generated/asm/
下,或者说就在 arch/riscv/include/
下
将系统调用表的代码与相关宏定义提取,并移入新建文件出来以便展开后阅读
借助指令 cpp syscall_tobeexc.c -I include -I include/generated > final.c
进行宏展开,在所得文件中得到 RV64 宏展开后的系统调用表
RV32
修改提取的代码,系统调用表中的引用改成:
#include <iasm/syscall_table_32.h>
得到 RV32 的系统调用表
阐述什么是 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
用来查看 ELF 文件内部结构的工具,能查看其头信息、节区信息、符号表,等等。
objdump
使用 -x
参数可打印所有头信息。
objdump 与 readelf 类似,不过后者专门用来查阅 ELF 文件,而前者查阅的是各种文件,包括可执行文件、目标文件、共享库等二进制文件。
内存布局
通过 ps aux
查询 PID,并进一步查询目标进程的内存布局,可见辨认程序与动态链接库的代码段、数据段、堆空间、栈空间。
通过查看 RISC-V Privileged Spec 中的 medeleg
和 mideleg
部分,解释上面 MIDELEG
和 MEDELEG
值的含义。
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。在联系微软工程师依旧没能解决并被吐槽不备份注册表后,决定重装系统并解决了问题,可见注册表不能随意修改,且需养成备份习惯。
重装系统前后注册表体积变化
在实验里,遇到的问题。。。说来惭愧,由于思考题写太久了,跨度接近一个月,我已经忘记做实验时哪里有卡住了。唯一一个问题时根据聊天记录回忆起来的。在RV64内核引导部分,我遇到了系统调用无返回值的问题,经过排查与询问,发现是 sbi.c
里的 shi_call
函数,我在使用内联汇编时,在 memory
一栏只置 "momory"
,将这一栏补充为已使用寄存器后恢复正常:
因实验文档里关于内联汇编的讲解中有提到这一行用于优化的,是非必要的,我就忽略了,没想到是这里出了问题。
这一行用于指定内联汇编代码中使用的寄存器,编译器需要在使用这些寄存器之前保存它们的值,并在使用之后恢复它们的值。只写
"memory"
,这只告诉编译器内存可能被修改,但没有指定哪些寄存器被修改。经过补全后,编译器会正确处理这些寄存器的保存和恢复,从而确保函数能够正确返回值。
上面一段来自资料,但是没理解为什么寄存器上下文会影响这个函数的返回值。。。