YangYuchen
xv6学习,Lab4-Traps

xv6学习,Lab4-Traps

四、Lab4: traps

根据实验要求,先阅读阅读 xv6 书籍的第 4 章,以及相关的源文件: kernel/trampoline.S:涉及从用户空间切换到内核空间以及反向切换的汇编代码 kernel/trap.c:处理所有中断的代码,以下是这部分的内容总结:

  1. 整体内容

    有三种事件会导致 CPU 暂停普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上。一种情况是系统调用,当用户程序执行 ecall 指令以请求内核为其执行某些操作时。另一种情况是异常:指令(用户或内核的)执行了非法操作,例如除以零或使用无效的虚拟地址。第三种情况是设备中断,当设备发出需要关注的信号时,例如当磁盘硬件完成读或写请求时。

    本书将陷阱(trap)作为这些情况的统称。通常,在发生trap时正在执行的任何代码稍后都需要恢复执行,并且不应该意识到发生了任何特殊情况(也就是说,先把如上图所示的用户空间的这32位寄存器,以及其它的一些寄存器存储起来,这样才能保证才中断结束后恢复到原来的执行状态,在进行trap时我们仍然要保证隔离性和安全性),我们通常希望陷阱是透明的(意思就是让用户空间的程序感受不到trap的存在)。

    Xv6 的陷阱处理分为四个阶段:RISC-V CPU 采取的硬件操作、一些为内核 C 代码做准备的汇编指令、一个决定如何处理陷阱的 C 函数,以及系统调用或设备驱动服务例程。虽然这三种陷阱类型存在共性,这表明内核可以用单一的代码路径来处理所有陷阱,但实际上,为三种不同的情况(来自用户空间的陷阱、来自内核空间的陷阱和定时器中断)设置单独的代码会更方便。处理陷阱的内核代码(汇编语言或 C 语言)通常被称为处理程序;第一个处理程序指令通常用汇编语言(而不是 C 语言)编写,有时被称为向量。

  2. RISC-V trap machinery

    在进入trap前,CPU的所有状态都设置成处于用户空间的状态。 如下图(RISC-V Calling Convention Table 18.1)所示,RISC-V 调用约定在可能的情况下通过寄存器传递参数。为此,最多可使用八个整数寄存器 a0 - a7,以及最多八个浮点寄存器 fa0 - fa7,在汇编代码中我们使用图中的ABI Name 。

    每个 RISC-V CPU 都有一组控制寄存器,内核通过写入这些寄存器来告知 CPU 如何处理trap,并且内核可以通过读取这些寄存器来了解已经发生的trap。riscv.h(kernel/riscv.h:1)包含了 Xv6 所使用的定义。寄存器是用来进行读取和运算的最快的方式,这就是为什么我们从用户空间调用ecall进入内核空间时使用寄存器来传递参数,当函数参数超过8个时就需要使用内存了。最后一列Saver,Caller寄存器在函数调用时不会保存,但Callee寄存器在函数调用时会自动保存。除了这些之外,RISC-V还包含:

    • 程序计数器(Program counter register)。
    • SATP寄存器(上一章讲过,包含指向page table的物理内存地址)。
    • Mode(说明是用户空间还是内核空间)。
    • STVEC寄存器(内核将其陷阱处理程序的地址写入此处;RISC-V 会跳转到 stvec 中的地址来处理陷阱)。
    • SEPC(当发生陷阱时,RISC-V 会将程序计数器的值保存到此处,因为此时程序计数器会被 stvec 中的值覆盖)。
    • SSCRATCH寄存器(陷阱处理程序代码使用 sscratch 来避免在保存用户寄存器之前将其覆盖)。
    • SCAUSE(RISC-V 会在此处放入一个数字,用于描述引发陷阱的原因)
    • SRET(从陷阱返回,指令会将 sepc 复制到程序计数器中,内核可以通过写入 sepc 来控制 sret 的返回位置)。
    • SSTATUS:SSTATUS 中的 SIE 位控制设备中断是否启用。如果内核清除 SIE,RISC-V 会推迟设备中断,直到内核设置 SIE。SPP 位表示陷阱是来自用户模式还是监督模式,并控制 sret 返回的模式。
    image.png

    切换到监督者模式后才能处理上述寄存器,在用户模式下无法读取或写入它们,除此之外监督者模式可以对PTE_U没有置位的进行处理,而用户模式就只能读取PTE_U置位的PTE,需要注意的是监督者模式并不能对任意的物理内存进行修改。

  3. Traps from user space(用户空间的trap)

    最常见的trap来自于用户程序中进行系统调用(Ecall指令),Ecall将系统从用户模式改为监督者模式;将程序计数器的值保存在SEPC寄存器;跳转到STVEC寄存器指向的指令,除了ecall外,一些非法操作,有设备产生中断,都会发生trap。具体的执行过程是一个十分复杂的过程,xv6的官方手册中用语言描述的这一过程,如下:

    从用户空间产生的陷阱的高级处理路径是:先到 uservec( kernel/trampoline.S:21),然后到 usertrap( kernel/trap.c:37);在返回时,先经过 usertrapret(kernel/trap.c:90),然后到 userret(位于 kernel/trampoline.S:101)。 Xv6 的陷阱处理设计的一个主要限制是,RISC-V 硬件在强制产生陷阱时不会切换页表。这意味着 stvec 中的陷阱处理程序地址在用户页表中必须有一个有效的映射,因为在陷阱处理代码开始执行时,生效的是用户页表。此外,Xv6 的陷阱处理代码需要切换到内核页表;为了在切换后能够继续执行,内核页表也必须为 stvec 所指向的处理程序有一个映射。 Xv6 使用一个跳板页(trampoline page)来满足这些要求。跳板页包含 uservec,即 stvec 所指向的 Xv6 陷阱处理代码。跳板页在每个进程的页表中都被映射到地址 TRAMPOLINE,该地址位于虚拟地址空间的顶部,因此它会在程序为自身使用的内存之上。跳板页在内核页表中也被映射到地址 TRAMPOLINE。由于跳板页被映射到用户页表中(没有 PTE_U 标志),所以陷阱可以在监督模式下从那里开始执行。由于跳板页在内核地址空间中的相同地址被映射,所以陷阱处理程序在切换到内核页表后可以继续执行。

    用户态陷阱处理程序 uservec 的代码位于 trampoline.Skernel/trampoline.S:21)中。当 uservec 开始执行时,所有 32 个寄存器都包含被中断的用户代码所拥有的值。这 32 个值需要被保存到内存的某个地方,以便在陷阱返回到用户空间时能够被恢复。向内存中存储需要使用一个寄存器来保存地址,但在此时没有可用的通用寄存器,幸运的是,RISC-V 以 sscratch 寄存器的形式提供了帮助。uservec 开头的 csrw 指令将 a0 保存到 sscratch 中。 现在 uservec 有了一个寄存器(a0)可以使用。uservec 的下一个任务是保存 32 个用户寄存器。内核为每个进程分配一页内存用于 trapframe 结构,该结构(除其他内容外)有空间来保存 32 个用户寄存器(kernel/proc.h:43)。因为 satp 仍然指向用户页表,所以 uservec 需要 trapframe 在用户地址空间中进行映射。Xv6 在每个进程的用户页表中,将该进程的 trapframe 映射到虚拟地址 TRAPFRAMETRAPFRAME 就在 TRAMPOLINE 下方。 进程的 p->trapframe 也指向 trapframe,不过是其物理地址,以便内核可以通过内核页表来使用它。 因此,uservec 将地址 TRAPFRAME 加载到 a0 中,并将所有用户寄存器保存到那里,包括从 sscratch 中读回的用户的 a0trapframe 包含当前进程的内核栈地址、当前 CPU 的 hartidusertrap 函数的地址以及内核页表的地址。uservec 检索这些值,将 satp 切换到内核页表,并调用 usertrap

    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
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    .globl uservec
    uservec:
    # trap.c会将stvec设置为指向此处,因此来自用户空间的陷阱会从此处开始,
    # 处于监督者模式,但使用的是用户页表。

    # 将用户的 a0 保存到 sscratch 中,以便
    # a0 可用于获取 TRAPFRAME。
    csrw sscratch, a0

    # 每个进程都有一个单独的p->trapframe内存区域,
    # 但在每个进程的用户页表中,它都映射到相同的虚拟地址
    li a0, TRAPFRAME

    # 将用户寄存器保存到TRAPFRAME中,此处省略下面若干行
    sd ra, 40(a0)
    sd sp, 48(a0)
    sd gp, 56(a0)
    sd tp, 64(a0)

    # 将用户的a0保存到p->trapframe->a0 中
    csrr t0, sscratch
    sd t0, 112(a0)

    # 从 p->trapframe->kernel_sp初始化内核栈指针
    ld sp, 8(a0)

    # 使 tp 保存当前的 hartid,从p->trapframe->kernel_hartid 中获取
    ld tp, 32(a0)

    # 从 p->trapframe->kernel_trap加载 usertrap() 的地址
    ld t0, 16(a0)

    # 从 p->trapframe->kernel_satp 中获取内核页表地址
    ld t1, 0(a0)

    # 等待之前的任何内存操作完成,以便它们使用用户页表。
    sfence.vma zero, zero

    # 安装内核页表。
    csrw satp, t1

    # 从 TLB 中刷新现在已过时的用户条目。
    sfence.vma zero, zero

    # 跳转到 usertrap(),该函数不会返回
    jr t0

