Chinaunix首页 | 论坛 | 博客
  • 博客访问: 3782
  • 博文数量: 3
  • 博客积分: 130
  • 博客等级: 入伍新兵
  • 技术积分: 35
  • 用 户 组: 普通用户
  • 注册时间: 2008-06-04 15:47
文章分类
文章存档

2008年(3)

我的朋友
最近访客

分类: C/C++

2008-06-04 16:47:25

文件: C_code_style.pdf
大小: 371KB
下载: 下载

优化你的代码

事情应该尽可能简化,而不只是简单一点点,一爱因斯坦

虽然使软件正确的工作好像应该是一个工程合乎逻辑的最后一个步骤,但是在嵌入式的系统的开发中,情况并不总是这样的。出于对低价系列产品的需要,硬件的设计者需要提供刚好足够的存储器和完成工作的处理能力。当然,在工程的软件开发阶段,使程序正确的工作是很重要的。为此,通常需要一个或者更多的开发电路板,有的有附加的存贮器,有的有更快的处理器,有的两者都有。这些电路板就是用来使软件正确工作的。而工程的最后阶段则变成了对代码进行优化。最后一步的目标是使得工作程序在一个廉价的硬件平台上运行。

提高代码的效率

所有现代的C C++编译器都提供了一定程度上的代码优化。然而,大部分由编译器执行的优化技术仅涉及执行速度和代码大小的一个平衡。你的程序能够变得更快或者更小,但是不可能又变快又变小。事实上,在其中一个方面的提高就会对另一方面产生负面的影响。哪一方面的提高对于程序更加的重要是由程序员来决定。知道这一点后,无论什么时候遇到速度与大小的矛盾,编译器的优化阶段就会作出合适的选择。

因为你不可能让编译器为你同时做两种类型的优化,我建议你让它尽其所能的减少程序的大小。执行的速度通常只对于某些有时间限制或者是频繁执行的代码段是重要的。而且你可以通过手工的办法做很多事以提高这些代码段的效率。然而,手工改变代码大小是一件很难的事情,而且编译器处于一个更有利的位置,使得它可以在你所有的软件模块之间进行这种改变。

直到你的程序工作起来,你可能已经知道或者是非常的清楚,哪一个子程序或者模块对于整体代码效率是最关键的。中断服务例程、高优先级的任务、有实时限制的计算、计算密集型或者频繁调用的函数都是候选对象。有一个叫作profiler 的工具,它包括在一些软件开发工具组中,这个工具可以用来把你的视线集中到那些程序花费大部分时间(或者很多时间)的例程上去。

一旦你确定了需要更高代码效率的例程,可以运用下面的一种或者多种技术来减少它们的执行时间。

inline 函数

c++中,关键字inline 可以被加入到任何函数的声明。这个关键字请求编译器用函数内部的代码替换所有对于指出的函数的调用。这样做删去了和实际函数调用相关的时间开销,这种做法在inline 函数频繁调用并且只包含几行代码的时候是最有效的。

inline 函数提供了一个很好的例子,它说明了有时执行的速度和代码的太小是如何反向关联的。重复的加入内联代码会增加你的程序的大小,增加的大小和函数调用的次数成正比。而且,很明显,如果函数越大,程序大小增加得越明显。优化后的程序运行的更快了,但是现在需要更多的ROM

查询表

switch 语句是一个普通的编程技术,使用时需要注意。每一个由机器语言实现的测试和跳转仅仅是为了决定下一步要做什么工作,就把宝贵的处理器时间耗尽了。为了提高速度,设法把具体的情况按照它们发生的相对频率排序。换句话说,把最可能发生的情况放在第一,最不可能的情况放在最后。这样会减少平均的执行时间,但是在最差情况下根本没有改善。

如果每一个情况下都有许多的工作要做,那么也许把整个switch 语句用一个指向函数指针的表替换含更加有效。比如,下面的程序段是一个待改善的候选对象:

enum NodeType {NodeA, NodeB, NodeC} switch(getNodeType())

{

 case NodeA:

...

 case NodeB:

...

 case NodeC:

...

}

为了提高速度,我们要用下面的代码替换这个switch 语句。这段代码的第一部分是准备工作:一个函数指针数组的创建。第二部分是用更有效的一行语句替换switch 语句。

int processNodeA(void);
int processNodeB(void);
int processNodeC(void);

/*

* Establishment of a table of pointers to functions.

*/ int (* nodeFunctions[])() = { processNodeA, processNodeB, processNodeC }; ... /*

* The entire switch statement is replaced by the next line.

*/
status = nodeFunctions[getNodeType()]();

手工编写汇编

