任务与调度

尽可能梳理成分类讨论了,笔者自己的思考过程耦合性有亿点点高。

任务的分类

任务划分结构可以被嵌套表示为

  • 异常任务(中断任务)
    词如其意。在内核 Handler Mode 处理的任务被称为异常任务。

  • 线程任务
    与上文类似的,在内核 Thread Mode 处理的任务被称为线程任务。线程任务按重要程度又可划分为

    • 重要任务
    • 次要任务
    • 内核任务

重要程度划分的定义参见前文。

综上,调度策略的分析中一共存在四种任务:

  1. 异常任务
  2. 重要任务
  3. 次要任务
  4. 内核任务

后三者被合称为线程任务。

上下文切换

从逻辑上看,在某一时刻时内核处理的任务发生了改变的动作被称为任务的上下文切换。
从动作上看,在某一时刻时内核的堆栈指针被手动修改称为任务的上下文切换。

在任一时刻,可能发生的上下文切换无非以下两种情况

  • 主动切换
    即当前任务主动触发上下文切换。

  • 被动切换
    即当前任务因操作系统内核/处理器内核调度而被动发生上下文切换。

调度策略

由顶向下划出大纲后,当然是自底向上具体分析。

约定 0:
在宏观上来看,所有需要执行的任务一定都是可以在其超时时间内被完成的。哪怕是最不重要的任务,也存在某种调度算法使其可以在超时时间内被完成。
约定 1:
不讨论 NMI 。NMI 超出三界以外,不在五行之中。任何 NMI 都必须被立刻处理。

微观层面的调度策略

任意时刻,系统一定是在执行某一种任务,对其进行分类讨论。

重要任务

为保证时序高度可控,届时在某个优先级以下的中断都将被屏蔽(详见异常任务),只存在某些极高优先级的中断允许触发。当这些异常触发,一定说明系统出现了严重错误,需要立即被处理。
故在正常情况下,不可能出现被动切换的情况,只有主动切换一种情况。下面讨论主动切换的不同情况下的切换策略。

执行完成后的切换

当本轮算法执行完毕后,一定被切换为内核任务。再由内核任务进行其他的任务调度。

执行过程中的切换

略为复杂,举个例子吧。

控制算法的执行步骤可简化为“数据采集 -> 数据处理 -> 输出调整”

在一些算法中数据采集需要使用总线进行传输,因此会出现较大的时间间隔。如 4MHz SPI 总线, 仅考虑 20byte 的连续读过程,其等待时间约为 40us 。当控制周期为 1ms 时,其性能损失约为 4% ;对于 F40x 约可执行 1.2516840=84001.25*168*40=8400 条指令。

在裸机情况下,往往通过中断来处理。当 DMA 传输完成时将触发中断,在中断中进行数据预处理,并完成全部数据接收/预处理后,执行控制算法。这需要在中断内完成逻辑的主要部分。
在中断内完成“最主要,也往往是最复杂”的部分。emmmmmm… 你懂的,很难受。

( ❗ 我没有任何 RTOS 的使用经验,这一部分可能有错误)
而在一般的 RTOS 中,这个问题是通过不同的任务解决的。当数据传输完成时,内核通过信号量/事件来表明数据就绪。根据不同 RTOS ,任务的切换可能通过抢占式调度/时间片调度亦或二者结合来完成。
考虑延迟反应最快的抢占式调度,不考虑任何其他干扰,只进行栈切换的理想上下文调度仍有 500 左右的指令延迟。

