嵌入式程序是运行于嵌入式设备中的程序系统。嵌入式设备一般为由单片机等专用MCU和简单外设构成的专用计算机系统,广泛存在于各类工业以及民用设备中,包括各种家电、汽车、仪器仪表、各种智能设备、数控机床等。
嵌入式系统一般都有具体的预先规划的功能要求,而且比较固定、不随时间变化(也有可以更新程序的设备,但更新频率也很低)。因此软硬件都为其功能优化。嵌入式系统往往生产数量巨大,其对成本一般比较敏感,因此配备的资源,包括计算能力、存储、内存、外部接口等都比较有限。
嵌入式系统运行于片上系统(SoC)。基本由MCU、Flash/ROM、SRAM、外部接口(UART、USB等)构成,一般不配备硬盘等外部设备,程序放在Flash/ROM中,在其中直接运行、或复制到内存中运行。由于资源受限,因此要求程序短小精悍。
1. 嵌入式程序任务的基本结构
嵌入式程序一般设计为连续运行,即除了硬件的原因(维修、更换)以外,系统不停机持续运行。因此,嵌入式程序的基本结构为事件驱动—事件处理模式,如图 1所示。
图 1 嵌入式程序基本结构
程序总是处于待机状态,等待外部事件输入。此处所说的事件是一个广义的概念,指来自外部的所有触发,包括传感器采集的信息、执行器的执行结果反馈、来自外部接口或软件内部的通信数据(包括来自外部的控制命令)、定时器的超时等等。而且外部事件是对处理程序而言的。在一个多任务(此处的任务相当于Linux中的线程)系统中,处理程序就是一个任务,因此来自其他任务的输入也被当作外部事件。
收到外部事件以后,程序开始对事件进行处理,并将处理结果反馈给外部,反馈结果包括对外的数据传输(包括给其他任务的)、控制外部设备进行指定的操作等。同时,程序回到待机状态,等待下一次外部事件的输入。下图是一个多任务的事件处理模型。
图 2 多任务事件处理模型
在这样一个多任务模型中,任务之间除了内部事件的通信、没有任何其他的交互方式。而各个任务的基本结构都是相同的。这样将内部事件与外部事件统一成一样的形式,一方面简化了任务的结构,可以将内部事件与外部事件统一处理;另一方面,如果随着系统的发展,需要将某个或者几个任务移动到别的MCU上时,任务本身毋需修改,使移植工作简单易行。
2. 嵌入式程序的实现
在单任务系统中,这个结构实际上构成了整个应用程序的框架;而在多任务系统中,这则是每个任务的基本框架。转换成伪代码,如下所示。
while(1) {
if(receiveEvent()) { handleEvent(); } } |
这是一个无限循环的程序结构,一般在接收事件处等待(待机状态,等待外部事件),一旦收到事件,则调用处理函数对其进行处理。
在一个单任务系统中,所有的输入都是来自中断服务程序;而在多任务系统中,除了来自终端服务程序的输入,还有来自其他任务的输入。
嵌入式操作系统都提供了任务间数据传递和通知的通信机制:包括消息队列、邮箱、信号量等。而且这种通信机制包含了发送和等待通知的API,如下面的函数即是FreeRTOS发送消息到队列和从队列中接收消息的API:
发送消息: BaseType_t xQueueSend( QueueHandle_t xQueue, const void * pvItemToQueue, TickType_t xTicksToWait );
接收消息: BaseType_t xQueueReceive( QueueHandle_t xQueue, void *pvBuffer, TickType_t xTicksToWait ); |
当任务调用接收消息API时,如果队列当中有数据,则返回该数据;如果队列为空,则根据用户指定的等待时间,将当前任务挂起,直到超时,或者收到了数据。
这种挂起—等待的事件接收机制是最常用的任务实现方式。如果采用这种方式,需要注意在一个任务中避免在多个点等待事件,如下面例子的伪代码所示:
while(1) {
if(receiveEvent_1()) { handleEvent_1(); } ……
if(receiveEvent_2()) { handleEvent_2(); } } |
如果receiveEvent_1()和receiveEvent_2()都有可能把任务挂起等待事件,这就成为在两个点等待事件的情形。这种实现会导致消息队列堵塞从而使得消息发送方因发送失败而丢弃消息,或阻塞。严重时可能导致整个系统因为消息阻塞而崩溃。在上面的例子中,假设程序运行到receiveEvent_2()处,因为消息队列2为空而挂起。此时如果有大量的消息进入消息队列1,因为消息队列1得不到处理有可能变满,从而导致消息丢失,或系统崩溃。
当然,给receiveEvent_1()和receiveEvent_2()设定合适的等待时间可以避免这种情况的发生。但这也有缺点,一方面会降低运行效率,另一方面如果等待时间设置不合适,不能完全避免这种情况的发生。因此最好的方式就是在一个任务中只设置一个事件等待点。
另一种需要避免的情形是一个任务自己给自己发消息。给自己发消息的设计初看挺奇怪,但有的时候可以简化程序结构,因此为一些程序员所喜爱。比如,有一个任务需要处理三种类型的数据,而在收到的每个消息当中,可能包含一个或多个这三种类型的数据,采用给自己发消息的实现伪代码如下所示。
while(1) {
if(receiveMessage(pMsg)) { switch(pMsg->pData->type) { case DATA_TYPE_1: { 处理数据类型1; if(存在后续数据) { SendMessage(self, 剩余数据); } break; } case DATA_TYPE_2: { 处理数据类型2; if(存在后续数据) { SendMessage(self, 剩余数据); } break; } case DATA_TYPE_3: { 处理数据类型3; if(存在后续数据) { SendMessage(self, 剩余数据); } break; } default: break; } } } |
因此,通过每次处理消息中的一种数据类型,然后将剩余的部分重新发给自己。通过这样反复,可以处理完消息中的所有数据。这种实现,结构简单明了,通过将数据的剩余部分发送给自己,构成一个处理的循环。但这种实现存在一个隐患,那就是该任务在处理消息的时候,外部任务也可能发消息给自己。在消息来得频繁的情况下,可能占满队列,此时,如果想给自己发消息,就会阻塞任务的执行。从而导致系统崩溃。
实际上,所有的给自己发消息的处理,都可以通过实现的调整消除掉,虽然会增加任务处理的复杂度。在上面的例子中,就可以通过在消息处理部分增加循环,来消除给自己发消息的处理。
while(1) {
if(receiveMessage(pMsg)) {
while(存在合法数据类型) { switch(数据类型) { case DATA_TYPE_1: { 处理数据类型1; break; } case DATA_TYPE_2: { 处理数据类型2; break; } case DATA_TYPE_3: { 处理数据类型3; break; } default: exit ; }
移到下一个待处理数据; } } } |
3. 消息等待定时器的设定
在嵌入式操作系统中,所有可能让任务挂起或者等待的系统调用,都提供了设定等待时间的参数。比如在FreeRTOS系统中的以下系统调用。
根据操作系统不同,等待时间的设定方式也不一样。但基本都支持三种等待模式:不等待,立即返回;等待有限的时间;无限等待。设为无限等待时,只有在等待的事件发生时,才会退出等待,继续执行。如果等待时间大于0,则如果没有事件发生,定时器超时,调用也会返回,继续执行。
等待接收消息: BaseType_t xQueueReceive( QueueHandle_t xQueue, void *pvBuffer, TickType_t xTicksToWait );
等待信号量: BaseType_t xSemaphoreTake( SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait );
|
是否让任务无限等待,取决于任务实现的方式。一般有两种实现模式:
while(1) {
if(receiveMessage(超时时间)) { handleMessage(); }
处理定时任务; } |
由于收到消息和定时器超时都会导致定时任务运行,因此定时任务的运行不能依赖于接收消息中设定的超时时间。另外,超时时间的设定需要考虑定时任务必须运行的最小时间间隔,通常设置一个比最小时间间隔更小的值。
不是永久等待,而是设置超时的另一个好处,是可以检查任务运行是否正常。通过在任务的循环中定时输出LOG信息,就可以确认这一点。
从实现的简洁性、调试的方便性来看,上述第一种实现模式更佳。因为所有的触发都来自外部事件,而这些外部事件是互相独立的,可以分开处理,不需要考虑其关联性。因此对不同事件的处理也是互不关联。这不但使实现更方便,不宜出错,还使程序具有更好的可扩展性。当需要增加新的外部事件时,只需要增加相应的处理函数即可。而采用第二种方式时,不能完全消除定时任务和外部消息之间的关联性,从而导致两个部分之间产生耦合。这增加了调试的复杂性,也使得程序不易扩展。
特别是在需要考虑省电的系统中,比如电池驱动的设备,更应采用第一种实现模式。因为它符合省电设计中的一个基本原则:只有在必要时MCU才运行,否则休眠。
其实第二种实现模式完全可以转换为第一种实现模式。对于用全局变量通知的部分可以简单地改为用消息通知;对于需要定时操作的任务,将定时器设置在任务之外即可。既可以直接采用硬件定时器,也可以利用OS的定时服务。
4. 任务的划分
将一个复杂的系统分割成几个子系统,是系统分析和设计的有效手段。在程序设计中,通过将整个软件系统划分成若干个任务(子系统),同样是软件设计的基本方法。这一方面可以有效地降低设计的复杂度,通过任务划分,使得每个任务的功能更简单,更易于实现。另一方面通过任务的优先级机制,可以通过合理设置优先级,使得高实时性的操作更快地得到执行,提高整个系统的运行效率。详细的介绍参照操作系统相关章节。
但是为了支持任务的运行,操作系统需要分配必要的管理资源,包括任务控制块、任务堆栈、任务调度执行开销等等。因此过多的任务也会导致系统资源的浪费,降低系统运行效率。因此合理的任务划分是系统设计的一个重要方面。
任务划分的基本原则可以总结为以下几条:
在根据功能进行任务划分、特别是在确定任务间的边界时,需要考虑解耦原则,即“高内聚、低耦合”。在任务一个任务实现一个完整的功能,其内部实现的子功能具有逻辑上的关联性,这个任务就是高内聚的。而耦合是不同任务之间相互关联的度量,耦合强弱取决于模块间接口的复杂程度、进入或访问一个模块的点以及通过接口的数据等等。任务之间关联度越低越好,即所谓的低耦合。
当然,不同的任务是作为一个整体实现系统功能的,因此任务间完全没有交互是不可能的。因此仔细设计任务间交互的方式对于保证系统的可维护性和可扩展性是十分关键的。任务之间的交互应尽量通过任务间通信,以数据或事件传递的方式来实现。而要尽量避免通过不同任务访问相同的全局变量或结构的方式等进行信息交互。因为一方面这种方式需要实现对全局变量的访问控制,增加了系统的复杂度;另一方面,任何对全局变量结构的修改都涉及到对两个或多个任务的修改,降低了系统的可维护性和可扩展性。更不能采用调用其它任务功能模块的函数的方式。这种方式使得一个任务可以直接修改另一个任务的内部数据,急剧降低了系统的可维护性和可扩展性,同时也损害系统的可理解性,也不利于问题的定位。
需要注意的是对功能的优先级分析必须准确合理,错误的实时性分析直接导致错误的优先级设置,可能导致一些功能的实时性要求不能满足。另外高优先级任务的处理时间要尽量短,以保证低优先级的任务也能得到及时运行。如果一个高优先级的功能执行时间长,需要对其进行进一步分析,切分为真正需要高优先级的处理和可以延迟的处理,分配在不同优先级的任务中。
在实际的系统设计中,根据功能进行任务划分是经常采用的方式。其他的原则则是作为补充,在根据功能划分以后,如果需要进一步划分任务时采用。