首先要声明的是这篇文章针对的是嵌入式编程,从做代码优化的角度来看编程的风格。个人觉得如果不是特别的针对效率上的优化( 比如说codec) ,就不需要过分纠结在比如if 和switch 哪个快这样的问题上,而只是说一个普遍的风格,怎样的风格会让程序跑得更快。
其实一个程序运行的时间效率有很大程度上是牺牲空间 来换取的,时间和空间的取舍是代码优化过程中首先要考虑的事情,需要根据实际情况来选择。不过在嵌入式产品上空间现在已经相对而言不是那么重要,更重要的时间上的效率。看过codec 代码,尤其是看过优化后的代码的人都知道,那些代码有时候很难理解,这就是嵌入式编程上的一个问题,因为需要效率,所以就牺牲了一些代码的规整性和可读性。不过在牺牲可读性并不是我们的目的,在可以的情况下我们都还是要尽量保证代码的严谨,易读。
我们首先来看看普遍的风格,这些风格有一部分很大程度上是和编译器对代码优化相关的,就是说编写的程序怎样才能符合编译器的口味,从而让它帮我们尽可能的优化。
1 、字节对齐
在编程上主要就表现在对变量的声明的顺序,结构体,类的成员变量声明的顺序上面。对变量糟糕的排列顺序,不仅浪费存储上的空间而且也会导致读取效率的低下。比如说一些处理器它总是从偶数地址读取数据 ,那么你存放在奇数地址的数据就需要都两次然后取高取低位最后组合成一个数据。gcc 默认对字节对齐是4 字节32 位的。举个例子
struct {
int a ;
short b ;
float c ;
char d[5] ;
}B ;
每种类型的有效字节对齐数是它本身的字节对齐数和强制字节对齐数( 比如说gcc 设置 的4 字节) 取小值。 假定这个结构体从0x00000000 开始存储,因为gcc 默认是4 字节对齐的,int 形也是4 字节, 所以a 存储在0 开始的地址占4 个字节;然后是b ,因为b 是2 字节的所以它必须存储在首地址是2 字节倍数的地址,所以它接着a 开始存储,占2 字节;然后是c ,它是4 字节对齐的,所以它必须放在4 字节倍数的地址,所以在b 后面需要填充2 个字节,然后再放c ;最后是d ,d 它是1 字节对齐的,所以它接着c 存放,放5 个字节;最后结构体长度必须是最大字节数类型的整数倍,所以它必须在后面再填充3 个字节,最后这个结构体大小就变成了20 字节。但是换一种方式排列的话它就只占16 个字节了:
struct {
float c ;
int a ;
short c ;
char d[5] ;
}
因此我们在声明变量,不管是在结构体,还是在类,函数里面声明变量的时候,都是尽量将字节对齐数大的放在前面,也可以认为是把占用位数多的类型方在前面。
2 、控制结构
首先循环是我们在编写代码中几乎上是必定使用到的控制结构,也是在代码优化过程中的头号内容,因为循环是相当的占用时间效率的。编译器一般都会对循环进行优化,但面对嵌套循环,甚至多重嵌套循环,以及循环内部满是if/switch 的循环编译器基本上束手无策的。
因此我们在编写代码的时候,首先要尽量避免循环,当然这基本是不可能的,但是如果是一些小的循环我们能将它拆分开来的,就尽量拆分开来。但这里也有一个度的问题,拆分循环带来的就是代码空间变大,指令变多,也就是拆分循环后的指令必须保证能被cpu 的cache 所容纳,如果cpu 频繁的在内存和cache 中读取指令这样速度反而变慢了。
然后能不放在循环中的内容就尽量不要放在循环中,比如说一些赋值啊,变量声明啊之类的,只需要处理一次的内容就不需要放在循环中处理多次。
最后就是尽量保证循环的完整性,便于编译器对循环的优化。从这个角度上来说首先要尽量避免嵌套循环,如果无法避免那么应该尽量把循环量大的内容放在嵌套的内部,从而减少循环之间频繁切换带来的效率损失;其次就是尽量避免循环中的if 语句,如果判断能放在循环外面进行的话就尽量放在循环的外面进行,因为if 语句会打断编译器对循环的优化。
除了循环就是判断语句了,经常用到的是if 和switch 。首先如果一个程序充斥着大量的if 和switch 的话,这个程序的效率肯定是很低的,if 语句是编译器优化代码的最大障碍。因此我们要尽量的少使用判断,而且一定要尽量减少大型计算中判断的次数。如果无法避免判断则我们应该将几率大的条件放在前面从而尽量减少判断的次数。
switch 语句一般会被编译器优化成各种算法,最常见的就是条件的比较链/ 树,和固定值的跳转表。因此在写switch 语句的时候要尽量保证常量的连续性来方便编译器的优化。
3 、减少运算强度
a 、首先要尽量避免除法和浮点运算,因为不管在什么处理器上这些实现起来都是最复杂的。因为复杂性,嵌入式设备要么对除法或者浮点运算不支持,或者即使是支持也需要付出昂贵的代价,所以要尽量避免浮点和除法运算。
b 、尽量用移位来代替乘法和除法,用与运算代替求余的计算,用乘法代替乘方的实现。
c 、尽量使用自增和自减运算,他们只涉及到一个寄存器的操作,循环判断的时候最好用自减运算来和0 判断。
d 、尽量使用复合表达式。
e 、如果是复杂的计算过程而数据又不是很多的情况下,可以将这些计算转化成查表,也就是说首先根据这些数据计算的结果生成一个表,以后每次计算都只需要在这些表中查找这个计算结果。这在代码优化中是经常使用的手段。
f 、尽量减少函数调用的开销,对于一些核心而有比较小的函数尽量用inline 。
g 、提高代码的并行性,减少代码之间的依赖关系,这在代码优化过程中也是一个很重要的内容。如何根据硬件的运算规则,来保持处理器高密度的计算从而使程序不致于等待下面我们会根据具体的硬件来讲。
h 、采用好的算法,这个要根据实际情况而定,比如说一段数据要进行频繁的插入和删除,则用链表就比队列有效率得多。
除了以上三个方面之外下面我们要说的就和我们所使用的硬件具有一定关系了。其实说穿了代码优化就是将代码优化到适合编译器为我们优化性能,并且优化后的代码适合硬件的规律来运算。这里我们以Arm Cortex A8 为例子来说明一下我们如何使写出来的代码更满足硬件的需要:
首先arm 一般使用r0,r1,r2,r3 来传递函数参数,如果有多余的函数参数就必须使用堆栈来传递,因此我们的函数参数要尽量控制在4 个子类,如果不行的话就应该用结构体来传递参数。
再比如我们前面提到的,提高代码的并行性。对编译器优化阻碍最大的两个因素一个是频繁的判断语句,另外一个就是代码之间的依赖了。当我们后面的计算需要前面的计算结果的时候,就会产生依赖关系,比如:
c =a *b ;
d =c +e ;
具有依赖关系的代码编译器无法进行优化,而运行的时候程序也会等待前面的计算结果而降低效率。因此在编程的时候我们要尽量避免这种粘乎乎粘在一起的代码。比如上面的代码,如果乘法运算需要3 个cpu cycle 的话,我们可以在它之间插入两个只需要1 个cycle 的加法运算,这样就提高了程序并行性:
c =a *b ;
t1 = t2 + t3 ;
t4 = t5 + t6 ;
d = c + e ;
再回到我们的Arm cortex A8 上面来,它提供了一个专用于多媒体处理的NEON 单元,这个单元包含16 个128 位的寄存器。寄存器中最小的存放单位是16 位,也就是说如果塞满的话它能塞入8 个short 形,并且支持两个寄存器之间的并行计算,也就是说如果是short 形的话它支持8 个数据的并行计算。因此如果是int 形运算的话它能达到一般运算的4 倍效率,而对于short 形他能达到8 倍的速率。
通过这些硬件特性我们就能有效的降低我们的循环次数,提高运行效率,但是实现这些并行运算的基础 还是我们的代码必须是可并行的,而不是依赖在一起的。
其实说了那么多,除了一些语言方面的基础特性之外,我们写代码最重要的还是需要让我们的代码能适合编译器和硬件的特性。只有满足了编译器的特性,它才会为我们最大优化代码,满足硬件特性它才会最高效率地运行。