查看原文
其他

超炫酷技巧!C语言代码优化的技巧

正念 嵌入式ARM 2021-01-31

直接来源:嵌入式大杂烩


一、变量与值得比较

1、布尔变量与零值的比较

不可将布尔变量直接与 TRUE、 FALSE或者 1、 0进行比较 。据布尔类型的语义,零值为“ 假”(记为 FALSE),任何非零值都是“ 真”(记为TRUE)。

TRUE的值究竟是什么并没有统一的标准。例如 Visual C++ 将 TRUE定义为 1, 而 Visual Basic则将 TRUE定义为-1 。

假设布尔变量名字为 flag,它与零值比较的标准 if语句如下:

if (flag) // 表示flag为真
if (!flag) // 表示flag为假

其它的用法都属于不良风格,例如:

if (flag == TRUE)
if (flag == 1 )
if (flag == FALSE)
if (flag == 0)

2、整形变量与零值的比较

应当将整型变量用“ ==” 或“ !=” 直接与 0比较 。假设整型变量的名字为 value,它与零值比较的标准 if语句如下:

if (value == 0)
if (value != 0)

不可模仿布尔变量的风格而写成:

if (value) // 会让人误解 value是布尔变量
if (!value)

3、浮点变量与零值的比较

不可将浮点变量用“ ==” 或“ !=” 与任何数字比较 。千万要留意, 无论是 float还是 double类型的变量, 都有精度限制。

所以一定要避免将浮点变量用“ ==” 或“ !=” 与数字比较,应该设法转化成“ >=” 或“ <=” 形式。假设浮点变量的名字为 x,应当 将:

if (x == 0.0) // 隐含错误的比

转化为:

if ((x>=-EPSINON) && (x<=EPSINON))

其中 EPSINON是允许的误差(即精度) 。

4、指针变量与零值的比较

应当将指针变量用“ ==” 或“ !=” 与 NULL比较 。指针变量的零值是“ 空”(记为 NULL)。

尽管 NULL 的值与 0相同,但是两者意义不同。假设指针变量的名字为 p,它与零值比较的标准 if语句如下:

if (p == NULL) // p与 NULL显式比较,强调 p是指针变量
if (p != NULL)

不要写成:

if (p == 0) // 容易让人误解 p是整型变量
if (p != 0)

或者:

if (p) // 容易让人误解p是布尔变量
if (!p)

二、变量及基本运算

1、整型数

如果我们确定整数非负,就应该使用unsigned int而不是int。

有些处理器处理无符号unsigned 整形数的效率远远高于有符号signed整形数(这是一种很好的做法,也有利于代码具体类型的自解释)。

因此,在一个紧密循环中,声明一个int整形变量的最好方法是:

registerunsignedint variable_name;

记住,整形in的运算速度高浮点型float,并且可以被处理器直接完成运算,而不需要借助于FPU(浮点运算单元)或者浮点型运算库。

尽管这不保证编译器一定会使用到寄存器存储变量,也不能保证处理器处理能更高效处理unsigned整型,但这对于所有的编译器是通用的。

例如在一个计算包中,如果需要结果精确到小数点后两位,我们可以将其乘以100,然后尽可能晚的把它转换为浮点型数字。

2、除法和取余数

在标准处理器中,对于分子和分母,一个32位的除法需要使用20至140次循环操作。

除法函数消耗的时间包括一个常量时间加上每一位除法消耗的时间。

Time (numerator / denominator) = C0 + C1* log2 (numerator / denominator)
= C0 + C1 * (log2 (numerator) - log2 (denominator)).

对于ARM处理器,这个版本需要20+4.3N次循环。这是一个消耗很大的操作,应该尽可能的避免执行。

有时,可以通过乘法表达式来替代除法。例如,假如我们知道b是正数并且b*c是个整数,那么(a/b)>c可以改写为a>(c*b)

如果确定操作数是无符号unsigned的,使用无符号unsigned除法更好一些,因为它比有符号signed除法效率高。

3、取模的一种替代方法

我们使用取余数操作符来提供算数取模。但有时可以结合使用if语句进行取模操作。考虑如下两个例子:

