循环是大多数程序中的常见结构。由于大量的执行时间通常花费在循环中,因此值得关注时间关键循环。
如果不谨慎地编写,环路终止条件可能会导致大量开销。在可能的情况下:
使用简单的终止条件。
写入倒计时到零循环。
使用 unsigned int
类型的计数器。
测试与零的相等性。
单独或组合遵循这些准则中的任何或全部准则可能会产生更好的代码。
下表显示了用于计算 n!
的例程的两个示例实现,它们共同说明了环路终止开销。第一个实现使用递增循环计算 n!,而第二个例程使用递减循环计算 n!
。
表7-1 递增和递减循环的C代码
递增循环 | 递减循环 |
---|---|
|
|
下表显示了 armclang -Os -S --target=armv8a-arm-none-eabi
针对上述每个示例实现生成的机器代码的相应反汇编。
表 7-2 C 递增和递减循环的反汇编
递增循环 | 递减循环 |
---|---|
|
|
比较反汇编表明,递增循环反汇编中的 ADD
和 CMP
指令对已替换为递减循环反汇编中的单个 SUBS
指令。由于 SUBS
指令更新状态标志(包括 Z 标志),因此不需要显式 CMP r1、r2
指令。
除了在循环中保存指令外,变量 n
不必在循环的生命周期内可用,从而减少了必须维护的寄存器数量。这简化了寄存器分配。如果原始终止条件涉及函数调用,则更为重要。例如:
for (...; i < get_limit(); ...);
将循环计数器初始化为所需迭代次数,然后递减到零的技术也适用于 while
和 do
语句。
循环是大多数程序中的常见结构。由于大量的执行时间通常花费在循环中,因此值得关注时间关键循环。
可以展开小循环以获得更高的性能,但缺点是代码大小增加。展开循环时,循环计数器需要更新的频率较低,执行的分支也较少。如果循环只迭代几次,则可以完全展开,使循环开销完全消失。编译器在 -O3 -Otime
处自动展开循环。否则,任何展开都必须在源代码中完成。
手动展开循环可能会阻碍编译器自动重新滚动循环和其他循环优化。
可以使用下表中所示的两个示例例程来说明循环展开的优缺点。这两个例程都通过提取最低位并对其进行计数来有效地测试单个位,然后将该位移出。
第一种实现使用循环来计算位数。第二个例程是第一个展开四次的实现,通过将 n
的四个班次合并为一个班次来应用优化。
频繁展开提供了新的优化机会。
表 7-3 滚动和展开位计数循环的 C 代码
位计数循环 | 展开的位计数循环 |
---|---|
|
|
下表显示了编译器为上述每个示例实现生成的机器代码的相应反汇编,其中每个实现的 C 代码已使用 armclang -Os -S --target=armv8a-arm-none-eabi
编译。
表7-4 滚动和展开的位计数循环的反汇编
位计数循环 | 展开的位计数循环 |
---|---|
|
|
位计数循环的展开版本比原始版本更快,但代码大小更大。
较高的优化级别可以揭示某些程序中的问题,这些问题在较低的优化级别下并不明显,例如,缺少易失性
限定符。
这可以通过多种方式表现出来。轮询硬件时,代码可能会卡在循环中,多线程代码可能会表现出奇怪的行为,或者优化可能会导致删除实现故意计时延迟的代码。在这种情况下,可能需要将某些变量声明为可变
变量。
将变量声明为 volatile
告诉编译器,该变量可以在实现外部随时修改,例如,由操作系统、另一个执行线程(如中断例程或信号处理程序)或硬件进行修改。由于可变
限定变量的值可以随时更改,因此每当在代码中引用该变量时,都必须始终访问内存中的实际变量。这意味着编译器无法对变量执行优化,例如,将其值缓存在寄存器中以避免内存访问。同样,在实现睡眠或计时器延迟的上下文中使用时,将变量声明为可变
变量会告诉编译器有特定类型的行为是有意的,并且此类代码不得以删除预期功能的方式进行优化。
相反,当变量未声明为可变
变量时,编译器可以假定其值不能以意外方式修改。因此,编译器可以对变量执行优化。
下表中的两个示例例程说明了 volatile
关键字的用法。这两个例程都在循环中读取缓冲区,直到状态标志 buffer_full
设置为 true。buffer_full
的状态可以随程序流异步更改。
例程的两个版本仅在声明buffer_full
的方式上有所不同。第一个例程版本不正确。请注意,变量 buffer_full
在此版本中未限定为 volatile
。相比之下,例程的第二个版本显示了相同的循环,其中buffer_full
被正确地限定为易失性
。
表 7-5 非易失性和易失性缓冲器环路的 C 代码
缓冲环路的非易失性版本 | 缓冲区循环的易失性版本 |
---|---|
|
|
下表显示了编译器为上述每个示例生成的机器代码的相应反汇编,其中每个实现的 C 代码已使用 armclang -Os -S --target=armv8a-arm-none-eabi
进行编译。
表7-6 非易失性和易失性缓冲器环路的反汇编
缓冲环路的非易失性版本 | 缓冲区循环的易失性版本 |
---|---|
|
|
在上表中缓冲环路的非易失性版本的反汇编中,语句 LDR r1 [r0]
将 buffer_full
的值加载到寄存器 r1
外部标记为 .LBB0_1
。由于 buffer_full
未声明为易失性
,因此编译器假定其值不能在程序外部修改。编译器已将 buffer_full
的值读入 r0
中,因此在启用优化时会省略重新加载变量,因为其值无法更改。结果是标记为 的无限循环。LBB0_1
。
相反,在反汇编缓冲区循环的易失性版本时,编译器假定 buffer_full
的值可以在程序外部更改,并且不执行任何优化。因此,buffer_full
的值被加载到寄存器 r2
中,该寄存器位于标记为 的循环中。LBB1_1
。因此,循环 .LBB1_1
在汇编代码中正确实现。
为了避免由实现外部的程序状态更改引起的优化问题,每当变量的值可能以实现未知的方式意外更改时,就必须将变量声明为可变
变量。
在实践中,每当出现以下情况时,都必须将变量声明为可变
变量:
访问内存映射的外围设备。
在多个线程之间共享全局变量。
访问中断例程或信号处理程序中的全局变量。
编译器不会优化已声明为可变变量的变量。
C 和 C++ 都大量使用堆栈。
例如,堆栈包含:
函数的返回地址。
必须保留的寄存器,由 ARM 64 位架构 (AAPCS64) 的 ARM 体系结构过程调用标准确定,例如,在进入子例程时保存寄存器内容时。
局部变量,包括局部数组、结构、联合,在 C++ 中还包括类。
有些堆栈使用并不明显,例如:
如果局部整数或浮点变量溢出(即未分配给寄存器),则会为其分配堆栈内存。
结构通常分配给堆栈。堆栈上保留了一个等效于 sizeof(struct)
的空间,该空间填充为 16 个字节的倍数。编译器尝试将结构分配给寄存器。
如果在编译时已知数组大小的大小,则编译器会在堆栈上分配内存。同样,在堆栈上保留了一个等效于 sizeof(struct)
的空间,该空间填充为 16 个字节的倍数。
一些优化可以引入新的临时变量来保存中间结果。优化包括:CSE 消除、实时范围拆分和结构拆分。编译器尝试将这些临时变量分配给寄存器。如果没有,它会将它们溢出到堆栈中。
通常,为仅支持 16 位编码的 Thumb 指令的处理器编译的代码比 A64 代码、ARM 代码和为支持 32 位编码的 Thumb 指令的处理器编译的代码更多地使用堆栈。这是因为 16 位编码的 Thumb 指令只有 8 个寄存器可供分配,而 ARM 代码和 32 位编码的 Thumb 指令则有 14 个寄存器。
AAPCS64要求通过堆栈而不是寄存器传递某些函数参数,具体取决于它们的类型、大小和顺序。
堆栈使用情况很难估计,因为它依赖于代码,并且根据程序在执行时采用的代码路径,在运行之间可能会有所不同。但是,可以使用以下方法手动估计堆栈利用率的程度:
使用 --callgraph
链接以生成静态调用图。这显示了有关所有功能的信息,包括堆栈使用情况。
这将使用 .debug_frame
部分中的 DWARF 帧信息。使用 -g
选项进行编译以生成必要的 DWARF 信息。
使用 --info=stack 或 --info=summarystack
链接以列出所有全局符号的堆栈使用情况。
使用调试器在堆栈中的最后一个可用位置设置观察点,并查看是否命中了观察点。
使用调试器,然后:
在内存中为比预期需要的堆栈大得多的堆栈分配空间。
用已知值的副本填充堆栈空间,例如 0xDEADDEAD
。
运行应用程序或应用程序的固定部分。目标是在测试运行中使用尽可能多的堆栈空间。例如,尝试执行最深嵌套的函数调用和静态分析找到的最坏情况路径。尝试在适当的位置生成中断,以便将它们包含在堆栈跟踪中。
应用程序完成执行后,检查内存的堆栈空间,查看有多少已知值已被覆盖。该空间在已使用部分中有垃圾,其余部分有已知值。
计算垃圾值的数量,然后乘以 sizeof(value),
以给出它们的大小(以字节为单位)。
计算结果显示了堆栈大小是如何增长的(以字节为单位)。
使用固定虚拟平台 (FVP),并使用映射文件定义一个内存区域,不允许在内存中堆栈的正下方进行访问。如果堆栈溢出到禁止区域,则会发生数据中止,调试器可能会捕获数据中止。
通常,可以通过以下方式降低程序的堆栈要求:
编写只需要少量变量的小函数。
避免使用大型局部结构或数组。
例如,通过使用替代算法来避免递归。
最小化函数中每个点在任何给定时间使用的变量数。
使用 C 块作用域并仅在需要的地方声明变量,因此与不同作用域使用的内存重叠。
C 块作用域的使用涉及仅在需要的地方声明变量。这通过重叠不同作用域所需的内存来最大程度地减少堆栈的使用。
有多种方法可以最大程度地减少将参数传递给函数的开销。
例如:
R0
中传递隐式 this
指针参数。long
参数的数量,因为这些参数需要两个参数字,这两个参数字必须在偶数寄存器索引上对齐。双精度
参数的数量。
对于不支持 SDIV
除法指令的目标,可以使用相应的 C 库辅助函数 __aeabi_idiv0() 和 __rt_raise(
)
捕获和识别整数除以零错误
您可以使用 C 库辅助函数 __aeabi_idiv0()
捕获整数除以零错误,以便除以零返回一些标准结果,例如零。
整数除法是通过 C 库辅助函数 __aeabi_idiv() 和 __aeabi_uidiv()
在代码中实现的。这两个函数都检查除以零。
当检测到整数除以零时,将创建 __aeabi_idiv0()
的分支。因此,要将除法捕获为零,只需在 __aeabi_idiv0()
上放置一个断点。
该库提供了 __aeabi_idiv0()
的两种实现。默认值不执行任何操作,因此如果检测到除以零,则除法函数返回零。但是,如果使用信号处理,则会选择调用 __rt_raise(SIGFPE, DIVBYZERO)
的替代实现。
如果您提供自己的 __aeabi_idiv0()
版本,则除法函数将调用此函数。__aeabi_idiv0()
的函数原型为:
int __aeabi_idiv0(void);
如果 __aeabi_idiv0()
返回一个值,则该值用作除法函数返回的商。
默认情况下,整数除以零返回零。如果要截获除以零,可以重新实现 C 库辅助函数 __rt_raise()。
__rt_raise()
的函数原型为:
void __rt_raise(int signal, int type);
如果重新实现 __rt_raise(),
则库会自动提供 __aeabi_idiv0(
) 的信号处理库版本,该版本调用 __rt_raise(),
则该库版本的 __aeabi_idiv0()
将包含在最终映像中。
在这种情况下,当发生除以零错误时,__aeabi_idiv0()
调用 __rt_raise(SIGFPE, DIVBYZERO)。
因此,如果重新实现 __rt_raise(),
则必须选中 (signal == SIGFPE) & (type == DIVBYZERO)
以确定是否发生了除以零的情况。
进入 __aeabi_idiv0(
) 时,链路寄存器 LR
包含应用程序代码中调用 __aeabi_uidiv()
除法例程后的指令地址。
通过在调试器中查找 LR
给出的地址处的 C 代码行,可以识别源代码中的违规行。