开发运行在SoC内的嵌入式处理器内核的程序时,工程师有两个主要目的:运行得足够快,使处理器运行的频率降到最低;消耗尽量少的内存,使内存开销降到最小。
对于不同的项目,有时候这两个因素的重要性会不一样。下面两个关键因素极大地影响着设计团队满足这些目标的能力:开发源程序的编译器对代码的优化效率以及用于开发源代码的编程风格。本文将深入地讨论这两种因素,并提出一些创建小而快的C程序的建议。
编译器原理
编译器通常是由前端和后端两部分组成。前端通常是指语法和语义的处理过程,后端通常是指优化、代码生成,以及针对特定处理器的优化过程。很多好的编译器后端依赖于多层的中间表述(IR)。优化和代码生成从高层(类型输入程序的句法)到低层逐级地传递中间表述。与处理器无关的优化一般倾向于在编译过程的早期在较高IR层上实现,而针对特定处理器的优化一般倾向于在编译过程的后期在低层IR上来实现。信息通过不同IR层向下传递,这样低层优化可以充分利用编译器早期处理得到的高层信息。
Tensilica针对其Xtensa可配置处理器和Diamond标准处理器的XCC/C++编译器包含四个基本的优化级,从-O0到-O3,对应着不断提高的优化级别。表1描述了这些级别及其相对应的代码大小和内部过程分析(IPA)。缺省情况下,XCC编译器一次优化一个文件,但是它也可以执行内部过程分析(通过加入IPA的编译选项)。当在多个原文件上优化整个应用程序时,优化将会被延迟到链接的步骤之后进行。表2描述了当前编译器(包括XCC编译器)支持的优化内容部分列表。
XCC编译器还可以利用编译产生的性能分析数据。性能分析的反馈可以帮助编译器减轻分支跳转的延迟。另外,反馈可以让编译器只是插入那些最常用的函数(inline),并且妥善处理常用代码段中寄存器溢出的问题。因此,性能分析反馈允许XCC编译器在所有地方进行正常优化的同时,还可以通过优化应用中的临界部分进行加速。
一些有用的C编码规则
为了利用编译器得到最好的性能,编程人员需要像编译器一样思考问题,并且理解C语言和目标处理器之间的关系。下面的一些基本原则可以帮助所有嵌入式编程人员在不需很大努力的情况下获得性能好很多的编译代码。
1. 观察编译得到的代码
完全理解编译器对全部代码如何编译是不可能的。如果XCC编译器设置了—S或者-save-temps编译选项,编译将产生汇编输出并且还有一些为了理解而添加的注释。对于那些性能要求很高的代码,你可以观察编译结果是否符合你的期望。如果不是,请考虑以下规则。
2. 了解混淆发生的情况
C语言允许任意地使用指针,这增加了混淆出现的机会,这允许程序用很多种方法去引用同一数据对象。如果全局变量的地址被作为子程序的参数传递,这个变量可以通过它的名字或者通过指针被引用。这就是一种混淆,编译器必须保守地把这样的数据对象保存在内存中而不是寄存器中,并且仔细地保持代码中可能引起混淆的变量的访问顺序。考虑下面的代码:
void foo(int *a, int *b)
{
int i;
for (i=0; i<100; i++) {
*a += b;
}
}
您会设想编译器应该产生代码是在循环开始前将*a保存到一个寄存器里面,并且在循环中把b保存到一个寄存器里面然后将它加到*a所在的寄存器里。但事实上却是,编译器产生的结果是*a被放置在内存里面,因为a和b可以产生混淆情况,*a也许是b数组的一个元素。虽然看起来在这个例子中不太可能出现这种混淆,但是编译器是没法确定这种情况是否会发生的。有几个技巧可以针对混淆的情况,帮助编译器能做到更好的编译工作:你可以使用-IPA编译选项进行编译,你可以用全局变量代替参数,你可以使用特殊编译选项进行编译,或者可以在声明变量中使用_restrict属性。
3. 指针常常引起混淆
编译器识别指针指向的目标对象经常会遇到问题。程序员可以通过使用本地变量帮助编译器去避免混淆,具体方法是使用本地变量去存储依据指针访问获得的值,因为不直接的操作和调用影响指针引用的值而不是本地变量的值。因此,编译器会把本地变量放到寄存器里面去。
下面的例子显示如何正确使用指针以避免混淆从而产生更好的编译代码。在这个例子里面,优化者不知道*p++=0是否会修改len,所以它不能把len放到寄存器里面去获得性能提升。相反每个循环中,len都被放到了内存里面。
int len = 10;
void
zero(char *p)
{
int i;
for (i=0; i}
通过使用本地变量而不是全局变量,可以避免混淆。
int len = 10;
void
zero(char *p)
{
int local_len = len;
int i;
for (i=0; i< local_len; i++) *p++ = 0;
}
4. 使用const和restrict限定词
_restrict限定词告诉编译器可以假设有资格的指针是唯一访问某内存或数据对象的方式。通过这个指针的Load和Store操作不会引起与这个函数内部其它Load和Store操作的混淆,除非通过这个指针的访问。例如:
float x[ARRAY_SIZE];
float *c = x;
void f4_opt(int n, float * __restrict a, float * __restrict b)
{
int i;
/* No data dependence across iterations because of __restrict */
for (i = 0; i < n; i++)
a = b + c;
}
5. 使用本地变量替代全局变量
这是因为全局变量会在整个程序的生命周期里面保留数值。编译器必须认为全局变量可能通过指针被访问。考虑下面的代码:
int g;
void foo()
{
int i;
for (i=0; i<100; i++){
fred(i,g);
}
}
理想情况下,g在每次fred循环时被加载一次,并且它的值将被传递到一个寄存器里面给fred函数使用。但是,编译器不知道fred是否会修改g的值。如果fred不会修改g的值,你应该像下面一样,使用本地变量。这样做可以避免每次调用fred函数时加载g到一个寄存器里面。
int g;
void foo()
{
int i, local_g=g;
for (i=0; i<100; i++){
fred(i,local_g);
}
}
6. 针对数据结构使用正确的数据类型
C编程人员对于数据类型一般都会有他们习惯上的假设,但是编译器却需要很谨慎地对待这些假设。比如,在几乎所有现代的计算机架构上,一个unsigned char使用8位表示从0到255。一个C程序会假设对值为255的unsigned char加1会使其变为0。而实际上,现代32位处理器是不会执行上述的那种8位加法,而是进行32位数值的加法。因此,如果一个unsigned char的本地变量进行加法,编译器必须使用多条指令进行运算以保证加法后的符号扩展。因此,针对各种变量尤其是循环索引的变量,应该尽量多的在可以的地方使用int型变量。
另外,许多嵌入式处理器有16位乘法指令,而缺少32位乘法指令。在这种情况下,32位乘法将被仿效执行,一般情况下都是很慢的。如果数据被执行乘法操作并且计算结果不会超过16位的精度,那么就使用short或者unsigned short变量。
7. 不要用不直接的调用
这是通过包含传递参数的函数指针的调用,因为那会产生不可预知的边际效应(比如修改全局变量),使得优化难以进行。
8. 编写返回数值的函数而不是返回指针的函数
9. 传递变量时使用数值而不是指针或者全局变量
传递大结构的数据时,才使用指针。每个通过数值被传递的结构都应该在函数调用入口处被完全拷贝存储过。
10. 使用变量的地址会使程序性能降低
因为本地变量的地址会引起混淆,这如同全局变量一样。
11. 用const声明指针参数
如果函数体内不会修改到指针指向的对象,就要用const声明指针参数,这样可以让编译器避免不必要的反面假设。
12. 使用数组而不是指针,考虑下面通过指针访问数组的代码
for (i=0; i<100; i++)
*p++ = ...
在每次循环中,*p被赋值。这种对指针对象的赋值会阻碍优化。某些情况下,指针指向它自己,那么这种赋值就会修改指针本身的值,这就会强迫编译器每次循环都重新加载该指针。还有,编译器不能确定这个指针不会被循环体以外的使用,所以每次循环外都要依据增量的数值更新该指针。因此,最好使用下面的代码:
for (i=0; i<100; i++)
p = ...
13. 编写简单易懂的代码
编译器擅长创建复杂的优化,比如函数嵌入和在适当的时候循环体展开。但编译器不擅长简化代码,他们不会合并循环或者不用函数嵌入。在源程序中为了支持某些处理器架构进行的手工的循环体展开会降低程序的可移植性,因为这阻止了编译器自动为其他的处理器架构进行正确的循环体展开和函数嵌入。
14. 避免编写参数数量可变的函数
如果一定要这么做,使用ANSI标准方法:stdarg.h.。使用数据表替代if-then-else或者switch分支处理。比如考虑下面代码:
typedef enum { BLUE, GREEN, RED, NCOLORS } COLOR;
替代
switch (c) {
case CASE0: x = 5; break;
case CASE1: x = 10; break;
case CASE2: x = 1; break;
}
使用
static int Mapping[NCOLORS] = { 5, 10, 1 };
...
x = Mapping[c];
15. 依靠libc函数库(比如:strcpy、strlen、strcmp、bcopy、bzero、memset和memcpy)。这些函数是经过精心优化的。
本文小结
编译器设计者已经开发了很多复杂的优化功能以使最新的处理器获得最大的性能,并且他们还在继续开发更智能的优化算法。应用程序开发人员可以通过使用恰当的编程规则来尽可能多地利用编译器的这些优化功能。
作者:Dror Maydan
软件工程总监
Steve Leibson
技术专家
sleibson@tensilica.com
Tensilica公司
文章评论(0条评论)
登录后参与讨论