xv6项目开源—04

理论

本章讲的是陷阱指令和系统调用

1)有三种事件会导致中央处理器搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上:

系统调用、异常、设备中断

2)Xv6陷阱处理分为四个阶段: RISC-V CPU采取的硬件操作、为内核C代码执行而准备的汇编程序集“向量”、决定如何处理陷阱的C陷阱处理程序以及系统调用或设备驱动程序服务例程。

3)重要的寄存器的概念:

  • stvec:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。
  • sepc:当发生陷阱时,RISC-V会在这里保存程序计数器pc(因为pc会被stvec覆盖)。sret(从陷阱返回)指令会将sepc复制到pc。内核可以写入sepc来控制sret的去向。
  • scause: RISC-V在这里放置一个描述陷阱原因的数字。
  • sscratch:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。
  • sstatus:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIESPP位指示陷阱是来自用户模式还是管理模式,并控制sret返回的模式

4)RISC-V有三种不同的页面错误: 加载页面错误 (当加载指令无法转换其虚拟地址时),存储页面错误 (当存储指令无法转换其虚拟地址时) 和指令页面错误 (当指令的地址无法转换时)。

5)当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:

  1. 如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
  2. 清除SIE以禁用中断。
  3. pc复制到sepc
  4. 将当前模式(用户或管理)保存在状态的SPP位中。
  5. 设置scause以反映产生陷阱的原因。
  6. 将模式设置为管理模式。
  7. stvec复制到pc
  8. 在新的pc上开始执行。

6)当CPU无法将虚拟地址转换为物理地址时,CPU会生成页面错误异常。Risc-v有三种不同的页面错误: 加载页面错误 (当加载指令无法转换其虚拟地址时),存储页面错误 (当存储指令无法转换其虚拟地址时) 和指令页面错误 (当指令的地址无法转换时)。scause寄存器中的值指示页面错误的类型,stval寄存器包含无法翻译的地址。

7)利用页面故障的另一个广泛使用的功能是从磁盘分页。如果应用程序需要比可用物理RAM更多的内存,内核可以换出一些页面: 将它们写入存储设备 (如磁盘),并将它们的PTE标记为无效。如果应用程序读取或写入被换出的页面,则CPU将触发页面错误。然后内核可以检查故障地址。如果该地址属于磁盘上的页面,则内核分配物理内存页面,将该页面从磁盘读取到该内存,将PTE更新为有效并引用该内存,然后恢复应用程序。为了给页面腾出空间,内核可能需要换出另一个页面。此功能不需要对应用程序进行更改,并且如果应用程序具有引用的地址 (即,它们在任何给定时间仅使用其内存的子集),则该功能可以很好地工作。

结合分页和页面错误异常的其他功能包括自动扩展栈空间和内存映射文件。

实践

1)RISC-V

阅读**call.asm*中函数gfmain的代码。RISC-V的使用手册在参考页上。以下是您应该回答的一些问题(将答案存储在answers-traps.txt***文件中):

  1. 哪些寄存器保存函数的参数?例如,在mainprintf的调用中,哪个寄存器保存13?

    (1). 在a0-a7中存放参数,13存放在a2中

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

    (2). 在C代码中,main调用f,f调用g。而在生成的汇编中,main函数进行了内联优化处理。

    从代码li a1,12可以看出,main直接计算出了结果并储存

  3. printf函数位于哪个地址?

    (3). 在0x630

  4. mainprintfjalr之后的寄存器ra中有什么值?

    (4). auipc(Add Upper Immediate to PC):auipc rd imm,将高位立即数加到PC上,从下面的指令格式可以看出,该指令将20位的立即数左移12位之后(右侧补0)加上PC的值,将结果保存到dest位置

  5. 运行以下代码。

unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);

程序的输出是什么?这是将字节映射到字符的ASCII码表

输出取决于RISC-V小端存储的事实。如果RISC-V是大端存储,为了得到相同的输出,你会把i设置成什么?是否需要将57616更改为其他值?

57616=0xE110,0x00646c72小端存储为72-6c-64-00,对照ASCII码表

72:r 6c:l 64:d 00:充当字符串结尾标识

因此输出为:HE110 World

若为大端存储,i应改为0x726c6400,不需改变57616

2)Backtrace

这个函数就是实现曾经调用函数地址的回溯,这个功能在日常的编程中也经常见到,编译器报错时就是类似的逻辑,只不过题目的要求较为简单,只用打印程序地址,而实际的报错中往往打印程序文件名,函数名以及行号等信息(最后的可选练习就是实现这样的功能)。

/**
 * @brief backtrace 回溯函数调用的返回地址
 */
void
backtrace(void) {
  printf("backtrace:\n");
  // 读取当前帧指针
  uint64 fp = r_fp();
  while (PGROUNDUP(fp) - PGROUNDDOWN(fp) == PGSIZE) {
    // 返回地址保存在-8偏移的位置
    uint64 ret_addr = *(uint64*)(fp - 8);
    printf("%p\n", ret_addr);
    // 前一个帧指针保存在-16偏移的位置
    fp = *(uint64*)(fp - 16);
  }
}