为了追求极致,最终采取的策略为预调度。以下为决策过程:

  1. 在什么情况下,从数据读取完成到进入处理过程最快?
    显然是同步阻塞时最快。无论是中断调度,还是 RTOS 上下文切换,必然会发生上下文切换。

  2. 采取同步阻塞时,其性能浪费是可接受的吗?
    不可接受。对于大多数 SPI 器件 4MHz 总线速度虽然不算极限,但一般极限情况也就是 10MHz ;再考虑到分频器、稳定性或是其他的总线,4MHz 应该比较快的一档速度了。
    但此时仍对于每个传感器有 4% 的性能损耗(注意,多传感器不具有损耗可加性)。

  3. 在这种情况下,最终选择了预调度/早返回的策略。(交够调度的,留足阻塞的,剩下都是 IDLE 的。 o((>ω< ))o)
    以上述情况为例,如果同步阻塞会浪费 8400 条指令,那我就使用 1000 条指令进行调度,1000 条指令进行阻塞等待,剩余时间的 6400 条指令都可以给其他任务。

    实际上,这里的具体的周期分配将采取自动分配策略,以实现尽可能高的 CPU 使用效率。因为在理想情况下,算法的运行时间及总线的占用时间,都是高度周期性的,也就是说没有错误出现的情况下,早返回的预留阻塞时间一定是可收敛的。那么就可以让系统来计算这预留的阻塞指令时间。

  4. 现在还剩最后一个问题,调度给谁?

    1. 其他的主要任务?
      不行,对于主要任务(控制算法)时间控制有严格的调度要求。

    2. 次要任务?
      这个是可行的。本来开发这个内核就是为了将 IDLE 时间分配给次要任务。

    3. 内核任务?
      在重要程度上和次要任务坐一桌。也是可行的。(注意:内核任务只是具有最高的稳定/鲁棒性控制策略,但在时间控制上并无高要求。)

    4. 等待处理的中断?
      需要进行处理方式的讨论。

      • 取消对中断的屏蔽,由 NVIC 进行自动调度。
        不推荐。一旦取消全部屏蔽,中断嵌套的次数是不可控的,很有可能导致超出原定的返回时间。

        详见 CM4 内核手册,简而言之:任何在高优先级异常活跃 Handler 期间尝试切入 Thread 模式的操作都会触发 UsageFault 。

      • 手动进行中断中断处理,并取消中断挂起标志。
        这个是可以的,就是 249 个可能的 IRQ 自己判断。。。没必要吧

      综上,不推荐进行中断处理。

内核任务

内核任务是较特别的一类任务。一般来说,绝大多数内核任务虽不及重要任务优先度高,但仍应该每周期执行一次。而且,任何线程任务之间的切换实际上都是由内核任务进行调度的。

内核任务以空闲任务的形式存在。最低的优先级 0 被空闲任务独占,空闲任务永远都是就绪的。
在特定条件下,空闲任务通过 SVC 升级为内核任务。

次要任务

在执行次要任务时,主动/被动切换均可能存在。

理论上来说,在执行次要任务时,不需要对异常处理进行控制。因为,次要任务的优先级很低,谁来都可以踩一脚。但实际上,还是之前说的“任何在高优先级异常活跃 Handler 期间尝试切入 Thread 模式的操作都会触发 UsageFault 。”

考虑这样一种情况,还剩最后 10 条指令就要进入重要任务了,这时来了一个异常,这一定会导致重要任务无法及时执行。

所以,我们有两种处理方式:

  1. 次要任务期间依旧禁用异常触发(NVIC:😅早知道烂厂子里了)
    显然不合理。因为异常就是被设计出来打断正常流程尽快进行处理/异步处理的。而且,不用 NVIC 又要变成自己管理中断了不麻烦嘛?抬走抬走

  2. 允许异常正常触发:
    这是势在必行的一条路。我们有如下的推断:

    • 在具有 RTOS 内核的程序设计中,任何长时间处于 Handler Mode 的程序都是绝对不合格的。
    • CortexM4 处理速度对于嵌入式设计,速度相当快。
      因此,我们只需要划定一个人为的 NVIC 调度禁区即可。另外,还需要遵守异常任务的设计要求。(见附录:异常任务的实现要求)

异常任务

异常任务分为两类,一种是正常运行时的异常,如 SysTick、SVC、PendSV 和大部分 IRQ 。这些异常是我们希望见到的,用于实现异步操作。但另外一种是不希望见到的,如 Fault 或看门狗被触发。

普通异常任务

普通异常任务在某些时刻会被屏蔽。

  • 异常任务切换至异常任务(异常嵌套)
    从上文的分析易知,这种中断产生嵌套只能是在执行次要任务期间。由内核自动处理。

    双手离开键盘,你是最简单的!

  • 异常任务切换至线程任务
    一定是异常处理完成,自动退出。

    双手离开键盘,你也是最简单的!