uint modulo_func1 (uint count)
{
return (++count % 60);
}

uint modulo_func2 (uint count)
{
if (++count >= 60)
count = 0;
return (count);
}

优先使用if语句,而不是取余数运算符,因为if语句的执行速度更快。这里注意新版本函数只有在我们知道输入的count结余0至59时在能正确的工作。

4、使用数组下标

如果你想给一个变量设置一个代表某种意思的字符值,你可能会这样做:

switch ( queue )
{
case0 : letter = 'W';
break;
case1 : letter = 'S';
break;
case2 : letter = 'U';
break;
}

或者这样做:

if ( queue == 0 )
letter = 'W';
elseif ( queue == 1 )
letter = 'S';
else
letter = 'U';

一种更简洁、更快的方法是使用数组下标获取字符数组的值。如下:

staticchar *classes="WSU";
letter = classes[queue];

5、使用别名

考虑如下的例子:

void func1( int *data )
{
int i;

for(i=0; i<10; i++)
{
anyfunc( *data, i);
}
}

尽管*data的值可能从未被改变,但编译器并不知道anyfunc函数不会修改它,所以程序必须在每次使用它的时候从内存中读取它。如果我们知道变量的值不会被改变,那么就应该使用如下的编码:

void func1( int *data )
{
int i;
int localdata;

localdata = *data;
for(i=0; i<10; i++)
{
anyfunc ( localdata, i);
}
}

这为编译器优化代码提供了条件。

6、局部变量的类型

我们应该尽可能的不使用char和short类型的局部变量。对于char和short类型,编译器需要在每次赋值的时候将局部变量减少到8或者16位。

这对于有符号变量称之为有符号扩展,对于无符号变量称之为零扩展。这些扩展可以通过寄存器左移24或者16位,然后根据有无符号标志右移相同的位数实现,这会消耗两次计算机指令操作(无符号char类型的零扩展仅需要消耗一次计算机指令)。

可以通过使用int和unsigned int类型的局部变量来避免这样的移位操作。这对于先加载数据到局部变量,然后处理局部变量数据值这样的操作非常重要。无论输入输出数据是8位或者16位,将它们考虑为32位是值得的。

考虑下面的三个函数:

int wordinc (int a)
{
return a + 1;
}
short shortinc (short a)
{
return a + 1;
}
char charinc (char a)
{
return a + 1;
}

尽管结果均相同,但是第一个程序片段运行速度高于后两者。

三、循环语句

1、多重循环

在多重循环中, 如果有可能, 应当将最长的循环放在最内层, 最短的循环放在最外层,以减少 CPU 跨切循环层的次数。例如示例 4-4(b)的效率比示例4-4(a)的高 :


2、循环体内的判断

如果循环体内存在逻辑判断, 并且循环次数很大, 宜将逻辑判断移到循环体的外面。

示例 4-4(c)的程序比示例 4-4(d)多执行了 N-1次逻辑判断。并且由于前者老要进行逻辑判断,打断了循环“ 流水线” 作业,使得编译器不能对循环进行优化处理, 降低了效率。

如果N非常大, 最好采用示例 4-4(d)的写法, 可以提高效率。如果 N非常小,两者效率差别并不明显,采用示例 4-4(c)的写法比较好, 因为程序更加简洁。


3、for 语句的循环控制变量

不可在 for 循环体内修改循环变量,防止 for 循环失去控制 。建议 for语句的循环控制变量的取值采用“ 半开半闭区间” 写法。

示例 4-5(a)中的 x值属于半开半闭区间“ 0 =< x < N”,起点到终点的间隔为 N,循环次数为 N。

示例 4-5(b)中的 x值属于闭区间“ 0 =< x <= N-1”,起点到终点的间隔为 N-1,循环次数为 N。

相比之下,示例 4-5(a)的写法更加直观,尽管两者的功能是相同的 。


4、更快的for()循环

这是一个简单而高效的概念。通常,我们编写for循环代码如下:

for( i=0; i<10; i++){ ... }

i从0循环到9。如果我们不介意循环计数的顺序,我们可以这样写:

for( i=10; i--; ) { ... }