一些软件模块最好是用汇编语言来写。这使得程序员有机会把程序尽可能变得有效率。尽管大部分的C/C++编译器产生的机器代码比一个一般水平的程序员编写的机器代码要好的多,但是对于一个给定的函数,一个好的程序员仍然可能做得比一般水平的编译器要好。比如,在我职业生涯的早期,我用C 实现了一个数字滤波器,把它作为TI TMS320C30 数字信号处理器的输出目标。当时我们有的编译器也许是不知道,也许是不能利用一个特殊的指令,该指令准确地执行了我需要的那个数学操作。我用功能相同的内联汇编指令手工地替换了一段C 语言的循环,这样我就能够把整个计算时间降低了十分之一以上。

寄存器变量

在声明局部变量的时候可以使用register 关键字。这就使得编译器把变量放入一个多用选的寄存器,而不是堆栈里。合适地使用这种方珐,它会为编译器提供关于最经常访问变量的提示,会稍微提高函数的执行速度。函数调用得越是频繁,这样的改变就越是可能提高代码的速度。

全局变量

使用全局变量比向函数传递参数更加有效率。这样做去除了函数调用前参数入栈和函数完成后参数出栈的需要。实际上,任何子程序最有效率的实现是根本没有参数。然而,决定使用全局变量对程序也可能有一些负作用。软件工程人士通常不鼓励使用全局变量,努力促进模块化和重入目标,这些也是重要的考虑。

轮询

中断服务例程经常用来提高程序的效率。然而,也有少数例子由于过度和中断关联而造成实际上效率低下。在这些情况中,中断间的平均时间和中断的等待时间具有相同量级。这种情况下,利用轮询与硬件设备通信可能会更好。当然,这也会使软件的模块更少。

定点运算

除非你的目标平台包含一个浮点运算的协处理器,否则你会费很大的劲去操纵你程序中的浮点数据。编译器提供的浮点库包含了一组模仿浮点运算协处理器指令组的子程序。很多这种函数要花费比它们的整数运算函数更长的执行时间,并且也可能是不可重入的。

如果你只是利用浮点数进行少量的运算,那么可能只利用定点运算来实现它更好。虽然只是明白如何做到这一点就够困难的了,但是理论上用定点运算实现任何浮点计算都是可能的。(那就是所谓的浮点软件库。)你最大的有利条件是,你可能不必只是为了实现一个或者两个计算而实现整个IEEE 754 标准。如果真的需要那种类型的完整功能,别离开编译器的浮点库,去寻找其他加速你程序的方法吧。

减小代码的大小

正如我早先说的那样,当问题归结于减小代码的大小的时候,你最好让编译器为你做这件事。然而,如果处理后的程序代码对于你可得的只读存贮器仍然太大了,还有几种技术你可以用来进一步减少体程序的大小。在本节中,自动的和人工的代码优化我们都要讨论。

当然,墨菲法则指出,第一次你启用编译器的优化特性后,你先前的工作程序会突然失效,也许自动优化最臭名昭著的是死码删除。这种优化会删除那些编译器相信是多余的或者是不相关的代码,比如,把零和一个变量相加不需要任何的计算时间。但是你可能还是希望如果程序代码执行了编译器不了解的函数,编译器能够产生那些不相关的指示。

比如,下面这段给出的代码,大部分优化编译器会去除第一条语句,因为

*pControl 在重写(第三行)之前没有使用过:

*pControl = DISABLE;
*pData = 'a';
*pCotrol = ENABLE;

但是如果pControl pData 实际上是指向内存映像设备寄存器的指针怎么办?这种情况下,外设在这个字节的数据写入之前将接收不到DISABLE 的命令。这可能会潜在地毁坏处理器和这个外设之间的所有未来的交互作用。为了使你避免这种问题,你必须用关键字volatile声明所有指向内存映像设备寄存器的指针和线程之间(或者是一个线程和一个中断服务程序之间)共享的全局变量。你只要漏掉了它们中的一个,墨菲法则就会在你的工程的最后几天里回来,搅得你心神不宁。我保证。—————————————————————————————————警告:千万不要误以为程序优化后的行为会和未优化时的一样。你必须在每一次新的优化后完全重新测试你的软件,以确保它的行为没有发生改变。—————————————————————————————————

更糟糕的是,或者退一步说,调试一个优化过的程序是富有挑战性的。启用了编译器的优化后,在源代码中的一行和实现这行代码的那组处理器指令之间的关联关系变得更加微弱了。那些特定的指令可能被移动或者拆分开来,或者两个类似的代码可能现在共用一个共同的实现。实际上,高级语言程序的有些行可能完全从程序中去除了(正如在前面例子里那样)。结果,你可能无法在程序特定的一行上设置一个断点或者无法研究一个感兴趣变量的值。

