详解基于 Cortex-M3 的任务调度(上)

Posted 车子 chezi

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了详解基于 Cortex-M3 的任务调度(上)相关的知识,希望对你有一定的参考价值。

什么是任务

对于嵌入式 RTOS,我觉得任务(task) 其实是线程。为什么这样说呢?首先,有几个知识点要明确:

  1. 进程是资源分配的最小单位,线程是 CPU 调度的最小单位
  2. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。
  3. 资源分配给进程,同一进程的所有线程共享该进程的所有资源。
  4. 处理器分给线程,即真正在处理机上运行的是线程。

其次,每个进程都有其自己的存储空间,它们是互相隔离的。比如你在浏览网页,浏览器崩溃了,但并不会影响到音乐播放器。但是对于线程来说,资源是共享的,所以对于共享资源的访问就会存在竞争问题,于是就产生临界区,互斥、信号量等概念。如果一个线程崩溃了,极大可能会影响到该进程中的其他线程。

对于 MCU 上的资源,每个任务都是共享的,可以认为是单进程多线程模型。MCU一般没有内存管理模块,这样无法很好地保证进程的安全,这也是当某个任务跑飞会导致整个程序崩溃的原因。

通常认为,嵌入式系统在运行时只有一个进程,而把这个进程进行分解之后的那些程序模块,由于没有独立的内存空间,实质上就是线程。在 μC/OS-II 中,把这样的线程叫做任务。

直观上来讲,

任务及其内存结构

既然是任务调度,那就需要系统把任务管理起来,系统管理任务的数据结构叫做任务控制块(TCB)

以 μC/OS-II 为例,任务控制块记录一个任务的各个属性,相当于是任务的身份证。TCB 中有两个指针特别重要:

  • 指向任务的指针:在任务初始化的时候,这个指针指向任务的代码入口
  • 指向任务堆栈的指针:指向任务的栈。每个任务都有自己的栈,用来保存局部变量,还有寄存器的快照。在这些寄存器中,最重要的就是 PC,指向任务当前运行的代码

系统中一般会有多个任务,这些任务的 TCB 用链表串起来,也可以用数组。

上下文切换

要进行任务调度,就要进行上下文切换。

先看看线程是如何对付中断的。当线程在执行时,所做的事情就是从存储器中取指令、译码、执行。在整个过程中,CPU 里寄存器的值会不断更新。此时如果一个中断来了,那么 CPU 就要把核心寄存器的值先保存到内存的某个地方(比如这个线程的栈),然后响应中断。等中断执行完了,再把刚才保存的值加载到对应的寄存器,从刚才中断的地方继续执行(由程序计数器 PC 记录)。

任务切换也是这个道理,如果要把当前任务 A 换出,就要先找到 A 任务的栈,把当前的寄存器信息保存到栈上,然后找出要换入的任务 B,再找到 B 任务的栈,把栈上保存的寄存器值恢复到寄存器里,最后让 B 开始运行。

前面做了很多铺垫,接下来我们就结合具体的一款 CPU 来讲任务调度。

CM3 的寄存器组

注意,在 CM3 处理器内核中共有两个堆栈指针,于是也就支持两个堆栈。当引用 R13(或写作 SP)时,引用到的是当前正在使用的那一个,另一个必须用特殊的指令来访问(MRS,MSR指令)。这两个堆栈指针分别是:

  • 主堆栈指针(MSP), 或写作 SP_main。这是缺省的堆栈指针,它由 OS 内核、异常服务例程以及所有需要特权访问的应用程序代码来使用。
  • 进程堆栈指针(PSP), 或写作 SP_process。用于常规的应用程序代码(不处于异常服用例程中时)。

要注意的是,并不是每个程序都要用齐两个堆栈指针才算圆满。简单的应用程序只使用 MSP 就够了。

在本文的示例代码中,采用了双栈。

CM3 的 CONTROL 寄存器

复位后,CONTROL[0]=0 ,也就是说线程模式处于特权级。

Cortex-M3 处理器支持两种处理器的操作模式,还支持两级特权操作。
两种操作模式分别为:handler 模式和线程模式(thread mode)。引入两个模式的本意,是用于区别普通应用程序的代码和异常服务例程的代码。

两级特权分别是:特权级和用户级。这可以提供一种存储器访问的保护机制,使得普通的用户程序代码不能意外地、甚至是恶意地执行涉及到要害的操作。

示例代码中有一句:

__set_CONTROL(0x3); // Switch to use Process Stack, unprivileged state

意思是强行切换到用户级,且用 PSP(后面马上就说)

双栈

我们已经知道了 CM3 的堆栈有两个:主栈和进程栈,CONTROL[1] 决定如何选择。

