作者:Hai Shalom 译者: KISSMonX
原文地址:http://www.rt-embedded.com/blog/archives/writing-efficient-c-code-for-embedded-systems/
/*
** 注:
** 本文的翻译是业余捣鼓的, 本人是个菜鸟, 不是谦虚. 呵呵...... 早就弄好了, 今天才发.
** 就是 5.1 没啥事干才头脑发热翻译的, 既是为了学英文锻炼自己, 也是为了学习文章中的知识.
** 如有错误, 误导之处, 希望大家给指出, 我会及时更改, 不胜感激! 先谢谢大家了!
*/
我们一般认为的效率主要可以概括为两类: 速度和大小(size and speed)也即时间和空间. 大多数情况下, 对某一项的优化(optimize)往往会导致其他方面的相对弱化(degradation), 要么时间换空间, 要么空间换时间. 所以要根据具体的应用(specific needs)对这两方面进行适当的折中处理. 对任何一个嵌入式系统甚至是某个软件模块而言, 要想获得最合适的策略必须在这两者之间取得适当的平衡. 但是在今天, 又有一个新的问题加入进来: 电源.
在本文中, 我将主要讨论传统的那两方面. 作为一名软件工程师, 这些年积攒了不少有关 C 语言的编程经验. 明白如何改变几行代码就能获取不同的效果---性能的提升或是最终程序的大小抑或是减小内存的使用量. 我在此所列举的例子采用的是 C 语言, 平台是 ARM. 当然对于其他类型的处理器, 一样可以获得类似的性能优化. 注意, 我可能会不时的更新这篇文章加入更多的技巧, 所以你最好保存个书签方便获得更新.
全局变量(Global Ariables)
除非必要, 不推荐使用全局变量. 比如, 有时我们需要为程序声明一个很大的 Arrays/Tables. 因此, 当你要用到全局变量时, 请参照下面的准则:
- 对从不需要改变的Tables/Arrays, 要定义成只读(const)类型(译注: 有些编译器支持关键字如 flash, code 将常量数据存储在 ROM 内). 当以常量的形式定义它们时, 编译器就会将其移到代码段内. 如果 table/array 没有被指定成 const 型, 且被定义成了共享库(shared library), 那么 table 每次都会在链接处理实例时拷贝一次. 但是, 如果被指定成了const类型, 那么他在内存中只有一个实例(single instance).
- 对全局变量的读写需要额外的操作(additional opcodes)(即加载全局变量地址, 获取地址内的值, 然后存回此值). 如果可能, 尽量使用局部变量或者使用本地拷贝(???local copy).
- 除非其他的 C 源文件也要使用这个全局变量, 不然就要声明成 static 类型(这个其实也是不推荐使用的. 你完全可以使用局部的读写函数代替. 并将其定义成 static 类型.)
变量的类型,符号和局部化(Variable types, signess and localization)
使用合适的变量类型很重要. 这里有几个规则:
- 尽量用处理器自带的类型大小来定义变量(如 32 位处理器用int). 对于一些32位处理器来说, 不建议使用short int或char类型. 有些处理器在读取 32 位字时要做一些移位和掩码(shifting and masking)操作, 才能得到正确的值.而对于拥有8位或者16位存取操作指令的处理器来说读取 32 位数据需要多次操作.
- 如果你不需要负数, 就声明 unsigned 类型变量. 尤其在你使用乘除法操作时,unsigned 类型变量可以获得更好的性能(better performance).
- 只在真正需要这个变量的时候才声明, 不要在函数的开始出就声明. 这样会使编译器更好的利用内部有限的寄存器, 从而避免将寄存器分配给那些只在后面才会用到的变量, 毕竟寄存器的数量上有限的.
除法和取余操作(Division and Modulus)
除法和取余这两个算术操作需要占用大量的CPU时钟周期. 在ARM平台下, 这些操作数以软件形式来实现的. 即便在其他处理器上, 这些操作比其他任何算术操作都要慢的事实都是很明显的.
取余操作用在一个简单的计数上面, 不管怎么看, 都不能算是高效率的方式.
// Bad example:
int tick_after_100_cycles_mod( void )
{
static unsigned int i = 0;
if( i % 100 == 0) {
return 1;
} else {
i++;
}
return 0;
}
// Good example:
int tick_after_100_cycles_cnt(
void )
{
static unsigned int i = 0;
if( i >= 100) {
return 1;
} else {
i++;
}
return 0;
}
当除数是2次幂(2, 4 , 8, 16, 32……)的形式时, 除法指令是非常高效的. 因为在处理器内部是以二进制形式表示的, 这些除法操作会被编译器自动转换成右移操作. 有时你用除以变量时, 编译器不知道其值可能不会优化这个除法操作, 所以, 你应该手动把它优化成移位操作. 这里有几个例子, 下方是ARM平台下对应的汇编代码输出:
/* Results in a call to division opcode or function */
int test_div( unsigned num, unsigned div )
{
return num / div;
}
/* Results in an optimized automatic shift right */
int test_div_hardcoded_power_2( unsigned num )
{
return num / 8;
}
/* Results in an optimized automatic shift */
int test_div_shift_right( unsigned num, unsigned powerof_two )
{
return num >> powerof_two;
}
// 对应汇编:
00000000 <test_div>:
0: e52de004 push {lr} ; (str lr, [sp, #-4]!)
4: e24dd004 sub sp, sp, #4 ; 0x4
8: ebfffffe bl 0 <__aeabi_uidiv>
c: e28dd004 add sp, sp, #4 ; 0x4
10: e8bd8000 pop {pc}
00000014 <test_div_hardcoded_power_2>:
14: e1a001a0 lsr r0, r0, #3
18: e12fff1e bx lr
0000001c <test_div_shift_right>:
1c: e1a00130 lsr r0, r0, r1
20: e12fff1e bx lr
很明显可以看到移位操作产生的高效率指令, 当函数真正用到除法指令时, 他将调用外部的__aeabi_uidiv()函数来计算结果.
for 循环
for循环在编码时用的很多. 因为人们很自然的要写一个从0到最大值的循环操作. 但是, 如果索引不是那么重要的话, 从最大值到0递减变化将会更有效率. 因为如果不和0比较, 编译器将会产生一个额外的比较指令. 每次重复的递增时, CPU都需要检查一下是否达到了循环的最大值, 然后才能打破循环. 所以, 如果我们将计数器以递减的方式循环时, 额外的比较指令就不需要了, 原因是, 当递减操作完成时会自动触发状态寄存器中的0(Z)标识, CPU看到这个位被置1时就会结束循环.
extern void foo(int );
void test_incrementing_for_loop( void )
{
int i;
for(i=0; i<100;i++) {
foo(i);
}
}
void test_decrementing_for_loop( void )
{
int i;
for(i=100; i; i--) {
foo(i);
}
}
00000000 <test_decrementing_for_loop>:
0: e92d4010 push {r4, lr}
4: e3a00064 mov r0, #100 ; 0x64
8: ebfffffe bl 0 <foo>
c: e3a04063 mov r4, #99 ; 0x63
10: e1a00004 mov r0, r4
14: ebfffffe bl 0 <foo>
18: e2544001 subs r4, r4, #1 ; 0x1
1c: 1afffffb bne 10 <test_decrementing_for_loop+0x10>
20: e8bd8010 pop {r4, pc}
00000024 <test_incrementing_for_loop>:
24: e92d4010 push {r4, lr}
28: e3a00000 mov r0, #0 ; 0x0
2c: ebfffffe bl 0 <foo>
30: e3a04001 mov r4, #1 ; 0x1
34: e1a00004 mov r0, r4
38: e2844001 add r4, r4, #1 ; 0x1
3c: ebfffffe bl 0 <foo>
40: e3540064 cmp r4, #100 ; 0x64
44: 1afffffa bne 34 <foo+0x34>
48: e8bd8010 pop {r4, pc}
可以看到, 以递减的方式操作循环会产生更少的汇编代码.
在有些需要短循环的情况下, 循环展开和去除循环开支将使代码更有效率(主要是速度上). 当你指定了优化级别-O3时, 编译器会自动配置他们产生高效的代码.
extern void foo( int );
void for_loop( void )
{
int i;
for(i=4; i; i--) {
foo(i);
}
}
void unrolled_loop( void )
{
foo(4);
foo(3);
foo(2);
foo(1);
foo(0);
}
00000000 <unrolled_loop>:
0: e52de004 push {lr} ; (str lr, [sp, #-4]!)
4: e3a00004 mov r0, #4 ; 0x4
8: e24dd004 sub sp, sp, #4 ; 0x4
c: ebfffffe bl 0 <foo>
10: e3a00003 mov r0, #3 ; 0x3
14: ebfffffe bl 0 <foo>
18: e3a00002 mov r0, #2 ; 0x2
1c: ebfffffe bl 0 <foo>
20: e3a00001 mov r0, #1 ; 0x1
24: ebfffffe bl 0 <foo>
28: e3a00000 mov r0, #0 ; 0x0
2c: e28dd004 add sp, sp, #4 ; 0x4
30: e49de004 pop {lr} ; (ldr lr, [sp], #4)
34: eafffffe b 0 <foo>
00000038 <for_loop>:
38: e92d4010 push {r4, lr}
3c: e3a00004 mov r0, #4 ; 0x4
40: ebfffffe bl 0 <foo>
44: e3a04003 mov r4, #3 ; 0x3
48: e1a00004 mov r0, r4
4c: ebfffffe bl 0 <foo>
50: e2544001 subs r4, r4, #1 ; 0x1
54: 1afffffb bne 48 <foo+0x48>
58: e8bd8010 pop {r4, pc}
咦? 循环展开后的代码变得更长了? 当你仔细他们产生的汇编代码时就会发现, unrolled_loop 运行的更快, 因为相比于for_loop的四个操作, unrolled_loop每次循环只需要两个操作. 在unrolled_loop里, 只进行了取值和调用foo()操作. 而在那个规整的循环内, 却需要四个操作: 取值, 调用函数foo(), 寄存器值减一, 将结果与零比较(如果递增的话还会多一个操作).
if-else和switch
/* Bad in worst case scenario */
if(i==1) {
do_something1();
} else if(i==2) {
do_something2();
} else if(i==3) {
do_something3();
} else if(i==4) {
do_something4();
} else if(i==5) {
do_something5();
} else if(i==6) {
do_something6();
} else if(i==7) {
do_something7();
} else if(i==8) {
do_something8();
}
/* Improved version */
if(i<=4) {
if(i==1) {
do_something1();
} else if(i==2) {
do_something2();
} else if(i==3) {
do_something3();
} else if(i==4) {
do_something4();
} else {
if(i==5) {
do_something5();
} else if(i==6) {
do_something6();
} else if(i==7) {
do_something7();
} else if(i==8) {
do_something8();
}
}
可以看到, i == 5 第一个版本也好比较5次, 而改进版只需要2次. i == 8 时两个版本的比较结果更明显. 所以, 如果需要的话, “折半查找”的方式是很好的.
惰性计算(Lazy Evaluation)
另一个if-else内表达式的计算原则就是惰性求值(Lazy Evaluation). 在有多个条件的语句中, 你得对多个条件进行检查, 产生的代码都是从左到右进行, 尽量最少的计算能够满足条件的表达式以节省时间. 例如, 对于多个”或”(||)运算的语句中, 只要有一个为真, 结果就为真. 而对于”与”(&&)运算, 只要有一个为假, 结果就为假. 我们可以利用这个特性对表达式进行更改以避免做多余的工作.
int check_something_1( int i )
{
if( i > 99 && i % 100 == 0 ) {
return 1;
}
return 0;
}
int check_something_2( int i )
{
if( i == 0 || i % 100 == 0 ) {
return 1;
}
return 0;
}
在第一个函数check_something_1()中, 我们先检查i是否大于99, 如果条件为真就要执行取余运算. 所以, 当i小于等于99的情况下, 调用这个函数时, 取余运算每次都是被忽略过去的. 而第二种情况就不一样了, 只有在i==0时表达式才会跳过取余运算. 在这两种情况下, 当你变换两个条件时就会出错, 因为最”简单”的情形总是首先出现.
返回值检查(Return Value Checking)
另一种情形也可以对代码进行微小的优化(在嵌入式系统中, 即使微小的优化也是很重要的), 那就是以与零进行比较的方式对函数的返回值进行检查. 而对于CPU来说, 与0比较所产生的操作指令往往只有1或2条. 如果一个函数以返回0表示成功, -1表示失败, 那么不要对返回的-1进行检查. 而以小于0的方式对其进行检查. 举个例子, 如果一个函数返回OK/NOK, 下面我们列举对返回值为0时进行检查的对比情形:
/* returns 0 on success, -1 on failure */
extern int foo( int i );
/* Good example 1: Good path */
if( foo( 7 ) == 0 ) {
/* Good path: do something */
}
/* Good example 2: Bad path */
if( foo( 7 ) != 0 ) {
/* Bad path: do something */
}
/* Good example 3: Bad path */
if( foo( 7 ) < 0 ) {
/* Bad path: do something */
}
/* Bad example */
if( foo( 7 ) == -1 ) {
/* Bad path */
}
查表法(Loop tables)
查表法可以提高处理速度, 因为不需要再计算他们, 增加大的空间就是为了保存提前计算好的结果. 这里就有个关于大小和速度的例子:
char get_char( unsigned int i )
{
switch(i) {
case 0:
return 'r';
case 1:
return 't';
case 2:
return '-';
case 3:
return 'e';
case 4:
return 'm';
case 5:
return 'b';
default:
return '\0';
}
}
static const char lookup_table[] = { 'r', 't', '-', 'e','m','b' };
char get_char_lookup( unsigned int i )
{
if(i>=sizeof(lookup_table)) {
return '\0';
}
return lookup_table[i];
}
00000000 <get_char>:
0: e3500005 cmp r0, #5 ; 0x5
4: 979ff100 ldrls pc, [pc, r0, lsl #2]
8: ea000005 b 24 <get_char+0x24>
c: 0000002c .word 0x0000002c
10: 00000034 .word 0x00000034
14: 0000003c .word 0x0000003c
18: 00000044 .word 0x00000044
1c: 0000004c .word 0x0000004c
20: 00000054 .word 0x00000054
24: e3a00000 mov r0, #0 ; 0x0
28: e12fff1e bx lr
2c: e3a00072 mov r0, #114 ; 0x72
30: e12fff1e bx lr
34: e3a00074 mov r0, #116 ; 0x74
38: e12fff1e bx lr
3c: e3a0002d mov r0, #45 ; 0x2d
40: e12fff1e bx lr
44: e3a00065 mov r0, #101 ; 0x65
48: e12fff1e bx lr
4c: e3a0006d mov r0, #109 ; 0x6d
50: e12fff1e bx lr
54: e3a00062 mov r0, #98 ; 0x62
58: e12fff1e bx lr
0000005c <get_char_lookup>:
5c: e3500005 cmp r0, #5 ; 0x5
60: 959f3008 ldrls r3, [pc, #8] ; 70 <get_char_lookup+0x14>
64: 83a00000 movhi r0, #0 ; 0x0
68: 97d30000 ldrbls r0, [r3, r0]
6c: e12fff1e bx lr
70: 00000000 .word 0x00000000
明显看到, 查表法要更快产生的代码量更小.
数据缓存,避免函数调用或系统调用(Data caching and avoiding calling other functions/system calls)
这里我们不是要讨论实际CPU的catch. 有时我们进行函数调用或者系统调用就是为了执行计算或者获取数据. 如果可能, 尽量减少使用他们, 尤其是系统调用, 因为系统调用会引起上下文切换(context switch)和数据拷贝(操作相对会慢).
下面这个例程, 当我们第二次调用这个函数时, 就会跳过getpid() 函数, 因为static 关键字描述的变量保存了上次的结果.
#include <unistd.h>
#include <stdlib.h>
pid_t my_getpid( void )
{
pid_t pid = getpid();
return pid;
}
pid_t my_getpid_optimized( void )
{
static pid_t pid = -1;
if(pid<0) {
pid = getpid();
}
return pid;
}
00000000 <my_getpid_optimized>:
0: e92d4010 push {r4, lr}
4: e59f4020 ldr r4, [pc, #32] ; 2c <my_getpid_optimized+0x2c>
8: e5943000 ldr r3, [r4]
c: e3530000 cmp r3, #0 ; 0x0
10: ba000001 blt 1c <my_getpid_optimized+0x1c>
14: e5940000 ldr r0, [r4]
18: e8bd8010 pop {r4, pc}
1c: ebfffffe bl 0 <getpid>
20: e5840000 str r0, [r4]
24: e5940000 ldr r0, [r4]
28: e8bd8010 pop {r4, pc}
2c: 00000000 .word 0x00000000
00000030 <my_getpid>:
30: eafffffe b 0 <getpid>
My_getpid()函数看起来要短小些(不管是代码还是汇编), 但他要比其他的函数要慢得多. 原因就是每次调用my_getpid() 函数都要引起系统调用, 换句话说, 当多次调用my_getpid_optimized()函数时只会引起有一次系统调用. 其他次数都是返回第一次保存的进程ID. 在此, 可以看到速度和大小之间的权衡因素.
高效率的查找和排序函数(Efficient search and sort functions)
uClibc(或者其他的标准 C 库)都会提供高效的查找和排序函数, 所以, 如果你要查找或排序某种类型的数据, 不要自作主张的自己”瞎写”, 尽量使用库里提供的函数. 二分法查找是 bsearch(), 快速排序叫 qsort(). 用这些函数时, 需要给出元素数目和用于比较的函数才能执行相应的算法操作. 下面这个例子就是用快速排序来对数组元素进行排序. 元素由名字和索引构成. 我们以索引号作为排序的指标, 然后再写一个比较函数.
#include <stdlib.h>
/* Entry element */
struct entry_t {
int index;
char name[ 10 ];
};
static int mycmp( const void *a, const void *b )
{
/* Compare the elements by indexes */
if(((struct entry_t *)a)->index == ((struct entry_t *)b)->index) {
return 0;
}
return ((struct entry_t *)a)->index > ((struct entry_t *)b)->index;
}
/* Maximum elements */
#define MAX_ENTRIES 100
/* The array to be sorted */
struct entry_t entries_array[ MAX_ENTRIES ];
int do_sort( void )
{
/* Sort the array */
qsort( &entries_array, MAX_ENTRIES, sizeof( struct entry_t ), mycmp );
return 0;
}
内联函数(Inline functions)
内联函数有个好处就是当需要调用它的时候只需要对其进行一次拷贝就行, 这样可以减小函数调用的开销. 尤其是在一些要求比较严格的场合. 要注意的是, 每次调用内联函数时都要重复创建一份代码的拷贝, 所以使用它时要足够聪明.
位图(Bitmaps)
位图(bitmap)是常用于表示数据的寄存器(32-bit)变量. 这32个位可被分为32份, 并且每份一位的两个状态可以代表不同的数据(on or off, enabled or disabled). 它既可以表示较少的数据块, 也可保存较大的数据(16份, 2位就可以代表4中不同的状态). 相比于其他的数据类型, 位图都是比较高效的. 主要是因为他们只有一次内存访问, 而且对这些数据的操作可以使用位运算符(&,|,~,^). 例如, 假设我们需要一张某种对象的信息表(接口,或者其他设备), 我们想记录他们的使能属性(连接, 断开等等). 通常我们会使用数组或其他类型数据来表示这些信息. 如果这些数据只有32位长或更短, 我们就可以将其存储在寄存器中, 并且以单个的位表示其属性. 每个实例对象在寄存器内都有自己固定的偏移量. 当要使能某个设置时, 只需要进行适当的偏移使用OR(|)操作即可. 要取消某个属性时也可进行适当的偏移并使用AND(&)操作即可实现目的.(类似于位字段)
编译器优化(Compiler optimizations)
千万别忘了, 让编译器进行适当的优化: 可以从这里获取有用的信息.
源文件(Resources)
快速排序:http://linux.die.net/man/3/qsort
二分法查找: http://linux.die.net/man/3/bsearch
GCC的使用: http://www.rt-embedded.com/blog/archives/using-gcc-part-1/