这样快的原因是因为它能更快的处理i的值–测试条件是:i是非零的吗?如果这样,递减i的值。对于上面的代码,处理器需要计算“计算i减去10,其值非负吗?

如果非负,i递增并继续”。简单的循环却有很大的不同。这样,i从9递减到0,这样的循环执行速度更快。

这里的语法有点奇怪,但确实合法的。循环中的第三条语句是可选的(无限循环可以写为for(;;))。如下代码拥有同样的效果:

for(i=10; i; i--){}

或者更进一步的:

for(i=10; i!=0; i--){}

这里我们需要记住的是循环必须终止于0(因此,如果在50到80之间循环,这不会起作用),并且循环计数器是递减的。使用递增循环计数器的代码不享有这种优化。

四、指针

我们应该尽可能的使用引用值的方式传递结构数据,也就是说使用指针,否则传递的数据会被拷贝到栈中,从而降低程序的性能。

函数通过参数接受结构数据的指针,如果我们确定不改变数据的值,我们需要将指针指向的内容定义为常量。例如:

void print_data_of_a_structure ( const Thestruct *data_pointer)
{
...printf contents of the structure...
}

这个示例告诉编译器函数不会改变外部参数的值(使用const修饰),并且不用在每次访问时都进行读取。

同时,确保编译器限制任何对只读结构的修改操作从而给予结构数据额外的保护。

五、懒检测开发

if(a>10 && b=4)这样的语句中,确保AND表达式的第一部分最可能较快的给出结果(或者最早、最快计算),这样第二部分便有可能不需要执行。

六、用switch()函数替代if…else…

对于涉及if…else…else…这样的多条件判断,例如:

if( val == 1)
dostuff1();
elseif (val == 2)
dostuff2();
elseif (val == 3)
dostuff3();

使用switch可能更快:

switch( val )
{
case1: dostuff1(); break;

case2: dostuff2(); break;

case3: dostuff3(); break;
}

在if()语句中,如果最后一条语句命中,之前的条件都需要被测试执行一次。switch允许我们不做额外的测试。如果必须使用if…else…语句,将最可能执行的放在最前面。


函数相关

1、参数的书写要完整

参数的书写要完整,不要贪图省事只写参数的类型而省略参数名字。如果函数没有参数,则用void填充。例如:

voidSetValue(intwidth,intheight); // 良好的风格
voidSetValue(int,int); // 不良的风格
floatGetValue(void); // 良好的风格
floatGetValue(); // 不良的风格

2、参数命名要恰当,顺序要合理

例如编写字符串拷贝函数StringCopy,它有两个参数。如果把参数名字起为str1和str2,例如:

void StringCopy(char*str1,char*str2);

那么我们很难搞清楚究竟是把str1拷贝到str2中,还是刚好倒过来。

可以把参数名字起得更有意义,如叫strSourcestrDestination。这样从名字上就可以看出应该把strSource拷贝到strDestination。

还有一个问题,这两个参数那一个该在前那一个该在后?参数的顺序要遵循程序员的习惯。一般地,应将目的参数放在前面,源参数放在后面:

void StringCopy(char*strDestination,char*strSource);

3、参数是指针

如果参数是指针,且仅作输入用,则应在类型前加const,以防止该指针在函数体内被意外修改。

例如:

void StringCopy(char*strDestination,constchar*strSource);

4、不要省略返回值的类型

C语言中,凡不加类型说明的函数,一律自动按整型处理。这样做不会有什么好处,却容易被误解为void类型。

5、函数名字与返回值类型在语义上不可冲突

违反这条规则的典型代表是C标准库函数getchar。例如:

charc;
c=getchar();
if(c==EOF)

按照getchar名字的意思,将变量c声明为char类型是很自然的事情。但不幸的是getchar的确不是char类型,而是int类型,其原型如下:

int getchar(void);

由于c是char类型,取值范围是[-128,127],如果宏EOF的值在char的取值范围之外,那么if语句将总是失败,这种“危险”人们一般哪里料得到!导致本例错误的责任并不在用户,是函数getchar误导了使用者。

6、不要将正常值和错误标志混在一起返回

正常值用输出参数获得,而错误标志用return语句返回。