一旦你使用了自动优化,这里有一些关于用手工的办法进一步减少代码大小的技巧。

避免使用标准库例程

为了减少你的程序的大小,你所能做的最好的一件事情就是避免使用大的标准库例程。很多最大的库例程代价昂贵,只是因为它们设法处理所有可能的情况。你自己有可能用更少的代码实现一个子功能。比如,标准C 的库中的spintf 例程是出了名的大。这个庞大代码中有相当一部分是位于它所依赖的浮点数处理例程。但是如果你不需要格式化显示浮点数值(%f 或者%d),那么你可以写你自己的sprintf 的整数专用版本,并且可以节省几千字节的代码空间。实际上,一些标准C 的库(这让我想起Cygnus newlib) 里恰好包含了这样一个函数,叫作sprintf

本地字长

每一个处理器都有一个本地字长,并且ANSI C C++标准规定数据类型int 必须总是对应到那个字长。处理更小或者更大的数据类型有时需要使用附加的机器语言指令。在你的程序中通过尽可能的一致使用int 类型,你也许能够从你的程序中削减宝贵的几百字节。

goto 语句

就像对待全局变量一样,好的软件工程实践规定反对使用这项技术。但是危急的时候,goto 语句可以用来去除复杂的控制结构或者共享一块经常重复的代码。

除了这些技术以外,在前一部分介绍的几种方法可能也会有帮助,特别是查询表、手工编写汇编、寄存器变最以及全局变量。在这些技术之中,利用手工编写汇编通常可以得到代码最大幅度的减少量。

降低内存的使用

在有些情况下,限制你的应用程序的因素是RAM 而不是ROM。在这些情况下,你想要降低对于全局变量、堆和栈的依赖。这些优化由程序员来做比用编译器来做会更好。

由于ROM 通常比RAM 更加便宜(以每字节为基准),所以一个可接受的降低全局数据量的策略是把常数移到ROM 中去。如果你用关键字const 声明所有的常数,那么这可以由编译器自动完成。大部分的C/C++编译器把所有它们遇到的常全局数据放入一个特殊的数据段里,这个数据段可以被定位器识别为可分配ROM 的数据段。如果有很多的字符串和导向表数据在运行的时候不发生变化,那么这项技术是最有价值的。

如果有些数据一旦程序运行起来就固定了,但不一定是不变的,那么常数数据段可以改放在一个混合存储设备中。然后,这个存贮设备可以通过网络来更新,或者由一个指派的技术员来完成这个改变。在你的产品要部署的每一个地区的税率就是这种数据的一个例子。如果税率发生了改变,那么存储设备可以更新,但是同时也节省了附加的RAM

减小栈的大小也可以降低你的程序对于RAM 的需要。有一种方法可以准确地计算出你需要多大的栈。做法是用一个特殊的数据类型填满整个为栈保留的存储区域。然后,在软件运行一段时间之后——最好在正常和紧张两种情况下都运行一下——用调试工具研究被修改过的栈。有一部分仍然包含有你的特殊类型数据的栈存储区,因此可以安全地从栈的大小中减去那部分存储区的大小(1)

如果你在使用一个实时的操作系统,就要特别当心栈的大小。大部分操作系—————————————————————————————————— 1:当然,你可能想在栈中留一点额外的空间——万一你的测试没有持续足够长的时间,或者没有准确地反映所有可能的运行场景。千万不要忘记栈的溢出对于你的软件来说是一个潜在的致命事件,要不惜一切代价避免。

统为每一个任务创建一个分离的栈。这些栈用于函数的调用以及在一个任务的设备场景中遇到的中断服务倒程。你可以通过前面介绍的方式为每一个任务的栈决定其数量。你可以设法减少任务的数量或者切换到一个操作系统,这个操作系统具有分离的为执行所有中断服务例程而建立的中断栈。后一种方法可以显著地降低每个任务对栈大小的要求。

堆的大小受限于RAM 在所有的全局数据和栈空间都分配以后剩余的数量,如果堆太小,你的程序就不能够在需要的时候分配内存,因此在废弃它之前一定要把malloc new 的结果和NULL 比较。如果你试过了所有这些建议,而且你的程序仍然需要太多的存储空间,那么你除了完全删除所有的堆之外没有别的选择。

限制C++的影响

在决定写这本书的时候我面临的一个最大的问题是:是否把C++加入到讨论中去。尽管我熟悉C++,但是我不得不用C 和汇编来写几乎所有我的嵌入式软件。而且在嵌入式软件界对于C++是否值得所产生的性能损失的问题存有很大的争议。一般认为C++程序会产生更大的代码,这些代码执行起来比完全用C 写的程序要慢。然而,C++给于程序员很多好处,并且我想在这本书中讨论一些这样的好处。因此,我最终决定把C++加入到讨论中来,但是在我的例子中只是使用那些性能损失最小的特性。

