前言
嵌入式系统在资源使用上受到极其苛刻的限制:处理器能力杯水车薪,内存使用以byte计,同时对系统实时性和时序精确性又有着近乎变态的要求。特别是对于持续处理大量数据的实时系统,瞬间的过载导致数据计算出现纰漏,极有可能引起不可恢复的错误甚至崩溃。鉴于运算资源的极度稀缺性,对于该类异常的保护通常不可能像pc端程序那样精巧完备和滴水不漏。现状是:简单粗暴,甚至置之不理,往往是嵌入式系统对大部分异常和错误的最佳处理策略。
这就对设计和开发人员提出了相当高的要求,在嵌入式领域,不能对每条指令了然于胸的设计人员不能设计出多肥少瘦的系统,而不能对整个系统架构融会贯通的开发人员,也无法将代码性能推至极限。
本篇主要从编码的角度总结出常用的性能优化手段,某些具体实现可能会随不同的器件和编译器变化,但原则和思想都是普适的。
1 算法优化
算法与具体应用相关,不作为本篇的重点内容,其优化的目标主要是减少运算量,在有些场景下,目标也可能是减少内存访问量。算法优化在开发阶段前就应该完成了。
值得一提的是,对于工程应用,算法优化大部分情况下并不需要使优化后算法与优化前的算法做到功能完全等效,其损失只要可接受即可。例如,对于通信中的一些估计算法,牺牲一点点估计性能,就可能极大地减少运算量。因此,工程上的算法优化往往意味着在算法效果和效率之间求平衡。
2 实现优化
实现上的优化可以通俗地理解为对代码进行功能等效变换,功能不变而效率提升,这是本篇的重点内容。下面有些性能提升手段的前提是要对编译器/器件特性有基本的了解,否则不但无法提升反而适得其反。
启用编译器优化
让工具承担更多的事情,这应该是大势所趋,虽然目前程序员仍是性能提升的主要因素,但有时候编译器还是能做很多工作的,对于本篇后面总结的各种实现优化,有一些手段本身能直接提升性能,而另一部分手段则是在为编译器优化创造条件,如循环展开/利用指令并行度。对程序员来说,启用编译器优化可以做这些事:
1. 提升编译优化等级,o0表示编译器不会对代码作任何优化,o1~o3分别代表不同程度的优化,数字越大优化越明显(原则上)。当然最高等级的优化要谨慎启用,因为此时编译器的行为可能远远超出你所知道的,这样可能会出现不该优化的而被优化得情况,导致功能错误;
2. 增加各种类型的编译提示符#paragma。具体使用可参考编译器文档,其优化功能不外乎这几类:将函数内联减少函数调用开销;调整条件分支以利于指令流水;还有指示编译器不要优化的提示符以防出错。
3. 对指针增加restrict修饰。如果不加修饰,那么编译器对函数内的指针操作都是串行处理的以保证功能正确。如果函数中的指针确实互不相干,那么用restrict修饰这些指针,就会令编译器对这些指针的操作并行起来甚至调整先后顺序,以此提高执行效率,而且不用担心有功能问题。
4. 使用其他的一些编译器选项,如跨函数优化功能。
循环中避免判断
现代处理器均采用流水线结构,即将一条指令的执行分割成若干阶段,这样可以提高执行的并行度,当前指令在执行的同时对下一条指令进行读取和译码。
代码中条件语句对应的是条件跳转指令,但执行完条件跳转指令后下一条应该执行什么指令?这在条件跳转指令执行完成前显然是无法确定的,但流水设计又要求在当前指令未执行完时就要读取下一条指令。
假设条件判断为真时,下一步执行指令A,否则执行指令B,编译器一般的处理是:在条件跳转指令执行过程中,同时读取指令A(也可以是B,这取决于写代码时对分支顺序的安排,是程序员可控的),如果条件跳转指令执行结果为真,那么可以直接执行A,否则处理器将丢弃指令A,再读取指令B并执行。对于后一种情况,显然比前一种情况多了一步丢弃和读取操作,这种情况下流水就被破坏了。在每次循环中判断,这种额外的开销就更大了。如果条件判断不依赖于循环变量,则可以将判断放到循环外部。
判断顺序调整
从上可以发现,如果运气很好,每次判断都为真,流水是不会被破坏的,从而也没有额外的开销,而另一种极端则是每次都破坏了。真实情况是,大部分时候判真判假都有,意味着不能完全避免流水破坏,此时可以将概率较大的分支写在另一个分支之前,使流水破坏最小化。
循环展开
将循环展开本身并没有减少运算量,其目的主要是将代码展开后为编译器提供了更多可选的指令并行组合,这样编译器输出的汇编具有更高的指令并行度,从而提高代码效率。循环展开的前提是循环次数是常量,或者其取值为有限的几种情况。例如,循环次数可能为2,4,5,则可以设三个条件分支,分别对三种情况进行循环展开。对于循环次数很大的情况如1024,可以考虑2x或4x展开,这样单次循环的操作量放大2倍或4倍,循环次数则相应倍数地减少。展开的一个缺点是重复代码看起来比较多,当然也千万不要为此再去封装一个函数,否则就是画蛇添足了。
对于循环次数为常量的情况,好的编译器也可以自动进行展开而不需要人工干预。展开效果可通过比较汇编代码识别,如并行度较高则说明展开效果较好。
循环无关操作提到循环外部
对于循环体内的计算,某些计算项与循环本身无关,即每次循环该计算项的结果均一样,则可以将该项的计算提到循环外部,减少无谓的运算。
避免大而全的通用函数
功能齐全的函数虽然更容易被复用,避免了重复造轮子,但不免对性能带来影响。这类函数往往会包含多个判断,且对任意一个特定场景都存在冗余的操作。应对方法即针对每个特定场景写一个专用函数,场景A下的函数不用考虑场景B,也就不用考虑场景B的操作。缺点是可能会造成代码冗余。
计算一次并保存;
这条方法的思路和 “循环无关操作提到循环外部” 一致,只是应用的上下文不同,需要纵观全局,识别不同模块重复的计算,在入口处计算一次后保存起来,后续再使用时直接读起即可,
查表代替判断
前面提到判断会破坏流水从而引入额外的开销,有些判断可以通过查表来代替,如下形式:
if ()
{funcA();}
else if()
{funcB();}
else
{funcC();}
可以定义一个函数指针数组,元素分别为funcA,funcB,funcC,而数组索引则由判断条件进行转化,这对Switch语句也同样适用。如下形式也可作类似优化:
if()
{a = 3;}
else if()
{a = 6;}
else
{a = 9;}
避免局部数组在定义时初始化
函数内定义一个局部数组并同时初始化,如
unsigned char array[3] ={0};
编译器实际上会对此初始化调用memcpy。如果仅仅因初始化几个节而调用库函数,未免过于大动干戈,这样的开销是完全不值得的。对这样的情况可以在代码中显式赋值,而删除定义时的初始化。如果数组元素较多,也完全可以自己实现一个专用函数,因为这样的库函数往往属于前面所说的大而全函数。
避免位域操作,用移位实现
对于位域读取,指令操作为:读取内存,移位,掩码相与获取指定位域,而写则更为复杂,通常对于连续几个位域的写操作可以优化实现为:1.掩码相与,2.移位,3.多项相或。
3 内存使用优化
有层次地使用内存
在具有多层内存架构的系统中,内存布局是一个很重要的性能提升课题。越接近处理核心的内存访问开销越小,反之则越大,根据这个性质可以得出这样一个性能提升原则:越频繁访问的代码和数据,越应该放在靠近处理核心的内存,而访问频度低得代码和数据则放在远离处理核心的内存。
cache的使用
cache属于多级内层中最接近处理核心的内存,cache可以显著提高访问速度,而提高cache命中率是提升内存访问效率的关键。代码和数据局部性越好,cache命中率越高。一般来说代码的cache命中率比较高,一则代码本身量不会太大,二则因为代码的执行不会太随机。对于数据则要小心设计,巧妙布局才能充分利用cache,一次处理的数据尽量相邻存放,尽量不要使用链表和树等物理上可能不连续的数据结构。
避免过多全局变量访问;
全局变量访问通常包含两个操作:1.获取全局变量地址;2.根据地址访问全局变量内容。如果用指针代替全局变量,那么第一步操作就省掉了。
注意库函数的内存分布情况;
库函数通常是自动链接的,而实际装载的地址根据需要进行优化。如果频繁访问库,那么库就不应该放在外层内存;反之,也不能白白占用稀有的内层内存资源。
4 其他
汇编是最后的选择
不到万不得已不用汇编直接编码,可维护性差,可读性差,容易出错。如果经常需要使用汇编,不能说明程序员是神一样的程序员,只能说明编译器是猪一样的编译器。
另外,如果一个系统非要到用汇编才能满足性能要求,那么这个系统设计本身是值得再审视的。
消灭不必要的参数检查
不要采取五步一哨,十步一岗式的参数检查,模块内部的参数检查可省则省,只对可能导致不可恢复错误的参数进行检查。
优化策略
1. 前面介绍的各种手段,应优先应用在开销排在前几位的函数;
2. 实测确认效果后再确定修改;
3. 先按优化点,再按函数或模块优化。