回顾上例,C标准库函数的设计者为什么要将getchar声明为令人迷糊的int类型呢?

在正常情况下,getchar的确返回单个字符。但如果getchar碰到文件结束标志或发生读错误,它必须返回一个标志EOF。为了区别于正常的字符,只好将EOF定义为负数(通常为负1)。因此函数getchar就成了int类型。

我们在实际工作中,经常会碰到上述令人为难的问题。为了避免出现误解,我们应该将正常值和错误标志分开。即:正常值用输出参数获得,而错误标志用return语句返回。

函数getchar可以改写成BOOL GetChar(char*c);

7、附加返回值,增强函数的灵活性

有时候函数原本不需要返回值,但为了增加灵活性如支持链式表达,可以附加返回值。例如字符串拷贝函数strcpy的原型:

char *strcpy(char *strDest,const char *strSrc);

strcpy函数将strSrc拷贝至输出参数strDest中,同时函数的返回值又是strDest。这样做并非多此一举,可以获得如下灵活性:

char str[20];
int length=strlen(strcpy(str,“HelloWorld”));


循环展开

简单的循环可以展开以获取更好的性能,但需要付出代码体积增加的代价。循环展开后,循环计数应该越来越小从而执行更少的代码分支。

如果循环迭代次数只有几次,那么可以完全展开循环,以便消除循坏带来的负担。例如:

for(i=0; i<3; i++)
{
    something(i);
}

展开为:

something(0);
something(1);
something(2);

这可以非常可观的节省性能,原因是代码不用每次循环需要检查和增加i的值。

if判断条件的顺序

if的判断条件中概率最大的情况应放在前面。例子:

if (1 == condition)
{

}
else if (2 == condition)
{

}
else
{

}

此处,若condition为1的概率大较大则把if (1 == condition)放在前面。

condition为2概率大较大则把if (2 == condition)放在前面,如:

if (2 == condition)
{

}
else if (1 == condition)
{

}
else
{

}

这里有个小细节:在用if判断某个变量与某个常量是否相等时,可以把常量写在前面变量写在后面,如:

if (2 == condition)

2放在前面,condition放在后面。这样的好处就是当你漏敲了一个=号时,编译器会指出你的这个错误。

尽早退出循环

通常,循环并不需要全部都执行。例如,如果我们在从数组中查找一个特殊的值,一经找到,我们应该尽可能早的断开循环。

例如:如下循环从10000个整数中查找是否存在-99。

found = FALSE;
for(i=0;i<10000;i++)
{
    iflist[i] == -99 )
    {
        found = TRUE;
    }
}

if( found ) 
{
    printf("Yes, there is a -99. Hooray!\n");
}

这段代码无论我们是否查找得到,循环都会全部执行完。更好的方法是一旦找到我们查找的数字就终止继续查询。

把程序修改为:

found = FALSE;
for(i=0;i<10000;i++)
{
    iflist[i] == -99 )
    {
        found = TRUE;
        break;
    }
}

if( found ) 
{
    printf("Yes, there is a -99. Hooray!\n");
}

假如待查数据位于第23个位置上,程序便会执行23次,从而节省9977次循环。

使用位运算替代四则运算

在许多古老的微处理器上, 位运算比加减运算略快, 通常位运算比乘除法运算要快很多。

在现代架构中, 位运算的运算速度通常与加法运算相同,但仍然快于乘法运算。所以通常乘以或除以2n可以使用位运算来代替四则运算,如

a = a * 8;
a = a / 8;
a = a % 8;

修改为:

a = a << 3;
a = a >> 3;
a = a & 7;

以空间换时间

在内存比较充足的情况下,可以使用空间来换取时间。比如使用查表法,把一些可能的结果事先保存到表中。例如求阶乘通常的做法是:

long factorial(int i)
{
    if (i == 0)
        return 1;
    else
        return i * factorial(i - 1);
}

若是空间比较足,而且所需的结果都能列举出来,则代码可以修改为:

static long factorial_table[] = {1, 1, 2, 6, 24, 120, 720  /* etc */ };
long factorial(int i)
{
    return factorial_table[i];
}

