ARM 处理器代码优化技术
本文梳理 ARM Cortex-A 系列代码优化技术,按三个层级递进展开:编译器选项、内存子系统优化、源码级微调。
1. 引言
优化不只有”更快”——在嵌入式系统上,省电和减少内存占用同样重要。手册第 18 章开头即说明了优化目标的多样性:更少的执行周期意味着处理器可以更早进入休眠,从而延长电池寿命。这几者常常是相互促进的:代码跑得更快 → 更少活跃时间 → 更长休眠时间 → 更省电。
在 Cortex-A 处理器上,你几乎不能手动预测一段代码的执行时间。你不能再像 ARM7TDMI 时代那样打开 TRM 查到每条指令的 cycle count 然后逐条累加——流水线中的指令运动依赖于周围指令的进度,并且会受到内存系统活动的显著影响。一条待定的 Load 或指令预取在 Cache 中未命中可能会使代码停顿数十个周期。一条 LDR 的实际延迟取决于它是否命中 L1 Cache——命中 1-3 个周期,未命中 20-100 个周期。这个差异远超任何指令级优化的收益。
因此,手册中给出的所有优化技术自然形成了三层递进优先级:
1 | Tier 1: 编译器优化 —— 开一个 flag 即可,零代码改动 |
2. 编译器优化
编译器是最容易利用的优化工具——不需要修改一行源代码,只改变命令行 flags 就能影响代码生成质量。
2.1 函数内联
内联的收益本质是用代码膨胀换取三个具体收益:消除函数调用开销(BL、返回地址保存、寄存器压栈、返回时的分支跳转);更重要地,扩大编译器能看见的代码窗口,内联后编译器可以在更大的上下文中做值域传播和死代码消除——这往往是远超调用开销本身的收益;副作用是过度内联增大 Code Size,降低 I-Cache 利用率,可能反而变慢。
实例:内联一个小函数的汇编级对比
1 | // 原始代码 —— tiny_func 没有被内联 |
1 | // 未内联时 caller() 的汇编 (ARM): 需要 BL + 参数准备 + 返回值使用 |
1 | // 编译器内联后 (GCC -O2):消除 BL 和 BX LR |
GCC 默认只在单个编译单元内做内联。跨文件内联需要 inline 关键字配合 static / extern。
2.2 公共子表达式消除
编译器在 -O1 及以上默认执行此优化,前提是相关变量不是 volatile:
1 | // 原始代码 |
这个优化同时降低了指令数和执行周期数,属于零代价优化。
2.3 循环展开
循环展开用代码膨胀消除每次迭代的分支和比较开销。一个简单的例子:
1 | // 原始:每次迭代都要 CMP + Bcc |
1 | // 展开后:无分支,10 条连续赋值 |
每次循环迭代可以省去一次比较和一次条件跳转,从而减少循环控制开销。
不过,循环展开并非总能提升性能。展开后代码体积会增大,可能降低 I-Cache 命中率;另一方面,现代 Cortex-A 处理器具有较高准确率的分支预测机制,对于这种规律性的循环跳转通常能够正确预测,因此循环分支本身的开销已经很低。对于迭代次数较少或循环体较小的场景,循环展开带来的收益可能不足以抵消代码膨胀带来的影响。
因此,循环展开并不是 Cortex-A 上的通用优化手段。建议结合 Profiling 工具分析热点代码,再决定是否启用 -funroll-loops 或手动展开循环。
2.4 GCC 优化级别全景
| 优化级别 | 主要特点 | 调试可见性 |
|---|---|---|
-O0 |
不进行优化,尽量保持源码与汇编的一一对应关系,编译速度最快。 | ★★★★★ |
-O1 |
启用基础优化,如常量传播、公共子表达式消除(CSE)、死代码消除等,在编译时间和运行效率之间取得平衡。 | ★★★★☆ |
-O2 |
在 -O1 基础上启用更多优化,如指令调度、循环优化、寄存器分配优化等,是大多数项目的默认发布优化级别。 |
★★★☆☆ |
-O3 |
在 -O2 基础上进一步启用更激进的优化,例如更积极的函数内联、循环展开以及自动向量化(-ftree-vectorize),以追求最高运行性能。 |
★★☆☆☆ |
-Os |
以减小代码体积为目标,在保持大部分 -O2 优化的同时,避免增加代码尺寸的优化。 |
★★★☆☆ |
-Og |
面向调试的优化级别,在保留较好调试体验的同时提供一定优化效果。 | ★★★★★ |
-funroll-loops |
独立于 -O 的循环展开选项,可与其他优化级别组合使用。 |
— |
说明: 实际启用哪些优化选项会随 GCC 版本变化,可通过 gcc -Q --help=optimizers 查看当前版本各优化级别对应的具体选项。
推荐使用方式
- 开发调试:推荐使用 **
-Og -g**,既能保持较好的调试体验,又能发现部分优化相关问题。 - 性能测试或发布版本:通常使用 **
-O2**,它在性能、代码体积和编译时间之间取得了较好的平衡,也是大多数项目的默认选择。 - 极致性能优化:当 Profiling 证明热点代码仍有优化空间时,再考虑使用 **
-O3**,并评估其带来的代码体积增长是否值得。 - Flash 或 ROM 空间受限:优先考虑 **
-Os**。
2.5 平台指定参数
| 参数 | 作用 | 示例 |
|---|---|---|
-march=armv7-a |
指定架构版本,决定可用指令集 | 启用对应架构支持的指令(如 Thumb-2、SDIV/UDIV、Barrier 指令等) |
-mcpu=cortex-a9 |
为特定处理器调度指令 | 指定目标 CPU,同时选择对应架构并针对该 CPU 的流水线、Cache 和执行单元进行优化 |
-mtune=cortex-a8 |
为特定处理器优化但不改变指令集 | 可配合 -march=armv5te 在旧指令集上做新处理器调度 |
-mfpu=neon |
启用 NEON SIMD 与 VFP 浮点指令 | Cortex-A5 示例 |
-mfloat-abi=hard |
使用硬件 FP 寄存器传浮点参数 | 比 softfp 减少参数拷贝开销 |
向编译器提供尽可能详细的目标平台信息,是获得最优代码的关键。如果目标平台支持 ARMv7-A ,指定 -march=armv7-a 后,编译器可以使用 ARMv7-A 支持的更多指令,例如硬件除法(SDIV/UDIV)、Barrier 指令等;同时也可以生成 Thumb-2 指令,以在性能和代码体积之间取得更好的平衡。
至此,你已经让编译器尽可能生成高效的指令。但如果 Profiling 仍然显示热点集中在某个函数上,不必急于手写汇编,更应该先检查这段代码的内存访问模式。编译器优化解决的是”如何更快地执行指令”,而内存优化解决的是”如何更快地拿到数据”。对于 Cortex-A 而言,一次 Cache Miss 带来的等待时间,往往远高于执行几条算术或逻辑指令的开销,因此数据访问模式通常比单纯优化指令更值得关注。
3. 内存系统优化
在大多数 Cortex-A 系列处理器中,Cache 命中与未命中的访问延迟相差很大。L1 Cache 命中通常只需几个处理器周期,而一次 Cache Miss 若需要访问更低层 Cache 甚至主存,则可能需要数十甚至上百个周期。因此,对大多数算法而言,尽可能减少 Cache Miss 往往是最重要的性能优化目标,其中 L1 Cache 的优化收益通常最为明显。
3.1 数据局部性:空间与时间
数据 Cache 优化的核心原则是数据局部性(Data Locality):尽量让已经加载到 Cache 中的数据被重复利用,减少对更低层存储器的访问。
- 空间局部性(Spatial Locality):一个 Cache Line 通常为 32 或 64 字节。访问地址
0x1000时,处理器会将包含该地址的整条 Cache Line 一并加载到 Cache 中,因此紧接着访问0x1004、0x1008等位于同一 Cache Line 内的数据,通常都能直接命中 Cache,几乎无需额外的访存开销。 - 时间局部性(Temporal Locality):刚访问过的数据,如果在较短时间内再次被访问,大概率仍然保留在 Cache 中,因此能够快速命中,而无需重新从更低层存储器读取。
绝大多数 Cache 优化技巧,本质上都是围绕这两个原则展开:按顺序访问数据,提高空间局部性;尽快重复使用数据,提高时间局部性。
3.2 Loop Tiling:用分块提升 Cache 利用率
Loop Tiling(又称 Loop Blocking)的核心思想是:将大规模循环拆分为多个能够放入 Cache 的小块,使数据在被替换之前得到充分复用,从而减少 Cache Miss。
矩阵乘法是最经典的例子:
1 | // 原始代码 |
性能瓶颈主要出现在 b[k][j] 的访问模式。
由于 C 语言二维数组采用行优先(Row-major)存储,固定 j、递增 k 时,每次访问都会跳到下一行:
1 | b[0][j] |
假设矩阵每行有 1024 个 int,每个 int 占 4 字节,则相邻两次访问的地址相差:
1 | 1024 × 4 = 4096 Bytes |
这远大于一个 Cache Line(通常为 32 或 64 字节),因此几乎每次访问都会落到新的 Cache Line。虽然每次都会把整条 Cache Line 加载到 Cache 中,但其中只有一个元素被立即使用,其余数据很可能在再次访问前就已经被替换掉,导致 Cache 利用率极低。
Loop Tiling 的做法是把矩阵划分为多个较小的子块,使参与一次计算的数据能够尽可能驻留在 Cache 中:
1 | // Loop Tiling(8×8×8 分块) |
采用分块后,每次只处理一个 8×8×8 的子问题。这样,同一个子块中的 a、b 和 result 数据能够在 L1 Cache 中被反复访问,而不是频繁重新加载,大幅提高了 Cache 命中率。
说明: 分块大小并不是固定为
8。实际选择通常需要根据 L1 Cache 容量、Cache Line 大小、数据类型以及算法特点综合确定,8×8×8只是一个便于说明原理的示例。
ARM 优化手册还建议交换 ki 与 ji 的循环顺序。原因是 ra[ki] 在一次内层循环中只使用一次,而 rb[ji] 和 rresult[ji] 会在整个内层循环中反复访问。将引用频率更高的数据放在最内层循环,可以减少地址计算和寄存器更新,提高流水线利用率,并为编译器优化创造更好的条件。
3.3 Loop Interchange:换一下循环顺序
Loop Interchange(循环交换)是指交换嵌套循环的执行顺序,使内层循环尽可能按连续地址访问数据,从而提高 Cache 命中率。
例如:
1 | // 按列访问(Cache 不友好) |
交换循环顺序后:
1 | // 按行访问(Cache 更友好) |
由于 C 语言采用行优先(Row-major)存储,第二种写法按连续地址访问数组,更容易命中 Cache。因此,Loop Interchange 的原则不是让迭代次数最多的循环放在最内层,而是让内层循环尽可能连续访问内存。
3.4 结构体布局优化
结构体布局优化
数据在内存中的布局会直接影响 Cache 利用率。优化的目标是:让一次 Cache Line 加载尽可能包含更多真正会被访问的数据,同时避免额外的访存开销。
例如,一个 64 字节结构体中只有一个字段是热点数据:
1 | // 修复前:一次 Cache Line 填充只有少量有效数据 |
如果热点代码只访问:
1 | sum += obj[i].hot_field; |
那么 CPU 每次都需要把包含整个结构体的 Cache Line 加载到 Cache,而其中绝大多数数据并不会被使用,造成 Cache 带宽浪费。
一种常见优化方法是将热点字段拆分出来,单独存放:
1 | // 修复后:一次 Cache Line 包含更多热点数据 |
这样一条 Cache Line 可以容纳更多连续的 hot_field 数据,提高 Cache 利用率。
此外,数据对齐(Alignment)也属于数据布局优化的一部分。虽然现代 Cortex-A 支持非对齐访问,但相比按自然边界对齐的访问,非对齐访问可能需要额外的访存操作,增加访问延迟。因此,在性能敏感的代码中,仍应尽量保证数据按自然边界对齐,避免不必要的非对齐访问。
3.5 Cache 关联性效应
ARM L1 Cache 通常采用 4-way 组相联(Set Associative) 结构,L2 Cache 则常见为 8-way 或 16-way。每个 Cache Set 最多只能同时保存有限数量的 Cache Line,当多个数据映射到同一个 Cache Set 时,即使其他 Cache Set 仍有空闲空间,也可能发生频繁替换,导致 Cache Miss,这种现象称为 Conflict Miss(冲突失效)。
一个典型场景是多个数据块恰好相隔 Cache 每 Way 的容量。以 16KB、4-way、32B Cache Line 的 L1 Cache 为例,每 Way 容量为 4KB,因此地址相差 4KB 的数据会映射到同一个 Cache Set。当同时访问超过 4 个这样的数据块时,就会不断发生 Cache Line 替换,即使整个 Cache 仍有大量空闲空间。
因此,在设计数据结构或选择分块大小时,应尽量避免大量热点数据集中映射到同一个 Cache Set,以减少冲突失效,提高 Cache 利用率。
3.6 I-Cache 与内联的微妙权衡
函数内联能够消除函数调用开销,并为编译器创造更多优化机会,但并非越多越好。对于调用点很多的小函数,过度内联会导致代码体积迅速膨胀,占用更多 I-Cache 空间,反而降低性能。
例如,一个很小的函数在 20 个位置被调用。如果保持独立实现,它只需在 I-Cache 中保存一份代码,所有调用点共享;而内联后,函数体会被复制 20 次,增加 I-Cache 占用,更容易发生 I-Cache Miss,并挤占其他热点代码。
因此,对于调用点很多但函数体较小的场景,函数调用带来的几个周期开销,往往比 I-Cache Miss 的代价低得多。现代 Cortex-A 处理器还配备了高效的分支预测器和 Return Stack Buffer(RSB),能够很好地预测函数调用和返回,因此不应为了消除 BL/RET 而盲目内联。
类似地,对于明显偏向某一分支的条件判断(例如 error == 0 的概率超过 99%),应尽量让高频执行路径保持线性,低频路径放到条件分支中。这样不仅有利于分支预测,也能避免异常处理等冷代码进入 I-Cache,提高指令 Cache 的利用率。
3.7 TLB 优化
TLB(Translation Lookaside Buffer)用于缓存虚拟地址到物理地址的映射。当程序访问的页面数量超过 TLB 容量时,会发生 TLB Miss,需要重新进行页表遍历,增加访存延迟。
对于应用开发者而言,能够直接干预 TLB 的手段并不多。优化的核心是减少活跃页面数量:将热点代码和热点数据尽可能集中,避免频繁跨页访问。在裸机或操作系统允许的场景下,还可以使用 Section(1MB) 或 Supersection(16MB) 映射代替 4KB 小页,使一个 TLB 条目覆盖更大的地址空间,进一步降低 TLB Miss 的概率。
3.8 Data Abort 优化
在支持虚拟内存的操作系统(如 Linux)中,访问尚未建立映射的页面会触发 Data Abort(Page Fault),由内核完成缺页处理,例如分配物理页面、建立页表映射等。对于采用 Copy-on-Write(COW) 的页面,首次写入共享页面时也会触发异常,由内核分配新的物理页并完成复制。这些异常处理都会带来较高的性能开销。
优化思路与 TLB 优化类似:减少页面数量和随机跨页访问。热点代码和数据越集中,不仅能够降低 TLB Miss 的概率,也有助于减少首次访问页面时的缺页处理开销。
3.9 预取:PLD 和 PLI
PLD(Preload Data)用于提示处理器提前将数据加载到 D-Cache;PLI(Preload Instruction)用于提前将指令加载到 I-Cache。它们都是预取提示(Hint),处理器可以选择执行,也可以忽略。
现代 Cortex-A 处理器通常还具备硬件自动预取能力。当检测到连续或规律的内存访问模式时,处理器会自动预取后续 Cache Line,因此大多数场景无需手动插入 PLD 或 PLI。只有在 Profiling 证明内存访问延迟仍然是主要瓶颈时,才值得尝试手动预取,并根据实际测试结果评估收益。
至此,我们已经介绍了编译器优化和内存系统优化,它们覆盖了大多数程序的性能瓶颈。只有当 Profiling 仍然显示热点集中在少数循环或函数时,才值得进一步从 C 源码甚至汇编层面进行针对性的优化。此时,开发者需要理解 ARM 微架构的执行代价,将热点代码改写为更适合处理器执行的形式,从而获得最后一部分性能收益。
4. 源码级微调
编译器做完代码生成后,ARM 汇编层面的规则仍然影响着你写的 C 代码。这里的每条技巧都基于一条特定的 ARM 汇编特性——循环倒计数利用 SUBS 免费更新条件标志位,restrict 解除编译器被迫的保守假设,int 类型避免窄类型的符号/零扩展指令。
4.1 循环倒计数
ARM 汇编知识可以直接指导 C 代码优化:让循环计数器倒数到 0 而非正数到 N。这是因为 SUBS(带标志更新的减法)会让与零的比较”免费”——Z flag 自动更新,省掉一条显式 CMP 指令。
1 | // 每次迭代需要 CMP 指令 |
成本对比:正数版本每条迭代是 ADD + CMP + B(cc),三条指令;倒数版本每条迭代是 SUBS + BNE,两条指令。如果循环执行 100,000 次,就省了 100,000 条 CMP。
此外,循环计数器应使用 int (32-bit) 类型。ARM 是原生 32-bit 机器,使用 short 或 char 作为循环计数器会导致编译器插入额外的溢出检查指令(如 SXTH、BIC 等)。
4.2 Loop Fusion:合并独立循环
1 | // 合并前 — 两个独立的循环 |
收益是减少了循环控制开销(比较 + 分支)的一半。但需注意:如果 x 和 y 恰好在 Cache 中被映射到同一个 Set(因为地址相差 Cache Way 大小的倍数),合并后两个数组会互相逐出对方的 Line,产生 Cache thrashing,合并反而更慢。
4.3 减少栈和堆的使用
ARM 处理器的寄存器数量有限。当所有寄存器都被当前活跃的变量占用时,额外的变量会被溢出到栈上,每次访问都需要内存操作,增加额外的执行周期。关键原则是:尽量减少同时活跃的变量数量。
一个直接的影响是函数参数数量。ARM AAPCS 规定前 4 个参数通过寄存器(R0-R3)传递,第 5 个及以后的参数通过栈传递。因此,4 个或更少的参数比 5 个以上的参数高效得多。64-bit 变量(如 double、long long)占用两个寄存器槽位。C++ 的非静态成员函数还会额外消耗一个槽位给 this 指针。递归函数通常会进一步恶化寄存器利用效率。
4.4 变量选择:int 是 ARM 上的最优类型
1 | // unsigned int i, j, k; → i = j + k; |
原因:ARM 的 ALU 指令对 32-bit 操作数做全宽度计算。当变量声明为更窄的类型时,编译器必须插入裁剪指令来模拟窄类型的溢出行为。虽然编译器有时可以处理不正确的类型指定,但最好从一开始就使用正确的类型。
4.5 restrict:解绑指针假别名
指针别名是编译器优化的最大障碍。C 语言中任何指针都可能与其他指针重叠,编译器必须保守地假设通过指针访问的内存区域可能重叠,这阻止了许多可能的优化。
1 | // 无 restrict — 编译器被迫保守:每次都要从内存重读 *i |
restrict (C99) 或 __restrict (GCC 扩展) 告诉编译器这个指针指向的内存不会被其他任何指针触及。这对于需要同时读写多个数组的循环体(如矩阵运算、信号处理)效果尤其显著。
4.6 除法与取模
并非所有 ARM 处理器都有硬件除法支持。对于没有硬件除法的处理器,C 除法通常会调用一个库例程,对 32 位整数除法需要数十个周期。即使有硬件除法器,除法仍然比乘法慢得多。
编译器可以将除以编译时常量的运算替换为移位乘法对——x = a / 60 会被优化为 (a × magic_constant) >> shift,等价于一次 32×32 乘法加移位,远快于库函数除法。但对于两个变量相除(a / b),编译器无法优化——必须避免出现在热路径中。
取模运算同样会使用除法库例程。一个典型案例:
1 | // 慢:取模运算调用除法库函数 |
从库函数调用缩减为 2 个周期的 ADD+CMP 组合。
4.7 其他技巧一览
| 技巧 | 原理 | 适用场景 |
|---|---|---|
| extern 数据放 struct | 多个 extern 变量共享同一个基指针,省去多次加载 GOT 条目的开销 | 全局变量较多时 |
| 内联汇编当最后手段 | 先用 profiling + 算法优化 + 编译器优化,都用完了再考虑手写汇编 | 确认的极端热点 |
| 避免复杂寻址模式 | 带移位偏移的 LDR 不能让 CPU 双发射,拆分为独立 MOV+LDR 可能更快 | 循环体内的 Load/Store |
| 对齐访问 | 非对齐 LDR 有额外 1 周期惩罚,跨 Cache Line 的非对齐更严重 | 数据结构的 padding 设计 |
| 链接时优化 | LTO 跨编译单元做内联和死代码消除 | 大型项目,-flto |
| 减少函数参数数量 | ARM 的前 4 个参数走寄存器,第 5 个开始走栈 | 高频调用函数 |
5. 优化决策路径
优化决策遵循”先廉价后昂贵、先通用后专用”的原则。起点永远是 profiling——只有测量才能告诉你真正的热点在哪里。确定了热点之后,按照三个层级逐级递进:
第一层是编译器开关——零代码改动。打开 -O2 -mcpu=<target> -mfloat-abi=hard。如果 profiling 确认性能达标,停止优化。
第二层是内存系统优化——修改数据访问模式。如果 Tier 1 不够,瓶颈通常在内存子系统。检查数据局部性:是否可以用 Loop Tiling 把工作集限制在 Cache 内?是否需要交换循环顺序?结构体是否可以拆分让热点字段独立成数组?Cache miss 的代价 (20-100 cycles) 远大于任何指令级优化。
第三层是源码级微调——修改 C 代码细节。如果前两层都不够,热路径的每一行 C 都要仔细审视。循环能否倒计数?指针是否有假别名?热路径中是否有除法或取模?函数参数是否超过 4 个?这些微调每次只省 1-2 个周期,但累积效果显著。
核心原则:永远从编译器开始,永远用 profiling 验证每一步的收益,永远不要猜。
6. 关键要点
以下是 Cortex-A 性能优化中需要牢记的十个关键点:
- Profiling 先行——不要猜测瓶颈,先测量再优化。
- 优先利用编译器优化——
-O2、-mcpu=<target>、-mfloat-abi=hard等选项通常能以最低成本获得可观收益。 - Cache Miss 往往是最大的性能瓶颈——一次访问主存的延迟可能远高于执行几十条普通指令。
- 优化数据局部性——合理使用 Loop Tiling、Loop Interchange 等技术,提高 Cache 命中率。
- 避免盲目展开循环和内联函数——代码膨胀会增加 I-Cache 压力,应以 Profiling 结果为依据。
- 关注 I-Cache 与分支预测——保持热点代码紧凑、连续,通常比消除一次函数调用更重要。
- 充分发挥编译器优化能力——合理使用
restrict、const等关键字,减少不必要的别名分析和优化障碍。 - 减少热路径中的高开销指令——避免频繁使用除法、取模等高延迟操作,必要时可采用等价的低成本实现。
- 关注地址转换开销——减少活跃页面数量,提高 TLB 命中率,降低缺页和页表遍历带来的性能损失。
- 优化目标不只有速度——降低功耗、减小代码体积和内存占用,同样是性能优化的重要目标。