usertrap的任务是确定陷阱的原因,进行处理并返回(kernel/trap.c:37)。它首先更改stvec,以便在内核中发生陷阱时由kernelvec而不是uservec来处理。它保存sepc寄存器(保存的用户程序计数器),因为usertrap可能会调用yield来切换到另一个进程的内核线程,而该进程可能会返回到用户空间,在这个过程中它会修改sepc。如果陷阱是一个系统调用,usertrap会调用syscall来处理它;如果是设备中断,就调用devintr;否则,这是一个异常,内核会杀死出错的进程。系统调用路径会将保存的用户程序计数器加四,因为在RISC-V中,对于系统调用,程序指针会指向ecall指令,但用户代码需要从后续指令继续执行。

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
35
36
37
38
39
40
41
42
43
44
45
46
// kernel/trap.c:37
// 处理来自用户空间的中断、异常或系统调用。
// 从上方trampoline.S中的uservec调用
void
usertrap(void)
{
int which_dev = 0;
// 如果不是从用户模式进入,触发panic
if((r_sstatus() & SSTATUS_SPP)!= 0)
panic("usertrap: not from user mode");
// 将中断和异常发送到 kerneltrap(),因为现处于在内核中
w_stvec((uint64)kernelvec);
// 获取当前进程
struct proc *p = myproc();
// 保存用户程序计数器。
p->trapframe->epc = r_sepc();

if(r_scause() == 8){
// 系统调用
// 如果进程已被标记为终止,退出并返回 -1
if(killed(p))
exit(-1);
// sepc 指向 ecall 指令,但我们想要指向到下一条指令
p->trapframe->epc += 4;
// 中断会改变 sepc、scause 和 sstatus,所以现在我们完成了对这些寄存器的操作后才启用中断
intr_on();
// 执行系统调用
syscall();
} else if((which_dev = devintr())!= 0){
// ok
} else {
// 打印出意外的异常原因和进程ID并终止进程
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
setkilled(p);
}
// 如果进程已被标记为终止,退出并返回 -1
if(killed(p))
exit(-1);

// 如果这是一个定时器中断,放弃 CPU。
if(which_dev == 2)
yield(); // 让出 CPU
// 进行用户陷阱返回的处理
usertrapret();
}