根据提示:返回地址位于栈帧帧指针的固定偏移(-8)位置,并且保存的帧指针位于帧指针的固定偏移(-16)位置。先使用r_fp()读取当前的帧指针,然后读出返回地址并打印,再将fp定位到前一个帧指针的位置继续读取即可。

根据提示:XV6在内核中以页面对齐的地址为每个栈分配一个页面。使用PGROUNDUP(fp) - PGROUNDDOWN(fp) == PGSIZE判断当前的fp是否被分配了一个页面来终止循环。

3)Alarm

这项练习要实现定期的警报。首先是要通过test0,如何调用处理程序是主要的问题。程序计数器的过程是这样的:

  1. ecall指令中将PC保存到SEPC
  2. usertrap中将SEPC保存到p->trapframe->epc
  3. p->trapframe->epc加4指向下一条指令
  4. 执行系统调用
  5. usertrapret中将SEPC改写为p->trapframe->epc中的值
  6. sret中将PC设置为SEPC的值

可见执行系统调用后返回到用户空间继续执行的指令地址是由p->trapframe->epc决定的,因此在usertrap中主要就是完成它的设置工作。

(1). 在struct proc中增加字段,同时记得在allocproc中将它们初始化为0,并在freeproc中也设为0

int alarm_interval;          // 报警间隔
void (*alarm_handler)();     // 报警处理函数
int ticks_count;             // 两次报警间的滴答计数

(2). 在sys_sigalarm中读取参数

uint64
sys_sigalarm(void) {
  if(argint(0, &myproc()->alarm_interval) < 0 ||
    argaddr(1, (uint64*)&myproc()->alarm_handler) < 0)
    return -1;

  return 0;
}

(3). 修改usertrap()

// give up the CPU if this is a timer interrupt.
if(which_dev == 2) {
    if(++p->ticks_count == p->alarm_interval) {
        // 更改陷阱帧中保留的程序计数器
        p->trapframe->epc = (uint64)p->alarm_handler;
        p->ticks_count = 0;
    }
    yield();
}

接下来要通过test1test2,要解决的主要问题是寄存器保存恢复和防止重复执行的问题。考虑一下没有alarm时运行的大致过程

  1. 进入内核空间,保存用户寄存器到进程陷阱帧
  2. 陷阱处理过程
  3. 恢复用户寄存器,返回用户空间

而当添加了alarm后,变成了以下过程

  1. 进入内核空间,保存用户寄存器到进程陷阱帧
  2. 陷阱处理过程
  3. 恢复用户寄存器,返回用户空间,但此时返回的并不是进入陷阱时的程序地址,而是处理函数handler的地址,而handler可能会改变用户寄存器

因此我们要在usertrap中再次保存用户寄存器,当handler调用sigreturn时将其恢复,并且要防止在handler执行过程中重复调用,过程如下

(1). 再在struct proc中新增两个字段

int is_alarming;                    // 是否正在执行告警处理函数
struct trapframe* alarm_trapframe;  // 告警陷阱帧

(2). 在allocproc和freeproc中设定好相关分配,回收内存的代码

/**
 * allocproc.c
 */
// 初始化告警字段
if((p->alarm_trapframe = (struct trapframe*)kalloc()) == 0) {
    freeproc(p);
    release(&p->lock);
    return 0;
}
p->is_alarming = 0;
p->alarm_interval = 0;
p->alarm_handler = 0;
p->ticks_count = 0;

/**
 * freeproc.c
 */
if(p->alarm_trapframe)
    kfree((void*)p->alarm_trapframe);
p->alarm_trapframe = 0;
p->is_alarming = 0;
p->alarm_interval = 0;
p->alarm_handler = 0;
p->ticks_count = 0;

(3). 更改usertrap函数,保存进程陷阱帧p->trapframep->alarm_trapframe

// give up the CPU if this is a timer interrupt.
if(which_dev == 2) {
  if(p->alarm_interval != 0 && ++p->ticks_count == p->alarm_interval && p->is_alarming == 0) {
    // 保存寄存器内容
    memmove(p->alarm_trapframe, p->trapframe, sizeof(struct trapframe));
    // 更改陷阱帧中保留的程序计数器,注意一定要在保存寄存器内容后再设置epc
    p->trapframe->epc = (uint64)p->alarm_handler;
    p->ticks_count = 0;
    p->is_alarming = 1;
  }
  yield();
}

(4). 更改sys_sigreturn,恢复陷阱帧

uint64
sys_sigreturn(void) {
  memmove(myproc()->trapframe, myproc()->alarm_trapframe, sizeof(struct trapframe));
  myproc()->is_alarming = 0;
  return 0;
}
03-26 16:47