Mit6.S081笔记Lab4: traps 中断陷阱处理
课程地址:https://pdos.csail.mit.edu/6.S081/2020/schedule.html
Lab 地址:https://pdos.csail.mit.edu/6.S081/2020/labs/traps.html
我的代码地址:https://github.com/Amroning/MIT6.S081/tree/traps
xv6手册:https://pdos.csail.mit.edu/6.S081/2020/xv6/book-riscv-rev1.pdf
相关翻译:http://xv6.dgs.zone/labs/requirements/lab4.html
参考博客:https://blog.miigon.net/posts/s081-lab4-traps
Lab4: traps
该实验从系统调⽤⼊⼿梳理中断的全流程。首先阅读xv6手册记录相关知识点,然后再做实验
陷阱
陷阱(trap):
- 系统调用,当用户程序执行
ecall
指令要求内核为其做些什么时; - 异常:(用户或内核)指令做了一些非法的事情,例如除以零或使用无效的虚拟地址;
- 设备中断,一个设备,例如当磁盘硬件完成读或写请求时,向系统表明它需要被关注
顺序:
- 陷阱强制将控制权转移到内核
- 内核保存寄存器和其他状态,以便可以恢复执行
- 内核执行适当的处理程序代码(例如,系统调用接口或设备驱动程序)
- 内核恢复保存的状态并从陷阱中返回
- 原始代码从它停止的地方恢复
xv6陷阱处理的四个阶段:
- RISC-V CPU采取的硬件操作
- 为内核C代码执行而准备的汇编程序集“向量”
- 决定如何处理陷阱的C陷阱处理程序
- 系统调用或设备驱动程序服务例程
虽然三种陷阱类型之间的共性表明内核可以用一个代码路径处理所有陷阱,但对于三种不同的情况:来自用户空间的陷阱、来自内核空间的陷阱和定时器中断,分别使用单独的程序集向量和C陷阱处理程序更加方便
RISC-V陷入机制
寄存器是CPU中用于存储和操作数据的小型存储单元,每个寄存器都负责特定的任务。每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱
kernel/riscv.h
包含在xv6中使用到的内容的定义,以下是最重要的一些寄存器概述:
stvec
:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。sepc
:当发生陷阱时,RISC-V会在这里保存程序计数器pc(因为pc会被stvec覆盖)。sret(从陷阱返回)指令会将sepc复制到pc。内核可以写入sepc来控制sret的去向。sepc
确保内核可以在处理完陷阱后恢复到正确的指令。scause
: RISC-V在这里放置一个描述陷阱原因的数字。sscratch
:存储一个供陷阱处理程序初始阶段使用的值,可以在处理开始时读取这个值来做一些必要的准备(下面会介绍)sstatus
:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIE。SPP位指示陷阱是来自用户模式还是管理模式,并控制sret返回的模式。
当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:
- 如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
- 清除SIE以禁用中断。
- 将
pc
复制到sepc
。 - 将当前模式(用户或管理)保存在状态的SPP位中。
- 设置
scause
以反映产生陷阱的原因。 - 将模式设置为管理模式。
- 将
stvec
复制到pc
。 - 在新的
pc
上开始执行。
举例:用户进程进行一次系统调用,比如读取文件,用户程序无法直接操作文件系统,必须通过系统调用让内核来处理:
1.检查是否允许中断:RISC-V 硬件检查设备中断是否被允许。如果
SIE
位被清空(禁用设备中断),并且陷阱类型是设备中断,硬件就不会继续执行后续的陷阱处理操作。由于这是系统调用陷阱,它不是设备中断,因此即便SIE
被清空,硬件还是会继续处理陷阱2.清除 SIE 位:系统调用陷阱需要切换到内核态,因此需要清空
SIE
位以暂时禁用中断,避免在内核处理系统调用时被打断3.保存当前的程序计数器 (PC) 到 sepc:当前程序计数器(即用户进程正在执行的指令地址)被保存到
sepc
寄存器中,方便在内核完成系统调用处理后返回用户程序4.保存当前模式到 SPP 位:
sstatus
寄存器中的SPP
位会记录当前的执行模式,即用户模式。这会帮助硬件在系统调用处理完毕后恢复回用户模式5.设置 scause 寄存器:
scause
寄存器会记录陷阱的原因,比如系统调用的类型。这让内核能够知道为什么触发了陷阱,以便进行适当的处理6.切换到管理模式:硬件将模式设置为管理模式(即内核模式),允许CPU执行内核代码,并使内核拥有对系统资源的控制权限
7.将 stvec 的值赋值给 PC:RISC-V 将
stvec
寄存器的地址(即内核定义的陷阱处理程序入口)赋给pc
,让硬件知道应该跳转到哪个内核地址执行陷阱处理代码8.从新的 PC 地址开始执行:处理器现在开始在
stvec
指定的内核地址执行代码。内核会从该地址处启动陷阱处理例程,来识别并处理该系统调用
请注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc
之外的任何寄存器。内核软件必须执行这些任务。CPU在陷阱期间执行尽可能少量工作的一个原因是为软件提供灵活性。例如,一些操作系统在某些情况下不需要页表切换,这可以提高性能
从用户空间陷入
2024.11.11更新:这一部分的整理较为混乱,另一篇笔记应该会清晰点:Mit6.S081笔记:知识点记录
处理来自用户代码的陷阱比来自内核的陷阱更具挑战性,因为satp
指向不映射内核的用户页表,栈指针可能包含无效甚至恶意的值
RISC-V硬件在陷阱期间不会切换页表(这部分理解错误,应该是执行ecall指令的时候不会切换页表),所以stvec
指向的陷阱处理入口(uservec
函数)必须在用户页表中有映射(uservec
负责最初的陷阱处理步骤,其首要任务是切换到内核页表),uservec
一旦被执行,必须尽快通过写入satp
寄存器,将当前进程的页表切换到内核页表,以确保内核能够访问必要的内存区域。为了在页表切换后继续执行,uservec
在内核页表中必须与用户页表中映射到相同的虚拟地址
uservec
启动陷阱处理流程的关键步骤举例
:假设用户态进程 Process_A
,此时发生了一个陷阱,导致 CPU 切换到陷阱模式,并开始执行 uservec
。当陷阱发生时,所有 32 个寄存器都保存了 Process_A
的数据,但 uservec
需要修改一些寄存器来执行陷阱处理,比如要设置 satp
切换内核页表。sscratch
寄存器提供帮助:在进入用户空间之前,内核已经设置 sscratch
指向当前进程的陷阱帧结构(trapframe
),其中可以存放用户寄存器的副本。
uservec
开始时执行 csrrw
指令,将寄存器 a0
和 sscratch
中的数据交换:①把用户态进程的 a0
寄存器的值存放到 sscratch
中,保留这个值以便之后恢复②将内核之前放入 sscratch
的值(即指向当前进程 trapframe
的指针)放入 a0
,从而使 uservec
拥有一个可用的寄存器。
现在,a0
中持有指向当前进程 trapframe
的指针。trapframe
是一个每个进程独有的内存区域,用于保存触发陷阱时的用户寄存器数据。uservec
使用 a0
作为指针,把当前 Process_A
的所有寄存器的值复制到 trapframe
中保存,包括从sscratch
读取并保存用户原本的a0
的值。
trapframe
中还保存了 Process_A
的内核栈地址、usertrap
地址、内核页表地址等关键信息,uservec
使用这些信息,将 satp
切换到内核页表。完成页表切换后,uservec
最后调用 usertrap
,进入完整的内核陷阱处理逻辑。
usertrap
首先将 stvec
设置为 kernelvec
,这样内核在处理过程中如果再有陷阱发生,就会由kernelvec
接管,而不是 uservec
。usertrap
会把用户进程的 sepc
再次保存到进程的陷阱帧中,以确保即使有进程切换(例如调度器中断)导致 sepc
被覆盖,内核也可以恢复用户态的程序计数器。
如果陷阱原因是系统调用,syscall
会被调用来处理。syscall
会执行具体的系统调用逻辑,syscall
将其返回值记录在trapframe->a0
中,因为RISC-V上的C调用约定将返回值放在a0
中。如果陷阱原因是设备中断,devintr
负责处理。否则,中断原因即为异常,内核会直接终止 Process_A
,避免进一步执行无效指令。
系统调用返回前,usertrap
会将 sepc
加 4,跳过 ecall
指令,这样回到用户空间后能够从 ecall
的下一条指令开始执行。在退出前,usertrap
检查 Process_A
是否已被标记为被杀死或是让出 CPU(设备中断,例如计时器中断会触发这种情况)。若是,则调度器会进行进程切换。
usertrap
最终调用 usertrapret
以准备返回用户空间。usertrapret
将 stvec
指向 uservec
,为将来来自用户空间的陷阱准备好处理函数。之后将 sepc
设置为保存的用户态 pc
,以确保返回后从用户进程上次执行的地方继续。usertrapret
最后在用户和内核页表中都映射的 trampoline
页面上调用 userret
,切换页表并返回用户态。
userret
进行与uservec
相反的步骤,将页表和寄存器进行恢复。userret
最后使用 sret
指令完成状态切换,从内核返回到用户态 Process_A
。这部分xv6手册上的描述似乎有点错误:
usertrapret’s call to userret passes a pointer to the process’s user page table in a0 and TRAPFRAME in a1 (kernel/trampoline.S:88).
按这句话的描述,a0寄存器存储的是页表,a1寄存器存储的是陷阱帧,这和汇编源码有点不一样:
.globl userret
userret:
# userret(TRAPFRAME, pagetable)
# switch from kernel to user.
# usertrapret() calls here.
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.
# switch to the user page table.
csrw satp, a1
sfence.vma zero, zero
# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
ld t0, 112(a0)
csrw sscratch, t0
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
......
# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
可以看到,a0存放的是在用户页表中的陷阱帧,a1存放的是用户页表。userret
执行流程:切换到用户页表,刷新TLB,将原本a0的值存入sscratch
(为了最后的交换),恢复除了a0以外的寄存器的值,交换a0和sscratch
的值(也就是恢复a0寄存器的值,保存陷阱帧的地址),最后返回用户态。
系统调用参数
用户进程在调用系统调用时会将参数放在 RISC-V 约定的寄存器中。当陷阱发生时,内核将保存用户寄存器的内容到当前进程的陷阱帧中,这样可以在内核中访问用户传递的参数。内核中像 argint
、argaddr
、argfd
这样的函数会从陷阱帧中读取这些参数值,这些函数调用 argraw
从陷阱帧中直接检索特定寄存器的内容。
某些系统调用需要处理用户传递的指针参数。比如exec
接收一个指针参数,指向一个字符串数组,这些字符串是用户传递的命令行参数。存在的问题:用户可能会传递无效或恶意的指针,指向不应该访问的内存区域(如内核空间);内核和用户的页表映射不同,因此内核不能直接使用用户空间的指针地址来访问用户内存。
xv6 实现了安全读取和写入用户内存的方法。比如,fetchstr
函数用于从用户空间获取字符串参数,fetchstr
会调用 copyinstr
来安全地从用户空间读取字符串。copyinstr
是一个用于从用户空间读取字符串的函数,逐字节地从用户空间的虚拟地址 srcva
读取数据,最多读取 max
字节,复制到内核的 dst
:copyinstr
调用walkaddr
,walkaddr
调用walk
遍历用户页表中的虚拟地址,检查虚拟地址是否属于用户的地址空间,并找到这个地址映射的物理地址。xv6 的内核页表直接映射物理内存,copyinstr
可以直接读取 srcva
对应的物理地址 pa0
,将数据复制到内核的 dst
。copyout
通过类似的方法将数据从内核复制到用户地址。
从内核空间陷入
当在内核态时,xv6 将 CPU 的 stvec
寄存器设置为 kernelvec
。由于现在为内核态,内核态的代码可以依赖当前的寄存器配置(内核页表和内核栈指针),kernelvec
可以使用这些内核态资源来安全地处理内核态陷阱。陷阱发生时首先执行kernelvec
,kernelvec
保存当前被中断代码的所有寄存器,保存在被中断内核线程的内核栈上,这样即便在陷阱期间发生了线程切换,原线程的寄存器状态也可以安全地保存在其栈上,不会受陷阱和线程切换影响。
保存寄存器后,kernelvec
跳转到kerneltrap
。如果是设备中断,kerneltrap
调用devintr
处理该中断并返回。如果不是设备中断,则必定是异常,内核中的异常通常意味着严重错误,因此会调用panic
停止执行。如果设备中断是一个计时器中断(系统定时器产生的周期性信号,用于维持线程的时间片管理),并且被中断的是一个普通进程的内核线程(而不是调度线程),kerneltrap
会调用 yield
,作用是让当前运行的线程放弃 CPU 以便调度器能安排其他线程执行,提升多线程运行的公平性。当某个线程主动让出 CPU 时,它的状态被保存,其他线程获得运行机会。当这些线程完成后,会重新调度,恢复原来的线程及其 kerneltrap
流程。
因为在kerneltrap
中有可能调用yield
,导致之前保存的sepc
(保存了用户代码的程序计数器)和sstatus
(保存了之前的CPU模式)被修改。因此,kerneltrap
在开始工作时会先保存这些控制寄存器的内容,以确保在返回时可以正确恢复这些寄存器的值。kerneltrap
结束时将这些控制寄存器恢复,然后跳转回kernelvec
。接下来,kernelvec
会将保存的寄存器从栈中弹出,然后执行sret
指令。sret
会将sepc
的值复制到pc
,使CPU重新恢复到被中断前的代码。
页面错误异常
Risc-v有三种不同的页面错误:加载页面错误:当加载指令访问的虚拟地址找不到对应的物理地址时触发。存储页面错误:当存储指令访问的虚拟地址找不到对应的物理地址时触发。指令页面错误:当指令获取的虚拟地址找不到对应的物理地址时触发。这些页面错误信息保存在 RISC-V 的两个寄存器中:scause
:指示页面错误的类型(加载、存储或指令);stval
:保存无法转换的虚拟地址。
COW fork
在标准 fork
中,父进程将内存完全拷贝给子进程。这样可以确保父子进程的内存是独立的,即便彼此之间内容相同,双方的修改不会互相影响。然而,这种方法需要分配大量内存、耗费时间。
在 COW fork
中,父进程和子进程会共享相同的物理内存页面,而不是立刻复制内存。共享的页面被标记为只读,无论是父进程还是子进程,都无法直接写入这些共享的页面。当父进程或子进程修改页面内容时,就会触发页面错误异常,内核捕获到异常,并根据 scause
和 stval
的信息确认错误类型和故障地址,执行操作:1.为子进程分配一个新的物理内存副本,为父进程分配一个新的物理内存副本。2.将新页面的物理地址映射到父子进程中产生页面错误的虚拟地址,并且更新页表中的权限为可读/写。3.返回到引发异常的指令位置,重新执行导致页面错误的写操作。
惰性分配(Lazy Allocation)
当应用程序请求额外内存时,比如通过 sbrk
系统调用增加地址空间,内核调整进程的地址空间范围,但会把新地址标记为无效。在应用程序实际访问这些无效地址时,CPU 会因为找不到对应的物理地址而触发页面错误,内核捕获到异常,分析错误地址属于之前 sbrk
增加的范围,说明这是惰性分配引发的页面错误,于是内核会分配一个新的物理页面,并将该虚拟地址映射到新页面上,更新页表中的该地址条目为有效状态,并重新执行触发异常的指令。
应用程序往往请求比实际需要更多的内存,通过惰性分配,系统仅在真正使用内存时才进行分配,避免了大量内存浪费。
页面换出(Paging Out)
当进程的内存需求超过物理内存的容量时,系统将部分不常用的内存页面写入到磁盘,释放出物理内存用于其他页面。被写到磁盘的页面的PTE会被标记为无效,这样当进程再次访问这些页面时会产生页面错误,内核捕获到页面错误异常,检查故障地址,属于换出的页面,内核会分配一个新的物理页面,并将该页面内容从磁盘读取回内存,更新PTE ,将该页面重新标记为有效,恢复进程的执行。
物理内存有限,页面换出机制可以让系统运行更多进程,或者让单个进程使用比实际物理内存更多的地址空间。
Lab
仅复制部分题目,完整要求请去该帖子顶部链接查看
RISC-V assembly (easy)
执行
make fs.img
编译user/call.c,在user/call.asm中生成可读的汇编版本,阅读call.asm中函数g
、f
和main
的代码。RISC-V的使用手册在参考页上。回答以下问题。
1.哪些寄存器保存函数的参数?例如,在main对printf的调用中,哪个寄存器保存13?
在RISC-V架构中,函数参数保存在a0
到a7
寄存器中。调用printf
的汇编源码:
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 <malloc+0xea>
30: 00000097 auipc ra,0x0
34: 600080e7 jalr 1536(ra) # 630 <printf>
可知13保存在a2
寄存器中
2.main的汇编代码中对函数f的调用在哪里?对g的调用在哪里(提示:编译器可能会将函数内联(inline))
没有调用f函数对应的汇编源码,因为g(x) 被内联到 f(x) 中,然后 f(x) 又被进一步内联到 main() 中
3.printf函数位于哪个地址?
汇编源码对应部分:
30: 00000097 auipc ra,0x0
34: 600080e7 jalr 1536(ra) # 630 <printf>
auipc
(Add Upper Immediate to PC)得到pc的值0x30存储在ra
寄存器,jalr
(jump and link register)指令跳转到ra
寄存器的值并加上偏移量1536(即0x600),所以printf
的地址是0x630
4.在main中printf的jalr之后的寄存器ra中有什么值?
auipc
和jalr
指令用于生成基于程序计数器(pc
)的地址和跳转,常用于计算相对地址。
auipc
指令格式:auipc rd, imm
。将20位的立即数imm
左移12位后加上当前pc
值,结果存入rd
寄存器。rd = PC + (imm << 12)
jalr
指令格式:jalr rd, offset(rs1)
。跳转到rs1
寄存器中的地址加上偏移offset
的位置,将跳转的下一条指令的地址存储在rd
寄存器中。
回到printf
汇编源码:
30: 00000097 auipc ra,0x0
34: 600080e7 jalr 1536(ra) # 630 <printf>
第1行代码:对比指令格式,这行代码将0x0左移12位(还是0x0)加到pc(当前为0x30)并存入ra中,即ra中保存的是0x30
第2行代码:这对比jalr的标准格式有所不同,可能是此两处使用寄存器相同时,汇编中可以省略rd
部分。ra中保存的是0x30,加上0x600后为0x630,即printf
的地址,执行此行代码后,将跳转到printf函数执行,并将当前pc+4=0X34+0X4=0X38保存到ra
中,供之后返回使用。
5.运行以下代码,程序的输出是什么?这是将字节映射到字符的ASCII码表。输出取决于RISC-V小端存储的事实。如果RISC-V是大端存储,为了得到相同的输出,你会把i
设置成什么?是否需要将57616
更改为其他值?
unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);
57616=0xE110,0x00646c72小端存储为72-6c-64-00,对照ASCII码表:72:r 6c:l 64:d 00:充当字符串结尾标识
因此输出为:HE110 World
若为大端存储,i应改为0x726c6400,不需改变57616
6.在下面的代码中,“y=
”之后将打印什么(注:答案不是一个特定的值)?为什么会发生这种情况?
printf("x=%d y=%d", 3);
原本需要两个参数,却只传入了一个,因此y=后面打印的结果取决于之前a2中保存的数据
Backtrace(moderate)
回溯(Backtrace)通常对于调试很有用:它是一个存放于栈上用于指示错误发生位置的函数调用列表。在*kernel/printf.c*中实现名为
backtrace()
的函数。在sys_sleep
中插入一个对此函数的调用,然后运行bttest
,它将会调用sys_sleep
。你的输出应该如下所示:backtrace: 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
你应该看到类似下面的输出:
kernel/sysproc.c:74 kernel/syscall.c:224 kernel/trap.c:85
编译器向每一个栈帧中放置一个帧指针(frame pointer)保存调用者帧指针的地址。你的
backtrace
应当使用这些帧指针来遍历栈,并在每个栈帧中打印保存的返回地址。提示:
在*kernel/defs.h*中添加
backtrace
的原型,那样你就能在sys_sleep
中引用backtrace
GCC编译器将当前正在执行的函数的帧指针保存在
s0
寄存器,将下面的函数添加到kernel/riscv.h
static inline uint64 r_fp() { uint64 x; asm volatile("mv %0, s0" : "=r" (x) ); return x; }
并在
backtrace
中调用此函数来读取当前的帧指针。这个函数使用内联汇编来读取s0
该实验要实现打印曾经调用过的函数的地址。打印出调用栈,用于调试。
先添加函数获取当前函数的fp(frame pointer):
// riscv.h
static inline uint64
r_fp(){
uint64 x;
asm volatile("mv %0, s0" : "=r" (x));
return x;
}
fp 指向当前栈帧的开始地址,sp 指向当前栈帧的结束地址。 (栈从高地址往低地址生长,所以 fp 虽然是帧开始地址,但是地址比 sp 高)
栈帧中从高到低第一个 8 字节 fp-8
是 return address,也就是当前调用层应该返回到的地址。
栈帧中从高到低第二个 8 字节 fp-16
是 previous address,指向上一层栈帧的 fp 开始地址。
剩下的为保存的寄存器、局部变量等。一个栈帧的大小不固定,但是至少 16 字节。
在 xv6 中,使用一个页来存储栈,如果 fp 不在页的有效范围内,说明遍历完了栈帧
查看 call.asm,可以看到,一个函数的函数体最开始首先会扩充一个栈帧给该层调用使用,在函数执行完毕后再回收:
int g(int x) {
0: 1141 addi sp,sp,-16 // 扩张调用栈,得到一个 16 字节的栈帧
2: e422 sd s0,8(sp) // 将返回地址存到栈帧的第一个 8 字节中
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 // 返回
栈的生长方向是从高地址到低地址,所以扩张是 -16,而回收是 +16
据此实现backtrace
函数:
// printf.c
//遍历帧指针打印函数地址
void backtrace() {
uint64 fp = r_fp();
printf("backtrace:\n");
while (PGROUNDDOWN(fp) != PGROUNDUP(fp)) { //当前帧指针fp是否在有效的页范围内
uint64 ra = *(uint64*)(fp - 8); // return address
printf("%p\n", ra);
fp = *(uint64*)(fp - 16); // previous fp
}
}
记得在头文件defs.h
中声明这个函数:
// printf.c
void printf(char*, ...);
void panic(char*) __attribute__((noreturn));
void printfinit(void);
void backtrace(); //加上
在kernel/sysproc.c
的sys_sleep
中调用backtrace
:
uint64
sys_sleep(void)
{
int n;
uint ticks0;
backtrace(); //加上
if (argint(0, &n) < 0)
return -1;
......
}
接下来可以运行./grade-lab-traps backtrace
验证实验是否完成
Alarm(Hard)
在这个练习中你将向XV6添加一个特性,在进程使用CPU的时间内,XV6定期向进程发出警报。这对于那些希望限制CPU时间消耗的受计算限制的进程,或者对于那些计算的同时执行某些周期性操作的进程可能很有用。更普遍的来说,你将实现用户级中断/故障处理程序的一种初级形式。例如,你可以在应用程序中使用类似的一些东西处理页面故障。如果你的解决方案通过了
alarmtest
和usertests
就是正确的。
为了后面不忘记,先把sigalarm
和 sigreturn
添加到系统调用声明里。涉及文件(仅声明函数):syscall.h
、syscall.c
、usys.pl
、user.h
,添加方法参考Lab2笔记
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
sigalarm
和 sigreturn
都是系统调用,把alarm相关的字段添加到进程的结构体中:
alarm_interval:时钟周期,0 为禁用
alarm_handler:时钟回调处理函数
alarm_ticks:下一次时钟响起前还剩下的 ticks 数
alarm_trapframe:时钟中断时刻的 陷阱帧,用于中断处理完成后恢复原程序的正常执行
alarm_goingoff:是否已经有一个时钟回调正在执行且还未返回(用于防止在 alarm_handler 中途闹钟到期再次调用 alarm_handler,导致 alarm_trapframe 被覆盖,user/alarmtest.c
中的test2就是测这个的)
// Per-process state
struct proc {
......
//时钟相关
int alarm_interval; //时钟周期,为0时表示禁用时钟
void(*alarm_handler)(); //时钟回调处理函数
int alarm_ticks; //当前时钟信号数(ticks数)
struct trapframe* alarm_trapflame; //时钟中断时刻进程的陷阱帧,用于恢复进程中断前的状态
int alarm_goingoff; //是否已经有一个时钟中断正在执行且还未返回
};
在进程初始化和释放进程时,也要对这些字段进行初始化和释放:
// proc.c
static struct proc* //初始化进程
allocproc(void)
{
......
found:
p->pid = allocpid();
// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
release(&p->lock);
return 0;
}
// 给alarm_trapflame分配陷阱帧
if((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0){
release(&p->lock);
return 0;
}
// 进程创建时初始化alarm相关
p->alarm_interval = 0;
p->alarm_handler = 0;
p->alarm_ticks = 0;
p->alarm_goingoff = 0;
// ......
return p;
}
static void
freeproc(struct proc *p) //释放进程中的资源
{
// ......
if(p->alarm_trapframe)
kfree((void*)p->alarm_trapframe);
p->alarm_trapframe = 0;
// ......
p->alarm_interval = 0;
p->alarm_handler = 0;
p->alarm_ticks = 0;
p->alarm_goingoff = 0;
p->state = UNUSED;
}
sigalarm
与 sigreturn
的系统调用:
//sysproc.c
uint64 sys_sigalarm(void) {
int n; //n个ticks
uint64 fn; //时钟回调函数
if (argint(0, &n) < 0) //获取第一个参数
return -1;
if (argaddr(1, &fn) < 0) //获取第二个参数
return -1;
return sigalarm(n, (void(*)())(fn)); //调用并返回sigalarm函数
}
uint64 sys_sigreturn(void) {
return sigreturn();
}
//trap.c
//设置进程中时钟的相关属性
int sigalarm(int ticks, void(*handler)()) {
struct proc* p = myproc();
p->alarm_interval = ticks;
p->alarm_handler = handler;
p->alarm_ticks = ticks;
return 0;
}
//将进程恢复到alarm中断前的状态
int sigreturn() {
struct proc* p = myproc();
*p->trapframe = *p->alarm_trapflame;
p->alarm_goingoff = 0;
return 0;
}
在trap.c
中的usertrap
实现该时钟中断的代码:
void
usertrap(void)
{
......
// give up the CPU if this is a timer interrupt.
if (which_dev == 2) {
if (p->alarm_interval != 0 && --p->alarm_ticks <= 0 && p->alarm_goingoff == 0) {
//是否设置了时钟 && 时钟倒计时是否结束 && 没有其他时钟正在运行
// 如果一个时钟到期的时候已经有一个时钟处理函数正在运行,
//则会推迟到原处理函数运行完成后的下一个 tick 才触发这次时钟
p->alarm_ticks = p->alarm_interval; //重置时钟倒计时
*p->alarm_trapflame = *p->trapframe; //保存当前进程陷阱帧
p->trapframe->epc = (uint64)p->alarm_handler; //跳转到时钟回调函数
p->alarm_goingoff = 1; //标记当前已有时钟正在运行
}
yield();
}
usertrapret();
}
可执行./grade-lab-traps alarmtest
验证实验是否正确
或者编译启动xv6,在命令行中执行alarmtest
,测试3个test是否通过(编译前别忘了在Makefile添加alarmtest)
$ alarmtest
test0 start
......................................alarm!
test0 passed
test1 start
.......alarm!
...........alarm!
.......alarm!
......alarm!
........alarm!
........alarm!
......alarm!
.......alarm!
.......alarm!
......alarm!
test1 passed
test2 start
.........................................................................alarm!
test2 passed
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」