在返回用户空间的过程中,第一步是调用usertrapretkernel/trap.c:90)。这个函数设置RISC-V控制寄存器,为从用户空间未来可能发生的陷阱做准备。这包括将stvec更改为指向uservec,准备uservec所依赖的trapframe字段,并将sepc设置为之前保存的用户程序计数器。

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
// kernel/trap.c:90
// 返回用户空间
void
usertrapret(void)
{
// 获取当前进程
struct proc *p = myproc();
// 把trap的处理方式的从kerneltrap()切换到usertrap(),
// 在回到用户空间之前关闭中断,在用户空间中usertrap()是正确的处理程序
intr_off();
// 将系统调用、中断和异常发送到trampoline.S中的uservec
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
// 设置stvec为 trampoline_uservec
w_stvec(trampoline_uservec);
// 设置当进程下一次陷入内核时 uservec 所需的陷阱帧(trapframe)值
p->trapframe->kernel_satp = r_satp();
p->trapframe->kernel_sp = p->kstack + PGSIZE;
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp();
// 设置 trampoline.S 的 sret 用于进入用户空间的寄存器。
// 将 S 先前特权模式设置为用户模式。
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // 将SPP清零以进入用户模式
x |= SSTATUS_SPIE; // 在用户模式下启用中断
w_sstatus(x);
// 将Sepc设置为保存的用户程序计数器
w_sepc(p->trapframe->epc);
// 告知trampoline.S要切换到的用户页表。
uint64 satp = MAKE_SATP(p->pagetable);
// 跳转到内存顶部的 trampoline.S 中的 userret,它会切换到用户页表,恢复用户寄存器,并使用 sret 切换到用户模式。
uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64))trampoline_userret)(satp);
}

