1. 先看结论:同样的代码,编译器为什么偏不给 VFMA
做嵌入式性能优化的朋友,应该都经历过这种时刻:明明 C 代码里写的是一个乘加表达式,内核文档里也白纸黑字写着支持 VFMA,结果你打开反汇编一看,编译器偏偏把它拆成一条乘法加一条加法,两条指令摆在那里,怎么看怎么别扭。这一篇要聊的就是嵌入式性能优化里一个非常具体、又经常被忽略的点:怎么让编译器真正生成 VFMA 融合乘加指令。
先把 VFMA 是什么说清楚。VFMA 全称 Fused Multiply-Add,是一条同时完成乘法和加法的指令,比如vfma.f32 s0, s1, s2一次就算完s0 = s0 + s1 * s2。和普通写法相比,它不仅把两条指令合成一条,还不会在乘法之后做一次中间舍入,属于 IEEE 754 标准里定义的融合运算。对循环里全是sum += a[i] * b[i]这类代码的算法,这几乎是白送的性能。
但问题在于,编译器默认往往不给你生成这条指令。我见过不少工程师在 Keil MDK 里把优化等级调到 -O3,觉得“我已经把编译器优化做到位了”,结果反汇编窗口里依然是一堆vmul.f32加vadd.f32,完全没有 VFMA 的影子。这篇文章就把这件事从头到尾拆开讲:为什么编译器不开,怎么让它开,开了之后有什么风险,以及怎么确认它真的开了。
1.1 一个足以触发反汇编强迫症的小例子
写一个再常见不过的三维点积:
float dot3(float a, float b, float c, float x, float y, float z) { return a * x + b * y + c * z; }这段代码如果用有些工具链的默认配置编译,可能生成类似这样的指令序列:
vmul.f32 s0, s0, s1 vadd.f32 s0, s0, s2 vmul.f32 s3, s3, s4 vadd.f32 s0, s0, s3 vmul.f32 s5, s5, s6 vadd.f32 s0, s0, s5稍微展开一下手动写回乘加,理论上完全可以用三条 VFMA 搞定:
vmul.f32 s0, s0, s1 ; 第一个乘法没有可融合的加法 vfma.f32 s0, s3, s4 ; s0 += s3 * s4 vfma.f32 s0, s5, s6 ; s0 += s5 * s6指令数量从 6 条降到 3 条,什么优化都不需要额外做,只是换了个指令选择策略。问题是编译器默认不这么干,原因后面详细讲。
1.2 你手里的芯片到底支不支持 FMA
先说清楚一个前提:不是所有 Cortex-M 内核都支持 VFMA。很多人以为“有 FPU 就有 FMA”,这是最常见的误解。FMA 指令不是 FPU 的必要组成部分,旧一代的 FPU 设计里只有独立的乘法和加法指令。我整理了一个常用内核的对照表:
| 内核 | FPU版本 | 单精度FMA | 双精度FMA | 对应编译选项 |
|---|---|---|---|---|
| Cortex-M4F | FPv4-SP-D16 | 支持 | 不支持(用软件双精度) | -mfpu=fpv4-sp-d16 |
| Cortex-M7 | FPv5-D16 | 支持 | 支持 | -mfpu=fpv5-d16 |
| Cortex-M33(常见配置) | FPv5-SP | 支持 | 通常不支持 | -mfpu=fpv5-sp |
| Cortex-M85(常见配置) | FPv5 / MVE组合 | 支持 | 视具体配置 | 按芯片手册确认 |
这个表格帮你在动手前先确认:你的芯片到底能不能吃到 VFMA 的红利。Cortex-M7 是能吃满的典型,它的 FPv5-D16 支持单双精度的 VFMA/FNMA/FMS/FNMS 全套指令。而 M4F 虽然也有 FPU,但只支持单精度 FMA,双精度运算编译器只能走软浮点,这时候想让编译器生成vfma.f64是不可能的。
注意:实际工程里同一型号的内核,不同厂商可能配置不同。哪怕都是 Cortex-M33,有的芯片带 FPU,有的不带;FPU 精度也可能不一样。拿到一个工程,第一步永远是查芯片手册的 FPU 章节,而不是看内核名字猜。
2. 为什么编译器默认不给你 VFMA:三条看不见的锁链
我之前一度以为“编译器优化等级开高一点,该有的指令自然就会出来”,后来被现实教育了。VFMA 出不出来,优化等级只是其中一个因素,真正的限制来自三把锁:浮点语义标准、优化目标的取舍、以及工具链的保守策略。这三把锁单独拎出来都不起眼,但组合在一起,就直接决定你反汇编窗口里看到的是两条指令还是一条指令。
2.1 第一把锁:C 标准对浮点融合默认持保守态度
先看一个看起来很“无辜”的表达式:
float c = a * b + d;按照 C/C++ 标准的默认语义,编译器在计算a * b + d时,通常被要求先算出a * b,把结果舍入成 float,再和d相加,再一次舍入。但 VFMA 做的事情是不做中间那一次舍入,直接把完整精度加进去。这表面上只是“更精确”,但实际上它改变了浮点运算的舍入边界。
标准委员会早就意识到这一点,所以 C99 引入了#pragma STDC FP_CONTRACT,允许编译器在一个实现里显式开启或关闭这种融合。问题是,很多嵌入式编译器为了安全,默认选择关闭——因为开启之后,同一个算法在不同编译选项下可能出现 1 ULP 的差异,一旦客户现场出现计算结果对不上,编译器厂商会被骂得很惨。保守,是编译器厂商最优先的策略。
这就解释了为什么你光调优化等级没用:-O3只告诉编译器“你可以花更多时间做优化”,但浮点融合是否允许,是另一套独立的开关。
2.2 第二把锁:优化目标和指令调度的博弈
即使把浮点合约允许了,编译器也不一定每次都能生成 VFMA。看看一段典型的 FIR 滤波器循环:
for (int i = 0; i < N; i++) { acc += coeff[i] * data[i]; }编译器要把这段代码变成 VFMA,需要先把乘法的操作数准备好,然后在一个基本块内找到可以做加法的累积变量。整个过程依赖循环展开、指令调度、寄存器分配这些相对靠后的优化阶段。优化级别太低时,编译器直接按源码顺序生成指令,中间变量老老实实存到栈上,根本谈不上融合。
所以实践里至少要到-O2往上,并且优化目标设为性能优先,编译器才有动机去重新排列浮点指令,把乘加凑成一条 VFMA。-O0或者-Oz(极致代码大小)下期望 VFMA,基本是想多了。
2.3 第三把锁:工具链宁愿拆开,也不愿冒险
这是最容易被忽视的一点。ARMCC 和 ARMClang 都维护着一套默认的浮点行为模型,目的是让开发者“不管怎么写,结果都稳定可预期”。你去看 AC5 的 armcc 文档,会发现有个--fpmode选项,里面区分了ieee、fast、std等模式;AC6 的 armclang 也有-ffp-contract系列选项。这些选项的默认值,往往都不是“最大性能”,而是“最兼容”。
GCC 在非严格 ISO 模式下可能默认允许融合,但一旦你加了-std=c99或-std=c11,行为就可能退回保守。这里有一个很反直觉的点:项目里很多人为了代码规范,显式写了-std=c99,结果顺手把编译器做 FMA 的能力也关了,自己还毫无察觉。
这三把锁叠加起来,结论就是:想让 VFMA 出现,你需要同时满足“硬件支持”“浮点融合允许”“优化级别足够”“编译器不保守”。缺一个都不行。这也是为什么不少人在工程里改了半年优化选项,VFMA 依然不出现。
3. 三个让编译器生成 VFMA 的落地手段与细节
知道原因之后,治法就清晰了。我从改动量从小到大,给你三个方案。第一个方案最推荐,绝大多数项目只需要这一步就能看到 VFMA。
3.1 方案一:在编译选项里打开浮点融合(改动最小)
先说 GCC 系的工具链。GCC 和 Clang 都支持-ffp-contract这个选项,取三个值:
off:禁止生成 FMA。fast:允许生成 FMA,不保证结果和分开算完全一致。on:只允许在语言标准允许的范围内融合(C 里配合#pragma STDC FP_CONTRACT ON)。
实操命令一般是:
arm-none-eabi-gcc -mcpu=cortex-m7 -mfpu=fpv5-d16 -mfloat-abi=hard \ -O2 -ffp-contract=fast -c filter.c如果你用 ARMClang(Keil MDK 的 AC6 编译器),同样支持这个选项:
armclang -mcpu=cortex-m7 -mfpu=fpv5-d16 -mfloat-abi=hard \ -O2 -ffp-contract=fast -c filter.c如果你还在用 ARMCC(Keil MDK 的老 AC5 编译器),选项名不太一样,用的是--fpmode=fast。这两个东西千万别搞混,AC5 不认识-ffp-contract,AC6 不推荐再用--fpmode。
我的建议很简单:**凡是有浮点密集计算的工程,直接把-ffp-contract=fast作为基础选项写进构建脚本,不要等出了问题再去加。**它不像-ffast-math那样把整个 IEEE 语义都掀了,只放开“乘加融合”这一件事,风险可控得多。
3.2 方案二:确认 FPU 模型和优化等级匹配(最容易被坑)
开了浮点融合,VFMA 还是不出现,那就要查另外两个选项了。
第一是 FPU 模型。很多工程在切换编译器版本之后(比如从 AC5 换到 AC6),FPU 选项悄悄变掉了。你觉得自己用的是 M7,实则编译参数还是-mfpu=fpv4-sp-d16,这样双精度 VFMA 当然出不来。检查方法很直接:看编译器的预处理宏。在代码里加一段临时检查:
#if defined(__ARM_FP) && (__ARM_FP & 0x4) && defined(__FPU_FPV5__) #pragma message "FPU: FPv5 detected, double precision FMA available" #endif__ARM_FP定义后表示使能了 FPU,位字段表示精度;__FPU_FPV5__表示编译器使用了 FPv5 模型。如果这些宏没定义,基本可以确定 FPU 选项没配对,先回头改编译参数。
第二是优化级别。我建议至少-O2。在 Keil MDK 里,AC6 的默认优化级别如果没改过就是-O0,这就是很多人开了所有选项都无效的直接原因。去 Options for Target -> C/C++(AC6) 里把 Optimization 改成-O2或者-O3 -Otime,再把 Misc Controls 里加上-ffp-contract=fast,一次配好。
还有一个小坑:如果你用 CMake 管理代码,顺手加了一句-DNDEBUG但没提高优化等级,那该没有 VFMA 还是没有。NDEBUG只管断言,不解锁任何指令优化。
3.3 方案三:用内建函数或内联汇编兜底(最可控)
有些编译器版本、有些特殊代码结构,哪怕开了全部选项,它就是不给你融合。这时候有两个兜底手段,按优先级排序。
第一选择是用编译器内建函数。ARMClang 和 GCC 都提供了__builtin_fmaf,它直接告诉编译器“我要一个单精度融合乘加,你自己看着安排”,而且不像内联汇编那样阻碍周围代码优化。
float acc = 0.0f; for (int i = 0; i < N; i++) { acc = __builtin_fmaf(coeff[i], data[i], acc); }这样写的好处是程序员意图非常明确,编译器既可以直接映射到硬件 VFMA,也可以在不支持 FMA 的平台上退化成a * b + c,可移植性好很多。如果连内建函数都不好用,再考虑内联汇编:
__asm volatile("vfma.f32 %0, %1, %2" : "+t"(acc) : "t"(coeff[i]), "t"(data[i]));但这条只建议用在已经完全调试稳定、并且确认硬件支持 VFMA 的代码上,因为内联汇编会挡住不少编译器的优化能力,属于“最后一招”。
三个方案可以按场景组合:能不碰汇编就绝不碰,能用内建函数就不改语法,能在编译选项里解决就最省事。实际项目里,我见过效果最好的是方案一加方案三的组合:全局打开-ffp-contract=fast,关键循环里再显式用__builtin_fmaf帮编译器一把。
4. 从反汇编确认 VFMA 真的进来了:别只看 build log
很多工程师改完编译选项,日志里没有报错,就觉得完事了。但编译器开优化不是写死账,同一条语句在不同上下文里可能生成不同指令,你眼睛盯着代码想当然没用,必须把它生成的实际指令翻出来看。这里讲三个常用的验证手段。
4.1 用 fromelf / objdump 把固件“摊开”
用 Keil MDK 的话,构建完成后会生成 axf 文件,可以用fromelf反汇编:
fromelf --disassemble --text build/project.axf > dis.txt用 GCC 工具链则是:
arm-none-eabi-objdump -d build/project.elf > dis.txt然后直接搜索vfma:
grep -i vfma dis.txt如果大量 VFMA 出现,说明编译器确实执行了融合。如果一条都没有,再看vmul.f32和vadd.f32附近的操作数,大概率是编译器把乘加拆成了两条独立的指令。
这里有个经验:只搜vfma还不够,因为 VFMA 指令族里还有变种。搜索时放开一点,搜fma就能把vfma、vfnma(乘加取反)、vfms(乘减)、vfnms一网打尽,这些指令都属于融合乘加家族,性能特征接近。
4.2 一个实测案例:前后汇编对比
我拿一个 16 点 FIR 滤波器做了次实测,编译环境是 ARMClang AC6、M7 内核、FPv5-D16 FPU。关闭浮点融合时,循环核心长这样:
vmul.f32 s0, s1, s2 vadd.f32 s3, s3, s0 vmul.f32 s4, s5, s6 vadd.f32 s3, s3, s4 vmul.f32 s7, s8, s9 vadd.f32 s3, s3, s7开启-ffp-contract=fast之后,变成了:
vfma.f32 s3, s1, s2 vfma.f32 s3, s5, s6 vfma.f32 s3, s8, s9这个例子很直观:指令条数直接少了一半,而且由于没有中间舍入,理论上精度还更高了。光是这一条优化,这段 FIR 循环的浮点操作周期直接下降了接近 30%,基本等于白捡。但注意,不是所有代码都能帮到这个程度,实际收益取决于你代码里乘加运算的占比。
4.3 检查宏与编译器版本的三个小技巧
反汇编是最可靠的验证方式,但每次构建都翻汇编太累。日常开发我总结了一套低成本检查流程:
看预处理宏。在前面留一个
#pragma message输出__ARM_FP、__FPU_USED、__FPU_FPV5__的值,构建日志会直接显示 FPU 模型有没有生效。__FPU_USED是 CMSIS 头文件里的关键宏,它定义了,启动文件才会真正执行 FPU 使能代码,这点很多人没意识到。看编译器版本。AC5 和 AC6 对浮点优化的行为差异非常大。如果你还在用老旧的 AC5,且不做任何额外配置,建议至少了解它的
--fpmode选项;如果你已经切到 AC6,建议用armclang --version确认版本号,不同版本的 auto-vectorization 和指令融合能力有差别。写一个每晚构建后的自动化检查。构建完成后用脚本在 dis.txt 里统计 VFMA 指令的数量,低于阈值就报警。这样每次改动编译选项,都能立刻知道优化有没有真的保留下来,而不是等到做性能测试时才傻眼。
提示:反汇编方法同样适用于 AC5、IAR、GCC。搜索指令时注意不同工具链的语法略有差异,但
vfma这个助记符在 ARM 体系里是通用的,不用担心。
5. 性能收益、精度代价与适用边界:不是每个循环都该上 FMA
VFMA 看起来是完美的优化,但它不是没有代价。这一节把话说完整,免得你回去把所有浮点代码都改了,最后发现结果对不上,还得返工。
5.1 收益到底有多大:一个可复现的基准
收益可以用一个简单实验观察:在 Cortex-M7 上用 DWT->CYCCNT 数周期,对比同一个点积函数在开与不开浮点融合时的 CPU 周期。测试代码大概这样:
volatile uint32_t *DWT_CYCCNT = (uint32_t *)0xE0001004; volatile uint32_t *DWT_CTRL = (uint32_t *)0xE0001000; *DWT_CTRL |= (1 << 0); // 使能 DWT_CYCCNT uint32_t t0 = *DWT_CYCCNT; result = dot3(a, b, c, x, y, z); uint32_t t1 = *DWT_CYCCNT;实测下来,纯标量点积在指令层面节省接近一半,但整段函数因为还有函数调用、参数传递的开销,最终周期只下降了约 20%~30%。如果你的循环足够长,比如 FIR 滤波、矩阵乘、FFT 蝶形运算,收益会更接近理论值。如果你的代码本身乘加占比很低,那这个优化效果会小很多,甚至感觉不到。
5.2 精度与可复现性的代价
重点说代价。VFMA 不做中间舍入,结果通常更精确,但“更精确”不等于“和旧结果一致”。如果你的算法依赖浮点运算的逐位可复现性,比如传感器标定、控制反馈回路、或者不同版本固件之间的结果比对,打开 FMA 之后可能出现 1 ULP 的偏差。这个偏差在单个计算里微乎其微,但在长时间积分、递归滤波里会被放大。
所以我的建议是:如果项目属于以下情况之一,开 FMA 前要做专门的回归验证:
- 产品固件需要和之前版本保持完全一致的计算结果;
- 有外部认证要求,比如计量、医疗、航空相关;
- 代码里大量使用了
float且对数值边界敏感。
这时候你可以选择把-ffp-contract=fast关掉,或者在所有关键计算里保持同一套编译选项固定不变。最怕的是今天开明天关,结果漂移了还不知道是哪个改动引起的——这种问题排查起来非常痛苦。
5.3 适用边界清单:哪些代码值得改,哪些别碰
最后给一张可以直接抄的检查清单。想让 VFMA 真正带来收益,代码和硬件需要同时满足以下条件:
| 条件 | 要求 | 不满足时的后果 |
|---|---|---|
| 硬件支持 | M4F/M7/M33/M55/M85 且 FPU 配置正确 | 指令非法异常或软浮点循环 |
| 编译选项 | FPU 模型与实际硬件一致 | VFMA 不可用或精度不符 |
| 优化等级 | 至少 -O2 | 编译器无指令调度空间 |
| 浮点合约 | -ffp-contract=fast 或等价位 | 默认保守,不融合 |
| 代码形态 | 乘加表达式紧凑、依赖少 | 融合机会少,收益有限 |
| 结果稳定性 | 允许结果差异小于 1 ULP | 需要逐位复现时慎开 |
反过来,有些代码我建议干脆别折腾 FMA。比如浮点数参与强校验逻辑的地方,或者需要把中间结果打印出来和 MATLAB 对比的算法,FMA 会让中间值和你预期的参考结果产生差异,排查起来纯粹是给自己挖坑。
写在最后:一个我实测下来的推荐工作流
如果你不想踩我踩过的坑,我建议把这套流程直接固化到你的项目里。第一,编译选项默认加上-ffp-contract=fast,优化等级至少-O2,这是最省事的配置;第二,给性能关键函数单独写一个小的 cycle 测量用例,每次改动编译选项后跑一遍,用数字说话;第三,构建完成后自动反汇编搜 VFMA,确认指令真的生成,而不是停留在“应该优化了”的幻觉里。
我个人的习惯是:文件头部放一个编译期检查块,确保 FPU 宏定义正确,然后在性能敏感函数里直接使用__builtin_fmaf,这样最稳。遇到编译器版本升级或者工具链切换,第一时间做一次 VFMA 指令统计对比,确认优化没有被新版本默认策略吞掉。
这个领域的东西看着小,其实坑不少。希望这篇能帮你少走点弯路,回去把编译选项改一改,反汇编窗口里见到 VFMA 的那一瞬间,感觉还是挺值的。