随风
任务调度是操作系统的基本功能。任务调度使得用户程序感觉自己在独占cpu。但是cpu是有限的,任务又那么多,操作系统是如何做到合理分配cpu时间的呢。其实rtos基本上是根据优先级抢占式调度,然后如果任务优先级相同就按照时间轮转片调度。
时间轮转片算法并不难,然而我阅读了大量的时间轮转片介绍的博客始终没有具体的硬件实现介绍。大部分博客都是仿真实现,这其实和真实的内核实现有着很大的差距。因为仿真切换你根本没法实现硬件中断机制,而真实的内核是利用中断机制来实现任务调度的。
为什么内核要使用中断机制而不是直接像普通程序那样编写。这其实非常显然,内核调度肯定占用的资源越低越好,实时性越高越好。而中断机制就能提供这样的属性。中断机制可以说是计算机最具革命性的一个设计,它大大提高了计算机系统的响应速度。
当看到利用中断机制的介绍时候我脑海里已经浮现了一种内核调度的实现方法。利用systick中断,systick中断实现流水灯相信大家都曾经编写过,因此想到利用systick来进行任务切换是一种非常自然的想法,只要设置好中断的时间间隔,那么就实现了时间片的设计。事实上tos系统同样利用了systick中断。下面是tos系统时基的入口。
void SysTick_Handler(void)
{
if (tos_knl_is_running())
{
tos_knl_irq_enter();
tos_tick_handler();
tos_knl_irq_leave();
}
}
但是仅仅利用systick中断肯定是要出问题的。因为单片机还要处理其它中断,在Cortex-M3中,如果OS在某个中断活跃时,抢占了该中断,而且又发生了任务调度,并执行了任务,切换到了线程运行模式,将直接触发Fault异常。这肯定是不行的,但你肯定会说不如直接把systick设置为最低优先级不就行了吗?当然可以,异常确实不会发生了,然而你仔细想想会发生什么?
仔细想好像也没问题啊。的确很难想到,但是我们处理中断的流程是怎么样的?第一步是关中断,然后保存现场再开中断,执行程序,最后再关中断,恢复现场然后开中断。这样做就是为了现场不被破坏同时保证更高优先级的中断能够正常被响应。一般OS在调度任务时,也是要关闭中断,也就是进入临界区,而OS任务调度是要耗时的,这就会出现一种情况:
在任务调度期间,如果新的外部中断发生,CPU将不能够快速响应处理。这对于智能驾驶这样的场景你能忍受吗?
到了这里你似乎走到了穷途末路。的确只利用systick在软件层面已经没有更好的办法了。但是狡猾的内核大佬利用了一个叫做pendsv的中断,这个中断很奇怪,它能够像普通外设中断一样悬起。它是系统级别的异常,但它又天生支持缓期执行。有了它我们就能轻松避免了不能快速处理的问题,pendsv会自动等待所有的中断处理完毕再进行任务切换的操作。
到了这里我们已经得到了一种较为成熟的os调度方案。
- 滴答定时器中断,制作业务调度前的判断工作,不做任务切换。
- 触发PendSV,PendSV并不会立即执行,因为PendSV的优先级最低,如果此时正好有中断请求,那么先响应中断,最后等到所有优先级高于PendSV的中断都执行完毕,再执行PendSV,进行任务调度。
那么tos系统也是这样实现的吗?我们看看源码。那么我们要启动tos系统,入口肯定要调用启动的api,源代码api头文件注释是get the kernel start to run, which means start the multitask scheduling.中文意思就是启动内核,意味着启动多任务调度。
__API__ k_err_t tos_knl_start(void)
{
if (tos_knl_is_running()) {
return K_ERR_KNL_RUNNING;
}
k_next_task = readyqueue_highest_ready_task_get();
k_curr_task = k_next_task;
k_knl_state = KNL_STATE_RUNNING;
cpu_sched_start();
return K_ERR_NONE;
}
可以看到前几行都是检测和获取任务的一些信息,重点在于 cpu_sched_start() ,这个是用来启动cpu任务调度的。刚才那个加了宏_api_表明它还是属于用户api,下面正式进入内核世界。
可奇怪的是它的实现c文件很短,就又调用了一个函数 port_sched_start 。
__KERNEL__ void cpu_sched_start(void)
{
port_sched_start();
}
继续前进已经没有c文件了,迎面而来的是汇编语言。我熟悉8051汇编,但是对于arm的汇编,我并不是很熟悉,但是又不是考试,还是可以查手册的。粗略看了看,大概知道这是一段很重要的代码。
NVIC_INT_CTRL EQU 0xE000ED04 ; Interrupt control state register.
NVIC_SYSPRI14 EQU 0xE000ED20 ; System priority register (priority 14).
NVIC_PENDSV_PRI EQU 0x00FF0000 ; PendSV priority value (lowest).
NVIC_PENDSVSET EQU 0x10000000 ; Value to trigger PendSV exception.
我们看一下注释,这不就是设置pendsv为设置为最低优先级吗,到这里我们还不清楚它与systick的关系,但至少已经证实它肯定利用了pendsv。幸运的是我滚动一下鼠标就发现的
pendsv的中断处理子程序(汇编我们还是不要叫函数吧)
这段代码很复杂,但是只要有点51单片机的功底,还是猜得出它在干什么,其实就是操作系统最重要的工作,任务堆栈的切换。它借用了寄存器实现的现场的保护。至此我们完全证明tos系统利用pendsv进行上下文切换。
我们还看到这样一段注释
@ set pendsv priority lowest
@ otherwise trigger pendsv in port_irq_context_switch will cause a context swich in irq
@ that would be a disaster
翻译过来跟我们上面描述的完全一致。
我们再去找systick,得到了这个函数
__API__ void tos_tick_handler(void)
{
if (unlikely(!tos_knl_is_running())) {
return;
}
tick_update((k_tick_t)1u);
#if TOS_CFG_TIMER_EN > 0u && TOS_CFG_TIMER_AS_PROC > 0u
timer_update();
#endif
#if TOS_CFG_ROUND_ROBIN_EN > 0u
robin_sched(k_curr_task->prio);
#endif
}
unlikely这是内核的一种优化函数,暂时不用管它,我们全力看 robin_sched ()函数。
__KERNEL__ void robin_sched(k_prio_t prio)
{
TOS_CPU_CPSR_ALLOC();
k_task_t *task;
if (k_robin_state != TOS_ROBIN_STATE_ENABLED) {
return;
}
TOS_CPU_INT_DISABLE();
task = readyqueue_first_task_get(prio);
if (!task || knl_is_idle(task)) {
TOS_CPU_INT_ENABLE();
return;
}
if (readyqueue_is_prio_onlyone(prio)) {
TOS_CPU_INT_ENABLE();
return;
}
if (knl_is_sched_locked()) {
TOS_CPU_INT_ENABLE();
return;
}
if (task->timeslice > (k_timeslice_t)0u) {
--task->timeslice;
}
if (task->timeslice > (k_timeslice_t)0u) {
TOS_CPU_INT_ENABLE();
return;
}
readyqueue_move_head_to_tail(k_curr_task->prio);
task = readyqueue_first_task_get(prio);
if (task->timeslice_reload == (k_timeslice_t)0u) {
task->timeslice = k_robin_default_timeslice;
} else {
task->timeslice = task->timeslice_reload;
}
TOS_CPU_INT_ENABLE();
knl_sched();
}
从源码中可以看到,时间片调度算法的实现非常简单,当时钟节拍来临的时候,将就绪列表中第一个任务控制块的时间片值递减,如果递减到0,则移到就绪列表的队尾去,让出此次执行机会,内核发生调度。我们重点看 knl_sched()函数。
__KERNEL__ void knl_sched(void)
{
TOS_CPU_CPSR_ALLOC();
if (knl_is_inirq()) {
return;
}
if (knl_is_sched_locked()) {
return;
}
TOS_CPU_INT_DISABLE();
k_next_task = readyqueue_highest_ready_task_get();
if (knl_is_self(k_next_task)) {
TOS_CPU_INT_ENABLE();
return;
}
cpu_context_switch();
TOS_CPU_INT_ENABLE();
}
这个就是检查一下当前是否有中断发生,还有检查一下锁是否释放,然后调用函数 TOS_CPU_INT_DISABLE()关闭中断,调用 cpu_context_switch() 进行上下文切换。不出意外,终点一定是汇编实现。
果然我们已经离开了c语言世界,进入汇编世界。看代码
port_context_switch
LDR R0, =NVIC_INT_CTRL
LDR R1, =NVIC_PENDSVSET
STR R1, [R0]
BX LR
这是什么?这是什么?触发pendsv异常,到了这里我们已经破解了tos系统的任务调度器的基本原理。这表明tos系统和freertos的实现机制是大差不差的。
内核是非常复杂的东西,我们一定要充满敬畏,不要随意去修改,但是了解内核还是很有必要的,否则应用层的很多东西我们理解可能都是错误的。在此,真的太佩服太佩服内核大佬了,瞬间觉得应用层开发那点困难不算什么了。
最后来个传送门