我讨论了编译器的工作原理,并给出了一些如何更好地编程的示例。在本系列的最后一部分中,我们将介绍一些更具体的技巧。
提示1:使用正确的数据大小
C的语义表明,所有计算都应具有与将所有操作数都强制转换为int以及对int进行操作(如果值不能容纳int的方式为unsigned int或long int)相同的结果。
如果将结果存储在较小类型的变量(如char)中,则将结果(至少在概念上)向下转换。在任何体面的8位微编译器上,此过程都将在适当的情况下短路,并且整个表达式使用char或short进行计算。
因此,要处理的数据项的大小应适合所使用的CPU。如果选择了不自然的大小,则生成的代码可能会变得更糟。例如,在8位微型计算机上,访问和计算8位数据非常有效。
使用32位值将生成更大的代码,并且运行速度更慢,并且仅当要处理的数据需要全部32位时才应考虑使用。使用大值还会增加寄存器分配寄存器的需求,因为32位值将需要存储四个8位寄存器。
在32位处理器上,处理较小的数据可能效率不高,因为寄存器为32位。如果存储变量类型小于32位,则必须舍弃计算结果,这会在代码中引入移位,掩码和符号扩展运算(取决于表示较小类型的方式)。
在此类机器上,应将32位整数用于尽可能多的变量。chars和shorts只应在需要精确位数的情况下使用(例如进行I / O时),或者在bigtypes使用过多内存(例如,包含大量元素的数组)时使用。
提示2:使用最佳指针类型
典型的嵌入式微控制器具有几种不同的指针类型,从而允许以各种方式访问内存,从小的零页指针到软件仿真的通用指针。显然,使用较小的指针类型比使用较大的指针类型更好,因为对于较小的指针而言,存储它们所需的数据空间和操作代码都较小。
但是,可能存在几个大小相同但属性不同的指针,例如两个存储区又长又远的24位指针,唯一的区别是,巨大的对象使对象可以越过边界。这种差异使操纵巨大指针的代码大得多,因为每次递增或递减都必须检查一个银行边界。除非您确实需要非常大的对象,否则使用较小的指针变体将节省大量代码空间。
对于具有许多不相交的内存空间的机器(例如Microchip PIC和Intel 8051),可能会有“通用”指针指向所有内存空间。这些指针可能非常容易使用,因为它们非常方便,但是它们带来了成本,因为每次访问指针都需要特殊代码来检查指针指向哪个内存并执行适当的操作。
还要注意,使用通用指针通常会带来一些库函数。总结:尽可能使用最小的指针,除非必要,否则避免使用任何形式的通用指针。切记要检查编译器的默认指针类型(用于不合格的指针,并由所使用的数据存储模型确定)。在许多情况下,它是一个相当大的指针类型。
技巧3:结构和填充
保证C结构以声明的顺序按字段排列在内存中。但是,在对加载和存储具有对齐限制的处理器上,编译器可能会在结构成员之间插入填充,以便有效地对齐每个成员。这将使结构大于成员类型的大小之和,并可能破坏假定结构连续放置在内存中的情况下编写的代码。
对齐要求在8位和16位CPU上很少见,但在32位CPU上很常见。某些CPU(如Motorola ColdFire和NECV850)会因未对齐的负载而产生错误,而其他CPU则只会降低性能(Intel x86)。
如果有必要,将在结构的末尾插入填充,以使结构的大小与计算机的最大对齐要求对齐(对于32位计算机,通常为4字节)。这是因为结构数组中的每个元素都必须从内存中对齐的边界开始。
sizeof()运算符将显示结构的总大小,包括末尾的填充。增加指向结构的指针将使指针sizeof()字节在内存中向前移动,从而反映出末尾的填充。当一个结构包含另一个结构时,将保留成员结构的填充。
在某些情况下,编译器可以打包内存(通过#pragma,特殊关键字或命令行选项),从而删除填充。这将节省数据空间,但可能会花费代码大小,因为加载未对齐成员的代码可能比加载对齐成员所需的代码更大,更复杂。
为了更好地利用内存,请按大小减小的顺序对结构的成员进行排序:首先是32位值,然后是16位值,最后是8位值。这将使内部填充变得不必要,因为每个成员都会自然对齐(如果结构的大小不是机器字大小的偶数,结构的末尾仍将填充)。
请注意,编译器的填充会破坏使用结构解码通过网络接收的信息或寻址内存映射的I / O区域的代码。当代码从没有对齐要求的体系结构移植到带有对齐要求的体系结构时,这尤其危险。
提示4:使用函数原型
ANSI C中引入了函数原型,以改进类型检查。在没有先声明它们的情况下调用函数的旧样式被认为是不安全的,并且这也是有效函数调用的障碍。
如果函数的原型设计不正确,则编译器必须回退到语言规则,该规则规定所有参数都应提升为int(对于浮点参数,则应提升为double)。这意味着函数调用的效率将大大降低,因为必须插入类型转换以转换参数。
对于台式机,效果不是很明显(大多数情况是int或double的大小),但是对于小型嵌入式系统,效果可能很大。
问题包括破坏寄存器参数传递(更大的值使用更多的寄存器)和许多不必要的类型转换代码。在许多情况下,当调用没有原型的函数时,编译器会警告您。请确保在编译时没有此类警告!
在调用函数之前(Kernighan&Ritchie或“ K&R”样式)声明函数的旧方法是将参数列表保留为空,例如“ extern void foo()”。这不是正确的ANSI原型,将无法帮助代码生成。不幸的是,很少有编译器对此默认发出警告。
总是可以从函数的类型,即参数类型的完整列表(如原型中给出的)推断出函数的寄存器到参数的分配。这意味着对函数的所有调用都将使用相同的寄存器来存储参数,这对于生成正确的代码是必需的。函数中的代码不会以任何方式影响寄存器到参数的分配。
提示5:使用参数
如上所述,寄存器分配很难使用globalvariables。如果要改善寄存器分配,请使用参数将信息传递给调用的函数,而不是共享的全局变量。通常会在调用函数和被调用函数中将参数分配给寄存器,从而导致非常高效的调用。
请注意,某些体系结构和编译器的调用约定限制了可用于参数的寄存器的数量,这使其成为一个好主意,对于需要在各种平台上移植且高效的代码,请减少参数的数量。将一个非常复杂的函数拆分为几个较小的函数,或者重新考虑将数据传递给一个函数,可能会有所收获。
提示6:不要采用地址
如果采用局部变量(“&var”构造)的地址,则它不太可能分配给寄存器,因为它必须有一个地址,并因此需要一个内存位置(通常在堆栈上)。
就像全局变量一样,它也必须在每个函数调用之前写回内存,因为某些其他函数可能已经失去了对地址的保留,并期望获得最新的值。取一个全局变量的地址并不会带来太大的伤害,因为无论如何它们都必须有一个内存地址。
因此,仅在您必须的情况下才应使用局部变量的地址(很少必要)。如果使用地址获取来接收被调用函数的返回值(例如,从scanf()),请引入一个临时变量以接收结果,然后将值从该临时变量复制到实数变量。
这应该允许实数变量被寄存器分配。(请注意,在C ++中,引用参数[“ foo(int&)”]可以将指针引入到调用函数中的变量,而调用的语法并不表明使用了变量的地址。)
使全局变量静态化是一个好主意(除非在另一个文件中引用它),因为这使编译器知道地址所在的所有位置,从而可能导致更好的代码。
以下是何时不使用地址运算符的一个示例,其中使用地址访问变量的高字节将强制变量进入堆栈。好的方法是使用shift来访问部分值。
提示7:不要使用内联汇编语言
使用内联汇编是阻碍编译器优化器的一种非常有效的方法。由于存在编译器不了解的代码块,因此无法跨该代码块进行优化。在许多情况下,变量将被强制存储到内存中,并且大多数优化都被关闭。
每次编译运行后,应检查包含内联汇编的函数的输出,以确保汇编代码仍按预期运行。此外,内联汇编的可移植性非常差,无论是跨机器(显然)还是针对同一目标的不同编译器。
如果需要使用汇编程序,最好的解决方案是将其拆分为汇编源文件,或者至少拆分为仅包含内联汇编的函数。不要在同一功能中混用C代码和汇编代码!
提示8:不要编写巧妙的代码
一些C程序员认为,编写更少的源代码字符并巧妙地使用C构造会使代码更小或更快速。结果是代码更难阅读,也更难编译。
以简单的方式编写代码有助于人类和编译器理解您的代码,从而为您带来更好的结果。例如,条件表达得益于明确表示为条件。
如果另一个(32位)变量的低21位不为零,则考虑两种设置变量b最低位的方法,如下所示。聪明的代码使用!C中的运算符,如果参数为非零(C中的“ true”是除零以外的任何值),则返回零;如果参数为零,则返回one。
由于位设置操作很明显并且屏蔽可能比移位更有效,因此简单的解决方案很容易编译为带置位指令的条件。理想情况下,两个解决方案应生成相同的代码。但是,由于聪明的代码执行两个!操作,因此可能会导致更多的代码,每个操作都可以编译为条件操作。
另一个例子是在计算中使用条件值。“聪明”的代码将导致机器代码更大,因为生成的代码将包含与简单代码相同的测试,并添加一个临时变量来保存要添加的一或零。简单的代码可以使用简单的增量运算而不是完整的加法运算,并且不需要生成任何中间结果。
由于聪明的代码几乎永远不会比直接代码更好地编译,为什么还要编写聪明的代码?从维护的角度来看,编写简单易懂的代码绝对是一种选择。
提示9:对跳转表使用Switch
如果要跳转表,请查看是否可以使用switch语句达到相同的效果。编译器很有可能会为开关生成更好,更小的代码,而不是通过表进行一系列间接函数调用。
同样,使用开关可以使程序流程明确,从而帮助编译器更好地优化周围的代码。编译器很可能会生成一个跳转表,至少对于一个小的密集开关(使用所有或大多数值)而言,它会生成一个跳转表。
在机器之间使用开关也更可靠;在一个CPU上可能最佳的布局在另一个CPU上可能并非最佳,但是每个CPU的编译器都将知道如何为两个CPU制作最佳的跳转表。将switch语句放入C语言中以促进多路跳转:使用它!
提示10:使用位之前研究
位字段位字段提供了一种非常可读的方式来处理小群的位整数,但是位布局是由实现定义的,这为可移植代码带来了问题。
为位字段生成的代码将具有非常不同的质量,因为并非所有编译器都认为它们非常重要。一些编译器将生成难以置信的糟糕代码,因为他们不认为它们值得优化,而另一些编译器将优化操作,以使该代码与手动屏蔽和移位一样有效。
建议是测试一些位字段变量,并检查位布局是否符合预期,以及操作是否有效实施。如果使用了多个编译器,请检查它们是否具有相同的位布局。通常,使用显式掩码和移位将在更多目标和编译器上生成更可靠的代码。
提示11:当心LibraryFunctions
如上所述,链接器必须将程序使用的所有库函数与该程序一起引入。对于诸如printf()和strcat()之类的C标准库函数来说,这是显而易见的,但是当需要某些类型的算术(尤其是浮点数)时,也隐含地引入了库的很大一部分。
由于C在表达式内部执行隐式类型转换的方式,即使不使用浮点变量,也很容易无意间引入浮点。例如,以下代码将带入浮点,因为ImportantRatio常量是浮点类型的,即使其值将为1.95 * 20 == 39,并且所有变量都是整数:
如果程序的较小更改导致程序大小的较大更改,请查看链接后包含的库函数。尤其是浮点数和32位整数库可能很隐蔽,并且由于C隐式强制转换而导致蠕变。
缩减程序代码的另一种方法是使用标准函数的受限版本。例如,标准的printf()是一个很大的函数。除非您确实需要完整的功能,否则应使用仅处理基本格式或忽略浮点的有限版本。
请注意,这应该在链接时完成:源代码相同,但是链接了更简单的版本。因为第一个参数toprintf()是一个字符串,并且可以作为变量提供,所以编译器无法自动确定程序需要函数的哪些部分。
提示12:使用额外的提示
一些编译器允许程序员指定有用的信息,而这些信息是编译器无法推断出的有助于优化代码的信息。例如,DSP编译器通常允许用户指定两个指针或数组参数是非别名的,这有助于编译器优化同时访问这两个数组的代码。
其他示例是将函数指定为纯 函数(无副作用)或任务 (将永远循环,因此无需在输入时保存寄存器)。一个常见的示例是inline ,编译器可能会将其视为提示或命令。通常使用不可移植的关键字引入此信息,并且应将其放入经过调整的头文件中(如果可能)。但是,它可能会在代码效率方面带来极大的好处。
最后说明
本系列的第5部分 以上技巧试图让您了解现代C编译器的工作原理,并为您提供一些具体的提示,说明如何通过明智地使用编译器来获得更小的代码。
编译器是一个非常复杂的系统,具有高度非线性的行为,其中源代码中的细微变化可能会对生成的汇编代码产生很大影响。编译过程的基础是,编译器应该能够理解您的代码应该做什么,以便以最佳方式对给定目标执行操作。
通常,对于普通程序员而言易于理解的代码(因此易于维护和移植)也更容易有效地编译。请注意,除非您让编译器使用更高的优化级别,否则您将浪费大量的投资。
购买编译器时,您所支付的大部分费用是为特定目标进行开发和优化优化的工作,如果您不使用这些优化,则不会发挥最佳效果。
明智地选择编译器:同一芯片的不同编译器可能会有很大差异。有些在生成快速代码方面更胜一筹,而在生成小的代码方面更胜一筹,而有些则一点都不好。要评估编译器,最好的方法是使用演示版来编译自己的“典型”代码的小部分。
一些芯片供应商还为他们的芯片提供了各种编译器的基准测试,通常针对其芯片的预期应用领域。编译器供应商自己的基准测试应该引起一些怀疑,几乎总是可以找到某个编译器的性能优于竞争对手的程序。
有关嵌入式系统高效C编程的更多技巧,您应该查看嵌入式系统贸易展览会(全球)上提供的类。进行编译器软件开发的公司的网站上包含一些针对其编译器的特殊技巧的技术说明或白皮书(但请务必当心那些纯营销材料!)。