lab4 traps

lab4 traps_第1张图片

在开始做lab之前务必弄清楚

  1. 所谓的系统调用,本质上就是内核态和用户态之间的切换
  2. 内核态和用户态的区别本质上就是一些关键属性的区别,比如页表
    而他们的运行方式都一样,就是取指执行,没有魔法
    只不过内核态和用户态的执行不在一个体系上,所以切换会比较复杂,但这些切换也就只是修改一些关键属性
  3. 进程的运行本质上就是一些值不断的变化,trapframe中的值完全可以保存并复现一个进程执行到了哪里加粗样式

PreRead

  • xv6 book的第四章
  • kernel/trampoline.S:在用户态和内核态进行切换的汇编代码
  • kernel/trap.c:处理陷入的代码

RISC-V assembly

task

有一个文件user/call.c,通过make fs.img可以编译并生成一个可读性很高的汇编代码user/call.asm

阅读这个汇编代码中的函数g,f,main,并回答以下问题

1. a0-a7,a2
2. 被优化了?
3. 630
4. 38
5. He110 World,第一个是直接输出十六进制的表示,第二个是将十六进制的每个字节看做一个字符,并且是小端法
   将i改成0x726c6400,57616不用改
6. 随机值,因为相当于调用printf的时候没有给出寄存器a2的值,那么这时候就会根据a2的值随机出现答案

Backtrace

task

如果能够清楚在错误发生之前的一系列函数调用,那么对debug很有帮助

  1. kernel/printf.c中实现一个backtrace()函数

    kernel文件夹里的,不是user文件夹里,user文件夹里也有一个printf.c

  2. sys_sleep中插入对backtrace函数的调用

  3. 运行bttest,它会调用sys_sleep,你的输出应该是

    backtrace:
    0x0000000080002cda
    0x0000000080002bb6
    0x0000000080002898
    

    bttest结束之后,在你的终端中运行

        $ addr2line -e kernel/kernel
        0x0000000080002de2
        0x0000000080002f4a
        0x0000000080002bfc
        Ctrl-D
    

    你会看到

        kernel/sysproc.c:74
        kernel/syscall.c:224
        kernel/trap.c:85
    
  4. 编译器会给每个栈帧一个frame pointer,你应该使用这个指针去遍历栈并且打印每个栈帧中保存的返回地址

hints

  1. 记得在kernel/defs.h中声明你的backtrace函数

  2. GCC编译器将栈指针存放在当前执行函数的s0寄存器中,将下面这个函数添加到kernel/riscv.h中,并且在backtrace函数中调用它

    static inline uint64
    r_fp()
    {
      uint64 x;
      asm volatile("mv %0, s0" : "=r" (x) );
      return x;
    }
    
  3. 返回地址位于fp-8的固定位置

    被保存的栈帧地址位于fp-16的固定位置

    lab4 traps_第2张图片

  4. 你可以使用这两个宏,有助于终止backtrace的循环

    PGROUNDDOWN(fp)计算栈顶的位置

    PGROUNDUP(fp)计算栈底的位置

  5. 如果你的backtrace工作了,可以在kernel/printf.cpanic调用它,这样一旦内核出错了,你就可以看到调用栈

思路

一行一行地打印函数调用的地址,思路比较简单

  1. 首先通过文档提供给我们的r_fp函数获得当前的fp
  2. 通过这个fp不断打印这个函数的返回地址,并将fp置为上一个函数的fp,具体位置如上面那个栈帧的图所示

具体实现上

  1. 需要通过hints里提示的宏,找到栈帧的终点,也是我们循环的终点。
    1. 注意了,这个终点也就是最高层的函数,它不需要继续打印了,而它自己也被它调用的函数打印了,因此就直接结束
    2. fp这个指针,并不能直接取值,将它减8,它才正好指向当前栈帧底部的第一个值,注意!
  2. 如果想打印出64位,并在前面补0,前面还加上0x,用%p就可以打印出来了,不用自己瞎搞,chatgpt还忽悠人
void backtrace() {
    printf("backtrace:\n");
    uint64 fp = r_fp();
    uint64 up_edge = PGROUNDUP(fp);
    while (fp < up_edge) {
        printf("%p\n", *(uint64 *)(fp - 8));
        fp = *(uint64 *)(fp - 16);
    }
}

