Computer System II Lab3 :RV64内核引导

引言:

系统课从Lab3开始,实验重心从硬件开始向软件转移。本实验主要实现利用RiscV编写简单的内核代码,包括实现一些跳转和函数的传参。

1 实验目的

2 实验步骤

2-1 实验环境准备

本实验要用到qemu-system-riscv64,在终端中输入以下命令进行相应环境的安装:

1
2
sudo apt update;
sudo apt install qemu-system-misc gcc-riscv64-linux-gnu gdb-multiarch

安装后检查相应的qemu版本:

2-2 head.S

head.S要求我们去开辟一块有4KB大小的栈空间,并跳转到start kernel

在实验指导中给我们指出了vmlinux.lds的作用与基础的实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
OUTPUT_ARCH( "riscv" )

/* 程序入口 */
ENTRY( _start )

/* kernel代码起始位置 */
BASE_ADDR = 0x80200000;

SECTIONS
{
/* . 代表当前地址 */
. = BASE_ADDR;

/* 记录kernel代码的起始地址 */
_start = .;

/* ALIGN(0x1000) 表示4KB对齐 */
/* _stext, _etext 分别记录了text段的起始与结束地址 */
.text : ALIGN(0x1000){
_stext = .;

*(.text.entry)
*(.text .text.*)

_etext = .;
}
......

嗯,拿.text段的内存分配来仔细分析一下lds文件的代码实现方法

1
2
3
4
5
6
7
8
.text : ALIGN(0x1000){ // 4kb对其
_stext = .; // _s + 段名称表示该段的起始

*(.text.entry) // .text段的入口,可能是供外部文件进行段地址的调转与访问
*(.text .text.*) // 这个应该也一样

_etext = .; // _e + 段名称表示段的末尾
}

假如我们要利用lds文件再开辟一块空间,我们需要明确:

  • 该段的名称
  • 所要开辟空间的大小(开辟的空间必须8字节对齐)
  • 在段内标注段起始和段结束
  • 有段的入口表示,并要求此标识有外部调用。

现在,要求我们在head.S文件内再开辟一块0x1000大小的空间,所以我们不妨将此空间在lds文件中就事先分配出来,然后再在head.S文件中将sp指向_end+0x1000(也就是栈顶)即可:

由于仓库中没有给出vmlinux.lds文件,我们把实验指导上的代码扒下来自己写一个:

1
2
3
4
5
6
7
8
9
10
11
12
	
......./*_end以上的代码与实验指导相同,在此不表*/
/* 记录kernel代码的结束地址 */
. = ALIGN(0x1000);
_end = .;

.stack : ALIGN(0x1000){ //标注栈段与栈空间
_sstack = .; //栈起始
*(.stack.entry) //外部调用的栈入口
_estack = .; //栈结束
}
}
  • 现在我们要在head.S中去进行栈段的调用与跳转:

在此之前我们需要明确链接器是如何识别程序中的标识符的:

> graph LR; 
.S文件 --> g((标识符));
g --> l((linker)) --> k((lds)) -.查找标识符.-> 分配空间与链接程序

即,linker可以识别我们.S文件中用标识符定义的段名称。

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.extern start_kernel     

.section .stack.entry ; 指定一个新的段开始
.globl .sbp ; 指定sbp为全局符号
sbp: ; 定义了一个标签sbp
.space 4096 ; 在当前位置保留4096字节的空间
.globl .stp ; 同样,stp被声明为全局符号
stp: ; 定义了一个标签stp,这指向堆栈的顶部


.section .text.entry ; 指定一个新的段开始,这里是代码段,命名为 .text.entry
.globl _start, _end ; 指定_start和_end为全局符号,它们可以被链接器看到

_start: ; 这里定义了一个全局符号 _start,它是程序的入口点
la sp, stp ; 将标签stp的地址加载到堆栈指针寄存器sp中
j start_kernel ; 跳转到标签start_kernel的位置


2-3 sbi.c

对于sbi.c的实现只需要对着实验指导抄就好了

