前三篇文章里,我用定点查表法把sinf()的计算速度提升了14倍,把atan2f()提升了8倍,把sqrtf()提升了2.46倍。今天我们来手撕expf()——这个在数字滤波、概率计算、衰减模型里无处不在的函数。
01 为什么是exp?
在嵌入式开发中,exp的出场频率比你想象的高:
- 数字滤波:一阶低通滤波器的系数计算
- 传感器线性化:热敏电阻、光电二极管的非线性校正
- 概率计算:贝叶斯推断、softmax
- 衰减模型:电池放电曲线、RC充放电
- 音频处理:指数包络、动态范围压缩
标准库的expf()在Cortex-M4上虽然可以用FPU加速部分运算,但内部有大量分支和范围检查。然而实际工程应用中,输出通常在某个固定范围之内,根据实际情况,制定定点fast_exp()用拆分整数+小数加查表和插值的方式,可以做到更快、更可控。
02 exp的特殊难点:动态范围极大
和sin()、atan2()、sqrt()不同,exp()有一个天然的难点:它的值域跨度极大。
输入 x | -10 | -1 | 0 | 1 | 5 | 10 |
exp(x) | 0.000045 | 0.368 | 1.0 | 2.718 | 148.4 | 22026 |
Q15 格式只能表示-1.0 ~ 0.99997,根本装不下exp()的全部结果。所以定点exp() 不能像sin()那样直接查表输出,必须做范围压缩。
03 核心思路:拆整数+小数
指数函数有一个天然性质:
其中:
- k 是整数部分
- r 是小数部分,范围 |r|≤ln2/2 ≈ 0.3466
- 2^k 用移位实现
- exp(r) 在[-0.3466, 0.3466] 范围内,值域约为[0.707, 1.414],Q15 装不下 1.414
关键改进:我们不用四舍五入取k,而是用ceil 向上取整:
这样exp(r) in (0.5, 1.0],Q15 刚好装得下,不需要任何额外的缩放。
所以流程是:
输入 x (Q6.10)
计算 k = ceil(x / ln2)
计算 r = x - k·ln2 (范围 (-ln2, 0])
查表求 exp(r) (Q15, 范围 (0.5, 1.0])
输出尾数 exp(r) 和指数 k
实际值 = 尾数 / 32768.0 × 2^k
关键洞察:exp()的定点实现不是查表求exp(),而是查表求exp(r) + 移位求2^k。移位是零成本的,所以真正耗时的只有查表那一步。
04 fast_exp实现
exp_table[258]:exp(r) 的 Q15 值,r = (i - 256) / 256,i = 0~256
extern const uint16_t exp_table[258];
// 常数,Q15 格式
#define INV_LN2_Q15 47274 // (1/ln2) × 2^15 ≈ 47274
#define LN2_Q15 22713 // ln2 × 2^15 ≈ 22713
typedef struct {
int16_t mantissa; // Q15,范围 [0.3679, 1.0]
int16_t exponent; // 2 的幂次
} exp_result_t;
/**
* @brief 快速指数函数
* @param x 输入,Q6.10 格式(实际值 = x / 1024)
* @return exp_result_t,实际值 = mantissa / 32768.0 × 2^exponent
*
* 推荐输入范围:x ∈ [-32768, 32767](实际值 [-32,31.999],
* /
exp_result_t fast_exp(int16_t x) {
// 1. 计算 t = x / ln2,Q6.10 格式(乘法代替除法,四舍五入)
int32_t t = ((int32_t)x * INV_LN2_Q15 + 16384) >> 15;
// 2. 计算 k = ceil(t / 256),无分支向上取整
int32_t k = (t + 1023) >> 10;
// 3. 计算 r = x - k × ln2,Q6.10 格式
int32_t r_q6_10 = (int32_t)x - ((k * LN2_Q15 + 16) >> 5)+1024;
int32_t idx = r_q6_10 >> 2;
int32_t frac = r_q6_10 & 0x03;
// 4. 查表
exp_result_t result;
int32_t res = (int32_t)exp_table[idx+1] - exp_table[idx];
res = (res * frac +2)>>2;
res += exp_table[idx];
result.mantissa = (int16_t)res;
result.exponent = (int16_t)k;
return result;
}
关于除法
代码中用乘法 x * INV_LN2_Q15 代替了除法。在 Cortex-M4 上,乘法只需 1~2 个周期,比除法快得多。如果芯片没有硬件除法(如 M0),这个优化更加明显。
关于执行时间
查表+移位的方式没有循环,每次调用的执行时间都是固定的,对实时控制非常友好。
05 使用示例
// 计算 exp(1.0)
// x = 1.0 × 1024 = 1024
exp_result_t r = fast_exp(1024);
// 结果:r.mantissa ≈ 22259,r.exponent = 2
// 实际值 = 22259 / 32768.0 × 2^2 ≈ 2.71716
// 计算 exp(-0.5)
// x = -0.5 × 1024 = -512
exp_result_t r2 = fast_exp(-512);
// 结果:r2.mantissa = 19875,r2.exponent = 0
// 实际值 = 19875/32768 ≈ 0.60654
// 计算 exp(0)
exp_result_t r3 = fast_exp(0);
// 结果:r3.mantissa = 32767,r3.exponent = 0
// 实际值 = 32767 / 32768.0 × 1 ≈ 0.99997
06 精度分析
fast_exp 的误差主要来自三个环节:
误差来源 | 量级 | 说明 |
查表+插值 | 0.04% | 256点查表,r∈(-ln2,0] |
Q15尾数量化 | 0.003% | 表格值存储为Q15 |
输入Q6.10量化 | 0.05% | 输入x的分辨率1/1024 |
总相对误差 | 0.05%-0.09% | 主要瓶颈是输入量化 |
注意:exp() 的误差应该用相对误差衡量,因为输出值域跨度极大(从1.24x10^-14到7.9x10^13),绝对误差没有意义。如果要降低总误差,需要提高输入格式的位宽,例如改用 Q16.16。
07 性能实测
测试平台:GD32F303VGT6 @120MHz,DWT周期计数,32768次调用。
函数 | 开启FPU(周期) | 关闭FPU(周期) |
expf() | 3669947 | 20164341 |
fast_exp() | 2228224 | 2228224 |
08 源码与工程
完整的Keil工程已上传Gitee,包含:
- GD32F303VGT6的完整工程
- fast_exp() 完整源码
- 性能测试代码(使用DWT周期计数)
- 精度测试代码
Gitee地址:https://gitee.com/jervis_luo/gd32_fast_exp.git
09 总结
对比维度 | expf()(开FPU) | expf()(关FPU) | fast_exp() |
32768次耗时 | 3,669,947 | 20,164,341 | 2,228,224 |
最大相对误差 | ~1e-7 | ~1e-7 | 0.05%-0.09% |
是否需要FPU | 是 | 否 | 否 |
适用芯片 | M4/M7 | M0/M3/M4/M7 | M0/M3/M4/M7 |
三点核心收获:
1. 定点exp()的核心是范围压缩——exp(x) = 2^k · exp(r),用ceil取整保证r≤0,让exp(r)落在Q15范围内。
2. 误差用相对误差衡量——exp的值域跨度极大,绝对误差没有意义;总误差主要来自输入量化。
3. 执行时间固定——没有循环和分支预测失败,对实时控制非常友好。
系列文章
1. [fast_sin:比官方sinf快14倍]
2. [fast_atan2:FOC角度计算优化:128点查表法让atan2f快8倍]
3. [fast_sqrt:FOC矢量幅值计算优化:256点查表+牛顿迭代,让sqrtf快2.5倍]
4. [手撕官方expf!拆整数+查表+插值,关闭FPU快9倍](本文)
后记:写完这篇文章后,我把 fast_exp() 用在了自己做的电池管理模块里。原本用 expf() 计算SOC曲线时,每个采样周期要花几十微秒;换上 fast_exp() 后,直接降到几微秒。整个模块的采样率从1kHz提升到了10kHz。这大概就是优化的乐趣所在。
如果你对定点数学库感兴趣,欢迎关注我。这个系列到这里已经覆盖了sin()、atan2()、sqrt()、exp() 四个函数,后续如果大家感兴趣,我可以继续写 fast_log()、fast_pow() 或者矩阵运算的定点实现。