最后,usertrapret在同时映射到用户和内核页表的跳板页上调用userret;这样做的原因是userret中的汇编代码会切换页表。 usertrapretuserret的调用在a0中传递一个指向进程的用户页表的指针(kernel/trampoline.S:101)。userretsatp切换到进程的用户页表。回想一下,用户页表将跳板页和TRAPFRAME进行映射,但不会映射内核中的其他内容。用户和内核页表中在相同虚拟地址处的跳板页映射使得userret在更改satp后能够继续执行。从这一点开始,userret唯一可以使用的数据是寄存器内容和trapframe的内容。userretTRAPFRAME的地址加载到a0中,通过a0trapframe中恢复保存的用户寄存器,恢复保存的用户a0,并执行sret以返回到用户空间(在第二章学习过,sret是唯一可以返回用户空间的方法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.globl userret
userret:
# 由 trap.c 中的 usertrapret() 调用,用于从内核切换到用户模式
# 切换到用户页表
sfence.vma zero, zero
csrw satp, a0
sfence.vma zero, zero

li a0, TRAPFRAME #将a0置为 TRAPFRAME 的地址

# 从 TRAPFRAME 中恢复除 a0 以外的所有寄存器,省略若干行
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)

# 恢复用户的a0
ld a0, 112(a0)
# 返回用户模式和用户程序计数器。
# usertrapret()已经设置了sstatus和sepc
sret
  1. Traps from kernel space(来自内核空间的trap)

    Xv6 根据执行的是用户代码还是内核代码,对 CPU 陷阱寄存器的配置略有不同。当内核在 CPU 上执行时,内核将 stvec 指向 kernelvec 处的汇编代码(kernel/kernelvec.S:12)。由于 xv6 已经在内核中,kernelvec 可以依赖 satp 被设置为内核页表,并且栈指针指向一个有效的内核栈。kernelvec 将所有 32 个寄存器压入栈中,稍后将从栈中恢复它们,以便被中断的内核代码能够不受干扰地继续执行。 kernelvec 将寄存器保存到被中断的内核线程的栈上,这是有意义的,因为寄存器的值属于该线程。如果陷阱导致切换到不同的线程,这一点尤为重要——在这种情况下,陷阱实际上会从新线程的栈中返回,而被中断线程的保存寄存器会安全地留在其自己的栈上。

    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
    .globl kerneltrap
    .globl kernelvec
    .align 4
    kernelvec:
    # 为保存寄存器腾出空间。
    addi sp, sp, -256

    # 保存寄存器,省略了若干行
    sd ra, 0(sp)
    sd sp, 8(sp)

    # 调用trap.c中的陷阱处理函数
    call kerneltrap

    # 恢复寄存器,省略若干行
    ld ra, 0(sp)
    ld sp, 8(sp)
    ld gp, 16(sp)
    # 不恢复 tp(包含 hartid),以防我们切换了 CPU
    ld t0, 32(sp)
    ld t1, 40(sp)

    addi sp, sp, 256
    # 返回内核中我们之前正在做的事情。
    sret

    在保存寄存器后,kernelvec 跳转到 kerneltrapkernel/trap.c:135)。kerneltrap 为两种类型的陷阱做好了准备:设备中断和异常。它调用 devintrkernel/trap.c:178)来检查并处理前者。如果陷阱不是设备中断,那它一定是一个异常,如果在 xv6 内核中发生这种情况,那总是一个致命错误;内核会调用 panic 并停止执行。 如果 kerneltrap 是由于定时器中断而被调用,并且一个进程的内核线程正在运行(而不是调度器线程),那么 kerneltrap 会调用 yield 给其他线程一个运行的机会。在某个时刻,这些线程中的一个会进行 yield,让我们的线程及其 kerneltrap 再次恢复。第 7 章解释了在 yield 中会发生什么。 当 kerneltrap 的工作完成后,它需要返回到被陷阱中断的任何代码。因为 yield 可能会干扰 sepcsstatus 中的先前模式,所以 kerneltrap 在开始时会保存它们。现在,它会恢复这些控制寄存器并返回到 kernelveckernel/kernelvec.S:50)。kernelvec 从栈中弹出保存的寄存器并执行 sretsretsepc 复制到 pc 中,从而恢复被中断的内核代码。

    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
     kernel/trap.c:135
    // 通过 kernelvec,内核代码中的中断和异常会来到这里,使用当前的内核栈。
    void
    kerneltrap()
    {
    int which_dev = 0;
    uint64 sepc = r_sepc();
    uint64 sstatus = r_sstatus();
    uint64 scause = r_scause();
    // 如果状态寄存器的SPP位为0,说明不是从监督者模式进入的,触发panic
    if ((sstatus & SSTATUS_SPP) == 0)
    panic("kerneltrap: not from supervisor mode");
    // 如果中断标志为1,说明中断是启用的,触发panic
    if (intr_get()!= 0)
    panic("kerneltrap: interrupts enabled");
    // 判断是否为设备中断,并获取产生中断的设备编号
    if ((which_dev = devintr()) == 0) {
    printf("scause %p\n", scause);
    printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
    panic("kerneltrap");
    }
    // 如果是定时器中断,并且当前进程存在且处于运行状态,让出CPU
    if (which_dev == 2 && myproc()!= 0 && myproc()->state == RUNNING)
    yield();
    // yield() 可能导致一些陷阱发生,所以恢复陷阱寄存器,以供kernelvec.S的sepc指令使用。
    w_sepc(sepc);
    w_sstatus(sstatus);
    }
  2. Page-fault exceptions(页面错误异常)

    Xv6 对异常的处理简单,在用户空间异常会终止进程,在内核异常会崩溃,而真正的操作系统会有更有趣的响应方式。以写时复制(COW)的 fork 操作为例,通过适当使用页表权限和页面错误,父进程和子进程可安全共享物理内存且最初映射为只读,写入时会引发页面错误异常,内核陷阱处理程序会进行相应处理。写时复制使 fork 操作更快,且应用程序无需修改即可受益。

    页表和页面错误的结合开启了许多有趣的可能性。懒分配(lazy allocation)有两部分,一是记录内存增加但不实际分配,二是在页面错误时分配物理内存并映射到页表中,它的优势是避免为未使用页面做工作,可分散成本,但会带来页面错误的额外开销,可通过一些方式降低此成本。按需分页(demand paging)可提高应用程序启动的响应时间,内核可对其透明实现。当内存不足需分页到磁盘时,若没有空闲物理 RAM,内核需释放一个物理页面,驱逐代价高,分页在不频繁时性能最佳,且内核通常以对应用程序透明的方式实现到磁盘的分页。在空闲物理内存稀缺时,懒分配和按需分页特别有利,急切分配内存可能产生额外成本且有浪费的风险。此外还提到了自动扩展堆栈和内存映射文件等特性。

  3. 真实情况

    蹦床(trampoline)和陷阱帧(trapframe)可能看起来过于复杂。其背后的一个驱动因素是,RISC-V 在强制产生陷阱时有意尽可能少地进行操作,以便能够实现非常快速的陷阱处理,而这一点非常重要。因此,内核陷阱处理程序的前几条指令实际上必须在用户环境中执行:使用用户页表和用户寄存器内容。并且,陷阱处理程序最初并不知道一些有用的信息,例如正在运行的进程的标识或内核页表的地址。由于 RISC-V 提供了受保护的位置,内核可以在进入用户空间之前将信息存储在这些位置:sscratch 寄存器以及指向内核内存但由于缺少 PTE_U 而受到保护的用户页表项。Xv6 的蹦床和陷阱帧利用了这些 RISC-V 特性。 如果内核内存被映射到每个进程的用户页表中(并带有适当的 PTE 权限标志),那么就可以消除对特殊蹦床页的需求。这也将消除从用户空间陷入内核时进行页表切换的需要。反过来,这将允许内核中的系统调用实现利用当前进程的用户内存映射,使内核代码能够直接解引用用户指针。许多操作系统都使用了这些想法来提高效率。Xv6 避免使用它们,是为了减少由于不经意间使用用户指针而导致内核中出现安全漏洞的可能性,并减少为确保用户和内核虚拟地址不重叠而需要的一些复杂性。 生产型操作系统实现了写时复制的 fork、延迟分配、按需分页、分页到磁盘、内存映射文件等功能。此外,生产型操作系统会尝试将所有物理内存用于应用程序或缓存(例如,文件系统的缓冲区缓存,我们将在第 8.2 节中详细介绍)。在这方面,Xv6 比较简单:您希望您的操作系统使用您付费购买的物理内存,但 Xv6 并没有做到。此外,如果 Xv6 内存不足,它会向正在运行的应用程序返回错误或终止它,而不是例如驱逐另一个应用程序的一页。