给了一段示例代码的编写:

1
2
3
4
5
6
7
8
9
10
11
12
unsigned long long s_example(unsigned long long type,unsigned long long arg0) {
unsigned long long ret_val;
__asm__ volatile (
"mv x10, %[type]\n"
"mv x11, %[arg0]\n"
"mv %[ret_val], x12"
: [ret_val] "=r" (ret_val)
: [type] "r" (type), [arg0] "r" (arg0)
: "memory"
);
return ret_val;
}

嗯,照葫芦画瓢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include "types.h"
#include "sbi.h"

struct sbiret sbi_ecall(int ext, int fid, uint64 arg0,
uint64 arg1, uint64 arg2,
uint64 arg3, uint64 arg4,
uint64 arg5)
{
struct sbiret ret_str;
long err;
long value;
__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 %[err], a0\n"
"mv %[value], a1\n"
:[err]"=r"(err),[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"
);
ret_str.error = err;
ret_str.value = value;
return ret_str;

// unimplemented
}

需要注意的是,本函数的返回类型必须是结构体,所以要先定义一个返回结构体,再给结构体里的变量赋值。

同时利用ecall并按照实验指导实现三个函数:

1
2
3
4
5
6
7
8
9
void sbi_set_timer(uint64 time){
sbi_ecall(0,0,time,0,0,0,0,0);
}
void sbi_console_putchar(int ch){
sbi_ecall(1,0,ch,0,0,0,0,0);
}
void sbi_console_getchar(uint64 ch){
sbi_ecall(2,0,ch,0,0,0,0,0);
}

2-5 puts和puti

直接上代码不解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 函数puts:用于输出一个字符串到控制台
void puts(char *s) {
char *h = s; // h用作遍历字符串s的指针
while(*h != '\0'){ // 当未到达字符串末尾时循环
sbi_console_putchar(*h); // 调用sbi_console_putchar来输出当前字符
h++; // 移动到字符串的下一个字符
}

}

// 函数puti:用于输出一个整数到控制台
void puti(int x) {
if(x < 0){ // 如果x是负数
sbi_console_putchar('-'); // 首先输出负号
x = -x; // 将x变为正数以便后续处理
}
if(x >=0 && x <= 10){ // 如果x是个位数
sbi_console_putchar('0' + x); // 直接输出这个数字对应的字符
}else{
puti(x/10); // 递归调用puti输出x除以10的结果,即除去最后一位的所有前面的位
sbi_console_putchar('0' + x%10); // 然后输出x对10取余的结果,即x的最后一位数字
}
}

其中,puts我们利用一个循环实现,整数输出利用递归。

需要注意的是,对于整数的输出我们必须把要输出的数字+'0'转化为字符类型。

2-6 defs.h

先弄清楚这两个特权指令是怎么回事csrr, csrw

在RISC-V体系结构中,csrr(Control and Status Register Read)和csrw(Control and Status Register Write)是两个用于操作控制和状态寄存器(CSR)的指令。

  • csrr:这个指令从CSR读取一个值到一个通用寄存器。它的常见格式是 csrr rd, csr,其中rd是目的寄存器,csr是控制和状态寄存器的地址。
  • csrw:这个指令将一个通用寄存器的值写入到CSR。它的常见格式是 csrw csr, rs,其中rs是源寄存器,csr是控制和状态寄存器的地址。

先观察例子中给出的csrw的展开:

1
2
3
4
5
6
7
8
9
#define csr_write(csr, val)                         \
({ \
uint64 __v = (uint64)(val); \
asm volatile ("csrw " #csr ", %0" \
: : "r" (__v) \
: "memory"); \
})

#endif

通过查找资料得知:

  • "csrw " #csr ", %0": 这是汇编指令本身。"csrw"是RISC-V的“控制状态寄存器写”指令,#csr是预处理器操作,它会将宏参数csr转换成对应的字符串。%0是内联汇编的操作数占位符,对应于输入部分的__v
  • : : "r" (__v): 这部分是指令的操作数。"r"表示使用任意通用寄存器,(__v)是该寄存器的值。在这个宏中,__v是一个宏参数,它将被宏调用者提供的实际值替换。

