文章目录
实验四 陷阱中断
一、代码理解
在开始编码之前,请阅读xv6 书的第 4 章和相关源文件:
kernel/trampoline.S:涉及从用户空间切换到内核空间并返回的程序集
kernel/trap.c:处理所有中断的代码
1.用户空间和内核空间的切换
用户模式到内核模式的切换(uservec)
当用户程序发生异常或系统调用时,控制权会跳到 uservec 标签下的代码,此代码段的主要目的是保存用户状态并为内核模式执行做准备。
csrrw a0, sscratch, a0:交换寄存器 a0 和 sscratch 的值。sscratch 是一个特殊的 CSR(Control and Status Register),用于暂存某些系统信息,在这里它存储了用户空间的陷阱帧地址(TRAPFRAME)。
sd ra, 40(a0) 到 sd t6, 280(a0):这些指令将寄存器的值保存到 TRAPFRAME。这包括通用寄存器和特殊寄存器,如返回地址 ra,堆栈指针 sp,全局指针 gp 等。
csrr t0, sscratch 和 sd t0, 112(a0):取出 sscratch 中的值(用户的 a0),并存储到 TRAPFRAME 中相应的位置。
ld sp, 8(a0):恢复内核栈指针。
ld tp, 32(a0):加载当前硬件线程ID。
ld t0, 16(a0) 和 jr t0:加载并跳转到内核的异常处理函数 usertrap()。
ld t1, 0(a0) 和 csrw satp, t1:从 TRAPFRAME 加载内核页表的地址,并设置当前的页表寄存器 satp。
sfence.vma zero, zero:执行虚拟内存地址转换(VMA)的同步操作,确保页表变更生效。
内核模式到用户模式的切换(userret)
执行完内核操作后,需要通过 userret 来安全返回用户模式。
csrw satp, a1:设置页表寄存器 satp 为用户页表,a1 为入口参数之一,含用户页表信息。
sfence.vma zero, zero:同步虚拟内存地址转换。
ld t0, 112(a0) 和 csrw sscratch, t0:将用户的 a0 寄存器值从 TRAPFRAME 加载到 sscratch,以便后续恢复。
从 ld ra, 40(a0) 到 ld t6, 280(a0):从 TRAPFRAME 恢复用户的寄存器值。
csrrw a0, sscratch, a0:恢复用户的 a0 寄存器的原值。
sret:使用 sret 指令从内核模式返回到用户模式。sret 会加载 sepc(保存着将要返回到的用户程序的指令地址)和 sstatus(处理器状态)寄存器。
2.中断相关
void
usertrap(void)
{
int which_dev = 0; // 初始化设备标识变量,用于记录哪种设备引起的中断
// 检查是否从用户模式进入,SSTATUS_SPP 是状态寄存器 sstatus 的一个标志位,表示前一个模式
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode"); // 如果不是从用户模式进入,则触发内核恐慌
// 设置中断向量寄存器 stvec 指向 kernelvec,用于后续的异常和中断处理
w_stvec((uint64)kernelvec);
// 获取当前进程的进程控制块
struct proc *p = myproc();
// 将当前的程序计数器值(sepc)保存到该进程的陷阱帧中
p->trapframe->epc = r_sepc();
// 检查引起陷阱的原因是否为系统调用(scause == 8)
if(r_scause() == 8){
// 系统调用处理
// 如果进程已被标记为结束,直接退出
if(p->killed)
exit(-1);
// 将程序计数器增加4,跳过当前的ecall指令到下一条指令
p->trapframe->epc += 4;
// 开启中断处理,因为中断会改变 sstatus 等寄存器
intr_on();
// 处理系统调用
syscall();
}
else if((which_dev = devintr()) != 0){
// 如果是设备中断,devintr() 处理后返回非零,代表处理成功
// ok
} else {
// 如果既不是系统调用也不是设备中断,打印出错信息
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1; // 标记进程为应该结束
}
// 如果进程被标记为结束,则退出
if(p->killed)
exit(-1);
// 如果是定时器中断(which_dev == 2),让出 CPU
if(which_dev == 2)
yield();
// 准备返回到用户模式,包括恢复用户状态并跳转到适当的地址
usertrapret();
}
二、RISC-V assembly
1.题目描述
您的 xv6 存储库中 有一个文件user/call.cmake fs.img 。编译它并在 user/call.asm中生成该程序的可读汇编版本。阅读 call.asm 中函数g、f和main的代码。回答以下问题。
2.解答
哪些寄存器包含函数的参数?例如,在 main 调用printf时,哪个寄存器保存 13 ?
答:a0-a7。a2。
在 main 的汇编代码中,对函数f 的调用在哪里?对g 的调用在哪里?(提示:编译器可能会内联函数。)
答:没有,被内联了。
普通函数调用:
系统首先要跳跃到该函数的入口地址,执行函数体,执行完成后,再返回到函数调用的地方,函数始终只有一个拷贝
内联函数调用:
内联函数不需要寻址的过程,当执行到内联函数时,此函数展开(很类似宏的使用),如果在 N处调用了此内联函数,则此函数就会有N个代码段的拷贝。
函数printf位于什么地址?
答:0x630
auipc ra, 0x0 # 设置 ra = 当前 PC 高 20 位 + 立即数 0x0
jalr 1536(ra) # 跳转到 ra + 1536 (也就是 0x30 + 0x600 = 0x630)
在main中的jalr到printf 之后, 寄存器ra中的值是什么?
答:当在 main 函数中使用 jalr 指令跳转到 printf 之后,ra 寄存器(通常用作返回地址寄存器)将被设置为 jalr 指令后的下一条指令的地址。jalr 指令在程序中的地址是 0x34,并且这是一条 4 字节的指令,那么 ra 的值将是 0x38。
运行以下代码。
unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);
输出是什么? 这是将字节映射到字符的 ASCII 表。输出取决于 RISC-V 是小端字节序的事实。
如果 RISC-V 是大端字节序,你会设置什么i来产生相同的输出?你需要更改 57616为不同的值吗?
答: "HE110 World"。
i = 0x726c6400。
在下面的代码中,后面会打印什么 'y='?(注意:答案不是一个具体的值。)为什么会发生这种情况?
printf("x=%dy=%d", 3);
答:由于缺少第二个参数,printf 会尝试读取堆栈上预期的第二个整数。这种情况下,由于未定义行为,printf 可能会读取到堆栈上的其他值,这个值可能是一个任意数,或者是程序运行时的垃圾值。
三、Backtrace
1.题目描述
在kernel/printf.c中 实现backtrace()函数。在sys_sleep中插入对此函数的调用,然后运行,调用sys_sleep。您的输出应如下所示: bttest
回溯:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898
bttest退出 qemu 后。在您的终端中:地址可能略有不同,但如果您运行addr2line -e kernel/kernel(或riscv64-unknown-elf-addr2line -e kernel/kernel)并剪切并粘贴上述地址,如下所示:
$addr2line -e kernel/kernel
0x0000000080002de2
0x0000000080002f4a
0x0000000080002bfc
Ctrl-D
你应该看到类似这样的内容:
内核/sysproc.c:74
内核/系统调用.c:224
内核/trap.c:85
2.解答
在 riscv.h 中给定获取当前 fp(frame pointer)寄存器的方法:
fp 指向当前地址的开始地址,sp 指向当前地址的结束地址。
// riscv.h
static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x));
return x;
}
/*这行代码使用内联汇编语法来执行一个汇编指令:
asm 关键字用于嵌入汇编代码。
volatile 关键字表示这段汇编代码不应该被编译器优化掉。
"mv %0, s0" 是一条 RISC-V 汇编指令,mv 是 move 的缩写,表示将 s0 寄存器的值移动到 %0 指定的位置。%0 是一个占位符,表示输出操作数。
: "=r" (x) 部分指定了输出操作数。"=r" 表示将结果存储到一个寄存器中,(x) 表示将结果存储到变量 x 中。*/
该函数返回当前的帧指针(frame pointer, fp),通常存储在 RISC-V 架构中的 s0 寄存器中。帧指针用于标识当前函数的栈帧。
// printf.c
void backtrace() {
uint64 fp = r_fp(); // 获取当前帧指针
if (fp == 0) { // 检查帧指针是否有效
printf("无有效帧指针。\n");
return;
}
while (fp != PGROUNDUP(fp) && fp) { // 检查是否到达栈底或帧指针非零
uint64 ra = *(uint64*)(fp - 8); // 获取返回地址
printf("%p\n", ra); // 打印返回地址
uint64 next_fp = *(uint64*)(fp - 16); // 获取前一个帧指针
if (next_fp <= fp) { // 检查栈是否向下增长(防止栈损坏或异常的帧指针)
printf("检测到栈损坏或已到栈底。\n");
break;
}
fp = next_fp; // 更新当前帧指针为前一个帧指针
}
}
四、Alarm
1.题目描述
在本练习中,您将向 xv6 添加一项功能,该功能会在进程使用 CPU 时间时定期向其发出警报。这可能对计算受限的进程很有用,这些进程希望限制它们占用的 CPU 时间,或者对想要计算但也想要采取一些定期操作的进程很有用。
更一般地说,您将实现一种原始形式的用户级中断/故障处理程序;例如,您可以使用类似的东西来处理应用程序中的页面错误。如果您的解决方案通过了警报测试和用户测试,则说明它是正确的。
2.解答
要在xv6中实现sigalarm(interval, handler)
和sigreturn
系统调用,您需要进行几个步骤。以下是详细的实现步骤,包括添加必要的代码和更新Makefile以编译alarmtest
。
1. 修改struct proc
首先,在struct proc
中添加与闹钟相关的字段。这些字段用于跟踪闹钟的状态和处理程序。
// 在 proc.h 中
struct proc {
// ......
int alarm_interval; // 闹钟间隔(0表示禁用)
void (*alarm_handler)(); // 闹钟处理程序
int alarm_ticks; // 距离下一次闹钟响起的时钟周期数
struct trapframe *alarm_trapframe; // 运行闹钟处理程序前的陷阱帧副本
int alarm_goingoff; // 当前是否有正在执行且尚未返回的闹钟处理程序(防止重入)
};
2. 添加sigalarm
系统调用
在xv6中,系统调用通常包括以下几个步骤:定义系统调用号、实现系统调用处理程序、在系统调用表中注册系统调用,并为用户提供一个接口。
a. 定义系统调用号
在kernel/syscall.h
中添加SYS_sigalarm
和SYS_sigreturn
的定义:
#define SYS_sigalarm 22
#define SYS_sigreturn 23
b. 实现系统调用处理程序
在kernel/sysproc.c
中添加sys_sigalarm
和sys_sigreturn
的实现:
// 在 kernel/sysproc.c 中
extern uint64 sys_sigalarm(void) {
int interval;
uint64 handler;
if (argint(0, &interval) < 0)
return -1;
if (argaddr(1, &handler) < 0)
return -1;
struct proc *p = myproc();
p->alarm_interval = interval;
p->alarm_handler = (void (*)())handler;
p->alarm_ticks = interval;
p->alarm_goingoff = 0;
return 0;
}
extern uint64 sys_sigreturn(void) {
struct proc *p = myproc();
if (p->alarm_trapframe) {
memmove(p->trapframe, p->alarm_trapframe, sizeof(struct trapframe));
kfree(p->alarm_trapframe);
p->alarm_trapframe = 0;
}
return 0;
}
c. 在系统调用表中注册系统调用
在kernel/syscall.c
中添加sys_sigalarm
和sys_sigreturn
:
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);
// 在 syscalls 数组中添加
[SYS_sigalarm] sys_sigalarm,
[SYS_sigreturn] sys_sigreturn,
d. 为用户提供接口
在user/user.h
中声明用户接口:
int sigalarm(int interval, void (*handler)());
int sigreturn(void);
在user/usys.pl
中添加系统调用接口:
entry("sigalarm");
entry("sigreturn");
3. 实现时钟中断处理
修改时钟中断处理程序,在kernel/trap.c
中添加代码,以处理闹钟:
// 在 kernel/trap.c 中
void
usertrap(void)
{
// existing code...
if (which_dev == 2) {
struct proc *p = myproc();
if (p->alarm_interval != 0 && !p->alarm_goingoff) {
if (--p->alarm_ticks == 0) {
p->alarm_ticks = p->alarm_interval;
p->alarm_goingoff = 1;
if (p->alarm_trapframe == 0) {
p->alarm_trapframe = (struct trapframe *)kalloc();
memmove(p->alarm_trapframe, p->trapframe, sizeof(struct trapframe));
}
p->trapframe->epc = (uint64)p->alarm_handler;
}
}
}
// existing code...
}
4. 编译alarmtest
在user/Makefile
中添加alarmtest
:
UPROGS=\
_alarmtest\
...
5. 编写用户代码alarmtest
创建user/alarmtest.c
,实现测试闹钟功能:
#include "kernel/types.h"
#include "user/user.h"
void periodic() {
printf("Alarm!\n");
sigreturn();
}
int main() {
sigalarm(2, periodic);
while(1) {
printf("Main loop\n");
sleep(1);
}
exit(0);
}
6. 编译和运行
编译xv6并运行alarmtest
以验证实现效果。
make qemu
五、心得体会
关于我这个gdb时好时坏,但是要想深入了解这个系统,只能进行断点调试。
听课对我来说无济于事,毕竟基础知识还是有点的。
之前的学习路径是讲义 ➡️ 讲座 ➡️ 实验;现在可以讲义 ➡️ 实验了。
最开始我对这个抱有太多期望,我觉得“太好了,学完这个我肯定会拥有很多!”;后来发现我高估了我自己的学习能力“学习道路磕磕绊绊不说,还有很多其他惊世骇俗的想法诱惑着我”。
我肯定不会被过去的自己约束,但是,这个课程是我当时还算有勇气的想法,如果我因为更好的想法而砍掉目前的课程,那我害怕我的每个想法都会无疾而终。
这门课程我应该会选择这两周all in 尽快更完,fighting!