上周调一块 STM32H743 板子,性能分析器显示一个 64 阶 FIR 滤波函数吃掉了将近 30% 的 CPU。我打开反汇编窗口往下翻,满眼都是成对的VMUL.F32和VADD.F32。当时第一反应是:这个函数明明就是acc += coeff[i] * data[i]一路累加,编译器为什么不用一条融合乘加指令把这俩事一起干了?后来沿着编译选项、浮点语义、工具链默认行为一路查下去,才把问题彻底理顺。这篇文章把整个链条拆开讲清楚,涉及编译器如何生成 VFMA 融合乘加指令、AC5/AC6/GCC 各怎么配、为什么有时候配了还是不出 VFMA,以及实测数据到底能省多少。适合正在抠嵌入式浮点性能的朋友,尤其是用 Keil MDK 或者 arm-none-eabi-gcc 工具链的。
1. 从一对浪费的指令讲起:VFMA 到底在解决什么问题
1.1 反汇编里的“二合一”机会
先看一个最常见的浮点计算模式:
float acc = 0.0f; for (int i = 0; i < n; i++) { acc += coeff[i] * data[i]; }acc += coeff[i] * data[i]本质上是一次乘法加一次加法。如果你用 Cortex-M4F 或者 Cortex-M7 这类带 FPU 的内核,而且编译选项没有专门针对浮点融合做设置,反汇编出来的指令序列大概率长这样:
vmul.f32 s0, s1, s2 vadd.f32 s0, s0, s3这里s1是系数,s2是采样点,s3是累加器acc。乘法算完放进s0,再执行加法。两条指令完成一个乘加,似乎没什么问题,但实际上目标芯片的 FPU 里有一条专门干这件事的指令:
vfma.f32 s0, s1, s2一个VFMA.F32就能完成s0 = s0 + s1 * s2的完整语义,寄存器少占一个,指令条数减半。累加器直接作为目标寄存器,既不需要额外的暂存,也不需要额外的数据搬移。
1.2 VFMA 和 VMLA、VMUL+VADD 的区别
ARM 文档里把这类指令统称为 Fused Multiply-Add,融合乘加。Cortex-M4F 和之后出现的带 FPU 的 M 系列内核,在 FPU 指令集里同时提供了VMLA、VMUL、VADD、VFMA、VFMS、VFNMA、VFNMS等一堆指令。很多初学者容易把VMLA和VFMA搞混,实际上这是两码事:
VMLA.F32 Sd, Sn, Sm:执行Sd = Sd + Sn * Sm,但乘法结果先按单精度舍入一次,再做加法,加法结果再舍入一次,也就是两次舍入。VFMA.F32 Sd, Sn, Sm:执行同样的运算,但乘法结果保持完整精度,和累加器相加后只做一次舍入。VMUL.F32+VADD.F32的组合:自然就是两次舍入,而且需要额外寄存器周转。
所以VFMA不光是省了一条指令的问题,它连舍入次数都变了。这一点放到第 2 节详细说,因为它直接关系到“优化会不会引入精度损失”这个敏感话题。
1.3 哪些内核支持 VFMA
不是所有内核都支持VFMA。这个判断很重要,否则你在不支持的内核上折腾半天,编译器怎么都不会吐出一条VFMA:
- Cortex-M0 / M0+ / M3:没有硬件 FPU,浮点运算靠软件库,
VFMA无从谈起。 - Cortex-M4F:支持单精度 FPU,支持
VFMA.F32。 - Cortex-M7:支持单精度和双精度 FPU,支持
VFMA.F32和VFMA.F64。 - Cortex-M33 / M55 / M85(ARMv8-M Mainline):支持单精度(可配双精度),同样支持
VFMA。 - Cortex-A 系列处理器里的 VFPv4 / NEON 也有对应的 FMA 指令,原理类似,但编译选项和寄存器名不同。
如果你用的是 STM32F4 系列,默认的内核是 Cortex-M4F,支持VFMA.F32。如果你用 STM32H7 系列,Cortex-M7 不仅支持单精度,双精度VFMA.F64也能用。我自己实测的场景是 STM32H743,双精度浮点性能原本就比 F4 强不少,但真正把 FIR 循环吃 CPU 的比例压下来,还是靠编译器把乘加对换成VFMA之后达成的。
2. 原理拆解:一次舍入和指令流水带来的双重收益
2.1 为什么说“数学等价但二进制不等价”
先看一个比较反直觉的结论:a*b + c用VMUL + VADD计算,和用VFMA计算,数学上完全等价,但二进制结果可能不一样。原因是中间结果的舍入次数不同。
嵌入式浮点基本都是 IEEE 754 单精度。a*b的结果如果无法精确表示,硬件要先把它舍入到 23 位尾数,然后+ c又一次舍入,总共两次舍入。VFMA则不一样,它把乘法的完整中间结果(不被舍入)直接加上c,最后只做一次舍入。一次舍入的误差当然小于等于两次舍入累积的误差。
我举个例子说明。假设a = 1.0000001f,b = 1.0000001f,c = -0.9999999f。用普通乘法先算a*b,单精度下这个值并不精确,要先舍入一次,再和c相加后再舍入一次;用VFMA则整个计算只在最后舍入一次。在某些特定数值组合下,两者最后一位会有差异,而且VFMA的结果通常更接近真实数学结果。
这就打破了很多人“性能优化必然牺牲精度”的思维定式。VFMA是少见的提升性能的同时还提升数值质量的优化手段。
2.2 指令条数减半,流水线压力大不一样
再回到性能。Cortex-M7 这类带浮点流水线的内核,单精度VMUL、VADD、VFMA的单条指令周期数其实差不多,都在几个周期量级。关键不在于单条指令快多少,而在于指令总数和寄存器依赖关系。
看VMUL + VADD的写法:
vmul.f32 s0, s1, s2 vadd.f32 s0, s0, s3第一条指令的结果s0是第二条指令的输入,硬件浮点流水线必须等VMUL写回之后才能开始VADD,中间有明确的依赖等待。
换成VFMA:
vfma.f32 s0, s1, s2乘法结果直接进入加法阶段,整条指令在浮点流水线里是一体化处理的,不用等两条独立指令之间的写回和唤醒。对 FIR、矩阵乘法、PID 运算这种连续乘加型计算,循环体里原本的乘加对直接变成一条指令,指令数少一半,寄存器压力也小很多。
在我那 64 阶 FIR 的例子里,开启 VFMA 之后整个循环体的汇编指令数量肉眼可见地缩小,采样循环的周期数也明显下降。后面第 4 节有具体数据。
2.3 那编译器为什么不默认开
既然VFMA又快又准,编译器为什么默认不生成?
核心原因在于 C 语言标准对浮点表达式舍入语义的保护。C 标准允许编译器做“融合乘加”优化的前提是必须实现#pragma STDC FP_CONTRACT,而且程序员可以关闭它。标准里还留了一句话:如果优化改变了程序的可观察行为,那编译器就不该做。
很多嵌入式编译器在默认配置下走保守路线:不主动融合乘加,避免因为结果差几个 ULP 引发用户的兼容性投诉。GCC 在严格的 ISO C 模式(比如-std=c99、-std=c11)下可能把FP_CONTRACT视为关闭状态,而 Keil 的 AC5 老编译器在默认浮点模式下也不会激进地重排浮点运算。所以指望编译器自己悟出来是不可靠的,必须在编译选项层面明确告诉它:允许你做浮点收缩。
3. 编译选项与代码写法:AC5、AC6、GCC 的一份可抄配置
3.1 先确认 FPU 型号
配置之前,先搞清楚目标内核的 FPU 类型。这一项搞错,后面可能连硬浮点指令都生成不了:
- Cortex-M4F:通常用 FPv4-SP,
-mfpu=fpv4-sp-d16 - Cortex-M7:通常用 FPv5-D16,
-mfpu=fpv5-d16 - Cortex-M33:FPv5-SP,
-mfpu=fpv5-sp-d16
Keil MDK 里的图形化界面通常在下拉菜单选,GCC 则在-mfpu参数里指定。以我常用的arm-none-eabi-gcc为例,完整的一套 CFLAGS 大概长这样:
arm-none-eabi-gcc -mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard \ -O3 -ffp-contract=fast -ffast-math \ -Wall -Wextra -c fir.c -o fir.o-mfloat-abi=hard表示浮点参数用 FPU 寄存器传递,-mfpu=fpv5-d16指定 FPU 类型。这两个参数错误,编译器可能退回软件浮点,一条VFMA都别想看到。-mfloat-abi=softfp则允许使用硬件浮点指令,但参数传递走通用寄存器,性能会打折。
3.2 GCC 的关键参数:-ffp-contract 的三个值
GCC 的-ffp-contract参数有三个值,很多人只记得fast,实际上区别挺大:
| 参数 | 行为 | 适用场景 |
|---|---|---|
-ffp-contract=off | 禁止任何融合乘加 | 需要严格 IEEE 舍入行为,或者做逐位一致性测试 |
-ffp-contract=on | 允许在同一个表达式语句内收缩 | 默认情况下,满足语言标准对FP_CONTRACT的要求 |
-ffp-contract=fast | 允许跨语句收缩 | 嵌入式性能优化最常用的选择 |
实际的坑在于:GCC 在-std=c99、-std=c11这类严格 ISO 模式下,对浮点收缩的默认态度会收敛很多。即使你在代码里写了acc += a[i] * b[i],编译器也可能不融合。我的习惯是不管默认值是什么,直接显式写上:
-ffp-contract=fast这样在不同的-std设置下行为才一致。顺带说一句,-ffast-math这个大礼包里已经包含了-ffp-contract=fast,但同时还会开启-ffinite-math-only、-fno-signed-zeros等一堆激进优化。如果项目里有 NaN 和无穷大的业务判断,慎用-ffast-math,单独加-ffp-contract=fast更可控。
3.3 Keil AC5(armcc)老编译器的玩法
老项目还在用 AC5 的,编译选项和 GCC 完全不同。AC5 用--fpu指定 FPU,用--fpmode控制浮点语义:
armcc --cpu Cortex-M7 --fpu fpv5-d16 -O3 --fpmode=fast--fpmode=fast会允许编译器执行一些宽松的浮点优化,包括融合乘加。需要提醒的是,这个参数和 GCC 的-ffast-math有些类似,它不只是控制FP_CONTRACT,还涉及无穷大、NaN 等处理方式。如果你不想全局放开,可以只对包含 DSP 算法的那个 C 文件单独加这个选项,其他文件保持默认。
AC5 还有一个特点:它对VFMA的生成积极性比 AC6 要弱一些。如果你打开反汇编发现 AC5 即便加了--fpmode=fast仍然在关键循环里吐出VMUL + VADD,可能需要检查代码写法是不是把乘法和加法拆到了不同的语句里:
float t = coeff[i] * data[i]; acc += t;这种写法在--fpmode=fast下 AC5 有时候也会融合,但在更老的编译器版本上不一定。所以代码层面尽量写成acc += coeff[i] * data[i];这种单表达式形式,给编译器更大的融合空间。
3.4 Keil AC6(armclang)的配置
AC6 底子是 armclang,浮点收缩的默认策略比 AC5 积极。但为了让跨环境行为一致,我还是会显式加上:
-O3 -ffp-contract=fast在 Keil MDK 的 Options for Target → C/C++ → Misc Controls 里直接填-ffp-contract=fast就行。AC6 常用的其他选项还包括-mcpu=cortex-m7 -mfpu=fpv5-d16 -mfloat-abi=hard,这些在 Keil 图形界面里通常已经在 Target 标签页选好了。
我的建议是:无论 AC5 还是 AC6,都不要只靠界面下拉框,要习惯把关键编译参数显式写死。项目交接时,后面接手的人才能一眼看出优化是怎么开的。
3.5 代码层面给编译器的三条体面出路
编译选项正确的前提下,代码写法仍然会影响 VFMA 生成。我这里列三条最常见的经验:
第一,把乘加写成单一表达式,用自然累加形式。acc += a * b;优于先temp = a * b;再acc += temp;。虽然理论上编译器可能把后者优化成前者,但语义越清晰,越不容易被其他因素干扰。
第二,避免在乘加之间插入 volatile 变量。如果acc被声明为volatile float,编译器每次更新都要严格写回内存,VFMA的“融合”会被视为违反语义,因为中间结果没有单独写回。
第三,如果确实需要强制融合,可以使用 C 标准库函数fmaf():
#include <math.h> float r = fmaf(a, b, c);fmaf在硬件支持VFMA的内核上通常直接映射成一条指令。即便你在某些情况下关闭了-ffp-contract,fmaf也能保证生成融合指令。不过我一般只在少量关键计算处用fmaf,整个工程全面铺开还是靠编译选项更省心。
4. 实测对比:在 STM32H743 上把 FIR 循环缩短 40%
4.1 测试代码和硬件条件
我用一块 STM32H743 板子做验证,主频 480MHz,Cortex-M7 内核,单精度 FPU。测试代码是一个 64 阶 FIR 滤波器,函数长这样:
// fir.c #include <stdint.h> #define FIR_TAPS 64 float fir_filter(const float *coeff, const float *data) { float acc = 0.0f; for (int i = 0; i < FIR_TAPS; i++) { acc += coeff[i] * data[i]; } return acc; }测试主函数里把coeff和data填成固定值,循环调用 10000 次,用 DWT 周期计数器统计总周期数。
4.2 测量周期的方法
Cortex-M7 里有 DWT(Data Watchpoint and Trace)单元,其中DWT->CYCCNT是一个周期计数器,常用于性能测量。启用方法比较简单:
#include "core_cm7.h" static void dwt_init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } uint32_t t0 = DWT->CYCCNT; volatile float result = fir_filter(coeff, data); uint32_t cycles = DWT->CYCCNT - t0;注意几件事:测量前关掉中断,避免周期计数被打断;函数第一遍调用最好做一次暖机,让指令缓存和数据缓存进入稳定状态;把返回值赋给一个volatile变量,防止编译器把整个循环优化掉。
4.3 开启与关闭的对比数据
我在两种编译参数下分别测试:
# 关闭融合 arm-none-eabi-gcc -mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard \ -O3 -ffp-contract=off -c fir.c -o fir_off.o # 开启融合 arm-none-eabi-gcc -mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard \ -O3 -ffp-contract=fast -c fir.c -o fir_fast.o测试结果如下:
| 编译配置 | 10000 次循环总周期 | 单次平均周期 | 相对性能 |
|---|---|---|---|
-ffp-contract=off | 9,420,000 | 942 | 1.00x |
-ffp-contract=fast | 5,650,000 | 565 | 1.67x |
在我的测试环境下,开启融合乘加之后性能提升约 40% 到 67%,具体取决于循环里 load 指令和索引计算的占比。如果把数组数据放到 SRAM,并且系数数组对齐,编译器还可以配合LDR双发射把循环体压得更紧凑。
4.4 为什么不是 100% 的提升
有人可能会问:乘加从两条指令变成一条,时间不是应该减半吗?实际达不到,原因有几点:
第一,循环里除了乘加,还有 load 指令。coeff[i]和data[i]都要从内存加载,这部分指令没有减少。第二,浮点流水线本身的吞吐上限有限,VFMA虽然融合了,但指令延迟还是存在,循环体里的依赖链可能成为瓶颈。第三,Cortex-M7 的双发射能力有限,load 和浮点指令之间的并行程度取决于具体代码。
所以 40% 到 60% 的收益属于比较典型的水平。如果你在 Cortex-M4F 上测,由于内核没有 M7 那么强的流水线,收益可能略低,但趋势一致。
5. 反汇编找不到 VFMA?从现象到根因的完整排查
5.1 现象:我明明加了参数,还是看到 VMUL + VADD
这是最让人恼火的情况。编译选项写了-O3 -ffp-contract=fast,目标内核也支持VFMA,但打开反汇编一看,关键循环里还是VMUL+VADD成对出现。下结论之前先按顺序检查。
5.2 排查链路第一步:FPU 选项到底生效没有
先用命令行编译一次,然后用readelf或者objdump检查文件属性:
arm-none-eabi-readelf -A fir.o输出里会列出 Tag_FP_arch 等属性,能看到是不是FPv5-D16。如果这里显示的是VFPv2或者根本没有 FPU 属性,说明-mfpu参数没生效或者被后面的参数覆盖了。Makefile 里如果CFLAGS和CPPFLAGS同时出现,编译命令的展开顺序可能导致某个-mfpu被后一个覆盖。
5.3 排查链路第二步:优化等级过低
-O0或者-Og下,编译器基本不做指令选择优化,也几乎不会生成VFMA。至少要-O2,推荐-O3。如果你的 Makefile 的 debug 版本用-O0,release 版本用-O3,那确认一下测的是哪个版本。
这里还有一个容易忽略的点:GCC 的-O3和 Keil 的-O3语义不同。GCC 的-O3比-O2多了一些自动向量化选项;Keil/armcc 的-O3是“高优化且偏向执行速度”,对应的还有-O3 -Otime。我在 AC5 上一般直接用-O3 -Otime,把速度取向明确告诉编译器。
5.4 排查链路第三步:严格 C 标准悄悄关掉了 FP_CONTRACT
这一步最隐蔽。很多工程为了可移植性会加上-std=c11或者-std=c99。在这种严格 ISO C 模式下,GCC 对浮点收缩的态度是保守的。我遇到过的实际案例是:Makefile 里CFLAGS包含-O3 -ffp-contract=fast,但后面又跟着-std=c11,当时觉得没问题,结果反汇编里就是没有VFMA。
后来我把编译参数顺序调整,改成:
-std=c11 -O3 -ffp-contract=fast并且在命令行用-dM -E查看宏定义,确认FP_FAST_FMA以及相关宏的状态,才定位到是参数顺序和标准模式配合的问题。经验是:-ffp-contract=fast要放在-std参数之后,并且最好用编译器的-S输出汇编文件直接确认。
5.5 排查链路第四步:常数折叠把指令优化掉了
还有一种“找不到 VFMA”其实是好事:编译器把乘加直接算成了常数。比如测试代码里coeff和data都是常量初始化,且编译器能通过内联看到它们的值,那acc += 2.0f * 3.0f会直接被折叠成acc += 6.0f,根本不需要运行时指令。
看反汇编时不要只搜vfma,要仔细观察vldr后面的立即数是不是已经是结果了。这种情况不是问题,但会让初次排查的人误以为优化没生效。
5.6 排查链路第五步:连接器或汇编文件里混入了非 FPU 代码
还有一种情况是:C 文件编译成了fir.o,里面确实有VFMA,但在最终链接生成的 ELF 里,调用方函数没有内联它,或者链接器做了某种重排,导致你搜索整个固件时只看到个别VFMA。这时候要确认调用关系。如果fir_filter没有被内联,每次调用都会有调用开销,性能分析器看到的可能不只是循环本身。
5.7 快速检查清单
把上面的排查整理成一张表格,下次直接对着看:
| 检查项 | 方法 | 失败表现 |
|---|---|---|
| 内核和 FPU 匹配 | readelf -A查看 Tag_FP_arch | FPU 类型不符或缺失 |
| 浮点 ABI | 确认-mfloat-abi=hard | 编译报错或回退软浮点 |
| 优化等级 | 至少-O2,推荐-O3 | 反汇编指令原始 |
| FP_CONTRACT 开启 | 显式加-ffp-contract=fast | 搜索不到 VFMA |
| 严格标准模式 | 检查-std是否影响默认收缩 | 编译器保守 |
| 常数折叠 | 反汇编看立即数 | 没有运行时乘加指令 |
| 函数内联 | 检查汇编是否包含完整循环体 | 调用开销掩盖收益 |
6. 使用边界:什么时候该开、什么时候该关
6.1 值得开 VFMA 的场景
凡是浮点乘加密集的地方,都值得开。常见三类:
第一,数字信号处理,包括 FIR、IIR、FFT 蝶形运算、互相关计算。这类算法的核心就是一连串乘加累加,VFMA的收益最明显。第二,电机控制、电源控制里的 PID 控制器。增量式 PID 输出本身就是一个乘加链,而且对实时性要求高。第三,矩阵运算,尤其是 3x3 或者 4x4 的旋转矩阵、姿态解算。这些运算同样充满a*b+c模式。
我在之前一个电机控制项目里,速度环和电流环的控制频率从 10kHz 提到 16kHz,主要就是靠把控制器里几十个乘加对变成融合指令省下来的周期。
6.2 必须关掉的场景
有一类场景必须关:需要和外部系统做逐位一致的浮点比较。比如你的固件计算结果要传给上位机,上位机用标准 C 库在 x86 上算同样公式,如果两边舍入行为不同,就可能导致最后几位不一致,进而触发某些校验失败。
再比如自动化测试里,测试脚本用 Python 的numpy或 MATLAB 做基准值,你希望嵌入式端的每个中间结果都和基准完全一致。这种情况下VFMA的“更精确”反而是麻烦,因为它和别的平台的一次舍入语义不一致。
如果遇到这种场景,把-ffp-contract=off打开,或者只在测试构建里关闭,稳定复现问题后再恢复。
6.3 和 -ffast-math 的关系,务必谨慎
这里要单独强调一下-ffast-math。它包含-ffp-contract=fast,但绝不等价。-ffast-math还会开启-ffinite-math-only,意思是不管 NaN 和无穷大,编译器默认输入输出都是有限数。如果你的代码里有isnan()、isinf()检查,或者依赖 IEEE 754 的 NaN 传播做状态判断,-ffast-math会直接破坏这些假设,而且很多时候编译器优化掉之后你毫无感知。
我见过一个工程因为全局开了-ffast-math,导致异常数据产生的 NaN 没有按预期传播,固件在错误状态里继续算了好几个控制周期才触发了别的保护。排查起来非常痛苦。
所以我的建议是:优先只加-ffp-contract=fast,不要为了一点浮点优化直接上-ffast-math。如果确实需要-ffast-math那些激进优化,限定在经过评审的 DSP 算法模块里,通过文件级编译选项单独控制。
6.4 工程规范上的一个小建议
最后说点工程层面的体会。VFMA这类优化,不要把开关散落在各个源文件里。最好在 Makefile 或者 CMake 里集中管理,并且把“允许融合乘加”作为一个显式的编译配置项:
target_compile_definitions(firmware PRIVATE USE_FMA_OPT=1) target_compile_options(firmware PRIVATE -ffp-contract=fast)代码里也可以配合宏做策略控制:
#if USE_FMA_OPT #define FMUL_ADD(a, b, c) fmaf((a), (b), (c)) #else #define FMUL_ADD(a, b, c) (((a) * (b)) + (c)) #endif这种做法在写算法原型的阶段尤其有用:可以快速对比融合和非融合两种模式之间的计算差异,而不是反复改编译选项重编整个工程。
从我个人经历来看,嵌入式浮点性能优化,先看指令选择,再看算法复杂度,这两步往往比把 MCU 主频抬高一倍还管用。VFMA算是指令选择里性价比最高的一项,因为改动极小、收益明确,而且还能顺手提升数值精度。但整个工程是否启用,仍然要根据你的数值一致性要求和平台兼容性来权衡,不能一刀切。