前言 关于任务如何切换,可以分为三种:
抢占式:任务自己不能决定是否让出,由外部原因引起;
协作式:任务自己放弃 CPU;
兼容协作式:抢占式任务的同时,自己还可以自愿放弃。
关于协作式,前面已经提到了。task0 通过 yield 来放弃当前 CPU。
与定时器配合 抢占式可以基于时钟中断来实现,通过不断地产生时钟中断来做到任务切换。
思路大致为,当时钟中断到达的时候,进入到 trap_vector,接着调用 trap_handler。 trap_handler 对时钟中断进行处理,处理的过程中直接调用 switch_to,这样就可以了吗?
没有这么简单。
之前的 switch_to 很简单,它由 yield 中的 schedule 函数引发。
switch_to 函数保存上下文后,切换到新的上下文,然后通过 jalr 指令返回到新的上下文(借助寄存器 ra)。这显然不怎么“合法”。合法的切换上下文最后应该由 mret 返回。但是 mret 返回的话,依赖的是 mepc 这个寄存器,因此在 mret 之前,得把 context 中的 mepc 恢复到 mepc。这就意味着,保存上下文的时候,得保存当前的 PC 的下一条指令地址(也就是硬件自动保存的 mepc )到上下文中!
这个和之前的简单上下文切换的关键不同点是什么?
很明显,之前的上下文根本不需要记住 mepc,因为是主动放弃的,下次回来的时候只需要在 context 中持有一个 ra 就行了:
1 2 3 4 5 6 7 8 9 10 int task_create (void (*start_routin)(void )) { if (_top < MAX_TASKS) { ctx_tasks[_top].sp = (reg_t )&task_stack[_top][STACK_SIZE]; ctx_tasks[_top].ra = (reg_t )start_routin; _top++; return 0 ; } else { return -1 ; } }
但是如果是抢占式的,任务自己根本不知道下次回来的地址是什么,只能依靠 trap_vector 把 mepc 保存到上下文中以便恢复。因此得修改上下文结构体:
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 struct context { reg_t ra; reg_t sp; reg_t gp; reg_t tp; reg_t t0; reg_t t1; reg_t t2; reg_t s0; reg_t s1; reg_t a0; reg_t a1; reg_t a2; reg_t a3; reg_t a4; reg_t a5; reg_t a6; reg_t a7; reg_t s2; reg_t s3; reg_t s4; reg_t s5; reg_t s6; reg_t s7; reg_t s8; reg_t s9; reg_t s10; reg_t s11; reg_t t3; reg_t t4; reg_t t5; reg_t t6; reg_t pc; };
了解了这些,就可以改造 trap_vector 了。
修改 trap_vector 首先保存上下文:
1 2 3 4 5 csrrw t6, mscratch, t6 reg_save t6 mv t5, t6 csrr t6, mscratch STORE t6, 30*SIZE_REG(t5)
这时候,还有一个新的上下文,那就是 mepc:
1 2 3 4 csrr a0, mepc STORE a0, 31*SIZE_REG(t5) # 恢复 mscratch csrw mscratch, t5
调用 trap_handler:
1 2 3 csrr a0, mepc csrr a1, mcause call trap_handler
我们可能希望 trap_handler 执行完后,从这里返回。但是,事实上由于这里发生的是时钟中断,并且进行了新的任务调度:
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 reg_t trap_handler (reg_t epc, reg_t cause) { reg_t return_pc = epc; reg_t cause_code = cause & MCAUSE_MASK_ECODE; if (cause & MCAUSE_MASK_INTERRUPT) { switch (cause_code) { case 3 : uart_puts("software interruption!\n" ); int id = r_mhartid(); *(uint32_t *)CLINT_MSIP(id) = 0 ; schedule(); break ; case 7 : uart_puts("timer interruption!\n" ); timer_handler(); break ; case 11 : uart_puts("external interruption!\n" ); external_interrupt_handler(); break ; default : printf ("Unknown async exception! Code = %ld\n" , cause_code); break ; } } else { printf ("Sync exceptions! Code = %ld\n" , cause_code); panic("OOPS! What can I do!" ); } return return_pc; }
至于怎么处理,就看 time_handler():
1 2 3 4 5 6 7 8 void timer_handler () { _tick++; printf ("tick: %d\n" , _tick); timer_load(TIMER_INTERVAL); schedule(); }
可以看到,这里调用了 schedule,而 schedule 调用了 switch_to。我们希望 switch_to 通过 mret 返回而不是 ret (jalr)。继续改造 switch_to。
当 schedule 结束后,它会返回一个新的上下文,这个上下文保存到 a0 中,因此我们需要设置新的 mscratch 寄存器,接着加载上下文:
1 2 3 4 5 6 7 8 9 10 11 12 csrw mscratch, a0 # 导出 mepc,mret 依赖于其返回 LOAD a1, 31*SIZE_REG(a0) csrw mepc, a1 mv t6, a0 reg_restore t6 # Do actual context switching. # Notice this will enable global interrupt mret
这样,mret 就能利用 mepc 完美跳到另一个上下文中。当然,这也不会影响其它异常处理过程,trap_vector:
1 2 3 4 5 6 7 8 9 # trap_handler will return the return address via a0. srw mepc, a0 # restore context(registers). csrr t6, mscratch reg_restore t6 # return to whatever we were doing before trap. mret
测试 同样设置两个函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void user_task0 (void ) { uart_puts("Task 0: Created!\n" ); while (1 ) { uart_puts("Task 0: Running...\n" ); task_delay(DELAY); } }void user_task1 (void ) { uart_puts("Task 1: Created!\n" ); while (1 ) { uart_puts("Task 1: Running...\n" ); task_delay(DELAY); } }
这两个任务并没有调用 yield。
设置好时钟中断后,就可以看效果了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Press Ctrl-A and then X to exit QEMU ------------------------------------ Hello, RVOS! HEAP_START = 0x80007118(aligned to 0x80008000), HEAP_SIZE = 0x07ff8ee8, num of reserved pages = 8, num of pages to be allocated for heap = 32752 TEXT: 0x80000000 -> 0x8000388c RODATA: 0x8000388c -> 0x80003bd2 DATA: 0x80004000 -> 0x80004004 BSS: 0x80004010 -> 0x80007118 HEAP: 0x80010000 -> 0x88000000 Task 0: Created! Task 0: Running... timer interruption! tick: 1 Task 1: Created! Task 1: Running... QEMU: Terminated
可以看到,一切都符合预期。
兼容协作式 如果 task0 主动要放弃 CPU 应该怎么做?继续调用 yield 吗???
之前 task0 放弃 CPU 调用 swicth_to 就可以,但是现在的 switch_to 已经被重新定义了,要不要写一个 switch_to_1,task0 调用 switch_to_1 后,实现切换?
当 trap 的时候,hart 会自动将 xIE 设置为 0。这意味着中断将会关闭。这时候上下文处于安全状态。
但是当 task0 不是通过 trap 而是直接进入 switch_to 的时候,中断是开着的,这时候如果来一个 trap,上下文没有保存完,接着跳转到 trap 中,再保存一次。从 trap 返回到 switch_to 后,接着保存上下文,switch_to 表现得就像是 task* 的一部分一样,虽然上下文切换的过程被打断了,但这确实没有什么问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 $ make run Task 0: Created! software interruption! Task 1: Created! timer interruption! tick: 1 Task 0: Running... Task 0: Running... timer interruption! tick: 2 Task 1: Running... Task 1: Running... timer interruption! tick: 3 Task 0: Running... Task 0: Running... timer interruption! tick: 4 Task 1: Running... Task 1: Running... QEMU: Terminated
但是我们希望,对于切换上下文的过程,永远从 trap 进去,毕竟保存上下文这么重要的操作,在期间,中断应该关闭。那么 task 应该怎么操作一下,就能引发一个中断呢?
好消息是,CLINT 提供了软中断。
CLINT 提供的软件中断 每个 hart 拥有一个 MSIP 寄存器。RISCV 规范规定,Machine 模式下的 mip.MSIP 对应到一个 memory-mapped 的控制寄存器。为此 QEMU-virt 提供 MSIP,该 MSIP 寄存器为 32-bit,高 31 位不可用,最低位映射到 mip.MSIP。具体寄存器编址采用 base + offset 的格式,且 base 由各个特定 platform 自己定义。针对 QEMU-virt,其 CLINT 的设计参考了 SFIVE,base 为 0x2000000。
对 MSIP 写入 1 时触发 software interrupt,写入 0 表示对该中断进行应答。
也就是说,我们可以操作 CLINT 中的寄存器来实现操作 CPU 中的 MSIP 位,且它们是同步的。
那么这样,我们就可以从 trap 进入到 switch_to 了:
1 2 3 4 5 6 7 8 9 10 11 12 13 void sched_init () { w_mscratch(0 ); w_mie(r_mie() | MIE_MSIE); }void task_yield () { int id = r_mhartid(); *(uint32_t *)CLINT_MSIP(id) = 1 ; }
还要在 trap_handler 中,手动将这个中断清零:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 reg_t trap_handler (reg_t epc, reg_t cause) { reg_t return_pc = epc; reg_t cause_code = cause & MCAUSE_MASK_ECODE; if (cause & MCAUSE_MASK_INTERRUPT) { switch (cause_code) { case 3 : uart_puts("software interruption!\n" ); int id = r_mhartid(); *(uint32_t *)CLINT_MSIP(id) = 0 ; schedule(); ...
运行效果:
1 2 3 4 5 6 7 8 9 10 11 12 Task 0: Created! software interruption! Task 1: Created! Task 1: Running... timer interruption! tick: 1 Task 0: I'm back! Task 0: Running... timer interruption! tick: 2 Task 1: Running... QEMU: Terminated
可以看到,符合预期。