Alarm

task

在这个练习中,你将给xv6增加一个特性,即会根据CPU时间周期性地alert一个进程

如果你的解答可以通过alarmtestusertests

  1. 你应该添加一个新的系统调用sigalarm(interval,handler)

  2. 如果一个应用调用了sigalarm(n,fn)

    那么在这个应用消耗了n ticks个CPU时间后,内核会调用函数fn

    fn返回后,应用会回到被打断的地方继续执行

  3. 如果一个应用调用了sigalarm(0,0),内核应该暂停生成周期性的alarm

  4. 在你的xv6文件中有一个文件叫user/alarmtest.c,你需要将其加入到Makefile

    只有你正确添加了sigalarmsigreturn系统调用之后,才可以正确编译

hints1

  • 首先修改内核,跳转到用户空间的alarm handler

    这将让test0打印alarm!

  • 现在还不用管打印之后发生了什么,你的程序在打印之后崩了就行

  1. 你需要去修改Makefile,让它去编译alarmtest.c成为用户程序

  2. user/user.h中正确的声明应该如下

        int sigalarm(int ticks, void (*handler)());
        int sigreturn(void);
    
  3. 更新user/usys.plkernel/syscall.hkernel/syscall.c

    使得alarmtest能够调用sigalarmsigreturn系统调用

  4. 至此你的sys_sigreturn应该只返回0

    你的sys_sigalarm应该存下alarm的间隔和处理函数的指针到proc结构体的新的区域

  5. 你需要去跟踪自从上次调用alarm的处理函数到现在已经过去了多少ticks

    这也需要在struct proc中增加一个新的字段,你可以在proc.c 的 allocproc()中初始化这个字段

  6. 每次来一个tick,都会在kernel/trap.cusertrap中被处理

    你只需要在有时钟中断的时候操作

  7. 只需要在一个进程有 t i m e r   o u t s t a n d i n g timer\ outstanding timer outstanding的时候调用alarm函数

    小心函数地址为0的情况,因为函数地址可以为0,我是傻逼!

  8. 你将需要去修改usertrap函数,使得当一个进程的alarm时间间隔到期时,用户进程执行处理函数

    当一个陷入返回到用户空间时,是什么决定着用户空间代码继续执行的指令地址?

  9. 如果你运行make CPUS=1 qemu-gdb,会使用用gdb查看trap的时候更容易

  10. 如果alarmtest打印了alarm!,你就成功了

思路1

  1. 首先根据它的提示去各个文件中把系统调用的声明给弄好

  2. 然后在struct proc中增加如下字段,其中关键在于uint64 handler,它是函数指针,不过终究也就是个指针,因此可以用uint64来表示

        int cur_ticks;
        uint64 handler;
        int ticks;
    
  3. sysprorc.c中完成sys_sigalarmsys_sigreturn

    uint64
    sys_sigalarm(void) {
        struct proc *p;
        p = myproc();
        argint(0, &p->ticks);
        argaddr(1, &p->handler);
        p->cur_ticks = 0;
        return 0;
    }
    
    uint64
    sys_sigreturn(void) {
        return 0;
    }
    
  4. 最后在trap.c中完成调用

    注意了,函数指针可能是0,所以用ticks是否为0判断是否需要计数

        if (which_dev == 2) {
            if (p->ticks != 0) {
                p->cur_ticks++;
                if (p->cur_ticks == p->ticks) {
                    p->cur_ticks = 0;
                    p->trapframe->epc = p->handler;
                }
            }
            yield();
        }
    

    这里的实现是如果当前已经到了第n个时钟中断,那么会先去中断,等下一次获得cpu使用权时,再去执行handler操作

    我试了一下在放弃cpu之前直接p->handler(),结果不允许

    估计是因为地址的原因,现在可是在内核态,怎么可能能够通过这个用户态的虚拟地址来执行

    所以,只能等到这个进程再次获得CPU并且回归用户态用,就会用epc这个参数来初始化pc,就会从这里开始执行了

hints2

你需要在执行完alarm处理函数之后,正确返回程序被中断的地方,并且各种寄存器的状态也要不变

