超级循环编程范式通常是嵌入式系统工程师最先接触到的编程方法之一。用超级循环实现的程序有一个单一的顶层循环,在系统需要执行的各种功能之间循环。这些简单的while循环很容易创建和理解(当它们很小的时候)。在FreeRTOS中,任务与超级循环非常相似--主要区别在于,系统可以有一个以上的任务,但只有一个超级循环。
在本章中,我们将仔细研究超级循环和用它们实现一定程度的并行性的不同方法。之后,将对超级循环和任务进行比较,并从理论上介绍任务执行的思维方式。最后,我们将看看任务是如何通过RTOS内核实际执行的,并比较两种基本的调度算法。
所有的嵌入式系统都有一个共同的特性--它们没有退出点。由于其性质,嵌入式代码通常被期望总是可用的--静静地在后台运行,处理内务工作,并随时准备接受用户的输入。与旨在启动和停止程序的桌面环境不同,如果微控制器退出main()函数,它就没有任何事情可做。如果发生这种情况,很可能是整个设备已经停止运作。由于这个原因,嵌入式系统中的main()函数从不返回。与应用程序不同的是,应用程序是由其主机操作系统启动和停止的,大多数基于嵌入式MCU的应用程序在上电时开始,在系统断电时突然结束。由于这种突然的关闭,嵌入式应用程序通常没有任何通常与应用程序相关的关闭任务,如释放内存和资源。
(相关资料图)
下面的代码代表了超级循环的基本思想:
void main ( void ){ while(1) { func1(); func2(); func3(); //do useful stuff, but don"t return //(otherwise, where would we go. . what would we do. . .?!) }}
虽然非常简单,但前面的代码有许多值得指出的特点。while循环从不返回--它一直在执行同样的三个函数(这是故意的)。这三个看似无害的函数调用可以在实时系统中隐藏一些令人讨厌的惊喜。
这个从不返回的主循环一般被称为超级循环。超级循环总是很有趣,因为它可以控制系统中的大多数事情--除非超级循环使之发生,否则下图中的任何事情都无法完成。这种类型的设置非常适合于非常简单的系统,只需要执行一些不需要花费大量时间的任务。基本的超级循环结构非常容易编写和理解;如果你想解决的问题可以用一个简单的超级循环来完成,那么就使用一个简单的超级循环。下面是前面介绍的代码的执行流程--每个函数都是按顺序调用的,而且循环永不退出:
当简单的超级循环快速运行时(通常是因为它们的功能/责任有限),它们的响应速度相当快。然而,超级循环的简单性可能是一种祝福,也是一种诅咒。由于每个函数总是跟在前面的函数后面,它们总是以相同的顺序被调用,并且完全相互依赖。一个函数引入的任何延迟都会传播到下一个函数,从而导致执行该循环迭代的总时间增加(如下图所示)。如果func1在循环中执行一次需要10毫秒,而下一次需要100毫秒,那么func2在循环中第二次被调用的时间就不会像第一次那样快:
让我们更深入地看一下这个问题。在上图中,func3负责检查一个代表外部事件的标志的状态(这个事件是一个信号的上升沿)。func3检查标志的频率取决于func1和func2执行的时间。一个设计良好、反应灵敏的超级循环通常会执行得非常快,检查事件的频率要比事件发生的频率高(呼出B)。当外部事件确实发生时,该循环直到func3的下一次执行才检测到该事件(呼出A、C和D)。注意,在事件产生和func3检测到它之间有一个延迟。还要注意的是,这个延迟并不总是一致的:这种时间上的差异被称为抖动。
在许多基于超级循环的系统中,与被轮询的缓慢发生的事件相比,超级循环的执行速度非常高。我们在页面上没有足够的空间来显示循环在检测到事件之间执行数百次(或数千次)的迭代!这就是所谓的抖动!
如果系统在响应事件时有一个已知的最大抖动量,它被认为是确定性的。也就是说,它将在事件发生后的指定时间内对事件作出可靠的反应。高水平的确定性对于实时系统中的时间关键型组件是至关重要的,因为如果没有它,系统可能无法及时响应重要的事件。
考虑到循环反复检查硬件标志的事件(这被称为轮询)。循环越紧密,标志被检查的速度就越快--当标志经常被检查时,代码将对感兴趣的事件做出更多的反应。如果我们有需要及时采取行动的事件,我们可以只写非常紧密的循环,等待重要事件的发生。这种方法是有效的--但前提是该事件是系统唯一感兴趣的事情。如果整个系统唯一的责任就是观察该事件(没有后台I/O、通信等),那么这是一个有效的方法。这种类型的情况在今天复杂的现实世界的系统中很少发生。响应性差是单纯基于轮询的系统的局限性。接下来,我们将看看如何在我们的超级循环中获得更多的并行性。
尽管基本的超级循环只能按顺序通过函数,但仍有办法实现并行化。单片机有一些不同类型的专用硬件,它们被设计用来减轻CPU的一些负担,同时还能实现高度响应的系统。本节将介绍这些系统以及如何在超级循环风格的程序中使用它们。
对单一事件进行轮询不仅在CPU周期和功率方面是浪费的--它还会导致系统对其他事物没有反应,这通常是应该避免的。那么,我们怎样才能让单核处理器并行地做事情呢?嗯,我们不能--毕竟只有一个处理器。...但由于我们的处理器很可能每秒运行数百万条指令,所以有可能让它执行足够接近于并行的事情。MCU还包括用于生成中断的专用硬件。中断向MCU提供信号,使其在事件发生时直接跳到中断服务程序(ISR interrupt service routine )。这是一个非常关键的功能,ARMCortex-M内核为其提供了一个标准化的外设,称为嵌套向量中断控制器(NVIC nested vector interrupt controller。NVIC提供了一种处理中断的通用方法。这个术语的嵌套部分标志着即使是中断也可以被其他具有更高优先级的中断打断。这相当方便,因为它允许我们将系统中时间最关键的部分的延迟和抖动量降到最低。
那么,中断如何融入超级循环,以更好地实现并行活动的假象?ISR内部的代码通常被保持得尽可能短,以尽量减少在中断中花费的时间。这一点很重要,有几个原因。如果中断发生得很频繁,而且ISR包含很多指令,那么ISR就有可能在被再次调用之前不返回。对于UART(universal asynchronous receiver / transmitter)或SPI(serial peripheral interface)等通信外设来说,这将意味着数据丢失(这显然是不可取的)。保持代码简短的另一原因是其他中断也需要得到服务,这就是为什么把任何责任推给不在ISR上下文中运行的代码是个好主意。
为了快速了解ISR是如何导致抖动的,让我们看看简单的例子:外部模数转换器(ADCanalogto digital converter )向MCU发出信号,表示已经采集了读数,转换结果准备传送给MCU(参考这里的硬件图):
在ADC硬件中,有一个引脚专门用来指示模拟值的读数已被转换为数字表示,并准备传输给MCU。然后,MCU将通过通信介质(图中的COM)启动传输。
接下来,让我们看看相对于转换准备线的上升沿,ISR调用如何随着时间的推移而相互叠加。下图显示了六个不同的ISR被调用以响应信号的上升沿的情况。硬件中的上升沿与固件中的ISR被调用之间的少量时间是最小延迟。ISR响应中的抖动是许多不同周期中延迟的差异:
有不同的方法来最小化关键ISR的延时和抖动。在基于ARM Cortex-M的MCU中,中断优先级是灵活的--在运行时可以为单个中断源分配不同的优先级。重新确定中断优先级的能力是确保系统中最重要的部分在需要时获得CPU的一种方式。
如前所述,保持在中断中执行的代码量尽可能短是很重要的,因为在ISR中的代码将优先于任何不在ISR中的代码(例如main())。此外,较低优先级的ISR不会被执行,直到较高优先级的ISR中的所有代码都被执行,并且ISR退出--这就是为什么保持ISR的简短是重要的。尝试限制ISR的责任(以及因此而产生的代码)总是好主意。
当多个中断被嵌套时,它们不会完全返回--实际上ARM Cortex M处理器有非常有用的功能,叫做中断-尾部链。如果处理器检测到一个中断即将退出,但另一个中断正在等待,那么下一个ISR将被执行,而处理器不会完全恢复中断前的状态,这进一步减少了延迟。
在ISR中实现最小指令和责任的一种方法是在ISR中做尽可能少的工作,然后设置标志,由超级循环中运行的代码来检查。这样一来,中断就可以尽快得到服务,而不需要整个系统都致力于等待该事件。在下图中,注意到在最后由func3处理之前,中断是如何被多次产生的。
根据该中断试图实现的具体目标,它通常会从相关的外设中获取一个值并将其推入数组(或从数组中获取值并将其送入外设寄存器)。在我们的外部ADC的情况下,ISR(每次ADC执行转换时触发)将出去到ADC,传输数字化的读数,并将其存储在RAM中,设置标志,表明一个或多个值已经准备好进行处理。这使得中断可以被多次服务,而不涉及高层代码:
在通信外设传输大块数据的情况下,可以用数组作为队列来存储要传输的项目。在整个传输结束时,可以设置标志来通知主循环的完成。有很多例子可以说明队列值是合适的情况。例如,如果需要对数据块进行一些处理,首先收集数据,然后在中断之外一起处理整个数据块,这往往是有利的。中断驱动的方法并不是实现这种阻断数据的唯一方法。
还记得处理器不可能真正做到并行的说法吗?这仍然是事实。然而......现代的MCU不仅仅包含一个处理核心。当我们的处理核心在处理指令时,还有许多其他硬件子系统在MCU内努力工作。这些努力工作的子系统之一被称为直接内存访问控制器(DMA Direct Memory Access Controller):
前面的图是非常简化的硬件框图,显示了从RAM到UART外设的两个不同的数据路径的视图。
在没有DMA的情况下,从UART接收字节流,来自UART的信息将进入UART寄存器,被CPU读取,然后推送到RAM进行存储:
CPU必须检测到何时收到单独的字节(或字),要么通过轮询UART寄存器标志,要么通过设置中断服务例程,当一个字节准备好时就启动。字节从UART传输后,CPU可以将其放入RAM进行进一步处理。步骤1和2重复进行,直到收到整个信息。当DMA被用在同样的场景中时,会发生以下情况:
CPU为传输配置DMA控制器和外围设备。DMA控制器负责UART外设和RAM之间的所有传输。这不需要CPU的干预。当整个传输完成时,CPU会得到通知,它可以直接处理整个字节流。大多数程序员发现DMA几乎是神奇的,如果他们习惯于处理超级循环和ISR。控制器被配置为在外设需要时向外设传输内存块,然后在传输完成时提供通知(通常是通过一个中断)--就是这样!这就是DMA!
当然,这种便利也是有代价的。最初设置DMA传输确实需要一些时间,所以对于小的传输,实际上可能比使用中断或轮询的方法要花费更多的CPU时间来设置传输。
还有一些需要注意的地方:每个MCU都有特定的限制,所以在指望DMA对系统的关键设计组件的可用性之前,一定要阅读数据手册、参考手册和勘误表的细节:
MCU内部总线的带宽限制了可以可靠地放在单一总线上的对带宽要求高的外设的数量。
偶尔,映射到外设的DMA通道的有限可用性也使设计过程复杂化。
这些类型的原因就是为什么要让所有的团队成员参与到嵌入式系统的早期设计中来,而不是直接把它扔到墙上。
DMA对于有效地访问大量的外设是很好的,使我们有能力为系统添加越来越多的功能。然而,当我们开始向超级循环添加越来越多的代码模块时,子系统之间的相互依赖关系也变得更加复杂。在下一节中,我们将讨论为复杂系统扩展超级循环的挑战。
现在已经有了能够可靠地处理中断的响应系统。也许我们已经配置了DMA控制器来处理通信外围设备的繁重工作。为什么我们需要实时操作系统?嗯,你完全有可能不需要! 如果系统只处理有限的任务,而且没有特别复杂或耗时的,那么可能就不需要比超级循环更复杂的东西。
但是,如果系统还要负责生成用户界面(UI),运行复杂的耗时的算法,或者处理复杂的通信栈,那么这些任务很可能要花费非同小可的时间。如果带有大量动画的华丽夺目的用户界面因为MCU正在处理从关键的传感器收集数据而开始有点结巴,那也没什么大不了的。要么动画可以回调,要么取消,而实时系统的重要部分则保持原样。但是,如果那个动画看起来仍然非常好,即使有一些来自传感器的数据被遗漏,又会发生什么呢?
在我们的行业中,这个问题每天都有各种不同的方式。有时,如果系统设计得足够好,丢失的数据会被检测到并被标记出来(但它不能被恢复:它永远消失了)。如果设计团队真的很幸运,它甚至可能在内部测试中以这种方式失败。然而,在许多情况下,遗漏的传感器数据将完全没有被注意到,直到有人注意到其中一个读数似乎有点偏离......有时。如果每个人都很幸运,关于粗略读数的错误报告可能包括一个提示,即它似乎只在有人在前面板上(玩那些花哨的动画)时发生。这至少会给被指派去调试这个问题的可怜的固件工程师一个提示--但我们往往甚至没有那么幸运。
这些是需要实时操作系统的系统类型。保证时间最紧迫的任务在必要时总是在运行,并在有空闲时间时将低优先级的任务调度到运行,这是抢占式调度器的一个强项。在这种类型的设置中,关键的传感器读数可以被推到他们自己的任务中,并分配一个高优先级--当需要处理传感器的时候,有效地中断了系统中的任何其他任务(除了ISR)。那个复杂的通信堆栈可以被分配一个比关键传感器更低的优先级。最后,具有花哨动画的华丽用户界面得到了剩余的处理器周期。它可以自由地执行任意多的滑动阿尔法混合动画,但只有在处理器没有其他更好的事情可做的时候。
到目前为止,我们只是非常随意地提到了任务,但任务到底是什么?思考任务的简单方法是,它只是另一个主循环。在一抢占式RTOS中,任务和超级循环之间有两个主要区别:
每个任务都收到它自己的私有堆栈。与共享系统堆栈的main中的超级循环不同,任务收到自己的堆栈,系统中的其他任务不会使用。这允许每个任务拥有自己的调用堆栈,而不干扰其他任务。
每个任务都有分配给它的优先级。这个优先级允许调度员决定哪个任务应该运行(目标是确保系统中最高优先级的任务总是在做有用的工作)。
考虑到这两个特点,每个任务都可以被编程,好像它是处理器唯一要做的事情。你有一个你想看的单一标志和一些计算的华美的动画要搅和吗?没问题:只需对任务进行编程,并给它分配合理的优先级,相对于系统的其他功能而言。抢占式调度器将始终确保最重要的任务在有工作要做时被执行。当一个较高优先级的任务不再有有用的工作要做,并且它正在等待系统中的其他东西时,较低优先级的任务将被切换到上下文并允许运行。
早些时候,我们看了在三个函数中循环的超级循环。现在让我们把这三个函数中的每一个移到自己的任务中。我们将用这三个简单的任务来研究以下内容:
理论上的任务编程模型: 如何在理论上描述这三个任务
实际的轮流调度: 任务在使用轮回调度算法执行时是什么样子的
实际的抢占式调度: 使用抢占式调度执行的任务是什么样子的?
在现实世界的程序中,每个任务几乎都没有单一的函数;我们只是把它作为类似于前面的过于简单的超级循环的例子。
下面是使用超级循环来执行三个函数的伪代码。同样的三个函数也包含在基于任务的系统中--每个RTOS任务(在右边)包含与左边的超级循环的函数相同的功能。当我们讨论使用超级循环与使用调度器的任务驱动方法时,代码执行方式的差异时,这点将被继续使用:
你可能会注意到超级循环实现和RTOS实现之间的直接区别是无限的while循环的数量。在超级循环的实现中,只有一个无限的while循环(在main()中),但是每个任务都有自己的无限的while循环。
在超级循环中,在调用下一个函数之前,被超级循环执行的三个函数分别运行到完成,然后循环继续到下一个迭代(如下图所示):
在RTOS的实现中,每个任务本质上是它自己的小的无限的while循环。超级循环中的函数总是一个接一个地被调用(由超级循环中的逻辑协调),而任务可以简单地被认为是在调度器启动后的所有并行执行。下面是一个执行三个任务的实时操作系统的图示:
在图中,你会注意到每个while循环的大小是不一样的。这是使用并行执行任务的调度器相对于超级循环的许多好处之一--程序员不需要立即关注最长的执行循环的长度会拖累其他更紧密的循环。图中描述了任务2的循环比任务1长很多。在超级循环系统中,这将导致func1的功能执行频率降低(因为超级循环需要先执行func1,然后是func2,最后是func3)。在基于任务的编程模型中,情况并非如此--每个任务的循环可以被认为是与系统中的其他任务隔离的--而且它们都是并行运行的。
这种隔离和感知的并行执行是使用实时操作系统的一些好处;它为程序员减轻了一些复杂性。所以,这是概念化任务的最简单的方法--它们只是独立的无限的while循环,都是平行执行的......在理论上。在现实中,事情并没有这么简单。在接下来的两节中,我们将瞥见幕后发生的事情,使其看起来像是任务在并行执行。
概念化实际任务执行的最简单方法之一是轮流调度。在轮流调度中,每个任务得到一小块时间来使用处理器,这是由调度器控制的。只要任务有工作要执行,它就会执行。就该任务而言,它完全拥有自己的处理器。调度器负责处理为下一个任务切换适当上下文的所有复杂问题:
这和之前展示的三个任务是一样的,只不过不是理论上的概念化,而是通过任务的循环的每一次迭代都是随着时间的推移而列举的。因为循环调度器给每个任务分配了相等的时间片,最短的任务(任务1)已经执行了将近六次循环,而最慢的循环任务(任务2)只完成了第一次循环。任务3已经执行了三次循环。
执行相同函数的超级循环与执行这些函数的轮回调度例程之间的一个极其重要的区别是这样的: 任务3在任务2之前完成了其适度紧密的循环。当超级循环以串行方式运行函数时,函数3甚至不会开始,直到函数2运行完成。所以,虽然调度器没有为我们提供真正的并行性,但每个任务都得到了它公平的CPU周期份额。所以,在这种调度方案下,如果任务有较短的循环,它将比有较长循环的任务执行得更频繁。
所有这些切换都有一个(轻微的)代价--调度器需要在任何有上下文切换的时候被调用。在这个例子中,任务没有明确地调用调度器来运行。在FreeRTOS运行在ARM Cortex-M上的情况下,调度器将被从SysTick中断中调用(更多细节可在第7章FreeRTOS调度器中找到)。为了确保调度器内核非常有效,并尽可能地减少运行时间,我们付出了相当多的努力。然而,事实是,它将在某些时候运行并消耗CPU周期。在大多数系统中,少量的开销通常不会被注意到(或显著),但在某些系统中,它可能成为问题。例如,如果一个设计处于可行性的极端边缘,因为它有非常严格的时间要求和非常少的空闲CPU周期,如果超级循环/中断方法已经被仔细地描述和优化,那么增加的开销可能是不可取的(或者完全有必要)。然而,最好是尽可能地避免这种类型的情况,因为即使是在中等复杂的系统中,忽视中断堆积(或嵌套条件语句偶尔需要更长的时间)并导致系统错过最后期限的可能性也是非常大的。
抢占式调度提供了一种机制,以确保系统总是在执行其最重要的任务。抢占式调度算法将优先考虑最重要的任务,不管系统中还有什么事情发生--除了中断,因为它们发生在调度器下面,总是有更高的优先级。这听起来非常直接--而且确实如此--只是有一些细节需要考虑到。
让我们看一下同样的三个任务。这三个任务都有相同的功能:简单的while循环,无休止地增加不稳定的变量。
现在,考虑以下三种情况,看看这三个任务中哪个会得到上下文。下图中的任务与之前介绍的轮流调度的任务相同。三个任务中都有足够多的工作要做,这将防止任务脱离上下文:
那么,当三个不同的任务被设置为三组不同的优先级(A、B、C)时会发生什么?
A(左上角): 任务1在系统中拥有最高的优先级--它获得了所有的处理器时间 不管任务1执行了多少次迭代,如果它是系统中优先级最高的任务,并且它有工作要做(不需要等待系统中的其他东西),它将被赋予上下文并运行。
B(右上方): 任务2是系统中优先级最高的任务。由于它有足够多的工作要做,不需要等待系统中的其他东西,任务2将被赋予上下文。由于任务2被配置为系统中的最高优先级,它将执行,直到它需要在系统中等待其他东西。
C(左下角): 任务3被配置为系统中的最高优先级任务。没有其他任务运行,因为它们的优先级较低。
现在,很明显,如果你真的在设计一个需要多个任务并行运行的系统,如果系统中所有的任务都需要100%的CPU时间,并且不需要等待任何东西,那么抢占式调度器就没有什么用了。这种设置对于实时系统来说也不是很好的设计,因为它完全超载了(而且忽略了系统所要执行的三个主要功能中的两个)!这种情况被称为 "任务"!所呈现的情况被称为任务饥饿,因为只有系统中优先级最高的任务获得了CPU时间,而其他任务则被剥夺了处理器时间。
另一个值得指出的细节是,调度器仍然以预定的时间间隔运行。无论系统中发生了什么,调度器都会勤奋地按照预定的时间间隔运行。
这有一个例外。FreeRTOS有无滴答的调度器模式,旨在用于极低功率的设备,它可以防止调度器在相同的预定间隔内运行。
这里显示了一个使用抢占式调度器的更现实的用例:
在这种情况下,任务1是系统中优先级最高的任务(它也恰好很快完成执行)--任务1只有在调度器需要运行的时候才会被剥夺上下文;否则,它将保持上下文直到没有任何额外的工作要执行。
任务2是下一个最高优先级的任务--你也会注意到,这个任务被设置为在每个RTOS调度器的勾选中执行一次(由向下的箭头表示)。任务3是系统中优先级最低的任务:只有当系统中没有其他值得做的事情时,它才会得到上下文。在这张图中,有三个要点值得关注:
A:任务2有上下文。即使它被调度器打断了,但在调度器运行后,它又立即得到了上下文(因为它还有工作要做)。
B: 任务2已经完成了迭代0的工作,调度器已经运行并确定(因为系统中没有其他任务需要运行)任务3可以拥有处理器时间。
C:任务2已经开始运行迭代4,但是任务1现在有一些工作要做--尽管任务2还没有完成该迭代的工作。任务1立即被调度器切换到执行其更高优先级的工作。在任务1完成了它需要做的事情后,任务2被切换回来完成迭代4。这一次,迭代运行到下一个tick,任务2再次运行(迭代5)。在任务2迭代5完成后,没有更高优先级的工作要执行,所以系统中最低优先级的任务(任务3)再次运行。看起来任务3终于完成了迭代0,所以它进入了迭代1并继续运行......。
希望你还在听我说! 如果没有,那也没关系,因为这只是一个非常抽象的例子。关键的启示是,系统中优先级最高的任务是优先的。
超级循环对于责任有限的简单系统是非常好的。如果系统足够简单,它们可以在响应事件时提供非常低的抖动,但前提是循环足够紧密。随着系统越来越复杂,获得更多的责任,轮询率会下降。这种轮询率的降低导致对事件的响应抖动大得多。中断可以被引入到系统中,以应对抖动的增加。随着基于超级循环的系统变得越来越复杂,跟踪和保证对事件的反应能力变得越来越难。
对于那些不仅有耗时的任务,而且还需要对外部事件有良好的响应能力的更复杂的系统,实时操作系统变得非常有价值。对于实时操作系统来说,系统复杂性、ROM、RAM和初始设置时间的增加是为了换取一个更容易理解的系统,它可以更容易地保证对外部事件的及时响应。
标签: