2.1 嵌入式软件编程模式
嵌入式系统和应用紧密结合,不同应用有不同的软件运行要求。下面将讨论几种常见的嵌入式系统编程模式,每种模式有对应的应用场合,可以单独使用或者混合使用。这里讨论的编程模式主要针对没有操作系统的嵌入式软件运行环境,在这种情况下,CPU的全部算力可以分配到和应用相关的计算,不需要额外执行IO资源状态、内存清理、调度等软件操作系统的管理任务,因此运行效率和内存使用效率会更高,但付出的代价是需要手动管理任务并发、IO状态检查、资源共享等,对开发者有更高的要求。
下面首先给出常见的嵌入式系统运行模式示意图,如图2-1所示。
图2-1 常见的嵌入式系统运行模式
图中系统的功能是从传感器获得外部数据,进行分析运算后输出控制命令,运行期间还需要接收用户输入。这一架构模型反映了大多数嵌入式系统的运行模式。比如,在语音识别应用中,嵌入式系统周期性地从ADC读入语音的波形数据,经过处理后识别用户语音指令,并根据识别结果输出控制命令。对于运行机器人视频避障软件的嵌入式系统,软件需要通过摄像头周期性地获得视频数据,分析视频内容,识别障碍和目的地并输出机器人移动控制命令。
上面描述的嵌入式系统运行模式涉及了多个需要并行执行的任务,每个任务有不同的运行周期性和处理优先级要求。表2-1给出了一个例子,其中嵌入式系统中需要同时运行4个任务,分别是传感器采样数据读取、数据计算及控制输出、用户输入命令检查、状态显示更新。它们的执行周期如表2-1所示。
表2-1 任务类型和执行周期示例
这几个任务中,读入传感器数据的时间优先级最高,根据采样率所规定的时间间隔运行,不能错过数据;计算及控制输出同样有实时性要求,但它的执行频率低于数据采样;用户命令的检查和状态显示更新的优先级最低,只要满足人们对信息的感知速度即可。
上面几个任务的时间要求和运行频率要求也各不相同。设计时,需要根据优先级制定软件执行方案。当使用实时操作系统时,可以使用比如“单调速率调度算法”(Rate Monotonic Scheduling,RMS)等方案实现以上多任务并发执行,如图2-2所示。我们后面讨论的几种编程模式能够不依赖于操作系统实现,允许开发者根据应用特点实现更灵活的定制化多任务执行模式。
图2-2 多个需要周期重复的任务交替执行的示意图
2.1.1 基于周期调用的运行模式
周期运行模式的一种实现方式如代码清单2-1所示,该代码实现了表2-1给出的嵌入式系统的几个任务周期执行。其中while循环内部的几个任务按执行顺序排列,并且每轮while循环根据周期要求对各个任务执行1次或者多次。
代码清单2-1 基于周期调用的运行模式伪代码
while(1) { 传感器采样数据读取(); 用户输入命令检查(); 传感器采样数据读取(); 数据计算及控制输出(); 传感器采样数据读取(); 状态显示更新(); 传感器采样数据读取(); 数据计算及控制输出(); }
while循环内的代码对应所需要的任务的一个运行周期,如图2-2所示。但在实际情况中,由于每个模块运行时间存在差异,因此往往得到疏密不均匀的“周期”运行效果,如图2-3所示。这使得系统对传感器输入数据的“采样率”不再固定,并且对用户的输入响应间隔也时快时慢,系统运行缺少“确定性”。改进措施如下:
1)将运行时间过长的任务进行状态分割。
2)将采样固定在时间格点上运行。
图2-3 各个任务运行时长不一致造成运行周期疏密不均匀
下面详细介绍这两种改进方案。
·运行时间长的任务拆分
任务拆分是将一个长时间任务分成几个阶段实现,每次循环执行时仅完成其中一个阶段的任务。例如,假设“状态显示更新”任务由于具有LCD特性而运行很慢,我们将其分成几部分,比如假设原始的操作可以分成三部分,如代码清单2-2所示。
代码清单2-2 运行缓慢的显示更新任务的伪代码
void状态显示更新() { 更新测量数据曲线图(); 更新数据计算结果(); 更新用户输入内容(); }
代码清单2-2所显示的三部分操作可以分成三个阶段执行,如代码清单2-3所示。
代码清单2-3 拆分成三个阶段执行的显示更新任务的伪代码
void状态显示更新() { static int state=STAGE0; switch (state): { case STAGE0: 更新测量数据曲线图(); state=STAGE1; break; case STAGE1: 更新数据计算结果(); state=STAGE2; break; case STAGE2: 更新用户输入内容(); state=STAGE0; break; } }
该函数内部有static类型状态变量state,它的取值在函数一次调用完成到下一次调用期间是保持不变的。我们第一次调用该函数时,由于state=STAGE0,因此执行“更新测量数据曲线图”;第二次调用时,由于state=STAGE1,因此执行“更新数据计算结果”;第三次调用时,由于state=STAGE2,因此执行“更新用户输入内容”;三次调用后,state回到STAGE0,下次调用再次执行“更新测量数据曲线图”,并重复上面的过程,如图2-4所示。
图2-4 将运行时间长的任务拆分为三个阶段的执行模式示意图
通过上面的例子可以看到,我们能够将需要一次运行时间长的函数拆分成多个阶段实现,使得周期执行循环中,执行该函数的时间降低到各个阶段的执行时间。该模式适用于可以分解为多个执行阶段的函数,并且各个执行阶段相对独立,相邻执行阶段的运行时间可以不连续。
·时间格点采样
对于传感器数据读取,当相邻两次调用读取数据的函数的间隔小于采样间隔时,通过代码清单2-4给出的伪代码实现采样时间调整,以确保每次数据读取在特定的时间格点上执行。
代码清单2-4 基于时间格点的采样过程的伪代码
void传感器采样数据读取() { while ((读入当前时间() % 采样时间间隔)>0) 延迟等待固定时间(); 读取并保存数据(); }
上面的伪代码中,while循环所依赖的条件“读入当前时间()%采样时间间隔”用于确定当前时间是否在采样时间格点上,如果不在,则延迟等待固定时间。
值得注意的是,在很多情况下,采样时间间隔可以通过外部数据采样硬件确定,用户程序通过FIFO读取数据,当数据尚未到达时,FIFO为空,这使得我们可以基于FIFO是否为空来判断时间格点上的数据采样是否完成,如代码清单2-5所示。
代码清单2-5 基于FIFO读取的数据采样的伪代码
void传感器采样数据读取() { while (外部FIFO空()) 延迟等待固定时间(); 读取并保存数据(); }
上述模式只有当两次执行“传感器采样数据读取”的间隔小于采样间隔时才有效,如果大于采样间隔,则会导致采样点“遗漏”,如图2-5所示。
图2-5 调用“传感器采样数据读取”函数间隔大于采样周期导致的采样缺失问题示意图
图2-5中虚线是采样时间格点,在中间由于“计算机控制输出”任务运行时间过长,出现过一次采样缺失现象。下面将讨论另外几种编程模式以避免这一问题。
2.1.2 基于中断的前后台运行模式
前面基于循环调用的编程模式难以确保各个任务的等时间周期运行,并且把实时性要求高的任务和实时性要求低的任务混在一起,难以同时兼顾不同任务实时性要求的差异。为了能够确保特定任务的实时性,我们通常使用基于中断的前后台编程模式,如图2-6所示。
其中,时钟中断服务程序负责从外部传感器得到数据输入,由于时钟中断信号具有严格的周期性,因此可以确保数据间隔的稳定,并且由于中断服务程序随时能够打断前台的数据处理、用户输入、显示输出程序执行,因此不受它们的运行速度影响。
使用前后台编程模式时,需要考虑中断服务程序的运行效率,对于前面介绍的固定时间间隔的数据采样任务,要求中断服务程序能够在采样间隔内完成,因此需要尽可能地提高运行效率,在中断服务程序中只保留必不可少的代码,尽可能将那些对实时性要求不高的数据操作放到前台应用程序中执行。比如来自传感器的数据经过了压缩和纠错编码,在中断服务程序运行期间执行数据解压缩和纠错可能导致运行时间过长,进而导致错过下一个采样的时间点,为避免这一现象出现,可以将这些运行时间长但又没有必要在中断服务程序内完成的任务转移到“数据计算及控制输出”函数中执行。
图2-6 基于中断的前后台运行模式
使用前后台编程模式的一个极端示例是将所有操作移入中断服务程序。硬件提供不同重复间隔和优先级的时钟中断,这样主程序会很简单,只有一个无限循环,而所有的工作由中断服务程序完成,如图2-7所示。
图2-7 全部基于中断运行模式
基于中断服务程序实现所有操作的方案中需要编程者处理数据访问冲突问题,比如传感器数据读取中断服务程序有可能打断“数据计算及控制输出”中断服务程序,“篡改”还未处理完的数据,这就需要可靠的数据交换机制,以确保不同的代码能够安全地访问共享数据交换空间。比如通过图2-8所示的缓冲器交换数据。
图2-8 利用环形缓冲器实现数据交换
“传感器采样数据读取”程序负责将读取的数据加入环形缓冲器,并修改缓冲器的“写指针”,而“数据计算及控制输出”程序负责从环形缓冲器读取数据,每次读完数据就修改缓冲器的“读指针”,避免对相同的共享数据区同时读写。
2.1.3 基于事件队列的运行模式
这一运行模式是基于中断的前后台运行模式的进一步改进。中断服务程序可以是传感器数据读取程序,也可以是用户指令输入检测程序。后台程序由一系列中断服务程序构成,负责读取数据供前台程序处理,还负责根据输入内容生成“事件”,将待处理事件加上数据放入固定格式的“事件数据包”,最后将事件数据包挂到全局的“事件队列”中去。前台程序则不断检查事件队列,找到其中待处理的事件数据包,提取并执行,如图2-9所示。
事件队列可以以链表形式或者环形缓冲器形式实现,由于它是由中断服务程序和前台处理程序共享的,因此在编程时需要避免访问冲突。
基于事件列表运行模式的一种扩充是在系统中构建多个事件队列,每个事件队列具有对应的优先级,前台程序根据事件队列的优先级优先处理高优先级的事件,这样能够使对实时性要求高的事件(比如数据计算)能够及时得到处理,如图2-10所示。
这一编程模式在实际应用中要求程序员考虑“低优先级事件积压”问题,即高优先级事件不断出现,导致低优先级事件长时间得不到处理、处理延迟过长的问题。需要程序员采取特定措施防止低优先级事件长时间得不到处理,一个解决方案是规定一个低优先级事件队列长度门限,当积压的低优先级事件数量超过门限时,临时提升该队列的处理优先级,避免低优先级事件队列内事件被过度积压。
图2-9 基于事件队列的运行模式
图2-10 基于多重事件队列的运行模式,每个事件队列具有不同优先级
2.1.4 带时间信息的事件队列运行模式
对于需要在特定时间执行指定动作的应用,比如有特定时序要求的硬件设备控制和网络通信协议等,可以使用带时间信息的事件队列的设计模式(见图2-11)。这一设计模式和带优先级的事件队列运行模式相似,差别是这里的每个事件队列的优先级被执行时间所取代,系统根据当前时间检查事件队列,对于时间匹配的事件队列,执行队列中的所有事件,执行完删除该队列。
图2-11 带时间信息的事件队列的运行模式
事件队列可以由当前正在执行的事件生成,也可以由中断服务程序生成。代码清单2-6中显示了这一模式的运行过程。
代码清单2-6 带时间的事件队列的运行模式的伪代码
void main() { while (1) { if (事件队列链表中最近的执行时间小于等于当前时间) { while (获取当前时间待执行事件()) 执行事件任务(); } } }
2.1.5 计算图运行模式
基于计算图的运行模式在机器学习领域得到广泛应用,多种主流的神经网络框架使用计算图来描述神经网络架构。比如图2-12a给出了某一神经网络的计算图。
图2-12中每个圆圈代表网络中的一个运算,而连接各个运算的箭头线上的数字代表运算输出的张量尺寸。该网络的输入是尺寸为100×100的RGB图片,输出128维的目标分类向量和一个10×10的目标位置信息图。为后面分析方便,我们用字母表示上面6个运算,如图2-12b所示。
图2-12 某一神经网络的计算图结构。图a为计算图结构和输入输出张量尺寸;图b为计算图中各个节点的字母表示
使用计算图进行计算时,需要根据各个运算之间的数据依赖关系制定执行顺序,即每个运算节点只有当它的所有输入数据都已经准备好之后才能执行。比如图中“拼接卷积”(D)运算依赖于两个输入端的卷积B和C的运算结果,需要在这前两个卷积全部完成后才能够执行。
对于一个计算图,可以通过下面的步骤获得运算顺序。
第一步:根据ASAP(As Soon As Possible,尽可能早)策略或者ALAP(As Late As Possible,尽可能晚)策略构建计算顺序,将运算分配到各个时间段。
第二步:根据处理器的并行运算能力将每个运算节点分配到不同的时间行执行。
我们首先说明第一步,这一步骤为每个运算分配可以执行的时间段,我们分别给出ASAP和ALAP两种时间分配策略。
·ASAP时间分配策略
这一策略自顶而下,寻找做好准备的运算,并将其分配到最早的可以运行的时间段。比如一开始运算A和B都能够执行,将它们分配到第一时段,接着发现运算C可以执行(由于D依赖于C,D不可以执行),将C分配到第二时段,然后看到D和E可以执行了,将它们分配到第三时段,最后剩下F,分配到第四时段。这一分配结果如图2-13a所示。
·ALAP时间分配策略
这一策略自下而上分配时段,首先设置最后一个时段的输出,并根据这些输出确定倒数第二个时段必须执行的运算,接着再倒退一个时段,根据倒数第二个时段的运算确定在倒数第三个时段必须完成的运算,依次类推,直到所有运算都被包括进来。这一分配结果如图2-13b所示。
图2-13 ASAP和ALAP方案对每个运算的时间分配结果
从上面两种方式得到的运算中,会出现某一个时段需要执行2个运算的情况,比如图2-13的ASAP的执行时间分配算法中,第一时段需要同时运行A和B两个运算,当我们只有一个处理器时,需要对这种情况进行拆分。将同时出现多个运算的时段进行拆分,可以使每个时段只包括一个运算,图2-14给出了ASAP时间分配算法的拆分结果。
完成上述运行时间分配之后,就能够获得运行这个图的执行流程了,参见代码清单2-7中的伪代码。
代码清单2-7 计算图运行模式的伪代码
while (1) { 得到图像数据(); 执行卷积A(); 执行卷积B(); 执行卷积C(); 执行数据拼接和卷积D(); 执行全连接层E(); 执行卷积层F(); 输出结果(); }
图2-14 将安排在同一时段的多个运算(基于ASAP策略)分到不同时段运行。图a为原先的ASAP分配方案,时段1和时段3分别有两个运算;图b为经过了时间拆分的运算分配方案,每个时段只有一个运算
类似地,ALAP时间分配的结果也需要分割,如图2-15所示。
图2-15 将安排在同一时段的多个运算(基于ALAP策略)分到不同时段运行。图a为原先的ALAP分配方案,时段2和时段4有两个运算;图b为经过了时间拆分的运算分配方案,每个时段只有一个运算
对于内存有限的嵌入式系统,上述运算过程可以根据内存进行优化,不同运算顺序所需的最大内存占用量不同,比如上面ALAP时间分配算法的原始结果中,第二时段的运算B和C所处的运算时间有两种“分裂”方式——先执行B运算或者先执行C运算,两种方式的选择是任意的。但不同的执行顺序可能带来不同的内存需求,因此执行顺序的选择是值得优化的。下面分别画出这两种时间分配方法,以及对应的系统最大数据存储量,如图2-16所示。
图2-16 两种运算时间分配方案对应的系统最大内存量。图a为时间段2执行运算C时,运算期间内存占用量;图b为时间段2执行运算B时,运算期间内存占用量
图2-16中写出了每个运算输出的数据尺寸,以及执行运算过程中最大的需要暂存的数据总量,可以看到两种方案对应不同的数据暂存总量。带下划线且用粗体标注出的为运算期间的最高数据暂存总量,其中图2-16a中为55 600,图2-16b中为51 200。可见不同的运算顺序带来不同的内存要求,对于存储空间有限的嵌入式系统,有必要仔细检查不同执行顺序的内存占用量,选择内存需求最低的执行顺序。