xv6已经为实现提供了一种思路,即每个alarm处理函数的最后都有一个alarmreturn函数,你可以通过usertrapsys_sigreturn合作来完成用户进程的恢复

  1. 你将需要保存和恢复寄存器,很多很多
  2. struct proc中保存足够多的状态,使得你可以在sigreturn中恢复
  3. 如果一个处理函数还没有结束,内核不应该再次调用它

思路2

到了这一步,必须要先搞清楚系统调用的过程中对于状态的保存和恢复了

  1. uservec保存了各种常用的寄存器
  2. usertrap将返回的pc地址存到了p->trapframe->epc
  3. usertrapret通过p->trapframe->epc恢复pc
  4. userret恢复各种寄存器

首先,我们希望在时钟中断之后,这个进程被调度回来的时候,去执行alarm处理函数,因此我们需要在时钟中断的处理中,将epc置为处理函数的地址,这样就完成了task0

但是如果只是这样的话,这个进程在执行完alarm处理函数之后并不能正确的返回需要执行的地方。那如何正确的返回呢?

可以发现,alarm处理函数的最后一句通常是alarmreturn,这是一个系统调用!如果我们能够在这个系统调用返回之前将trapframe(因为trapframe包括了所有返回用户态需要的信息,所以我们只需要这个就行了)变成在时钟中断处理之前的样子,那么就可以借用alarmreturn这个系统调用的返回操作回到我们想去的地方

而需要注意的是,如果已经执行了alarm处理函数,那此时的trapframe肯定是不行的,因为包括pc和各种通用寄存器都被破坏的,那哪个时间点的trapframe可以呢?

答案是刚进入if (which_dev == 2) 的时候,想一想,如果我们不需要搞这个什么alarm,那么等之后这个进程再次被调度到cpu之后,那不就是继续正常执行吗?说明这个时间点的trapframe可以通过任何一个系统调用的返回过程使得进程执行到继续执行的地方

不过我们也没必要每次进入这里都保存了,只需要在确定了会去执行alarm处理函数的时候保存,在alarmreturn中恢复即可

除此之外,题目还要求如果已经有一个alarm处理函数在执行,那么其他的必须等待,因此额外增加一个变量代表是否有在执行

具体实现如下

首先给proc结构体增加如下变量

    struct trapframe *alarm_tf;
    int is_runing;

并且在进程初始化和终止的时候对这两个变量进行处理

    // 进程初始化,这里主要是防止申请不成功,那就学着已有的代码对进程进行销毁
	p->alarm_tf = (struct trapframe *)kalloc();
    if (p->alarm_tf == 0) {
        freeproc(p);
        release(&p->lock);
        return 0;
    }
    p->is_runing = 0;
	// 进程结束
	kfree(p->alarm_tf);

trap.c中的代码进行如下更新

  • 注意p->cur_ticks >= p->ticks,这里变成大于等于,是为了保证现在可以等,但之后如果没有正在运行的了,那就可以进入alarm处理函数的流程。如果和之前一样是等于号的好,那可能就错过了
    if (which_dev == 2) {
        if (p->ticks != 0) {
            p->cur_ticks++;
            if (p->cur_ticks >= p->ticks && p->is_runing == 0) {
                memmove(p->alarm_tf, p->trapframe, sizeof(struct trapframe));
                p->is_runing = 1;
                p->cur_ticks = 0;
                p->trapframe->epc = (uint64)p->handler;
            }
        }
        yield();
    }

最后修改sys_sigreturn函数

uint64
sys_sigreturn(void) {
    struct proc *p;
    p = myproc();
    memmove(p->trapframe, p->alarm_tf, sizeof(struct trapframe));
    p->is_runing = 0;
    return 0;
}

总结

  1. 系统调用的过程很复杂,设计的也很巧妙。并且由于内核态和用户态的虚拟地址空间不一样,导致了一些麻烦的操作。不过在各种状态的切换中,进程的trapframe包含了这个进程所有的信息,拥有一个进程某个时刻的trapframe,就可以在任意时候将这个进程恢复到这个状态,这也是这个lab考察的内容
  2. 在这个lab中我们不需要自己去做各种东西的切换,只需要提供一个正确的trapframe即可
  3. 系统调用会经历那四个阶段,时钟中断导致的进程切换和恢复最起码也会经历最后的两个阶段

你可能感兴趣的:(6.S081,linux,运维,服务器)