紧急异常任务

紧急异常任务在任何时刻都不会被屏蔽。

  • Fault:
    内核错误的处理,具有最高优先级。

  • SysTick:
    用于触发系统内核回调,具有相当高的优先级。但在 SysTick 中只会做最小时间要求的任务,通过将标记位置位将长时间任务移交给线程模式中的内核任务。

  • PendSV:
    用于实现上下文切换,具有最低优先级。即任何活跃的中断存在时都无法实现进程间的上下文切换,从而保证了不触发 UsageFault 。

  • SVC:
    用于呼叫系统服务,优先级仅高于 PendSV 。

PendSV 和 SVC 的"最低优先级"是相对性的,意味着所有不被屏蔽中断内的最低优先级。

  • WatchDog:
    避免某些异常情况导致系统卡死。

  • 其他:
    具体情况具体讨论的一些其他的重要异常。

宏观层面的调度策略

同等重要程度间任务的调度

当重要任务就绪时,一定是执行重要任务的。因此首先需要处理复数个同等优先级的任务调度。

复数个重要任务的调度

重要任务之间任务的调度策略不采用常见的优先级策略。因为优先级策略容易给人一种当高优先级的重要任务被打断时,应该去执行低优先级重要任务的错觉。
在我的定义中,各个重要任务之间需要被看作是阻塞的。即任务 1 一定发生在任务 0 执行完成之后。这样做的好处是时序的绝对可控。

用术语来说,重要任务是硬实时的。而不同重要任务的周期及相位要求使用者明确定义。这可以使用两种策略:

  1. 单个重要任务内的 FSM 。
  2. 静态调度表。

实际上,这相当程度上是受笔者在裸机环境下的开发习惯影响的。
绝大多数 RTOS 的重点都是在强调实时性。这种操作系统往往是基于事件驱动的,用户的操作/事件的发生,这些都是不规律的。经典 RTOS 强调的是外部驱动的实时响应。
但笔者更熟悉的领域是自动控制,我所重点关心的是周期性高重复性的任务,这些都是内部驱动的,且具有较高时间控制的需求。那些外部驱动的事件也往往是不需要重点关心的。
以遥控小车举例,无论什么情况下,最重要的是对于系统来说小车姿态必须是可控的,绝对不允许出现小车发疯的情况。在此相比较之下,用户遥控器的输入就显得不那么重要了。

复数个次要任务的调度

这没什么好说的,次要任务依然采用常见的优先级调度策略。

总结

异常任务与线程任务之间的调度策略

  • 线程任务切换进异常任务
    一定是由 NVIC 触发的自动切换。

  • 异常任务切换进线程任务
    一定是由 NVIC 触发的自动切换。

总结, NVIC 赛高。

线程任务内部的调度关系

由内核任务调度的主/被动切换。无论哪种切换都是由 PendSV Handler 作为手段而具体实现的上下文切换。

  • 主动切换时:

    1. 进程 0 主动释放处理器 (呼叫 SVC)
    2. 进入系统内核,内核处理,并尝试切换上下文 (PendSV 被挂起)
    3. (退出 SVC 同时进入 PendSV) 执行上下文切换
    4. (返回 Thread Mode) 开始执行任务 1
  • 被动切换时:

    1. SysTick 触发异常,调用上下文切换服务并尝试切换上下文 (PendSV 被挂起)
    2. (退出 SVC 同时进入 PendSV) 执行上下文切换
    3. (返回 Thread Mode) 开始执行任务 1

其他

操作系统内核所使用的 Cortex-M4 特性

  1. 双堆栈指针

    • 异常任务,内核任务使用 MSP 。
    • 重要任务,次要任务使用 PSP 。
  2. 浮点支持
    支持硬件浮点计算,同时使用 Cortex-M4 的 lazy stacking 策略。

附录

异常任务的设计要求

任何异常处理都应该尽可能的快,使处理器在 Handle Mode 的时间尽量短。