我相信很多的读者在他们自己的嵌入式系统编程的时候会面对相同的问题。在结束这本书之前。我想简单地评判一下每一种我使用过的C++特性。并且提醒你一些我没有使用过的比较昂贵的特性。

当然,并不是每一件C++引入的事情都是昂贵的。很多老的C++编译器并入了一个叫作C.front 的技术,这项技术把C++的程序变成C,并且把结果供给标准的C 编译器。这个事实暗示这两种语言之间的句法差别很小,或与运行代价无关(2)。只有最新的C++特性,如模板,不能够用这种方式处理。

比如,类的定义是完全有益的。公有和私有成员数据及函数的列表与一个——————————————————————————————————

2: 而且,要澄清的是,用C++编译器编译一个普通的C 程序不会有损失。

struct 及函数原型的列表没有大的差别。然而,C++编译器能够用public private 关键字决定,哪一个方法调用和数据访问是允许的或者是不允许的。因为这个决定在编译的时候完成,所以运行时不会付出代价。单纯的加入类既不会影响代码的大小,又不会影响你的程序的效率。

嵌入式的C++标准

你可能想知道为什么C++语言的创造者加入了如此多的昂贵的——就执行时间和代码大小来说——特性。你并不是少数,全世界的人都在对同样的一件事情困惑——特别是用C++做嵌入式编程的用户们。很多这些昂贵的特性是最近添加的,它们既不是绝对的必要也不是原来C++规范的一部分。这些特性一个接着一个的被添加到正在进行着的标准化进程中来。

1996 年,一群日本的芯片厂商联台起来定义了一个C++语言和库的子集,它更加适合嵌入式软件开发。他们把他们新的工业标准叫作嵌入式C++。令人惊奇的是,在它的初期,它就在C++用户群中产生了很大的影响。

作为一个C++标准草案的合适子集,嵌入式C++省略了很多不限制下层语言可表达性的任何可以省略的东西。这些被省略的特性不仅包括像多重继承性、虚拟基类、运行时类型识别和异常处理等昂贵的特性,而且还包括了一些最新的添加特性,比如:模板、命名空问、新的类型转换等。所剩下的是一个C++的简单版本,它仍然是面向对象的并且是C 的一个超集,但是它具有明显更少的运行开销和更小的运行库。

很多商业的C++编译器已经专门地支持嵌入式C++标准。个别其他的编译器允许手工的禁用具体的语言特性,这样就使你能够模仿嵌入式C++或者创建你的很个性化的C++语言。

默认参数值也是没有损失的。编译器只是加入代码使得在每次函数被无参数调用的时候传递一个默认的值。类似地,函数名的重载也是编译时的修改。具有相同名字但是不同参数的函数在编译过程中分别分配了一个唯一的名字。每次函数名出现在程序中的时候编译器就替换它,然后连接器正确的把它们匹配起来。我没有在我的例子中使用C++的这一特性,但是我这幺做过而没有影响性能。

操作符的重载是另一个我使用过但是没有包括在例子中的特性。无论何时编译器见到这样一个操作符,它只是用合适的函数调用来替换它。因此,在下面列出的代码,最后两行是等价的,性能的损失很容易明白:

Complex a, b, c;

c = operator+(a, b)

// The traditional way: Function Call

// The C++ way: Operator Overloading

构造函数和析构函数也有一点与它们相关的损失。这些特殊的方法去分别保证每次这种类型的对象在创建或者超出了范围时被调用。然而,这个小量的开销是为减少错误而支付的一个合理代价。构造函数完整地删除了一个C 语言编程中与未初始化数据结构编程错误有关的类。这个特性也被证明是有用的,因为她隐藏了那些与像Timer Task 这样复杂的类相关的笨拙初始化顺序。

虚拟函数也具有一个合理的代价收益比。不要深究太多的关于什么是虚拟函数的细节,让我们只是说一下没有它们多态性就是不可能的。而没有多态性,C++就不可能是一个真正的面向对象的语言。虚拟函数唯一一个明显的代价是在调用虚拟函数之前附加了一个存储查询。普通的函数和方法调用是不受影响的。

就我的体验来说太昂贵的C++特性有模板、异常事件及运行类型识别。这三个特性都对代码的大小有负面的影响,而且异常事件和运行时类型识别还会增加执行时间。在决定是否使用这些特性之前,你可能要做一些实验来看看它们会怎么样影响你自己的应用程序的大小及速度。

 

阅读(570) | 评论(0) | 转发(0) |
0

上一篇:没有了

下一篇:精炼的置位操作函数

给主人留下些什么吧!~~