使用复合赋值语句

增加一个变量的值有两种方式,如:a = a + 5a += 5。存在两种增加一个变量值的方法有何意义呢?

K&R C设计者认为复合赋值符可以让程序员把代码写得更清楚些。另外,编译器可以产生更为紧凑的代码。

现在,a = a + 5和a += 5之间的差别不再那么显著,而且现代的编译器为这两种表达式产生优化代码并无多大问题。

但是,要考虑类似如下的语句:

a[2*(y-6*f(x))] = a[2*(y-6*f(x))] + 5;
a[2*(y-6*f(x))] += 5;

此处a为数组。在第一种形式种,由于编译器无从知道f函数是否具有副作用,所以它必须两次计算数组a的下标表达式的值。

而在第二种形式中,下标表达式只需计算一次,所以第二种形式效率更高。并且,从书写的角度看,第一种形式的下标表达式需要书写两次,而第二种形式只需书写一次。

尽量使循环体内的工作量达到最小化

循环中,随着循环次数的增加,会加大对系统资源的消耗。我们应当确认一些操作是否必须放在循环体内。示例代码:

for (i = 0; i < n; i++)
{
    tmp += i;
    sum = tmp;
}

这是个求和操作,但是这里每循环一次,就要进行一次sum = tmp;操作,这样的写法很浪费资源。这一条语句完全可以移至循环体外:

for (i = 0; i < n; i++)
{
    tmp += i;
}
sum = tmp;

这样,sum = tmp;语句只执行一次,不仅可以调高程序效率,也提高了可读性。同时,我们还可以考虑类似这样的代码是否有必要封装成一个函数供多个地方调用。

无限循环优先选用for(;;),而不是while(1)

在C语言中,最常用的无限循环语句主要有两种:while(1)for(;;)。从功能上讲, 这两种语句的效果完全一样。那么,我们究竟该选择哪一种呢?

其实,for(;;)语句运行速度要快一些。按照for的 语法规则,两个分号分开的是3个表达式。现在表达式为空,很自然地被编译成无条件的跳转(即无条件循环,不用判断条件)。如代码for(;;)Microsoft Visual Studio 2010 集成开发环境VC++的Debug模式下将生成如下汇编代码:

for(;;)
00931451 jmp main+41h (931451h)

相比之下,while语句就不一样了。按照while的语法规则,while()语句中必须有一个 表达式(这里是1 )判断条件,生成的代码用它进行条件跳转。即while语句()属于有条件循环,有条件就要判断条件是否成立,所以其相对于for(;;)语句需要多几条指令。如代码 while (1)Microsoft Visual Studio 2010集成开发环境VC++的Debug模式下将生成如下汇 编代码:

while(1)
011A1451 mov   eax,1
011A1456 test  eax,eax
011A1458 je    main+55h (11A1465h)
011A1463 jmp   main+41h (11A1451h)

根据上面的分析结果,很显然,for(;;)语句指令少,不占用寄存器,而且没有判断、 跳转指令。当然,如果从实际的编译结果来看,两者的效果常常是一样的,因为大部分编译 器都会对while (1)语句做一定的优化。

但是,这还需要取决于编译器。因此,我们还是应该优先选用for(;;)语句。

没有参数的函数必须用void填充

在C语言中,void的作用主要有两个:

1、对函数返回值的限定。
2、对函数参数的限定。

看一个示例函数:

int f()
{
    return 100;
}

从表面看,函数f()没有参数,也就是说,它不允许接受参数。但事实并非如此,我们来验证一下:

#include <stdio.h>
int f()
{
    return 100;
}

int main(void)
{
    printf("%d\n", f(666));
    return 0;
}

编译、运行结果为:

可见,使用GCC可正常通过编译,这说明可以向无参数的函数传递参数。但是,需要注意的是,在一些IDE中不能通过编译。

所以,为了提高程序的统一性、安全性与可读性。我们对没有参数的函数必须使用void进行填充。我们使用void填充上面的f函数之后,编译就不通过了,报错如下:

尽可能为简单功能编写函数

有时候,我们需要用函数去封装仅用一两行代码就可完成的功能。对于这样的函数,单 从代码最上看,好像没有什么封装的必要。但是,用函数可使其功能明确化、具体化,从而增加程序可读性,并且也方便代码的维护与测试。示例代码如下:

int Max(int x,int y)
{
    return (x>y? x : y);
}
int Min(int x,int y)
{
    return (x<y?x:y);
}

当然,也可以使用宏来代替上面的函数,代码如下:

#define MAX(x,y)  (((x) > (y)) ? (x) : (y))
#define MIN(x,y)  (((x) < (y)) ? (x) : (y))

在C程序中,我们可以适当地用宏代码来提高执行效率。宏代码本身不是函数,但使用起来与函数相似。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的CALL调用、返回参数、执行return等过程,从而提高了运行速度。但是,使用宏代码最大的缺点就是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。关于带参宏的笔记:【C语言笔记】学习使用带参宏(一)【C语言笔记】学习使用带参宏(二)

因此, 尽管看起来宏要比函数简单得多,但还是建议使用函数的形式来封装这些简单功能的代码。

函数地抽象级别应在同一个层次

先来看下面一段示例代码:

void Init(void)
{
    /* 本地初始化 */
    ......
    /* 远程初始化 */
    InitRemote();
}

void InitRemote(void)
{
    /* 远程初始化 */
    ......
}

上面地Init函数主要完成本地初始化与远程初始化工作,在其功能上没有什么不妥之处。但从设计观点看,却存在这一定缺陷。因为本地初始化与远程初始化层次相当,本地初始化也应当作为独立的函数存在。应改为:

void Init(void)
{
    /* 本地初始化 */
    InitLocal();
    /* 远程初始化 */
    InitRemote();
}

void InitLocal(void)
{
    /* 本地初始化 */
    ......
}

void InitRemote(void)
{
    /* 远程初始化 */
    ......
}

尽量避免在非调度函数中使用控制参数

在函数设计中,我们可以将函数简单地分为两大类:调度函数与非调度函数(非调度函数一般也称为功能函数或实现函数)。

所谓的调度函数是指根据输入的消息类型或控制命令来启动相应的功能实体(即函数或过程)的函数。调度函数本身不能提供功能实现,相反,它必须委托给实现函数来完成具体的功能。也就是说,调度型函数永远只关注“what to do”,而“how to do”则是由实现函数来关心的,调度函数不需要关心“how to do”。这种调度函数与实现函数的分离设计也满足了单一职责的原则,即调度的不实现,实现的不调度。

对调度函数来讲,控制参数是指改变函数功能行为的参数,即函数要根据此参数来决定具体怎样工作。然而,如果在非调度函数中也使用控制参数来决定具体怎样工作,那么这样做无疑会增加函数间的控制耦合,很可能使函数间的耦合度增大,并使函数的功能不唯一, 违背了函数功能的单一原则。示例代码如下:

int Calculate( int a, int b, const int calculate_flag )
{
    int sum = 0;
    switch(calculate_flag)
    {
        case 1: 
            sum = a + b; 
            break;
        case 2
            sum = a - b;
        case 3: 
            sum = a * b; 
            break;
        case 4: 
            sum = a / b; 
            break;
        default: 
            printf("error\n");
            break;
    }
    return sum;
}

上面的函数虽然看起来很简洁,实际上这种设计是不合理的。由于控制参数calculate_flag的原因,使函数间的耦合度增大,也违背了函数的功能单一原则。因此,不如分为如下4个函数清晰,示例代码如下:

int Add(int a,int b)
{
    return a + b;
}
int Sub(int a,int b)
{
    return a - b;
}
int Mul(int a,int b)
{
    return a * b;
}
int Div(int a,int b)
{
    return a / b;
}


-END-


推荐阅读

【01】嵌入式是否真的高大上?为什么没有嵌入式软件架构师?【02】该如何学习嵌入式?看大佬的职业规划【03】工程师深度:FPGA 高手养成记【04】一位经验丰富的嵌入式工程师的模块化编程总结【05】抛砖引玉,教你学习各种总线技术


免责声明:整理文章为传播相关技术,版权归原作者所有,如有侵权,请联系删除

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存