当 CONTROL[1]=0 时,只使用 MSP,此时用户程序和异常 handler 共享同一个栈,这也是复位后的缺省使用方式

我们的示例代码采用了双栈。

当 CONTROL[1]=1 时,线程模式将不再使用 MSP,而改用 PSP(注意:handler 模式永远使用 MSP)。这样做的好处在哪里?原来,在使用 OS 的环境下,我们想让 OS 内核仅在 handler 模式下执行,用户程序仅在用户模式下执行,这种双栈机制的好处是:万一用户栈崩溃了,并不会累及 OS 的栈。

在双栈模式下,进入异常时的自动压栈使用的是进程栈,进入异常后会自动改为 MSP,退出异常时切换回 PSP,并且从进程栈上弹出数据。 如下图所示:

CM3 的中断

任务切换一般是在中断中进行的,所以了解 CPU 的中断过程非常必要。

当 CM3 开始响应一个中断时,会在它小小的体内奔涌起三股暗流:

  • 入栈: 把 8 个寄存器的值压入栈
  • 取向量:从向量表中找出对应服务程序的入口地址
  • 更新寄存器:选择堆栈指针 MSP/PSP,更新堆栈指针 SP,更新连接寄存器 LR,更新程序计数器 PC

好,我们一个一个来说。

入栈

自动入栈的寄存器有 8 个,见表 9.1:

取向量

当数据总线(系统总线)正在为入栈操作而忙得风风火火时,指令总线(I-Code)可不是袖手旁观——它正在为响应中断紧张有序地执行另一项重要的任务:从向量表中找出正确的异常向量,然后在服务程序的入口处预取指。由此可以看到各自都有专用总线的好处:入栈与取指这两个工作能同时进行。

更新寄存器

在入栈和取向量操作完成之后,执行服务例程之前,还要更新一系列的寄存器:

  • SP:在入栈后会把堆栈指针(PSP 或 MSP)更新到新的位置。在执行服务例程时,将由 MSP 负责对堆栈的访问。
  • PSR:更新 IPSR 位段的值为新响应的异常编号。
  • PC:在取向量完成后,PC 将指向服务例程的入口地址。
  • LR:在出入 ISR 的时候,LR 的值将有新意义,这种特殊的值称为“EXC_RETURN”,在异常进入时由系统计算并赋给 LR,并在异常返回时使用它。EXC_RETURN 的值除了最低 4 位外全为 1,而其最低4位则有另外的含义(见表9.3和表9.4)。

以上是在响应异常时通用寄存器的变化。另一方面,在 NVIC 中,也会更新若干个相关寄存器。例如,新响应异常的悬起位将被清除,同时其活动位将被置位。

异常返回

当异常服务例程执行完毕后,需要很正式地做一个“异常返回”的动作序列,从而恢复先前的系统状态,才能使被中断的程序得以继续执行。从形式上看,有 3 种途径可以触发异常返回序列,如表 9.2 所示。而不管使用哪一种,都需要用到先前储到 LR 的 EXC_RETURN。

在示例代码中,使用的是第一个方法:

BX LR // Return

在启动了中断返回序列后,下述的处理就将进行:

  1. 出栈:先前压入栈中的寄存器值恢复到对应的寄存器,出栈顺序与入栈时相对应,堆栈指针也改回先前的值。
  2. 更新 NVIC 寄存器:伴随着异常的返回,它的活动位也被硬件清除。对于外部中断,倘若中断输入再次被置为有效,悬起位也将再次置位,新一次的中断响应也可再次开始。

切换的时机

已经说了,任务切换在中断中进行,但是在哪个中断呢?

例如,一个系统中有两个任务,上下文切换被触发的场合可以是:

  • 执行一个系统调用(SVC 异常)
  • 系统滴答定时器(SYSTICK)中断,(轮转调度中需要)

让我们举个简单的例子。假设有这么一个系统,里面有两个就绪的任务,并且通过 SysTick 异常启动上下文切换。如图 7.15 所示。

上图是两个任务轮转调度的示意图。但若在产生 SysTick 异常时正在响应一个中断,则 SysTick 异常会抢占其 ISR。在这种情况下,OS 是不能执行上下文切换的,否则将使中断请求被延迟,而且在真实系统中延迟时间还往往不可预知——任何有一丁点实时要求的系统都决不能容忍这种事。因此,在 CM3 中也是严禁没商量——如果 OS 在某中断活跃时尝试切入线程模式,将触发用法 fault 异常(但是有例外情况,感兴趣的读者可以看本文末尾的“非基级线程模式”)。

