文章目录
一、什么是任务调度器
1.1 多任务并行机制
对很多人来说,多任务并行机制挺神秘的,甚至对很多有多线程并发编程经验的工程师来说也是如此。前面我用五篇文章谈了C++多线程并发编程的原理与技巧,但对操作系统的多任务并行机制并没有过多阐述。现在回顾下从单任务顺序执行到多任务并行执行的演变过程,有利于打破多任务并行机制的神秘感。
多任务并行机制从技术上来说就是程序流折断 + 现场(在操作系统里叫上下文)保护,这两部分我们也并不陌生,回顾下常用的中断响应、调子程序等。那么多任务并行机制的神秘性在哪里呢?在于程序流反向控制机制,就是由子函数决定父函数的执行流程。对于通常的程序来说,总是由父函数决定何时调用哪个子函数,而在并行多任务系统中,却是由一个被称为任务调度器的子函数决定何时调用哪一个父函数。
需要说明的一点是,这种子函数对父函数的调用并非任意的,它只能将流程指向父函数的折断点,也就是最近一次调用任务调度器时的位置。原因很简单,因为子函数根本不知道应该从父函数的哪个地方开始执行,除非它保存了父函数在折断时的上下文。这种“由任务调度器保存任务流程折断点信息(上下文),并在将来某个时间恢复该上下文,然后继续该任务流程”的方式,就是多任务并行的核心机制。
如何在子函数中修改父函数的执行流程呢?下文将会给出答案。
1.2 任务调度器
前面一篇文章《有限状态机》谈到了状态机作为一个事件驱动模型,将一个大任务分割为多个小任务,特别是对于协议栈数据处理这类比较复杂的大任务,使用状态机而非操作系统可以占用更少的资源获得更高的执行效率。但对于多个相互独立的任务来说,虽然也可以采用多个任务状态机分别管理不同的任务,将每个任务拆分为多个不同的状态并控制任务流程的状态迁移处理起来并不轻松。就像有限状态机那篇文章谈到的,不方便实现任务的动态调度,难以实现对特定任务的实时响应。下面给出一个状态机实现多任务流程控制的图示:
状态机需要我们自己将任务分割为一个个小的任务片段,增加了我们程序开发的难度。那么,任务分割是否可以交给操作系统自己处理呢?任务调度器便可以实现任务分割功能。每次调用任务调度器时,任务调度器会先保存程序流折断点的上下文信息(保存在该任务的私有堆栈中),然后将该任务流折断将CPU交给下一个任务。下一个任务也是从折断点开始执行的,先将目标任务折断点的上下文信息装填到CPU的主堆栈中,CPU就可以从该任务的折断点继续往下执行了。任务分割、程序流折断、现场上下文保护等这些任务都是由任务调度器自动完成的,为我们开发多任务并行的程序带来了极大的便利。下面给出一个任务调度器实现多任务流程控制的图示:
从上面两个图的对比也能看出,状态机中的任务片段是由主程序主动调用的,属于传统的由上至下的控制任务流程,任务调度器中的任务片段则是由任务调度器被动调用的,属于从下到上的切换任务流程,这也印证了前面谈到的程序流反向控制机制。
1.3 堆栈迁移
前面提到程序折断点上下文的现场保护需要保存堆栈,每个任务都设有一个私有堆栈,用于保存任务流被折断(任务切换)时的堆栈内容。堆栈是上下文切换时最重要的切换对象,这种对堆栈的切换叫作“堆栈迁移”。
堆栈迁移有两种方式:一种方法是(左图)使用私栈作为主堆栈,发生任务切换时,只需将栈指针切换到新任务的栈顶即可,另一种是(右图)使用公栈作为主堆栈,每切换一个任务,就将公栈的内容搬向旧任务的私栈,并将新任务从私栈搬至公栈,然后修改栈指针指向新的栈顶。
栈指针切换和堆栈搬移两种方式的优缺点在上图中简单列出了,分别从时间和空间两个角度对比下。栈指针切换只需要移动两个字节,而堆栈搬移则需要搬移若干字节(每增加一层函数调用还需要增加至少两个字节)且换入/换出各需搬移一次,可见栈指针切换消耗时间短得多。一般时间与空间难以两全,在内存空间的占用方面就正好相反了。栈指针切换方式,每个私栈都需要有足够的栈深支撑调子函数、调中断、寄存器压栈等动作,至少占用8-12字节栈深,而使用堆栈搬移方式时,私栈只要保存从栈底到任务切换时的栈深,中断和调子函数的栈深可由公栈承担,所以私栈分配4-8字节栈深基本够用。
二、任务调度器工作原理
一般任务调度器应用于操作系统设计中,多任务调度器根据使用场景主要分为两大类:抢占式多任务调度器与非抢占式多任务调度器。
抢占式多任务调度器主要应用于MCU等需要及时响应外设事件的场景,强调特定任务响应的实时性,所以一个常用的调度策略是给每个任务分配一个不同的优先级,每次调度运行处于就绪态的优先级最高的任务,这就是RTOS的任务调度算法原理。
非抢占式多任务调度器主要用于CPU等处理复杂运算和资源I/O等没那么注重实时响应的场景,这种场景更注重针对每个任务的运行状况合理分配CPU等资源,既要保证每个任务都能分配到合理的执行时间和空间资源不至于某个任务迟迟得不到执行机会,又要最大限度的利用计算机资源尽量减少计算存储等资源的浪费。比较经典的是Linux系统的CFS完全公平调度策略,这个比较复杂,读者感兴趣可以自行了解。
考虑到RTOS使用的抢占式多任务调度算法相对简单,下面以UCOS的高优先级抢占式任务调度算法为例介绍下任务调度器的设计原理。任务调度器分为任务切换和任务选择两大部分,任务选择自然是选择接下来要切换到哪一个任务,UCOS中是选择处于就绪态下优先级最高的任务,任务切换则是将旧任务折断点的上下文信息保存到旧任务私栈,然后将待切换的新任务折断点上下文信息从该任务私栈搬移到公栈,然后从新任务折断点开始继续执行就完成了任务的切换。
但任务切换需要触发事件,UCOS通过SysTick定时器产生定时中断作为任务调度器的驱动事件,任务切换涉及到公栈与私栈数据的搬移,所以每个任务也需要有一个数据结构来保存其折断点上下文信息、优先级等重要信息。下面先从描述任务的数据结构任务控制块TCB谈一下UCOS是如何管理任务的。
2.1 任务描述与管理
UCOS中是使用一个叫任务控制块(TASK CONTROL BLOCK)的结构体来描述一个任务的,下面给出UCOS TCB数据结构的代码如下(已经删除了部分不重要的编译选项和成员变量):
// Micrium\Software\uCOS-II\Source\ucos_ii.h
/*
*********************************************************************************************************
* TASK CONTROL BLOCK
*********************************************************************************************************
*/
typedef struct os_tcb {
OS_STK *OSTCBStkPtr; /* Pointer to current top of stack */
struct os_tcb *OSTCBNext; /* Pointer to next TCB in the TCB list */
struct os_tcb *OSTCBPrev; /* Pointer to previous TCB in the TCB list */
#if (OS_EVENT_EN)
OS_EVENT *OSTCBEventPtr; /* Pointer to event control block */
#endif
#if ((OS_Q_EN > 0u) && (OS_MAX_QS > 0u)) || (OS_MBOX_EN > 0u)
void *OSTCBMsg; /* Message received from OSMboxPost() or OSQPost() */
#