0.briefly speaking
点此跳转到上一篇博客
在上一篇博客中,我们通过系统调用这个重要的机制了解了Xv6操作系统中用户态陷阱的处理全流程。这篇博客则准备研究一下内核陷阱的处理流程,在研究内核陷阱流程中一个麻烦的家伙是定时器中断,首先它是一种由CLINT转发而来的本地中断,定时器中断往往会导致CPU的调度,进而将陷阱的处理流程变得错综复杂,这篇博客并不打算深入研究CPU调度过程,这部分内容我们放在后面阅读对应源码时仔细研究。
上次我们在阅读从用户态陷阱的流程时,是以系统调用作为研究对象的,从内核态陷阱是没有系统调用这种情况的,同时因为内核代码的相对可靠性,内核出现严重错误时一般会使用panic停止内核的工作,所以从内核陷阱在Xv6中几乎就是中断(定时器中断、外部中断等)的代名词,这部分逻辑将会在kerneltrap函数中看到。
除此之外,在这篇博客中还会详细讲述时钟中断的一些处理细节😃
本篇博客将要阅读的代码:
1.kernel/trap.c
2.kernel/kernelvec.s
3.kernel/start.c
1.从内核空间陷阱
在上一篇博客中我们研究了系统调用,当时我们在阅读到usertrap函数时(kernel/trap.c:37),里面有一段这样的代码,它将stvec寄存器的值设置为kernelvec:
void usertrap(void) {
int which_dev = 0; if((r_sstatus() & SSTATUS_SPP) != 0) panic("usertrap: not from user mode"); // send interrupts and exceptions to kerneltrap(), // since we're now in the kernel. // 译:将中断和异常的处理发送到kerneltrap中去 // 因为我们现在处于内核态 w_stvec((uint64)kernelvec); // 此后的所有代码略
这段代码将内核态陷阱处理程序kernelvec的地址放置到寄存器stvec中,这样在内核发生陷阱时PC就会从kernelvec中开始执行。和用户态进入陷阱不同,因为此时已经进入了内核态,所以有很多准备工作已经完成了,比如内核页表和内核堆栈指针等等(我们在uservec中使用汇编代码已经完成了这些寄存器的初始化)。我们下面来看看内核陷阱的具体流程:
2.kernelvec
kernelvec函数定义在kernel/kernelvec.S文件中,代码如下:
# # interrupts and exceptions while in supervisor # mode come here. # push all registers, call kerneltrap(), restore, return. # 译:在S-Mode下的中断和异常会到达这里处理 # 将所有寄存器入栈,调用kerneltrap处理,回复寄存器并返回 .globl kerneltrap .globl kernelvec .align 4 kernelvec: // make room to save registers. // 译:为存放寄存器腾出空间 // 堆栈都是向低地址生长的,所以将sp指针向下调整256个字节 // 足以装下32个8字节(64位)长的寄存器 // sp指针是在trampoline.S中提前设置的,它指向内核栈的地址顶端 addi sp, sp, -256 // save the registers. // 将当前寄存器的值全部放入内核栈中保存 sd ra, 0(sp) sd sp, 8(sp) sd gp, 16(sp) sd tp, 24(sp) sd t0, 32(sp) sd t1, 40(sp) sd t2, 48(sp) sd s0, 56(sp) sd s1, 64(sp) sd a0, 72(sp) sd a1, 80(sp) sd a2, 88(sp) sd a3, 96(sp) sd a4, 104(sp) sd a5, 112(sp) sd a6, 120(sp) sd a7, 128(sp) sd s2, 136(sp) sd s3, 144(sp) sd s4, 152(sp) sd s5, 160(sp) sd s6, 168(sp) sd s7, 176(sp) sd s8, 184(sp) sd s9, 192(sp) sd s10, 200(sp) sd s11, 208(sp) sd t3, 216(sp) sd t4, 224(sp) sd t5, 232(sp) sd t6, 240(sp) // call the C trap handler in trap.c // 译:调用trap.c中C语言编写的陷阱处理程序 call kerneltrap // restore registers. // 恢复寄存器内容 // 注意tp(thread pointer)的值不再恢复 // 因为可能发生了定时器中断而导致执行当前线程的CPU发生了变化 // 因此hartid就保留当前值即可 ld ra, 0(sp) ld sp, 8(sp) ld gp, 16(sp) // not this, in case we moved CPUs: ld tp, 24(sp) ld t0, 32(sp) ld t1, 40(sp) ld t2, 48(sp) ld s0, 56(sp) ld s1, 64(sp) ld a0, 72(sp) ld a1, 80(sp) ld a2, 88(sp) ld a3, 96(sp) ld a4, 104(sp) ld a5, 112(sp) ld a6, 120(sp) ld a7, 128(sp) ld s2, 136(sp) ld s3, 144(sp) ld s4, 152(sp) ld s5, 160(sp) ld s6, 168(sp) ld s7, 176(sp) ld s8, 184(sp) ld s9, 192(sp) ld s10, 200(sp) ld s11, 208(sp) ld t3, 216(sp) ld t4, 224(sp) ld t5, 232(sp) ld t6, 240(sp) // 恢复栈顶指针 addi sp, sp, 256 // 回到内核代码中当时被中断的地方 sret
可以看到其实kernelvec的实现逻辑相对于之前uservec来说非常简单,这是因为kernelvec相对于uservec不用处理包含切换地址空间、设置内核栈、交换sscratch寄存器等逻辑的问题,而是只需要保存寄存器,调用kerneltrap,回复寄存器并返回即可。
3.kerneltrap函数
之前我们在研究系统调用的时候,usertrap函数负责在内核态中鉴别陷阱的原因并调用不同的处理函数来处理之。其实kerneltrap函数也是一样的,因为在内核态没有系统调用这一说,这里导致陷阱的原因只剩下中断和程序错误而引起的异常。
所以在下面的代码中我们将会看到kerneltrap首先尝试调用devintr来尝试处理中断,如果devintr并不能识别陷阱的来源,那么说明这不是一个中断导致的陷阱,而是因为程序异常而导致的,那么就会触发一个panic并中止内核的运行。
// interrupts and exceptions from kernel code go here via kernelvec, // on whatever the current kernel stack is. // 译:从内核代码的中断和异常会经由kernelvec到达这里 // 无论当前的内核栈是什么 void kerneltrap() {
// 保存当前CPU的一些重要的寄存器 // 因为有可能当前处理的是一个时钟中断,进而会导致CPU的调度 // 再次返回到此进程时,sepc,sstatus和scause寄存器可能已经面目全非了 // 所以必须保留下来以备将来恢复 int which_dev = 0; uint64 sepc = r_sepc(); uint64 sstatus = r_sstatus(); uint64 scause = r_scause(); // 异常检测,检查是否是从内核态而来的陷阱 // 并且检查中断是否已经关闭 // 注意,这里保证中断关闭,其实相当于禁止了中断的进一步嵌套 if((sstatus & SSTATUS_SPP) == 0) panic("kerneltrap: not from supervisor mode"); if(intr_get() != 0) panic("kerneltrap: interrupts enabled"); // 尝试使用devintr去响应中断(包括时钟中断和外部中断) // devintr也相当于一个中转站,它会通过检查scause寄存器中的值 // 确定中断的类型并加以分门别类的处理 // 返回值表明了中断的类型,0表示没能识别中断来源,那其实也就意味着这是个异常 // 打印出必要的debug信息后陷入panic即可 if((which_dev = devintr()) == 0){
printf("scause %p\n", scause); printf("sepc=%p stval=%p\n", r_sepc(), r_stval()); panic("kerneltrap"); } // give up the CPU if this is a timer interrupt. // 如果是一个时间中断,那么就会产生CPU的调度,当前进程放弃CPU // yield函数的细节在此不再展开 if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING) yield(); // the yield() may have caused some traps to occur, // so restore trap registers for use by kernelvec.S's sepc instruction. // 译:yield调度CPU到另外一个进程时,可能在那个新的进程中会有陷阱发生 // 所以为了kernelvec.S中的sret(这里注释有误?)指令所用,恢复陷阱寄存器 w_sepc(sepc); w_sstatus(sstatus); }
kerneltrap函数其实和usertrap函数很类似,只不过因为在内核态不会有系统调用异常,所以kerneltrap只需要处理外部中断和时钟中断即可,kerneltrap调用了devintr来处理中断,并在中断类型是时钟中断时使用yield()让出当前CPU的处理权。
在kerneltrap函数完成之后,就会返回到上述的kernelvec函数中恢复寄存器并返回到内核被中断的地方继续执行下去,这就是内核陷阱的完整执行流程,不得不说它远远比用户态陷阱要简单的多。
4.时钟中断
时钟中断是一种特殊类型的中断,在RISC-V架构中它是由CLINT(Core Local Interrupt Controller)来管理的。我们曾在6.S081——补充材料——RISC-V架构中的异常与中断详解一文中简单提过,所谓时钟中断被硬连线为一个M-Mode下的中断,在处置此中断时本质上借用了S-Mode下的软中断来实现。在接下来的部分,我们将深入研究一下时钟中断的处理细节。
4.1 定时器的初始化——CLINT
要使定时器可以周期性地产生时钟中断,我们必须给它一个初始化配置,这个初始化配置是在Xv6启动时就要完成设置的,代码位于timerinit函数(kernel/start.c:61)中,以下是它的代码解析:
// set up to receive timer interrupts in machine mode, // which arrive at timervec in kernelvec.S, // which turns them into software interrupts for // devintr() in trap.c. // 译:完成以下设置来接收M-Mode下的时钟中断 // 时钟中断会进入到kernelvec.S中的timervec // 在这之后会将它们转化为软中断进而被trap.c中的devintr接管 void timerinit() {
// each CPU has a separate source of timer interrupts. // 译:每个CPU都有一个独立的时钟中断源 int id = r_mhartid(); // ask the CLINT for a timer interrupt. // 译:让CLINT产生一个时钟中断 int interval = ; // cycles; about 1/10th second in qemu. // 译: cycles大约是qemu中的0.1s // 设置触发时钟中断的间隔interval // CLINT中有两种寄存器与时钟中断有关: // mtime:它记录了从输入时钟源RTCCLK输入的总时钟周期数 // mtimecmp: 当mtime的时间超过mtimecmp寄存器中的值之后,会自动触发时钟中断(MIP.mtip) // 所以这里将mtimecmp寄存器的值设置为mtime+interval // 就是相当于设定了时钟终端间隔为interval *(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval; // prepare information in scratch[] for timervec. // scratch[0..2] : space for timervec to save registers. // scratch[3] : address of CLINT MTIMECMP register. // scratch[4] : desired interval (in cycles) between timer interrupts. // 译:为timervec准备scratch[]中的信息 // scratch[0..2] : 为timervec保存寄存器的空间 // scratch[3] : CLINT MTIMECMP寄存器的地址 // scratch[4] : 中断的间隔(以时钟周期数cycles计) // 其实这里的所谓timer_scratch定位有些类似于trapframe,都是来保存一些信息的 // 不同的是,因为时钟中断在Xv6启动时就必须打开,因为它关系到CPU的调度 // 所以timer_scratch是以全局变量的形式出现在代码中,它占用的是内核空间的内存 // 而不是以虚拟内存的形式,在进程创建的时候再分配(像trapframe那样) uint64 *scratch = &timer_scratch[id][0]; scratch[3] = CLINT_MTIMECMP(id); // scratch[3]:保存mtimecmp寄存器的值 scratch[4] = interval; // scratch[4]:保存时钟中断间隔 w_mscratch((uint64)scratch); // 将timer_scratch的地址写入mscratch // mscratch寄存器作用和sscratch一样,进入陷阱时与a0交换以腾出寄存器空间 // set the machine-mode trap handler. // 译:设置内核态下陷阱处理程序地址 // 可以看到内核态下要处理的只有时钟中断 w_mtvec((uint64)timervec); // enable machine-mode interrupts. // 译:使能M-Mode下的中断(打开中断的总开关) w_mstatus(r_mstatus() | MSTATUS_MIE); // enable machine-mode timer interrupts. // 译:使能M-Mode下的时钟中断 w_mie(r_mie() | MIE_MTIE); }
上面的这段代码虽然不长,但是涉及到的东西很多,首先要了解CLINT种可配置的寄存器,其中与时钟中断有关的有mtime和mtimecmp两个,它们的含义和用法我已经注释在了上述的代码中。另外就是timer_scratch这个数组,它起到了保存寄存器和一些必要信息的作用,从作用上来说它和用户态陷阱里的trapframe很像,但是它属于内核代码的组成部分,这是因为时钟中断的地位不同寻常,它是构成操作系统核心功能(CPU调度)的基础。
4.2 时钟中断的触发与响应——第一阶段——timervec
通过上述的初始化设置,现在CLINT已经可以周期性地产生时钟中断了。因为我们在上面设置了mtvec寄存器的内容,使它指向timervec,那么现在一旦一个时钟中断被触发,就会跳转到timervec这个地址(kernel/kernelvec.S:91),它对应到一段汇编:
.globl timervec .align 4 timervec: # start.c has set up the memory that mscratch points to: # scratch[0,8,16] : register save area. # scratch[24] : address of CLINT's MTIMECMP register. # scratch[32] : desired interval between interrupts. # 这段不译,只是将上面timer_scratch中的内容又解释了一遍 # 但是注意,汇编语言中以字节为单位寻址,uint64是8个字节 # 对换mscratch与a0 # 并将a1 a2 a3都暂时保存到timer_scratch种去 # 有trapframe内味了... csrrw a0, mscratch, a0 sd a1, 0(a0) sd a2, 8(a0) sd a3, 16(a0) # schedule the next timer interrupt # by adding interval to mtimecmp. # 译:通过向mtimecmp种递增一个interval # 来调度下一次时钟中断 ld a1, 24(a0) # CLINT_MTIMECMP(hart),保存的是当前CPU的mtimecmp寄存器地址 ld a2, 32(a0) # interval,时钟中断间隔的cycle数量 ld a3, 0(a1) # 将原先的mtimecmp的值读出到a3寄存器 # 加上一个interval大小的时间间隔 # 放入mtimecmp寄存器中 add a3, a3, a2 sd a3, 0(a1) # raise a supervisor software interrupt. # 译:发起一个S-Mode下的软中断 # 直接将sip寄存器中的SSIP位置为1即可 li a1, 2 csrw sip, a1 # 恢复寄存器a0-a3 ld a3, 16(a0) ld a2, 8(a0) ld a1, 0(a0) csrrw a0, mscratch, a0 # 从陷阱中返回 # 返回被时钟中断的地方,可能位于用户态也可能是内核态 # 返回之后会继续响应S-Mode下的软中断,以完成时钟中断真正要做的事 mret
这段汇编做了很简单的两件事情(抛开保存和恢复寄存器不谈):
1.将mtimecmp中的值加上interval,相当于为下一次时钟中断重置计数值
2.引发一个S-Mode下的软中断
当发生时钟中断信号时,RISC-V CPU会将自身特权等级提升至M-Mode(在此之前它可能位于U-Mode或者S-Mode,即分别对应到用户态和内核态),然后将mtvec寄存器中的值加载到PC中开始执行上述汇编代码,并最终在触发了一个S-Mode软中断之后返回,返回之后的程序会去紧接着响应这个S-Mode下的软中断,进而完成时钟中断的功能,所以请注意:
时钟中断的响应是两段式的!
第一段就是上面的timervec汇编部分(重置了计数值并出发了S-Mode下的软中断),接下来我们看看第二阶段,这一阶段将会继续响应S-Mode下的软中断。
4.3 时钟中断的触发与响应——第二阶段——devintr识别与转发
故事现在到了如何响应S-Mode下的软中断,其实这部分就相对简单了,我们在6.S081——补充材料——RISC-V架构中的异常与中断详解中和usertrap本篇博客中的kerneltrap都提到了devintr函数。devintr函数相当于一个中转站,对各种设备中断(包括软中断)进行转发和处理,并根据返回值告知usertrap和kerneltrap函数本次处理的中断类型。
devintr代码(kernel/trap.c:190)中对S-Mode软中断的识别和处理如下,当发现本次中断的类型是S-Mode软中断时,devintr将会清除调sip.SSIP位,并返回值2。
int devintr() {
uint64 scause = r_scause(); if((scause & 0x00000L) && (scause & 0xff) == 9){
// 对外部设备中断的处理,此处从略 // 返回1表示处理了外部设备中断 return 1; // <对S-Mode软中断的处理,其实也就是时钟中断的第二阶段> } else if(scause == 0x00001L){
// 如果CPU ID为0,递增ticks变量 // 相当于记录从系统启动之后一共发生了多少次时钟中断 // 为sleep、uptime等系统调用提供依据 // ID=0的CPU是S51 monitor core, 这个CPU专门用来监控整个系统的工作 // 所以由它来负责维护全局ticks if(cpuid() == 0){
clockintr(); } // acknowledge the software interrupt by clearing // the SSIP bit in sip. // 译:通过将sip.SSIP位置为0,来表示已经响应了此软中断 w_sip(r_sip() & ~2); // 返回值为2表示处理的是时钟中断 return 2; // 未识别的中断类型,返回后将会使内核陷入panic } else {
return 0; } }
然后usertrap和kerneltrap在接收到返回值为2时,会接着调用yield()让出当前CPU的使用权,从而完成CPU的调度,这部分细节我们到后面的CPU调度部分再详细研究。至此,时钟中断的第二阶段结束,通过devintr的识别,不仅清除了sip.SSIP位,并且通过yield()函数完成了CPU的调度。
今天的文章 6.S081——陷阱部分(内核陷阱与时钟中断)——xv6源码完全解析系列(6)分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/bian-cheng-ji-chu/96393.html