本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:P3->Page tables
操作系统MIT6.S081:Lab3->Page tables
操作系统MIT6.S081:P4->RISC-V calling conventions and stack frames
操作系统MIT6.S081:[xv6参考手册第4章]->Trap与系统调用
操作系统MIT6.S081:P5->Isolation & system call entry/exit
本实验探讨如何使用trap实现系统调用。你将首先使用栈进行热身练习,然后你将实现用户级trap处理程序。在开始实验之前,请阅读xv6参考手册的第4章以及相关的源文件:
make fs.img
命令对其进行编译,并在user/call.asm中生成程序的可读汇编版本。g
、f
和main
的代码。RISC-V的说明手册在参考地址上。以下是你应该回答的一些问题(将答案存储在文件answers-traps.txt中):我们首先查看user/call.c的代码。
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int g(int x) {
return x+3;
}
int f(int x) {
return g(x);
}
void main(void) {
printf("%d %d\n", f(8)+1, 13);
exit(0);
}
接着根据实验描述中的make fs.img
命令编译user/call.c文件。
编译后得到user/call.asm,我们只看g
、f
和main
函数对应的代码。
user/_call: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 :
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int g(int x) {
0: 1141 addi sp,sp,-16
2: e422 sd s0,8(sp)
4: 0800 addi s0,sp,16
return x+3;
}
6: 250d addiw a0,a0,3
8: 6422 ld s0,8(sp)
a: 0141 addi sp,sp,16
c: 8082 ret
000000000000000e :
int f(int x) {
e: 1141 addi sp,sp,-16
10: e422 sd s0,8(sp)
12: 0800 addi s0,sp,16
return g(x);
}
14: 250d addiw a0,a0,3
16: 6422 ld s0,8(sp)
18: 0141 addi sp,sp,16
1a: 8082 ret
000000000000001c :
void main(void) {
1c: 1141 addi sp,sp,-16
1e: e406 sd ra,8(sp)
20: e022 sd s0,0(sp)
22: 0800 addi s0,sp,16
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13
26: 45b1 li a1,12
28: 00000517 auipc a0,0x0
2c: 7b050513 addi a0,a0,1968 # 7d8
30: 00000097 auipc ra,0x0
34: 600080e7 jalr 1536(ra) # 630
exit(0);
38: 4501 li a0,0
3a: 00000097 auipc ra,0x0
3e: 27e080e7 jalr 638(ra) # 2b8
问题
问: 哪些寄存器包含函数的参数?例如,在main对printf的调用中,哪个寄存器保存了13?
答:a0-a7
保存函数的参数,其中a0-a1
还可以保存返回值。在对printf的调用中,13保存在a2
中,由汇编代码中main函数中的li a2,13
可以看出。
问: main对应的汇编代码中对函数
f
的调用在哪里? 对g
的调用在哪里?(提示:编译器可能内联函数)
答: 没有调用。g
被内联到f
中,然后f
又被内联到main中。由汇编代码中main函数中的li a1,12
可以看出,直接将最后的结果12传递到了a1
。
问: 函数printf位于哪个地址?
答: 可以通过计算得到:
----auipc ra,0x0
代表将当前立即数向右移动12位,然后加上pc
寄存器的值,赋给ra
寄存器。由于立即数为0,因此ra
的值即为pc
的值0x30
。
----jalr 1536(ra)
代表1536加上ra寄存器的值,然后赋值给pc。将1536转为16进制再加上0x30即为0x0000000000000630
,也就是要跳转到printf的地址。
----也可以在汇编文件中去查找,如下图所示。
问: 在
jalr
到main中的printf之后,寄存器ra
中有什么值?
答:ra
寄存器用来保存函数执行以后的下一条指令的地址,因此ra
寄存器应当存放从printf返回main函数的地址,为0x38
。
问: 运行以下代码
unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);
输出是什么?
答:
----%x表示以十六进制形式输出整数,因此首先将57616转为16进制数,为0xe110
。
----%s表示按照字符的格式读取字符并输出,直到读取到 ‘\0’ 为止。RISC-V是小端字节序,因此在内存中存储的形式为0x726c6400。对应的ASCII值为0x72=‘r’,0x6c=‘l’,0x64=‘d’,0x00=‘\0’。
----因此最后printf输出的结果为"He110, World\0"
。
问: 能够得到上述输出是由于RISC-V是小端序的。如果 RISC-V是大端序的,要实现同样的效果,需要将i
设置为什么?需要将57616修改为别的值吗?
答: 不需要修改57616。i
需要进行反转,即i=0x726c6400
。
问: 在下面的代码中,'y='之后会打印什么?(注意:答案不是特定值)为什么会发生这种情况?
printf("x=%d y=%d", 3);
答: printf需要接收2个参数,将第一个参数3放在a1
中,第二个参数放在a2
中。由于没有第二个参数,所有直接输出寄存器a2
中的值。
实验目的
对于调试来说,回溯通常很有用:当栈上的某一点发生错误,可以通过回溯得到上方的函数调用列表。现需要在kernel/printf.c中实现
backtrace()
函数,并在系统调用sys_sleep
中插入对该函数的调用。然后在xv6中运行bttest
,它会调用sys_sleep
。你的输出应如下所示:
复制上面三个地址,然后退出qemu。接着在你的终端中运行addr2line -e kernel/kernel
(或riscv64-unknown-elf-addr2line -e kernel/kernel
),然后粘贴上述地址(地址可能略有不同):
你应该看到如下内容:
编译器在每个栈帧中放入一个帧指针fp
,该指针保存调用者帧指针的地址。你的backtrace
应该使用这些fp
向上遍历整个栈,并在每个栈帧中打印被保存的返回地址。
实验提示
①将
backtrace
的原型添加到kernel/defs.h,以便你可以在sys_sleep
中调用。
②GCC编译器将当前执行函数的帧指针fp
存储在寄存器s0
中。将以下函数添加到 kernel/riscv.h中:
并在backtrace
中调用此函数以读取当前帧指针fp
。此函数使用内联汇编来读取s0
。
③上一节课的讲义有一张栈帧布局的图片。请注意,返回地址(return address)位于距栈帧的帧指针fp
固定偏移量(-8)处。而保存的前一个栈帧的帧指针位于距当前帧指针(fp
)的固定偏移量(-16)处。
④xv6为内核中的每个栈在以PAGE对齐的地址处分配一个页面。你可以使用PGROUNDDOWN(fp)
和PGROUNDUP(fp)
计算栈页面的顶部和底部地址(参阅kernel/riscv.h,这些数字有助于backtrace
终止其循环)
⑤一旦你的回溯工作正常,从kernel/printf.c中的panic
调用它,这样你就可以看到内核在panic时的回溯。
参照实验提示的步骤一步一步来完成实验:
1、 根据实验提示①,将backtrace
的原型添加到kernel/defs.h。
void backtrace(void);
接着要将backtrace
的定义添加到kernel/printf.c中。由于现在才开编写该函数,只知道首先要打印一行backtrace:\n
。
void backtrace(void) {
printf("backtrace:\n");
// ......后面继续完成其它功能
}
2、 根据实验提示②,将r_fp
的定义添加到kernel/riscv.h。
static inline uint64 r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}
接着要在backtrace
中调用该函数。
void backtrace(void) {
printf("backtrace:\n");
uint64 fp = r_fp();
// ......后面继续完成其它功能
}
3、 根据实验提示③,返回地址位于fp-8
处,前一个栈帧的帧指针位于fp-16
。于是我们需要循环地打印返回地址,然后移动到前一个栈帧。
void backtrace(void) {
printf("backtrace:\n");
uint64 fp = r_fp();
while( ){
uint64 ra = *(uint64*)(fp-8);
printf("%p\n", ra);
fp = *(uint64*)(fp-16);
}
}
4、 根据实验提示④,需要为循环添加终止条件。xv6中用一个页来存储栈,且向低地址扩展,所以当fp到达最高地址时说明到达栈底。
void backtrace(void) {
printf("backtrace:\n");
uint64 fp = r_fp();
while(fp < PGROUNDUP(fp))
{
uint64 ra = *(uint64*)(fp-8);
printf("%p\n", ra);
fp = *(uint64*)(fp-16);
}
}
5、根据实验提示⑤,在kernel/printf.c的painc
中添加backtrace
的调用。
void panic(char *s)
{
// ......
printf("\n");
backtrace(); //添加
panicked = 1; // freeze uart output from other CPUs
// ......
}
根据实验目的,在kernel/sysproc.c中的系统调用sys_sleep
里面也添加对backtrace
的调用。
uint64
sys_sleep(void)
{
// ......
release(&tickslock);
backtrace();//添加
return 0;
}
测试
启动xv6,输入
bttest
,结果如下:
将输出的三个地址复制,然后退出xv6。在终端中输入addr2line -e kernel/kernel
,按下回车。然后再将复制的三个地址粘贴上去,输出的结果如下。
实验目的
----在本练习中,你将向xv6添加一个功能,该功能会在进程使用CPU时定期提醒它。这对于想要限制占用CPU时间的计算绑定进程、想要计算但又想要采取一些定期操作的进程可能很有用。更通俗地讲,你将实现一种原始形式的用户级中断/故障处理程序。例如,你可以使用类似的东西来处理应用程序中的页面错误。如果它通过了警报测试和用户测试,则你的解决方案是正确的。
----你应该添加一个新的sigalarm(interval, handler)
系统调用。如果应用程序调用sigalarm(n, fn)
,那么在程序消耗每n
个CPU时间“tick”之后,内核应该调用应用程序函数fn
。当fn
返回时,应用程序应该从中断的地方继续。在xv6中,tick是一个相当任意的时间单位,由硬件定时器产生中断的频率决定。如果应用程序调用sigalarm(0, 0)
,内核应停止生成定期警报调用。
----你将在xv6存储库中找到文件user/alarmtest.c,将其添加到Makefile。在你添加sigalarm
和sigreturn
系统调用之前,它不会正确编译。
测试案例
alarmtest
在test0中调用sigalarm(2,periodic)
以要求内核每2个滴答声强制调用一次periodic()
。可以在user/alarmtest.asm中看到alarmtest
的汇编代码,这对于调试可能很方便。当alarmtest
产生这样的输出并且usertests
也正确运行时,你的解决方案是正确的:
完成后,你的解决方案将只有几行代码,但要正确处理可能会很棘手。我们将使用原始存储库中的alarmtest.c版本测试你的代码。你可以修改alarmtest.c来帮助你调试,但要确保原始的alarmtest
表明所有测试都通过了。
开始修改内核以跳转到用户空间的警报处理程序,这将导致test0打印“alarm!”。不要担心输出alarm之后会发生什么!如果你的程序在打印“alarm!”后崩溃也是可以的。这里有一些提示:
⑴你需要修改 Makefile 以使alarmtest.c编译为xv6用户程序。
⑵放入user/user.h的正确声明是:
⑶更新user/usys.pl(生成user/usys.S)、kernel/syscall.h和kernel/syscall.c以允许alarmtest
调用sigalarm和sigreturn系统调用。
⑷现在,你的sys_sigreturn
应该只返回0。
⑸你的sys_sigalarm()
应该将警报间隔和指向处理函数的指针存储在proc
结构(在kernel/proc.h中)的新字段中。
⑹你需要跟踪自上次调用进程的警报处理程序以来已经过去了多少tick。为此,你还需要在struct proc
中新加一个字段。你可以在proc.c中的allocproc()
中初始化proc
的各字段。
⑺对于每个tick,硬件时钟都会强制中断,该中断在kernel/trap.c中的usertrap()
中处理。
⑻如果有计时器中断,但你只想操纵进程的警报tick,你需要以下代码:
⑼仅当进程有未完成的计时器时才调用警报函数。请注意,用户的警报函数的地址可能为0(例如,在user/alarmtest.asm中,periodic
在地址0处)。
⑽你需要修改usertrap()
以便在进程的警报间隔到期时,用户进程执行处理函数。当RISC-V上的trap返回到用户空间时,是什么决定了用户空间代码恢复执行的指令地址?
⑾如果你告诉qemu只使用一个CPU,那么使用gdb查看trap会更容易。你可以通过运行下面指令实现。
⑿如果alarmtest打印出“alarm!”,你就成功了。
参照实验提示的步骤一步一步来完成实验:
1、 根据实验提示⑴,在Makefile的UPROGS
中添加$U/_alarmtest\
2、 根据实验提示⑵,在user/user.h中放入sigalarm
和sigreturn
的声明。
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
3、 根据实验提示⑶,更新user/usys.pl(生成user/usys.S)。
entry("sigalarm");
entry("sigreturn");
更新kernel/syscall.h
#define SYS_sigalarm 22
#define SYS_sigreturn 23
更新kernel/syscall.c。
extern uint64 sys_sigalarm(void); //添加
extern uint64 sys_sigreturn(void);//添加
static uint64 (*syscalls[])(void) = {
//......
[SYS_sigalarm] sys_sigalarm, //添加
[SYS_sigreturn] sys_sigreturn,//添加
}
4、 根据实验提示⑷,在kernel/sysproc.c中编写sys_sigreturn
,返回0。
uint64 sys_sigreturn(void) {
return 0;
}
5、 根据实验提示⑸,将警报间隔和指向警报处理函数的指针存储在proc
结构中。
struct proc {
//... ...
int interval; // alarm interval time
uint64 handler; // alarm handle function
}
6、 根据实验提示⑹,定义一个跟踪自上次调用警报处理程序以来已经过去了多少滴答的成员变量ticks
。
struct proc {
//... ...
int interval; // alarm interval time
uint64 handler; // alarm handle function
uint64 ticks; // how many ticks have passed since the last call
}
在kernel/proc.c中的allocproc()
中初始化proc
字段。
//......
p->context.sp = p->kstack + PGSIZE;
p->interval = 0; //添加
p->handler = 0; //添加
p->ticks = 0; //添加
return 0;
//......
7、 根据实验提示⑺-⑽,我们去usertrap
中进行处理。
----如果有时钟中断,我们操作进程的警报tick,因此在if (which_dev==2)
情况下进行处理。
----如果tick没到达设定的间隔,则将tick++。
----如果tick达到设定的间隔,则将tick清零,同时转去相应的处理程序handler
。此处主要考虑如何调用处理函数handler。在usertrap
中页表已经切换为内核页表,而handler仍然是用户页表的函数虚拟地址,因此不能直接调用。这里我们将p->trapfram->epc
置为p->handler
,这样在返回到用户空间时,程序计数器为handler定时函数的地址,便达到了执行定时函数的目的。(这里做的其实就是在内核态进行赋值,返回到用户态再执行。)
//......
// give up the CPU if this is a timer interrupt.
if(which_dev == 2){
//添加
if(p->interval)
{
if(p->ticks == p->interval)
{
p->ticks = 0;
p->trapframe->epc = p->handler;
}
p->ticks++;
//添加
}
yield();
}
8、 最后,我们还需要来完成sys_sigalarm
函数的定义。该函数主要完成对proc结构体中与警报程序相关的成员变量进行赋值。
uint64 sys_sigalarm(void)
{
int interval;
uint64 handler;
struct proc * p;
// sigalarm的第一个参数为ticks,第二个参数为void(*handler)()
if(argint(0, &interval) < 0 || argaddr(1, &handler) < 0 || interval < 0) {
return -1;
}
p = myproc();
p->interval = interval;
p->handler = handler;
p->ticks = 0;
return 0;
}
测试
实验目的
----有可能alarmtest在打印“alarm!”后在test0或test1中崩溃,或者alarmtest打印“test1 failed”,或者alarmtest退出而不打印“test1 pass”。
----要解决此问题,你必须确保在警报处理程序完成后,控制权返回到用户程序最初被定时器中断所中断的指令。
----你还必须确保寄存器内容恢复到它们在中断时保持的值,以便用户程序可以在警报后不受干扰地继续运行。
----最后,你应该在每次关闭后清零警报计数器,以便定期调用处理程序。
----我们已经为设计了一种解决方案:用户警报处理程序需要在完成后调用sigreturn系统调用。如alarmtest.c中的periodic
。这意味着你可以将代码添加到usertrap
和sys_sigreturn
中,它们会协同工作以使用户进程在处理完警报后正确恢复。
实验提示:
①您的解决方案将要求你保存和恢复寄存器----你需要保存和恢复哪些寄存器才能正确恢复中断的代码?(提示:会很多)
②当计时器关闭时,让usertrap
在struct proc
中保存足够的状态,以便sigreturn
可以正确返回到被中断的用户代码。
③防止对警报处理程序的重入调用----如果处理程序尚未返回,内核不应再次调用它。 test2对此会进行测试。
④一旦你通过了test0、test1和test2,运行usertests以确保你没有破坏内核的任何其他部分。
针对实验描述和实验提示,有3个问题需要思考:
1、本实验涉及的系统调用的整体流程、调用前后寄存器的值变化
①首先会调用系统调用
sys_sigalarm
。在调用之前,把所有的寄存器信息保存在了trapframe中。
②然后进入内核中执行sys_sigalarm
函数。在执行的过程中只需要做一件事:为警报处理相关字段(如ticks、interval)进行赋值。
③赋值完成后,该sys_sigalarm
系统调用就完成了。trapframe中保存的寄存器的值恢复,然后返回到用户态。(此时的trapframe没有保存的必要)
④在用户态中,每经历一次时钟中断(在trap.c中处理)就去对比ticks是否达到了规定的interval。如果达到了规定的interval,将返回地址(epc
)更改为handler
函数,返回用户态后便开始执行handler
函数。
⑤执行完handler
后,我们希望返回到调用handler
前的状态。然而调用handler之前,返回地址epc
已经被覆盖了。同时,执行handler
后寄存器状态也会发生变化。
⑥因此,需要在handler
覆盖掉epc
之前需要保存好相应的寄存器状态。
解决方案:
①sigalarm(interval, handler)
和sigreturn()
两个函数是配合使用的,在handler
函数返回前会调用sigreturn()
。
②可以在struct proc
中保存一个trapframe
的副本,在覆盖epc
之前先保存副本,然后在sys_sigreturn()
中将副本还原到p->trapframe
中,从而在sigreturn
系统调用结束后恢复用户寄存器状态时,能够将执行定时函数前的寄存器状态进行恢复。
2、如何保存和恢复相应寄存器的值
保存寄存器的值有若干种方案:
①可以在struct proc中新增若干寄存器字段,用于保存trapframe中的寄存器值。在调用handler之前先将trapframe中的寄存器值全部保存在这些寄存器字段中,后续在sys_sigreturn之前将寄存器字段对应更新到trapframe中。
②重新分配一个页面,并在struct proc中新增一个指针指向这个页。在调用handler之前先将trapframe中的寄存器值全部复制到新的页面中,后续在sys_sigreturn之前将新的页中的寄存器值更新到trapframe中。
③由于struct trapframe结构体只有288B,而一个页面是4096B,可以看到有大量内存空间未被使用。于是可以创建一个struct trapframecopy,让其与struct trapframe共用一个页面。
选择: (有一些寄存器状态不用保存)第一种方案要新增几十个寄存器字段,代码量冗余。第二种方案需要分配新的页面,会浪费内存。第三种方案仅需要新增一个指针指向struct trapframecopy,将该指针指向的位置指定为trapframe+512的地址即可(为了对齐)。
因此,我们选择第三种方案。
3、如何防止警报处理程序的重复调用
①可以在struct proc中新增一个字段,用于指示是否能够调用
handler
。当第一次调用handler
时,将该字段置为1,表明不能再调用了。在sys_sigreturn之前将此字段清零即可。
②也可以直接根据ticks值进行判断。在test0的usertrap中,当ticks达到interval后我们将ticks置0了。现在将这句代码删除,而移至sys_sigreturn
之后,即在最后函数返回前才会清零。因此,在handler
还未结束时,ticks
会继续递增,从而不会再满足调用handler
的条件,自然就可以避免重入。
选择: 我们采用第三种方案
参照实验提示的步骤一步一步来完成实验:
1、 根据实验提示①,在proc结构体中添加一个指向trapframe副本的指针。
struct proc {
int interval; // alarm interval time
uint64 handler; // alarm handle function
uint64 ticks; // how many ticks have passed since the last call
struct trapframe* trapframecopy; //添加
}
2、 根据实验提示②、③,在kernel/trap.c的usertrap
中覆盖p->trapframe->epc
将trapframe的副本进行保存。同时,将之前调用handler
后执行的ticks=0
删除,以实现防止handler
重入。
// give up the CPU if this is a timer interrupt.
if(which_dev == 2){
if(p->interval)
{
if(p->ticks == p->interval)
{
p->trapframecopy = p->trapframe + 512;
memmove(p->trapframecopy,p->trapframe,sizeof(struct trapframe)); // 复制trapframe
p->trapframe->epc = (uint64)p->handler;
}
p->ticks++;
}
3、 根据实验提示②,在sys_sigreturn
中将trapframecopy拷贝到原trapframe中。恢复后将trapframecopy置零,表示当前没有副本。同时在拷贝trapframecopy前做了一个地址判断,是为了防止用户程序未调用sigalarm
便使用了该系统调用。此时没有trapframecopy是无效的,可以避免错误拷贝。
uint64 sys_sigreturn(void) {
struct proc* p = myproc();
// trapframecopy must have the copy of trapframe
if(p->trapframecopy != p->trapframe + 512) {
return -1;
}
memmove(p->trapframe, p->trapframecopy, sizeof(struct trapframe)); // restore the trapframe
p->ticks = 0; // prevent re-entrant
p->trapframecopy = 0; // 置零
return 0;
}
测试
运行xv6,执行
alarmtest
,可以看到test0、test1、test2都通过。
退出xv6,在终端中输入./grade-lab-traps alarmtest
,可以看到单项测试也通过。
执行make grade
,测试通过。