最近在将公司开发的实时操作系统移植到织女星开发板与另外一款国产的 risc-v 架构芯片的评估板上,在移植的过程中我对上下文切换的实现有了更深入的理解,在这里记录一下!
1. 上下文切换如何触发
ARM 中上下文切换通过触发一个 【pendsv 异常】来进行触发,实际的上下文切换过程由 【pendsv】 的异常处理程序来完成。通常 pendsv 的优先级设定为最低,以优先响应其它中断。
在 risc-v 架构中,【没有】 pendsv 异常,不能使用这个异常来完成上下文切换。通过查看 risc-v 架构的说明文档,我发现可以使用 【ecall】 来代替 pendsv 异常实现上下文切换。
在 risc-v 中,【ecall】 被称为环境调用异常,它又根据处理器的状态分为 【user mode ecall】 与 【machine mode ecall】,在这里我仅仅使用了 【machine mode ecall】 来完成任务。
2. 上下文切换的原理
上下文切换的核心在于【恢复现场】与【保存现场】的工作。这里的三个关键词是【现场】、【恢复】、【保存】,这三个关键词就是上下文切换需要解决的问题。
2.1 现场由什么组成?
任务切换的关键在于栈与 pc 的切换。栈的切换用于恢复或保存现场,这一现场能够完整的描绘任务在某一执行点所依赖的所有处理器环境,任务函数就绑定在这样的环境上,并且在执行的过程中也会改变环境。
cpu 提供了一套完整的计算方法,这样的方法让我们能够使用 cpu 提供的功能来进行实际的计算,我们在创建任务时注册的函数就是一套计算的具体实例,它依赖 cpu 硬件来完成计算工作,同时也在执行的过程中产生了副作用,改变了 cpu 的硬件状态,这一状态对于程序员而言主要就是处理器相关寄存器如通用寄存器与状态寄存器的当前值,而这也是我们这里所要描述的现场,即通用寄存器与处理器状态寄存器。
由于每个任务都是独立的执行流,因此我们必须保存每个任务中可能会访问到的所有寄存器内容,在 risc-v 中,对于 rv32i 这一基础指令集,我们需要保存诸如 ra、gp、tp、t0-t2 等通用寄存器与 mstatus、mepc 等处理器状态寄存器,这就是现场的所有组成部分。
注意!!对于织女星开发板,如果要考虑在任务函数中可能会使用到的硬件循环功能,那么我们还需要保存与这一功能相关的六个寄存器。
2.2 现场保存到哪里?
在 ARM 中可以为不同的异常设定不同的栈,同时用户模式与系统模式也可使用不同的栈,而栈切换的过程在一些处理器上直接由硬件完成,另外一些处理器上需要通过软件来实现。
risc-v 中没有区分异常、中断使用的栈与普通用户程序使用的栈,这样我们就需要通过软件来切换栈。
对于 ecall 异常而言,不必切换栈指针,直接将现场保存到任务栈中即可。对于中断处理而言,可以切换栈指针,将现场保存到系统栈中,不过也完全可以像 ecall 异常的处理方法一样保存到任务栈中,不过这种方式并不推荐,我在移植时,就将中断处理保存的现场保存到系统栈中。
这里需要注意的是执行异常与执行中断需要保存的现场存在着区别。我们的中断程序一般都使用 c 语言来编写,因此只需要保存 abi 规定的过程调用中需要保存的寄存器与处理器状态即可,不必像上下文切换一样把所有可能改变的寄存器全部保存下来!
2.3 保存现场的关键要素
在保存现场中,我们需要注意的是任务栈指针的保存与执行断点的保存。
子过程调用一般都依赖栈,而在执行的过程中,栈也随着函数的调用与返回而动态增减,这是需要注意的一点。
执行断点从字面意思上来看就是任务的执行流被打断的点,这与实际情况却有点偏差。在常见的芯片中,一般 cpu 会在每一个指令执行完成后检测中断,这也就意味着对于中断打断用户任务执行的情况,当中断执行完成,用户任务重新恢复执行时,我们需要执行的是任务函数中断点之后的第一条指令。对于异常而言,也有类似的过程。
明白了这一点我们对执行断点可能就有了一个清楚的认识。在 risc-v 中,异常发生后,mepc 中保存发生异常的指令,这也就是用户任务跳到异常服务程序的断点位置。从上面的叙述中我们了解到,用户任务恢复后要从断点之后的第一条指令开始执行,根据这样的原理我们就可以完成保存断点的过程。
在 risc-v 中,当异常发生时,mepc 中保存的是异常指令的 pc 值,当中断发生时,mepc 中保存的是中断处理后应该恢复执行的位置。这样的过程意味着我们要在 ecall 异常的服务程序中对 mepc 中的值进行调整,让它指向下一条指令的位置。
这样的过程可以通过给 mepc 中存的值加 ecall 指令的大小来完成,这里 ecall 指令的大小是 4 字节,因此在保存现场时给 mepc 的值加 4 后保存到栈中即可!
2.4 恢复现场的关键要素
risc-v 的异常服务程序是在关闭总中断的情况下执行的,先前的 MIE 的值将会保存到 MPIE 中,在恢复现场的时候只需要调用 mret 指令就能够恢复之前的处理器状态,中断就能够重新打开!
mret 会将 pc 的值设置为 mepc,并将 mstatus 的 MPIE 域复制到 MIE 来恢复之前的中断使用设置,并将权限模式设置为 mstatus 的 MPP 域中的值(搬运自 RISC-V 中文手册)。
2.5 第一个任务的调度
一些实时操作系统中第一个任务的调度可能并不使用触发上下文切换的方式来完成,这与第一个任务调度的过程有很大的关系。
第一个任务调度不需要保存任务的现场,只需要恢复最高优先级任务的现场。至于第一个任务的现场从哪里来,这其实是在任务的创建过程中完成的。在创建的过程中就预先在任务栈中存储了一份现场,在这个现场中,需要注意的是任务执行的函数地址与此函数执行后的返回地址。
任务执行的函数地址将会赋值给 pc 来从第一条指令开始执行,同时任务函数的返回地址一般是某一个 exit 函数。这里通过软件配置了一个栈帧,在这个栈帧中,返回地址指向的函数可以说是父函数,此父函数调用了任务函数这一子函数。这样在任务函数返回的时候,会返回到 exit 函数开始执行,在这个函数中就能够完成任务的死亡过程。
不过在一些实时操作系统中,任务的死亡是分阶段的。它们可能会有一个 exit 函数,在这个函数里面可能仅仅只将任务从就绪表中移除,并将任务的 tcb 指针添加到某一个待回收的链表中,在下一次系统的空闲时刻通过 idle 任务来进一步的回收其它资源,并设置任务的状态为 dead,至此任务才算真正死亡了!
不过如果你需要确保任务死亡,你可以主动进入 idle 任务,这可以通过一段时间的任务延时来实现。在延时时间过去之后,你可以再次检查任务是否真正死亡,如果还没有死,那你可以重复这样的过程。
3. 在中断中执行上下文切换
在中断中执行上下文切换也是一个难题,而且这个难题几乎是不可避免的。
想想任务延时是如何实现的?一般都是在 systick 的中断函数中判断是否有任务的延时时间到达,并重新调度,如果待调度任务的优先级最高,则要进行上下文切换。
对于在中断中需要执行上下文切换的方式,我们可以通过设定某一标志变量来表示是否需要在中断中执行上下文切换,在中断服务程序退出后通过检查这一标志变量的值来判断是否要进行上下文切换,不需要切换则正常退出,需要切换则执行上下文切换的过程。
对于嵌套中断,可以再设定一个记录嵌套中断的变量。每次进入中断时对这个变量加一,退出中断时对这个变量减一,对于需要在中断中执行上下文切换的情况,在变量值减到 0 的时候再进行上下文切换就可以了!
也许你会问为什么要在所有的嵌套中断全部执行完了之后再执行上下文切换?
实际上这里的处理过程是优先处理中断的,这也是硬件的方针,中断拥有比用户任务更高的优先级,可以随时中断用户任务,所以我在这里选择在最后一个中断退出时进行上下文切换,这样的方式是合理的!
总结
上文中对移植实时操作系统的过程中需要解决的问题进行了描述,并提供了一些解决的思路。同时以 risc-v 开源架构为例子,讲解了将实时操作系统移植到 risc-v 架构芯片上的一些过程,希望对研究 risc-v 架构芯片的朋友有所帮助!