所以该条汇编代码的结构就是:

csrw .., ..(代码段本身) + :: ..(指令的操作数) + : "memory"

照葫芦画瓢:

1
2
3
4
5
6
7
8
9
#define csr_read(csr)                       \
({ \
register uint64 __v; \
asm volatile ("csrr %0," #csr \
: "=r" (__v) : \
: "memory"); \
__v; \
})

一开始想看着csrw的展开写csrr并且没怎么理解csrw那个指令的结构是怎么回事,就导致,写出来各种奇形怪状的展开......下次写还是得先弄清原理.....

2-7 Makefile

关于/lib文件夹下面的Makefile文件的编写,我们只需要!把/arch/riscv/include下面的Makefile复制过来就好了....

嗯,还是认真分析一下吧:

在/lib文件夹下面的Makefile的作用就是将该目录下的所有文件进行编译包括

  • .S --> .o
  • .c --> .o

由于在/lib目录下没有.S文件,所以只需将所有.c文件编译成.o即可:

在/arch/riscv/include下的Makefile具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 通过通配符获取所有.S汇编源文件的列表,并使用sort函数对文件列表排序
ASM_SRC = $(sort $(wildcard *.S))
# 通过通配符获取所有.c C源文件的列表,并使用sort函数对文件列表排序
C_SRC = $(sort $(wildcard *.c))
# 将汇编源文件列表转换为目标文件列表(.o),同样对C源文件执行相同操作
OBJ = $(patsubst %.S,%.o,$(ASM_SRC)) $(patsubst %.c,%.o,$(C_SRC))

# 默认目标all,依赖于所有的OBJ文件
all:$(OBJ)

# 如何从.S文件生成.o文件:对每个.S文件使用GCC编译器和CFLAG标志进行编译
%.o:%.S
${GCC} ${CFLAG} -c $<

# 如何从.c文件生成.o文件:对每个.c文件使用GCC编译器和CFLAG标志进行编译
%.o:%.c
${GCC} ${CFLAG} -c $<

# 清理目标clean,执行时删除所有的.o文件
clean:
$(shell rm *.o 2>/dev/null)

嗯,把.S的部分删了就得到了在/lib目录下的Makefile实现(笑):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 通过通配符获取所有.c C源文件的列表,并使用sort函数对文件列表排序
C_SRC = $(sort $(wildcard *.c))
# 将汇编源文件列表转换为目标文件列表(.o),同样对C源文件执行相同操作
OBJ = $(patsubst %.c,%.o,$(C_SRC))

# 默认目标all,依赖于所有的OBJ文件
all:$(OBJ)

# 如何从.c文件生成.o文件:对每个.c文件使用GCC编译器和CFLAG标志进行编译
%.o:%.c
${GCC} ${CFLAG} -c $<

# 清理目标clean,执行时删除所有的.o文件
clean:
$(shell rm *.o 2>/dev/null)

3 实现结果

执行make指令:

执行make run,成功输出目标字符串2022 ZJU Computer System II

4 Questions

4-1 编译之后,通过 System.map 查看 vmlinux.lds 中自定义符号的值,比较他们的地址是否符合你的预期?

查看System.map符号表:

对照vmlinux.lds,并观察符号表

4-1-1 符号的属性标记
  • bss,data,rodata段均标记为R(只读)
  • text及代码段中的一些函数标记为T(代码段)
  • sbp,stp为我们自己定义的符号,用n表示

段标识符合预期

4-1-2 段的空间大小
  • _sstack指向0x80202000,_estack指向0x80203000,为开辟的栈空间大小,且栈起始地址为
  • bss,data段由于没有数据,故空间被合并
  • puti,puts,sbi_*,start_kernel等函数地址均在_etext,_stest间,表明他们处于代码段中。且由于text段大小不足0x1000,自动进行0x1000字节对齐,即
  • 按照顺序_srodata_etext之后,且rodata中存在数据,,由于不满0x1000,进行0x1000对齐,即

综上所述,System.map所展示的符号表符合预期。

4-2 在你的第一条指令处添加断点,观察你的程序开始执行时的特权态是多少,中断的开启情况是怎么样的?

要查看程序运行的特权态,先了解一下什么是特权寄存器:

在计算机架构中,特权(priv)寄存器是指那些只能在操作系统的内核模式或管理模式下访问的寄存器,而在用户模式下是不允许直接访问的。这些寄存器通常用于控制重要的系统资源和设置,例如内存管理单元(MMU)的配置、中断管理和处理器状态控制。

在RISC-V架构中,有一套特权寄存器,用于控制和管理特权级别的行为,比如mstatusmepcmtvecmie等。其中:

  • mstatus 寄存器包含了全局中断使能位和其他用于控制处理器状态的位。
  • mepc 寄存器包含了最后一次异常发生时的程序计数器(PC)的值。
  • mtvec 寄存器包含了中断向量表的起始地址。
  • mie 寄存器包含了中断使能位。

首先在一个终端中执行make debug

在另一个终端中,执行gdb-multiarch vmlinux指令

然后远程连接qemutarget remote localhost:1234

_start处打断点,c运行至断点。

由于_start在System.map文件中可以找到对应的符号,所以可以在gdb中直接根据symbols打断点。且start指向程序的第一条指令。

查看特权寄存器的值,可以看到,priv寄存器为1,表示当前特权态是Supervisor。

且mie寄存器的值是8,表示中断开启。

mie寄存器的不同的位置1通常代表不同的中断情况,其中:

  • 第0位代表软件中断的使能状态。
  • 第1位代表定时器中断的使能状态。
  • 第3位设置为1代表某个具体类型的中断,如外部中断的使能状态。

在本程序中,mie的低三位为100,所以应该属于第三类终端,外部中断。

在第一条指令处插入:csrr a0, mstatus

第一条指令处设置断点,单步运行

特权态变为machine。

4-3 在你的第一条指令处添加断点,观察内存中 text、data、bss 段的内容是怎样的?

  • text段:

  • rodata段:

  • bss段中没有数据,该段不存在:

4-4 尝试从汇编代码中给 C 函数 start_kernel 传递参数

Risc-V64的函数传参规范如下:

对于整数和指针参数,RISC-V 的标准调用约定使用以下寄存器:

  • a0 - a7x10 - x17): 用于传递前八个整数或指针参数。
  • 返回值也通过 a0 (和 a1 如果返回值是 128 位)返回。

如果函数参数超过八个,则溢出的参数将通过调用函数的栈传递。返回值(如果是整数或指针类型)通常通过 a0(和 a1 如果需要)寄存器返回给调用者。

  • head.S:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.extern start_kernel
.section .rodata
str: .string "SPY's Argument!\n"
.section .text.entry
.globl _start, _end
_start:

la sp, stp

la a0, str # 字符串参数
li a1, 6666 # 整形参数

j start_kernel
.section .stack.entry
.globl .sbp
sbp:
.space 4096
.globl .stp
stp:
  • main.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "print.h"
#include "sbi.h"
#include "types.h"

extern void test();

int start_kernel(char *arg0, int arg1)
{
puti(2022);
puts(" ZJU Computer System II\n");
puts(arg0);
puti(arg1);
test(); // DO NOT DELETE !!!

return 0;
}

输出结果:

我们尝试使用内联汇编模仿参数的传递过程,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "print.h"
#include "sbi.h"
#include "types.h"

extern void test();

int start_kernel()
{
char *arg0; unsigned arg1;
char *argu;
__asm__ volatile( "mv %[arg0], a0\n" "addiw a1, a1, 444\n" "mv %[arg1], a1\n":[arg0]"=r"(arg0),[arg1]"=r"(arg1)::"memory");
puti(2022);
puts(" ZJU Computer System II\n");
puts(arg0);
puti(arg1);
test(); // DO NOT DELETE !!!

return 0;
}

运行结果如图: