如何在微控制器中实现多任务处理?

16

我使用嵌入式(C)编写了一个利用8051微控制器的手表程序。总共有6个七段显示器,如下所示:

         _______________________
        |      |       |        |   two 7-segments for showing HOURS
        | HR   | MIN   |   SEC  |   two 7-segments for showing MINUTES and
        |______._______.________|   two 7-segments for showing SECONDS
          7-segment LED display

为了更新小时,分钟和秒钟,我们使用了3个 for 循环。这意味着首先秒会更新,然后是分钟,最后是小时。然后我问我的教授为什么不能同时更新(我的意思是在等待分钟更新的情况下增加小时)。他告诉我,由于指令的顺序执行,我们无法进行并行处理。

问题:

数字生日贺卡会在闪烁LED的同时持续播放音乐。数字闹钟会在特定时间发出蜂鸣声。在发出声音的同时,时间将继续更新。因此,声音和时间增量都在并行运行。他们如何通过顺序执行实现这些结果?

如何在微控制器中同时运行多个任务(调度)?


2
你了解多任务和并行的区别吗?en.wikipedia.org/wiki/Computer_multitasking。真正的并行意味着你有几个CPU。多任务意味着单个(但不一定)CPU可以快速切换不同的任务。因此,您可以在任何系统上实现多任务(某些地方更容易,某些地方更难)。 - btolfa
现在,我的问题有效吗?@btolfa - gangadhars
有人可以审核一下建议的编辑队列吗?我相信它会让这个问题更清晰明了。 - ouflak
在主循环中,如果设置了特定的函数标志,则执行该函数并重置标志。从函数返回时,重新启动标志检查。按照运行最频繁到最不频繁的顺序检查标志。在后台函数中(当没有其他函数运行时),在关键代码段中启用/禁用中断,特别是对于位操作。初始化函数在复位时运行,以设置I/O并执行上电自检。 - user3629249
在您的项目中,没有必要运行非常快,因此应该有足够的时间来执行每个主要功能。例如:闪烁LED,每次执行只有一个状态改变,更新7段显示器。后台函数通常只执行BIT,并且在禁用中断时应仅执行单个BIT函数(例如测试一字节RAM)。这将产生实时性能,但不是“硬”实时性能。对于您的应用程序来说,这应该是完全满意的。 - user3629249
显示剩余2条评论
6个回答

30
首先,让我们来谈一下“顺序执行”是什么意思。这个系统只有一个内核、一个程序空间和一个计数器。MPU逐条执行指令,按照顺序移向另一个指令。在这个系统中,没有固有的机制来使它停止做某件事情然后开始做另一件事情 - 所有都在同一个程序中,完全由程序员决定顺序和操作;只要MPU在运行,它将不间断地按顺序逐条执行指令,除非程序员首先让其他事情发生。
现在,关于多任务:
通常,操作系统提供多任务处理,使用相当复杂的调度算法。
通常而言,微控制器在没有操作系统的情况下运行。
那么,在微控制器中如何实现多任务处理呢?
简单的答案是“你不能”。但通常,简单的答案很少涵盖超过5%的情况……
编写真正的抢占式多任务处理将会非常困难。大多数微控制器都没有这样的设施,而Intel CPU可以用几个特定的指令完成一些事情,而你则需要编写数英里长的代码。如果你真的没有更好的时间安排,最好忘记经典的微控制器多任务处理。
现在,有两种常用的方法,可以更轻松地实现多任务处理。

中断

大多数微控制器都有不同的中断源,通常包括定时器。因此,主循环持续运行一个任务,当计时器计数到零时,就会发生中断。主循环停止,执行跳转到称为“中断向量”的地址。在那里,会启动不同的过程,执行不同的一次性任务。一旦完成(可能需要重置计时器),就从中断返回并恢复主循环。
微控制器通常有几个计时器,你可以为每个计时器分配一个任务,更不用说其他外部中断的任务(例如键盘输入 - 按下键或通过RS232接收数据)。

虽然这种方法非常有限,但对于大多数情况来说已经足够了;尤其是你的情况:设置定时器以循环1秒,在中断上计算新的小时数、更改显示,然后离开中断。在主循环中等待日期达到生日,并在达到时开始播放音乐和闪烁LED。

合作式多任务处理

这就是早期的处理方式。您需要将“任务”编写为子程序,每个子程序内部都有一个有限状态机(或一个单程循环),并且“操作系统”是一组连续任务的简单跳转循环。

在每次跳转之后,MPU开始执行给定任务,并将继续执行直到任务返回控制权,首先保存其状态以便在下次启动时恢复。每个任务工作的每个阶段应该非常短。任何延迟循环都必须替换为有限状态引擎中的等待状态(如果条件不满足,则返回;如果满足,则更改状态)。所有较长的循环都必须展开为不同的状态(“状态:正在复制数据块,复制字节N,增加N,N=结束?是:下一个状态,否:返回控制权”)

用这种方式编写更加困难,但解决方案更加稳健。在您的情况下,您可能有四个任务:

  • 时钟
  • 显示更新
  • 播放声音
  • 闪烁LED

时钟如果没有新秒到达就返回控制权。如果到了,重新计算秒数、分钟数、小时数、日期,然后返回。

显示更新显示的值。如果您在8段显示器上复用数字,则每次更新一个数字,接下来是下一个数字等。

播放声音将在不是生日时等待(产生)。如果是生日,则从内存中选择样本值,将其输出到扬声器,产生。如有需要,也可以提前调用以输出下一个声音。

闪烁 - 好吧,将正确的状态输出到LED,产生。

非常短的循环 - 比如说,5行10次的循环 - 仍然是允许的,但任何更长的循环都应该转换为进程所处的有限状态引擎状态。

现在,如果你感觉自己很强硬,可以尝试去执行...

抢占式多任务处理。

每个任务都是一个过程,通常会无限地执行自己的事情。正常编写,尽量不干扰其他过程的内存,但使用资源时就像没有其他可能需要它们的东西一样。

您的操作系统任务从定时器中断启动。

在被中断启动后,操作系统任务必须保存上个任务的所有当前易失性状态 - 寄存器、中断返回地址(从中恢复任务)、当前堆栈指针,将其记录在该任务的记录中。

然后,使用调度算法,从列表中选择另一个应该立即开始的进程;恢复它的所有状态,然后用该进程中断前离开的地址覆盖自己的中断返回地址。在结束中断时,恢复抢占的进程的正常运行,直到另一个中断将控制权切换回操作系统。

正如你所看到的,有很多开销,需要保存和恢复程序的完整状态,而不仅仅是任务当前所需的内容,但程序不需要编写成有限状态机 - 正常的顺序样式就够了。


13

虽然SF提供了优秀的多任务概述,但大多数微控制器还具有一些额外的硬件,使它们能够同时执行多个任务。

并行执行的错觉 - 从技术上讲,您的教授是正确的,无法实现同时更新。但是,处理器非常快。对于许多任务,它们可以顺序执行,例如依次更新每个7段显示器,但执行速度非常快,以至于人类感知不到每个显示器都是顺序更新的。声音也是如此。大多数可听声音在千赫兹范围内,而处理器运行在兆赫范围内。处理器有足够的时间播放部分声音,做其他事情,然后返回播放声音而您的耳朵无法检测到差异。

中断 - SF很好地涵盖了中断的执行,因此我将简要介绍机制并更多地讨论硬件。大多数微控制器都有小型硬件模块,可以与指令执行同时操作。计时器、UART和SPI是常见的模块,它们在处理器的主要部分执行指令时执行特定的动作。当给定模块完成其任务时,它会通知处理器,并且处理器会跳转到模块的中断代码。这种机制允许您在执行指令时执行诸如通过UART传输字节(相对较慢)之类的任务。

PWM - PWM(脉宽调制)是一种硬件模块,基本上生成两个方波,但方波不必相等(此处进行了简化)。一个可以比另一个更长,或者它们可能是相同大小的。您可以在硬件中配置方块的大小,然后PWM将持续生成它们。该模块可用于驱动电机或甚至生成声音,其中电机的速度或声音的频率取决于两个方块的比率。为了播放音乐,处理器只需要在更换音符的时间更改比率(可能基于定时器中断),同时可以执行其他指令。

DMA - DMA(直接内存访问)是一种特定类型的硬件,它自动将字节从一个内存位置复制到另一个内存位置。例如ADC可能会连续将转换后的值写入内存中的某个寄存器。DMA控制器可以被配置为从一个地址(如ADC输出)连续读取,同时顺序地写入内存范围(如用于接收多个ADC转换结果并计算平均值的缓冲区)。所有这些都在硬件层面完成,而主处理器则执行指令。

定时器、UART、SPI、ADC等 - 还有许多其他硬件模块(此处无法全部列举)可以与程序执行同时执行特定任务。

TL/DR - 尽管程序指令只能按顺序执行,但处理器通常可以快速执行它们,以至于它们看起来是同时发生的。同时,大多数微控制器还具有额外的硬件,可以同时执行特定任务与程序执行。


6

ZackSF.的回答很好地涵盖了大局。但有时候一个工作示例非常有价值。

虽然我可以轻率地建议浏览Linux内核源代码套件(它是开源的,并且即使在单核机器上也提供多任务处理),但这并不是实际实现调度程序的最佳起点。

一个更好的起点是从数百个(如果不是数千个)实时操作系统的源代码套件开始。其中许多是开源的,并且大多数都可以运行在极小的处理器上,包括8051。我将在此详细介绍Micrium的uC/OS-II,因为它具有典型的功能集,而且我已经广泛使用过。我过去评估过的其他操作系统包括OS-9、eCos和FreeRTOS。通过这些名称以及"RTOS"等关键字作为起点,Google会给你提供许多其他操作系统的名称。

我首先考虑的实时操作系统内核是uC/OS-II(或者它的较新的家庭成员uC/OS-III)。这是一个商业产品,起源于《嵌入式系统设计》杂志的读者教育练习。杂志文章及其附带的源代码成为该领域最好的书籍之一的主题内容。该操作系统是开源的,但在商业使用方面存在许可限制。为了公开披露,我是将uC/OS-II移植到ColdFire MCF5307上的作者。

由于它最初是作为教育工具编写的,因此源代码有很好的文档。教材(至少是我这里放置的第2版)也写得很好,并对其支持的每个功能进行了大量的理论背景介绍。

我在几个产品开发项目中成功地使用它,并会再次考虑它用于需要多任务处理但不需要承载类似Linux的完整操作系统权重的项目。

uC/OS-II提供了抢占式任务调度程序,以及有用的一组任务间通信原语(信号量、互斥量、邮箱、消息队列)、定时器和线程安全的池化内存分配器。

它还支持任务优先级,并包括死锁预防(如果正确使用)。

它完全使用标准C子集编写(几乎满足MISRA-C:1998指南的所有要求),这有助于使其能够获得各种安全性认证。

虽然我开发的应用程序从未在安全关键系统中使用,但知道我所依赖的操作系统内核已经达到了这些评级是令人欣慰的。它提供了保证,使我相信我遇到的错误最有可能是对基本功能的误解,或者更有可能是我的应用逻辑中实际存在的错误。

大多数实时操作系统(RTOS)(特别是uC/OS-II)都能够在有限的资源下运行。uC/OS-II可以构建为仅6KB的代码,并且只需要1KB的RAM来存储操作系统结构。

总之,显式并发可以通过多种方式实现,其中一种方式是使用专为调度和执行每个并发任务而设计的操作系统内核,通过在所有任务之间共享顺序CPU的资源来并行运行这些任务。对于简单情况,您可能只需要中断处理程序和一个主循环,但当您的需求增长到实现几个协议、管理显示、管理用户输入、后台计算和监视整个系统健康状况时,倚靠一个设计良好的RTOS内核以及已知的通信基元,可以节省大量开发和调试工作。


6
好的,我看到其他答案已经涵盖了很多内容;希望我不会把这变成比我意图更大的事情。(TL;DR: 女孩来拯救! :D)但是,我有一个(我认为是)非常好的解决方案可以提供;所以我希望您能好好利用它。我只有一点8051[☆]的经验;尽管我曾在另一款微控制器上工作了约3个月(加上约3个全职),取得了一定的成功。在这个过程中,我几乎做了所有小东西要提供的东西:串行通信、SPI、PWM信号、伺服控制、DIO、热电偶等等。当我在工作时,我走运地遇到了一个优秀(依我之见)的解决方案,用于(协作式)“线程”调度,这与从PIC中断中进行的一些额外的实时处理混合得很好。当然,还有其他设备的中断处理程序。 pt_thread:由Adam Dunkels(与Oliver Schmidt)发明(v1.0于2005年2月发布),他的网站是它们的一个很好的介绍,并包括从2006年10月开始的v1.4下载;我很高兴再次去看,因为我发现;但是有一个来自2009年1月的项目说明,Larry Ruane使用了事件驱动技术“[进行]完整的重新实现[使用GCC;并且具有]非常好的语法”,并在sourceforge上提供。不幸的是,自2009年左右以来,似乎没有对任何一个版本进行更新;但是2006年的版本为我服务得非常好。最后一条新闻(来自2009年12月)指出,“索尼克:失控”在其手册中指出使用了protothreads!
我认为pt_threads的一件令人惊叹的事情是它们非常简单;而且,无论新版本(Ruane)的好处如何,它肯定更复杂。虽然可能值得一看,但我要在这里坚持Dunkels的原始实现。他的原始pt_threads“库”包括:五个头文件。而且,真的,这似乎是一个夸张,因为一旦我缩小了一些宏和其他东西,删除了doxygen部分、示例,并将注释减少到我仍然觉得给出了解释的最少量,它只有大约115行(以下包括)。源tarball中包含了示例,他的网站上还提供了非常好的.pdf文档(或.html)。但是,让我通过一个快速的示例来阐明一些概念。(不是宏本身,我花了一段时间才理解它们,而且它们并不是使用功能所必需的。:D)
很抱歉,今晚的时间已经用尽了;但是我会尽量在明天某个时候回来写一个小例子;无论如何,他的网站上有大量的资源链接;这是一个相当简单的过程,对我来说棘手的部分(我想对于任何合作多线程来说都是如此吧?Win 3.1呢?:D)是确保我已经正确地进行了代码循环计数,以便在产生 pt_thread 前不会超出我处理下一件事情所需的时间。

我希望这能给你一个开始;如果你尝试它,请让我知道它的进展情况!

文件: pt.h
#ifndef __PT_H__
#define __PT_H__
#include "lc.h"
// NOTE: the enums are mine to compress space; originally all were #defines enum PT_STATUS_ENUM { PT_WAITING, PT_YIELDED, PT_EXITED, PT_ENDED };
struct pt { lc_t lc; } // protothread control structure (pt_thread) #define PT_INIT(pt) LC_INIT((pt)->lc) // initializes pt_thread prior to use // you can use this to declare pt_thread functions #define PT_THREAD(name_args) char name_args // NOTE: looking at this, I think I might define my own macro as follows, so as not // to have to redclare the struct pt *pt every time. //#define PT_DECLARE(name, args) char name(struct pt *pt, args)
// start/end pt_thread (inside implementation fn); must always be paired #define PT_BEGIN(pt) { char PT_YIELD_FLAG = 1; LC_RESUME((pt)->lc) #define PT_END(pt) LC_END((pt)->lc); PT_YIELD_FLAG = 0; PT_INIT(pt); return PT_ENDED;}
// {block, yield} 'pt' {until,while} 'c' is true #define PT_WAIT_UNTIL(pt,c) do { \ LC_SET((pt)->lc); if(!(c)) {return PT_WAITING;} \ } while(0)
#define PT_WAIT_WHILE(pt, cond) PT_WAIT_UNTIL((pt), !(cond))
#define PT_YIELD_UNTIL(pt, cond) \ do { PT_YIELD_FLAG = 0; LC_SET((pt)->lc); \ if((PT_YIELD_FLAG == 0) || !(cond)) { return PT_YIELDED; } } while(0)
// NOTE: no corresponding "YIELD_WHILE" exists; oversight? [shelleybutterfly] //#define PT_YIELD_WHILE(pt,cond) PT_YIELD_UNTIL((pt), !(cond))
// block pt_thread 'pt', waiting for child 'thread' to complete #define PT_WAIT_THREAD(pt, thread) PT_WAIT_WHILE((pt), PT_SCHEDULE(thread))
// spawn pt_thread 'ch' as child of 'pt', waiting until 'thr' exits #define PT_SPAWN(pt,ch,thr) do { \ PT_INIT((child)); PT_WAIT_THREAD((pt),(thread)); } while(0)
// block and cause pt_thread to restart its execution at its PT_BEGIN() #define PT_RESTART(pt) do { PT_INIT(pt); return PT_WAITING; } while(0)
// exit the pt_thread; if a child, then parent will unblock and run #define PT_EXIT(pt) do { PT_INIT(pt); return PT_EXITED; } while(0)
// schedule pt_thread: fn ret != 0 if pt is running, or 0 if exited #define PT_SCHEDULE(f) ((f) lc); \ if(PT_YIELD_FLAG == 0) { return PT_YIELDED; } } while(0)


文件: lc.h
    #ifndef __LC_H__
        #define __LC_H__
#ifdef LC_INCLUDE #include LC_INCLUDE #else #include "lc-switch.h" #endif /* LC_INCLUDE */
#endif /* __LC_H__ */


文件: lc-switch.h
    // 警告:使用 switch() 实现的代码无法在 switch() 中使用 LC_SET()!
    #ifndef __LC_SWITCH_H__
    #define __LC_SWITCH_H__
typedef unsigned short lc_t;
#define LC_INIT(s) s = 0; #define LC_RESUME(s) switch(s) { case 0: #define LC_SET(s) s = __LINE__; case __LINE__: #define LC_END(s) }
#endif /* __LC_SWITCH_H__ */


文件: lc-addrlabels.h
    #ifndef __LC_ADDRLABELS_H__
    #define __LC_ADDRLABELS_H__
typedef void * lc_t;
#define LC_INIT(s) s = NULL #define LC_RESUME(s) do { if(s != NULL) { goto *s; } } while(0) #define LC_CONCAT2(s1, s2) s1##s2 #define LC_CONCAT(s1, s2) LC_CONCAT2(s1, s2) #define LC_END(s)
#define LC_SET(s) \ do {LC_CONCAT(LC_LABEL, __LINE__):(s)=&&LC_CONCAT(LC_LABEL,__LINE__);} while(0)
#endif /* __LC_ADDRLABELS_H__ */


文件: pt-sem.h
    #ifndef __PT_SEM_H__
    #define __PT_SEM_H__
#include "pt.h"
struct pt_sem { unsigned int count; };
// 宏用于初始化、等待和信号化 pt_sem 信号量 #define PT_SEM_INIT(s, c) (s)->count = c #define PT_SEM_WAIT(pt, s) do \ { PT_WAIT_UNTIL(pt, (s)->count > 0); -(s)->count; } while(0) #define PT_SEM_SIGNAL(pt, s) ++(s)->count
#endif /* __PT_SEM_H__ */


[☆]  *花费了一周的时间学习微控制器[†],并在评估期间使用它进行了一周的实验,以查看它是否能够满足我们对小型线可替换远程 I/O 单元的需求。 (长话短说:不行)

[†]  《The 8051 Microcontroller, Third Edition》被推荐为8051编程的“圣经”,我不知道它是否确实如此,但我确实能够使用它理解这些东西。[‡]

[‡]  现在再看一遍,我也没有发现什么不喜欢的地方:)嗯,我的意思是……我希望我没有买两本;但它们真的很便宜!

LICENSE AGREEMENT (where applicable)
本文包含基于(或取自)“Protothreads Library”(以下简称“PTLIB”,包括v1.4和早期版本)的代码,大量依赖PTLIB的源代码和文档。 PTLIB的原始源代码和文档来自作者的PTLIB网站'http://dunkels.com/adam/pt/',可通过'http://dunkels.com/adam/pt/download.html'上的下载页面链接或直接通过'http://dunkels.com/adam/download/pt-1.4.tar.gz'下载。 本文由原创文本组成,我在此免除我可能拥有的任何版权利益,并按照以下条款授权您使用:“copyheart ♥ 2014,shelleybutterfly,分享爱!”;或者,如果您愿意,适用于此材料的完全非限制性的仅署名许可证(例如Apache 2.0用于软件;或CC-BY用于文本),以便您根据需要使用它。 本文还包含源代码,几乎完全是通过删除解释性材料、重新格式化和释义行内文档/注释以及我(堆栈交换网络上的shelleybutterfly)进行一些修改/添加而创建的。对于我可能在法律上获得的任何PTLIB衍生物,我在此放弃所有这种利益,并将原始作品的所有版权利益归还给原始版权持有人,如PTLIB的许可证所规定的那样。在任何不可能出于任何原因适用于您的条款的司法管辖区域中,对于我拥有的任何材料利益,我在此授权您根据您选择的任何非限制性、仅署名的许可证使用它;或者,如果这也不可能,那么我允许堆栈交换公司根据他们确定的在您的司法管辖区域内可接受的任何条款向您提供它。 来自PTLIB和PTLIB衍生物的所有源代码,未被涵盖在上述其他条款中的,均根据以下协议提供给“堆栈交换公司”和您:


《Protothreads Library》授权协议
版权所有 (c) 2004-2005,瑞典计算机科学研究所。保留所有权利。 允许在源代码和二进制形式下自由分发、使用和修改,前提是满足以下条件: 1. 在分发源代码时必须包含上述版权声明、本条件列表和下列免责声明。 2. 在分发二进制文件时必须在文档和/或其他提供的材料中重现上述版权声明、本条件列表和下列免责声明。 3. 未得到特定事先书面许可,不得使用机构的名称或其贡献者的名称来认可或推广从本软件派生的产品。
本软件由瑞典计算机科学研究所和贡献者按原样提供,不提供任何明示或暗示担保,包括但不限于适销性和适用于特定目的的暗示担保。无论因何种原因,在任何情况下,瑞典计算机科学研究所和贡献者均不对直接、间接、附带、特殊、惩罚性或后果性损害(包括但不限于替代商品或服务的采购、使用、数据或利润损失或业务中断)负责,即使已被告知此类损害的可能性。
作者: Adam Dunkels

由于这些源代码是现有源文件的改编版本,因此您应该提及它们可用的版权许可和谁持有代码的版权。 - Bart van Ingen Schenau
扶额 是的,我的错;我现在正在加上它;:) - shelleybutterfly

2
这里有一些非常好的答案,但在深入阐述更长的答案之前,关于您的生日卡片示例可能需要更多上下文。
单个CPU看起来可以同时执行多个任务的方法是快速地在任务之间切换,并利用计时器、中断和独立的硬件单元等辅助设备,这些设备可以独立于CPU执行任务。(参见@Zack的回答,了解硬件的详细讨论和起始清单) 因此,对于您的生日卡片,CPU可能会告诉一个音频硬件"播放这一段声音",然后去闪烁LED,接着再加载下一段声音,而第一部分的播放还没有完成。在这种情况下,CPU可能需要花费1毫秒的时间来加载音频,然后在5毫秒的实际时间内播放它,剩余4毫秒的时间可以用来做其他事情,然后再加载下一段声音。
数字时钟可能通过设置PWM硬件以某个频率输出到压电蜂鸣器来发出嘟嘟声,使用定时器触发中断停止响铃,并去检查实时计数器是否需要更新时间显示LED。当定时器触发中断时,您的代码将关闭PWM。
详情将根据芯片的硬件而异,阅读数据手册是查找给定微控制器的功能和如何访问它的方法。

1
我曾与Freertos有过良好的经验,尽管它使用了相当数量的内存。 Freertos 提供真正的抢占式线程,如果您想升级那些旧的 8051 微控制器,有大量的端口可用,还有信号量、消息队列、优先级等各种功能,而且完全免费。我个人只使用过 arduino 端口,但它似乎是免费实时操作系统中最受欢迎的之一。
我认为他们出售一本非免费的书,但他们的网站和 arduino 示例中有足够的信息可以基本弄清楚。

请问您能否提供您所提到的网站和书籍的链接,这样有助于寻找答案的人更容易找到相关信息。 - gbudan
已添加到主要网站的链接,书籍可以在那里购买。 - user11360

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接