AC编译器是大多数嵌入式开发工程师的基本工具。它是将应用程序中的思想和算法(表示为C源代码)转换为目标处理器可执行的机器代码的工具。
在很大程度上,C编译器确定应用程序的可执行代码将有多大。编译器对程序执行许多转换,以便生成最佳的代码。
此类转换的示例包括将值存储在寄存器中而不是内存中,删除无用的代码,以更有效的顺序对计算进行重新排序以及用更便宜的运算代替算术运算。
C语言为编译器提供了很大的自由度,使其可以在目标系统上精确实现每个C操作。这种自由度是为什么通常可以非常高效地编译C的重要因素,但是程序员为了编写健壮的代码,需要意识到编译器的自由度。
对于大多数嵌入式开发工程师来说,程序不太 适合可用内存的情况是一种常见现象。用汇编语言重新编写应用程序的部分或放弃功能似乎是唯一的选择,而解决方案可能像以更易于编译的方式重写C代码一样简单。
为了编写对编译器友好的代码,您需要对编译器有一定的了解。对程序进行一些简单的更改(例如更改经常访问的变量的数据类型)可能会对代码大小产生很大影响,而其他更改则完全无效。对编译器可以做什么和不可以做什么的想法使这种优化工作大有作为更轻松。
组装程序指定执行计算的方式,方式和精确顺序。另一方面,AC程序仅指定应执行的计算。在某些限制下,用于实现计算的顺序和技术取决于编译器。
编译器将查看代码并尝试了解正在计算的内容。给定已设法获取的信息,它会在单个语句中本地生成并在整个函数甚至整个程序中生成最佳代码。
编译器的结构
通常,在现代编译器中,程序是通过六个主要步骤处理的(并非所有编译器都完全遵循此蓝图,但从概念上来说,这是足够的):
解析器。 从C源代码到中间语言的转换。
高级优化。 优化中间代码。
代码生成。 从中间代码生成目标机器代码。
低级优化。 机器代码上的优化。点击领取嵌入式物联网学习路线
部件。 可以从目标机器代码链接的目标文件的生成。
连结中。 将程序的所有代码链接到可执行文件或可下载文件中。
如果在源代码中发现语法错误,则解析器将解析C源代码,检查语法并生成错误消息。如果未发现错误,则解析器然后生成中间代码(已解析代码的内部表示形式),并通过第一遍优化进行编译过程。
高级优化器将代码转换为更好的代码。优化器具有大量可用于改进代码的转换,并将执行它认为与手头程序相关的转换。请注意,我们使用的是“转换”一词,而不是“优化”。
“优化”有点用词不当。它传达出这样的直觉,即更改总是会改进程序,我们实际上会找到最佳解决方案,而事实上,最佳解决方案非常昂贵,甚至找不到(在计算机科学术语中无法确定)。为了确保合理的编译时间和终止编译过程,编译器必须使用启发式方法(“好的猜测”)。
对程序进行转换是高度非线性的活动,其中不同的转换顺序将产生不同的结果,并且某些转换实际上可能会使代码更糟。叠加更多的“优化”并不一定会产生更好的代码。
完成高级优化程序后,代码生成器会将中间代码转换为目标处理器指令集。该阶段是对优化器的中间代码逐段执行的,编译器将尝试在单个表达式或语句的级别上执行智能操作,但不要跨多个语句执行。
代码生成器还必须考虑C语言和目标处理器之间的任何差异。例如,对于一个小型嵌入式目标(例如Intel 8051,Motorola 68HC08,SamsungSAM8或Microchip PIC),必须将32位算术分解为8位算术。
代码生成的一个非常重要的部分是将寄存器分配给变量。目标是在寄存器中保留尽可能多的值,因为基于寄存器的操作通常比基于内存的操作更快且更小。
代码生成器完成后,进行优化的另一个阶段,即对目标代码进行转换。低级优化器将在代码生成器之后清理(有时会做出次优的编码决策),并执行更多的转换。
有许多转换只能应用于目标代码,有些转换是从高级阶段重复执行的,但在较低级别上是重复的。例如,如果我们已经知道进位标志为零,则诸如删除“ clearcarry”指令之类的转换仅可能在目标代码级别进行,因为这些标志在代码生成之前是不可见的。
低级优化器完成后,代码将发送到汇编器并输出到目标文件。然后将程序的所有目标文件链接起来,以生成最终的二进制可执行ROM映像(以适合于目标的某种格式)。链接器还可以执行一些优化,例如,通过丢弃未使用的功能。
因此,可以看到,通过高度复杂的系统,看似简单的C程序编译任务实际上是一条漫长而曲折的道路。不同的转换可能相互作用,并且整个程序的局部改进可能更糟。例如,如果给定更多临时寄存器,通常可以更高效地评估表达式。
从本地角度来看,因此根据需要提供大量寄存器似乎是一个好主意。但是,全局影响是寄存器中的变量可能必须溢出到内存中,这比使用较少的寄存器评估表达式的开销更大。
程序的含义
在编译器可以将转换应用于程序之前,它必须分析代码以确定哪些转换是合法的,并且有可能导致改进。转换的合法性由C语言标准规定的语义确定。
C程序的最基本解释是,只有具有副作用或用于执行副作用的计算值的语句才与程序的含义相关。
副作用是任何会更改程序全局状态的语句。通常被认为是副作用的示例包括写入屏幕,更改全局变量,读取volatile变量以及调用未知函数。
副作用之间的计算是根据“按我的意思而不是我的意思”的原则进行的。编译器将尝试将每个表达式重写为可能的最有效形式,但是仅当重写代码的结果与原始表达式相同时才可以进行重写。C标准定义了“相同”的概念,并设置了允许的优化的极限。
基本转换 。现代的编译器执行大量在本地起作用的基本转换,例如折叠常量表达式,用便宜的运算符代替昂贵的运算(“强度降低”),删除冗余计算以及将不变计算移到循环外。编译器可以像人类程序员一样进行大多数机械上的改进,但不会造成疲劳或出错。
下表 显示(以C形式表示,易于阅读)由现代C编译器执行的一些典型基本转换。请注意,此基本清理的一个重要含义是,您可以以可读的方式编写代码,并让编译器计算常量表达式,并担心使用最高效的操作。
根据上一节中的定义,所有不有用的代码都将被删除。删除无法访问或无用的计算可能会导致一些意外的影响。一个重要的例子是完全放弃了空循环,从而使“ emptydelay循环”无用。 升级到现代的编译器并删除了无用的计算后,下面显示的代码停止正常工作:
注册分配 。当使用寄存器而不是内存执行计算时,处理器通常会提供更好的性能并需要更少的代码。因此,编译器将尝试将函数中的变量分配给寄存器。
如果局部变量或参数在功能持续时间内可以保存在寄存器中,则不需要分配任何RAM。如果变量多于可用寄存器,则编译器需要确定哪些变量要保留在寄存器中,哪些要保留在存储器中。
这是寄存器分配的问题,并且不能最佳地解决。而是使用启发式技术。使用的算法可能非常敏感,即使对功能进行很小的更改也可能会大大改变寄存器分配。
请注意,变量在使用时仅需要一个寄存器。如果变量仅在函数的一小部分中使用,它将在该部分中分配寄存器,但在函数的其余部分中将不存在。这解释了为什么调试器有时会告诉您变量已“在此时被优化”。
寄存器分配器受C语言规则的限制-例如,调用其他函数时,全局变量必须写回内存,因为它们可以被调用函数访问,并且对全局变量的所有更改必须对所有函数可见。在函数调用之间,全局变量可以保留在寄存器中。
请注意,有时您不希望对变量进行寄存器分配。例如,读取I / O端口或锁定时旋转,您希望每次从内存中读取源代码,因为可以在程序的控制范围之外更改变量。这是volatile关键字所在的位置用过的。它向编译器发出信号,通知该变量永远不要分配在寄存器中,而应在每次访问时从内存中读取(或写入)。
通常,仅考虑简单值(例如整数,浮点数和指针)进行寄存器分配。数组必须驻留在内存中,因为它们被设计为可以通过指针访问,并且结构通常太大。另外,在小型处理器上,可能很难将大值(如32位整数和浮点数)分配给寄存器,并且可能只会为寄存器提供16位和8位变量。
为了帮助分配寄存器,您应努力将同时活动变量的数量保持在较低水平。另外,请尝试为变量使用最小的可能数据类型,因为这将减少8位和16位处理器上所需的寄存器数量。
函数调用 。如汇编程序员所知,调用以高级语言编写的函数可能相当复杂且成本很高。调用函数必须将全局变量保存回内存,确保将局部变量移至可以保存调用的寄存器(或保存到堆栈),并且参数可能必须在堆栈上插入。
在被调用的函数内部,必须保存寄存器,从堆栈中删除参数,并在堆栈上为本地变量分配空间。对于具有许多参数和变量的大型函数,调用所需的精力可能非常大。
但是,现代编译器会尽力减少函数调用的成本,尤其是减少堆栈空间的使用。将为参数指定许多寄存器,以便简短的参数列表很可能会完全在寄存器中传递。同样,返回值将放置在寄存器中,并且局部变量仅在无法将其分配给寄存器时才会放置在堆栈中。
寄存器参数的数量在不同的编译器和体系结构之间会千差万别。在大多数情况下,至少有四个寄存器可用于参数。还要注意,就像寄存器分配一样,只有较小的参数类型将在寄存器中传递。
数组始终作为指向数组的指针传递(C语义规定,andand通常将结构复制到堆栈中,并将结构参数更改为指向结构的指针。但是,该指针可能在寄存器中传递。为了节省堆栈空间,因此最好始终使用指向结构的指针作为参数,而不要使用结构本身。
C支持带有可变数量的参数的函数。这在诸如printf()和scanf()的标准库函数中使用,以提供方便的界面。但是,对函数参数使用可变数量的实现会产生大量开销。
所有参数都必须放在堆栈上,因为函数必须能够使用指向参数的指针来逐步遍历参数列表,并且访问参数的代码比固定参数列表的效率低得多。没有对参数进行类型检查,这会增加错误的风险。嵌入式系统中不应使用可变数量的参数!
函数内联 。好的编程习惯是将常见的计算过程和对共享数据结构的访问分解为(小)函数。然而,这带来了每次应该做某事时调用一个函数的成本。为了减轻这种成本,已经开发了函数内联的编译器转换。内联函数意味着将函数代码的副本放置在调用函数中,并删除该调用。
内联是一种非常有效的代码加速方法,因为可以避免函数调用开销,但可以执行相同的计算。许多程序员通过使用预处理器宏代替通用函数而不是函数来手动执行此操作,但是宏缺少函数的类型检查,并且会产生难以发现的错误。
由于将代码复制到多个位置,因此可执行代码通常会由于内联而增长。内联还可以帮助缩减代码:对于小的函数,函数调用的代码大小成本可能大于该函数的代码。在这种情况下,内联函数实际上将节省代码大小(以及加快程序速度)。
内联大小的主要问题是估计代码大小的增益(当优化速度时,几乎可以保证增益)。由于内联通常会增加代码大小,因此内联必须非常保守。内联对代码大小的影响无法精确确定,因为调用函数的代码受到非线性影响。
为了减少代码大小,理想的方法是内联所有对函数的调用,这使我们可以从程序中完全删除该函数。仅当所有调用都已知(即,与该函数放置在相同的源文件中)且该函数标记为static,从而无法从其他文件中看到时,才有可能。
否则,必须保留该函数(即使在某些调用中仍可能内联该函数),并且如果未调用该函数,我们将依赖链接器将其删除。由于这会减少从内联获得的收益,因此我们不太可能内联这种函数。
低级代码压缩 。目标代码级别的常见转换是从多个函数中查找常见的指令序列,并将其分解为子例程。这种转换在缩小程序的可执行代码方面非常有效,以执行更多跳转为代价(请注意,这种转换仅引入机器级别的子例程调用,而没有引入全功能函数调用)。经验表明,此转换可从10“ 30%获得收益。
链接器 。链接器应被视为编译系统的组成部分,因为在链接器中执行了一些转换。最基本的嵌入式系统链接器应从程序中删除所有未使用的函数和变量,而仅包括标准库中实际使用的部分。
从文件或库模块到单个功能甚至代码段,丢弃程序部分的粒度各不相同。粒度越小,链接器越好。不幸的是,某些从桌面系统派生的链接器基于文件运行,这会产生不必要的大代码。
一些链接程序还对程序执行编译后转换。常见的转换是删除不必要的存储区和页开关(由于在那时可变变量的确切位置尚不清楚,因此在编译时无法完成)和如上所述的代码压缩扩展到了整个程序。
控制编译器优化
可以指示编译器以不同的目标(通常是速度或大小)来编译程序。对于每种设置,已经选择了一组倾向于实现目标的转换-最大速度(最小执行时间)或最小大小。该设置应该被认为是近似的。为了提供更好的控制,大多数编译器还允许启用或禁用单个转换。
为了进行大小优化,编译器使用了易于生成更小的代码的转换组合,但是由于编译程序的特性,在某些情况下它可能会失败。举个例子,函数内联对于速度优化而言更具攻击性,这使得某些程序在速度设置上比在大小设置上小。
下面的示例数据 说明了这一点,这两个程序使用相同的内存和数据模型设置,使用相同版本的同一编译器进行编译,但针对速度或大小进行了优化:
通过速度优化,程序1会变得略小,而程序2会变得更大,这可以追溯到函数内联在程序1上很幸运的事实。结论是,应该始终尝试使用不同的优化设置来编译程序,然后看看会发生什么。
通常对于项目中的不同文件使用不同的编译设置是值得的:将必须非常快速地运行的代码放入单独的文件中,并以最小的执行时间(最大速度)对其进行编译,而其余的代码则将代码大小最小化。这将提供一个小程序,该程序仍然足够快。某些编译器使用#pragma指令为同一源文件中的不同功能提供不同的优化设置。
记忆模型 。嵌入式微控制器通常有几种变体,每种都有不同数量的程序和数据存储器。对于较小的芯片,编译器可以利用可寻址的内存量有限这一事实来生成较小的代码。
8位直接指针比24位bankedpointer占用更少的代码存储空间,而软件在每次访问之前都必须切换bank。这既适用于代码也适用于数据。
例如,某些Atmel AVR芯片的代码区仅为8 kb,这允许使用偏移量为+/- 4 kb的小跳转到达所有代码存储区,并使用回绕从高地址跳转到低地址。产生更小,更快的代码。
可以使用内存模型选项将目标芯片的容量传达给编译器。通常有几种不同的内存模型可用,从“小”到“大”。通常,函数调用随着允许的代码量的增加而变得越来越昂贵,并且数据访问和指针随着可访问数据量的增加而变得越来越大且越来越昂贵。