为解决此问题,早期的 OS 大多会检测当前是否有中断在活跃中,只有在无任何中断需要响应时,才执行上下文切换(切换期间无法响应中断)。然而,这种方法的弊端在于,它可以把任务切换动作拖延很久(因为如果抢占了 IRQ,则本次 SysTick 在执行后不得作上下文切换,只能等待下一次 SysTick 异常),尤其是当某中断源的频率和 SysTick 异常的频率比较接近时,会发生“共振”,使上下文切换迟迟不能进行。
现在好了,有 PendSV 来完美解决这个问题。PendSV 异常会自动延迟上下文切换的请求。为实现这个机制,需要把 PendSV 编程为最低优先级的异常。若 OS 需要执行上下文切换,它将悬起一个 PendSV 异常,并在 PendSV 异常内执行上下文切换。如图 7.17 所示

解释:

  1. 任务 A 呼叫 SVC 来请求任务切换(例如,等待某些工作完成)
  2. OS 接收到请求,做好上下文切换的准备,并且悬起一个 PendSV 异常。
  3. 当 CPU 退出 SVC 后,它立即进入 PendSV,从而执行上下文切换。
  4. 当 PendSV 执行完毕后,将返回到任务 B,同时进入线程模式。
  5. 发生了一个中断,并且中断服务程序开始执行
  6. 在 ISR 执行过程中,发生 SysTick 异常,并且抢占了该 ISR。
  7. OS 执行必要的操作,然后悬起 PendSV 异常以作好上下文切换的准备。
  8. 当 SysTick 退出后,回到先前被抢占的 ISR 中,ISR 继续执行
  9. ISR 执行完毕并退出后,PendSV 服务例程开始执行,并且在里面执行上下文切换
  10. 当 PendSV 执行完毕后,回到任务 A,同时系统再次进入线程模式。

遗留问题:在调试的时候,我认为 SysTick 异常返回后会立刻进入 PendSV 服务例程,应该看到 Tail chaining 现象,但测试结果是 SysTick 中断处理后返回到了任务 B,执行了一点点,马上进入 PendSV

切换

具体的切换如下图所示。

我给出的解释:

  1. Task A 正在执行的时候,发生了 PendSV 异常
  2. xPSR, PC, LR, R12以及R3-R0由硬件自动入栈(注意:发生异常的时候,当前代码使用哪个栈,就压入哪个栈,图中显示使用的是 PSP。一旦进入服务例程,就会使用 MSP)
  3. 手动保存 R4-R11 到 A 的栈
  4. 更新 A 的栈指针到 PSP array[]
  5. 从 PSP array[] 中找到 Task B 的栈指针
  6. 根据 Task B 的栈指针,找到 Task B 的栈,手动出栈 R4-R11 的值到寄存器
  7. 从 PendSV 异常返回,xPSR, PC, LR, R12以及R3-R0由硬件自动出栈
  8. Task B 开始执行

非基级线程模式(补充材料)

在 CM3 中,原则上异常服务程序要在 handler 模式下执行,但是也允许在服务例程中切换到线程模式。通过设置 NVIC 配置与控制寄存器的“非基级线程模式允许”位(NONBASETHRDENA,位偏移:0),可以在服务例程中把处理器切换入线程模式。为什么要这么做?如果中断服务例程是用户程序的一部分,可能需要让它在线程模式下执行,以限制它访问特权级下的资源,此时可以让此功能派上用场。

如果使用此功能,则需要手工调整堆栈指针,还要重建堆栈中的数据。这种乾坤大挪移可是高度危险的作业,一不小心就很容易把整个系统弄垮。所以必须格外严肃地对待。另外,在使用时,系统设计者还必须保证服务例程能正确地返回。因为在线程模式下是不允许作中断返回的,所以必须用一点手腕才行。如果放任不管,则中断无法退出,这会永远阻塞其它同级和更低优先级中断。通常,由系统软件负责完成这种工作。

此节内容和本文主旨无关,所以仅放一个图片在这里,提示读者“居然可以如此操作”!

代码

囿于篇幅,代码下一篇博文再讲。

欢迎读者批评指正。


参考资料

【0】RTOS中的任务是线程、进程、还是协程?-面包板社区

【1】任务、进程和线程的区别(转自博客园) - 雷明 - 博客园

【2】《Cortex-M3 权威指南 》

【3】《The Definitive Guide to ARM Cortex-M3 and Cortex-M4 Processors(Third Edition)》

以上是关于详解基于 Cortex-M3 的任务调度(上)的主要内容,如果未能解决你的问题,请参考以下文章

详解基于 Cortex-M3 的任务调度(上)

详解基于 Cortex-M3 的任务调度(上)

3天掌握Spark--内核调度详解

《基于Cortex-M4的ucOS-III的应用》课程设计 结题报告

详解BI系统中的任务调度

详解BI系统中的任务调度