1. RISC-V assembly (

理解一些 RISC-V 汇编语言是很重要的,在 xv6 代码库中有一个文件 user/call.c。执行 make fs.img 命令会对其进行编译,并在 user/call.asm 中生成该程序的可读汇编版本。

阅读 call.asmgfmain 函数的代码。RISC-V 的指令手册可以在参考页面上找到。在 answers-traps.txt 中回答以下问题:

  • 哪些寄存器包含函数的参数?例如,在 main 函数调用 printf 时,哪个寄存器保存着 13?

    如下图,使用gdb在printf处设置断点并查看寄存器信息,可知在a2中存着13。

    image.png
  • main 函数的汇编代码中,对函数 f 的调用在哪里?对 g 的调用在哪里?(提示:编译器可能会内联函数。)

    参考提示,查看call.asm汇编代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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: 7c850513 addi a0,a0,1992 # 7f0 <malloc+0xf0>
    30: 00000097 auipc ra,0x0
    34: 612080e7 jalr 1554(ra) # 642 <printf>
    exit(0);
    38: 4501 li a0,0
    3a: 00000097 auipc ra,0x0
    3e: 290080e7 jalr 656(ra) # 2ca <exit>

    编译器内联函数,直接把函数的运算结果存入了寄存器,而并非调用函数。

  • 函数 printf 位于什么地址?

    位于0x642

    1
    2
    3
    4
    5
    void
    printf(const char *fmt, ...)
    {
    642: 711d addi sp,sp,-96
    # .....
  • main 中执行 jalrprintf 之后,寄存器 ra 中的值是什么?

    参考 RISC-V 基础指令汇总-CSDN博客

    auipc ra,0x0,就是把0x0 + pc存入ra中,pc是0x30,。

    jalr 1554(ra) ,跳转到以ra为基地址偏移1554 = 0x612 + 0x30 = 0x642(其实后面给注释了),也就是上面代码块

    同时ra 的值设置为 pc + 4,即0x38

  • 运行以下代码:

    1
    2
    3
    4
    5
    unsigned int i = 0x00646c72;
    printf("H%x Wo%s", 57616, &i);

    // output
    // He110 World

    输出是什么?这里有一个 ASCII 表,可以将字节映射到字符。输出取决于 RISC-V 是小端模式这一事实。如果 RISC-V 是大端模式,为了得到相同的输出,您应该将 i 设置为什么值?您是否需要将 57616 更改为其他值?这里有关于小端和大端的描述 以及一个更奇特的描述

    在 big-endian 系统中,序列中最重要的值存储在最低存储地址(即第一个)处。在 little-endian 系统中,序列中的最低有效值首先被存储。

    57616 = 0xe110,不管小端大端这一部分的输出不会变,所以57616不需要改变。

    0x00646c72 的二进制为 11001000110110001110010

    RISC-V 是小端模式,从低位开始读取,即01110010对应ASCII码114即r,后面也相同

    若为大端模式,则从高位开始读取,那应该换为0x726c64,这样才能得到相同的输出。

  • 在以下代码中,在 'y=' 之后会打印什么?(注意:答案不是一个特定的值)为什么会这样?

    1
    printf("x=%d y=%d", 3);
    image.png

    使用gdb查看寄存器信息,如图所示,打印的是a2寄存器中的数,但是并没有给printf的第二个格式化输出,所以不是一个特定的值。

2.Backtrace ()

对于调试而言,拥有一个回溯(backtrace)通常是很有用的:它是一个在错误发生点之上的栈上的函数调用列表。为了有助于回溯,编译器会生成机器代码,该代码在栈上维护一个与当前调用链中的每个函数相对应的栈帧(stack frame)。每个栈帧由返回地址和一个指向调用者栈帧的“帧指针”组成。寄存器 s0 包含一个指向当前栈帧的指针(实际上,它指向栈上保存的返回地址的地址加 8)。您的回溯应该使用帧指针在栈上向上遍历,并打印出每个栈帧中保存的返回地址。

根据实验提示完成实验:

  • 将您的 backtrace() 函数的原型添加到 kernel/defs.h 中,以便您可以在 sys_sleep 中调用 backtrace(和前几个实验一样)。

  • GCC 编译器将当前正在执行的函数的帧指针存储在寄存器 s0 中。在 kernel/riscv.h 中添加以下函数:

    1
    2
    3
    4
    5
    6
    7
    static inline uint64
    r_fp()
    {
    uint64 x;
    asm volatile("mv %0, s0" : "=r" (x) );
    return x;
    }

    并在 backtrace 中调用此函数来读取当前帧指针。r_fp() 使用内联汇编来读取 s0

  • 在sys_proc.c的sys_sleep(void)方法中调用backtrace。

  • 首先要知道这个函数返回的是什么,s0中存储的是对应函数stackframe的栈底,实验提示告诉我们lecture notes中有关于栈帧布局的图片,返回地址位于栈帧的帧指针的固定偏移量(-8)处,而保存的上一级的帧指针位于帧指针的固定偏移量(-16)处。

    您的 backtrace() 需要一种方法来识别它已经看到了最后一个栈帧,并应该停止。一个有用的事实是,为每个内核栈分配的内存由一个单一的页面对齐的页面组成,因此给定栈的所有栈帧都在同一页面上。您可以使用 PGROUNDDOWN(fp)(见 kernel/riscv.h)来确定帧指针所指的页面。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // lab4-2
    void backtrace()
    {
    printf("backtrace:\n");
    // 获取当前函数的stackframe
    uint64 fp = r_fp();
    // 获取当前页面的栈底位置
    uint64 pageAddr = PGROUNDDOWN(fp);
    // 所有stackframe占用的一个页面,最大地址不会超过+PGSIZE
    while(fp < pageAddr + PGSIZE)
    {
    // 上级函数的返回地址
    printf("%p\n", *(uint64*)(fp-8));
    // 上级函数的stackframe
    fp = *(uint64*)(fp-16);
    }
    return;
    }
    image.png

3.Alarm ()

这是一个复杂的实验,为 xv6 添加一个功能,当一个进程使用 CPU 时间时,定期向该进程发出警报。

您应该添加一个新的 sigalarm(interval, handler) 系统调用。如果应用程序调用 sigalarm(n, fn),那么在该程序消耗的每 n 个 CPU 时间 “ticks” 后,内核应该使应用程序函数 fn 被调用。当 fn 返回时,应用程序应该从它中断的地方继续执行。在 xv6 中,一个“ticks”是一个相当任意的时间单位,由硬件定时器产生中断的频率决定。如果应用程序调用 sigalarm(0, 0),内核应该停止生成周期性的警报调用。 在您的 xv6 代码库中,您会找到一个文件 user/alarmtest.c。将其添加到 Makefile 中。在您添加 sigalarmsigreturn 系统调用(见下文)之前,它不会正确编译。 alarmtesttest0 中调用 sigalarm(2, periodic),要求内核每 2 个“ticks”强制调用一次 periodic(),然后循环一段时间。您可以在 user/alarmtest.asm 中查看 alarmtest 的汇编代码,这可能对调试很有帮助。

Test0

首先修改内核,使其跳转到用户空间的警报处理程序,这将导致 Test0 打印出“alarm!”。

  • 您需要修改 Makefile,以使 alarmtest.c 作为 xv6 用户程序进行编译。在 user/user.h 中应添加的正确声明:

    1
    2
    int sigalarm(int ticks, void (*handler)());  
    int sigreturn(void);
  • 更新 user/usys.pl(它会生成 user/usys.S)、kernel/syscall.h 和 kernel/syscall.c,以使 alarmtest 能够调用 sigalarm 和 sigreturn 系统调用。目前,sys_sigreturn 只需返回 0。(前两步同lab2添加系统调用相同)

  • sys_sigalarm() 应该将警报间隔和处理函数的指针存储在 proc 结构(在 kernel/proc.h 中)的新字段中。需要跟踪自上次调用(或到下次调用剩余)一个进程的警报处理程序以来经过了多少个“ticks”;需要在 struct proc 中添加一个新字段。您可以在 proc.c 中的 allocproc() 中初始化 proc 字段。

    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
    35
    36
    37
    38
    39
    40
    // kernel/proc.h
    // Per-process state
    struct proc {
    struct spinlock lock;
    // 省略若干行
    char name[16]; // Process name (debugging)
    uint64 ainterval; // 警报间隔
    uint64 handler; // 处理函数
    uint64 ticksnum; // 经过的ticks数量
    };

    // kernel/proc.c
    // 放在allocproc的found后以及freeproc中
    // 在初始化和释放进程时对这三个变量进行同步操作
    p->ainterval = 0;
    p->handler = 0;
    p->ticksnum = 0;

    // kernel/proc.c
    uint64
    sys_sigalarm(void)
    {
    // 获取系统调用的两个参数
    // 第一个参数是ainterval即间隔,第二个参数是指向函数的指针
    int ainterval;
    uint64 handler;
    argint(0,&ainterval);
    argaddr(1,&handler);
    struct proc *p=myproc();
    p->ticksnum = 0;
    p->ainterval = ainterval;
    p->handler = handler;
    return 0;
    }

    uint64
    sys_sigreturn(void)
    {
    return 0;
    }
  • 每一个“ticks”,硬件时钟都会强制产生一个中断,这个中断在 kernel/trap.c 中的 usertrap() 中进行处理。只有在有定时器中断时,您才想要操作一个进程的警报“ticks”数;您需要类似这样的代码: if(which_dev == 2) … 只有当进程有未完成的定时器时,才调用警报函数。请注意,用户的警报函数的地址可能为 0(例如,在 user/alarmtest.asm 中,periodic 位于地址 0)。 您需要修改 usertrap(),以便当一个进程的警报间隔到期时,用户进程执行处理函数。当 RISC-V 上的trap返回到用户空间时,是什么决定了用户空间代码恢复执行的指令地址?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // kernel/trap.c
    // give up the CPU if this is a timer interrupt.
    if(which_dev == 2) {
    // 如果间隔被设置
    if (p->ainterval != 0) {
    // 每次计数加一
    p->ticksnum++;
    // 当计数和间隔相同时,重置间隔为0,设置被执行函数
    if (p->ticksnum == p->ainterval) {
    p->ticksnum = 0;
    p->trapframe->epc = p->handler;
    }
    }
    yield();
    }
  • image.png

Test1,2,3:恢复被中断的代码

在打印出“alarm!”后, alarmtest最终打印出“test1 failed”。为了解决这个问题,必须确保当警报处理程序完成后,控制权返回到用户程序最初被定时器中断的指令处。必须确保寄存器的内容恢复到中断时的值,以便在警报后用户程序可以不受干扰地继续执行。最后,应该在每次警报触发后“重新设置”警报计数器,以便定期调用处理程序。 用户警报处理程序在完成后必须调用 sigreturn 系统调用。可以查看 alarmtest.c 中的 periodic 作为示例,向 usertrap 和 sys_sigreturn 添加代码,使其协作以使用户进程在处理完警报后能够正确地恢复。

  • 当定时器触发时,让 usertrap 在 struct proc 中保存足够的状态,以便 sigreturn 能够正确地返回到被中断的用户代码。我们要避免重复的调用alarm,就要在proc中定义一个alarmed,用于判断是否已经调用过,其次使用一个trapframe去保存寄存器。

  • // kernel/proc.h
    // proc结构体中加入新字段io
    int alarmed;   //是否已经调用过alarm
    struct trapframe *atrapframe;  //保存trapframe,结束后恢复
    
    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

    - 需要和Test0中的操作一样,在allocproc和freeproc时对上述新变量进行初始化和释放(同之前一样)。

    - 进一步修改`kernel/trap.c的usertrap`方法

    ```c
    // kernel/trap.c
    // give up the CPU if this is a timer interrupt.
    if(which_dev == 2) {
    // 如果间隔被设置
    if (p->ainterval != 0 ) {
    // 每次计数加一
    p->ticksnum++;
    // 当计数和间隔相同时且没有调用过,重置间隔为0
    if (p->ticksnum == p->ainterval && p->alarmed !=1) {
    // 用之前的方法保存trapframe
    memmove(p->atrapframe, p->trapframe, sizeof(struct trapframe));
    // 把调用标记置位1
    p->alarmed = 1;
    p->ticksnum = 0;
    p->trapframe->epc = p->handler;
    }
    }
    yield();
    }
  • 最后我们要让调用alarm后恢复运行,即恢复之前的寄存器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // kernel/proc.c
    uint64
    sys_sigreturn(void) {
    struct proc *p;
    p = myproc();
    memmove(p->trapframe, p->atrapframe, sizeof(struct trapframe));
    p->alarmed = 0;
    return p->trapfram->a0;
    }
  • 编译运行测试,Test2通过,Test3提示register a0 changed,实验提示中要求确保恢复 a0。sigreturn 是一个系统调用,它的返回值会存储在 a0 中,那就应该修改返回值为之前的a0,否则返回0后就会更改原先trapframe中的a0。(上方代码已经改正)

  • image.png
本文作者:YangYuchen
本文链接:https://www.littlewhite.site/xv6学习,Lab4-240728/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可