在PC软件开发中,我们习惯了随手写下 double a = 3.14;。但在嵌入式领域,尤其是资源受限的MCU(如Cortex-M0/M3或8位机)上,随意使用浮点数可能是性能的"隐形杀手",也是通信故障的"幕后黑手"。
这一期,我们深入MCU的数学世界,解开浮点数从存储 、运算 到传输的所有秘密。
1. 浮点数的"富贵病" (性能代价)
我们先看一行平平无奇的代码:
// 假设在一个没有FPU的单片机上(如STM32F103)
float voltage = adc_value * 3.3f / 4095.0f;
你以为CPU只是做了两次简单的乘除法?
实际上,编译器在背后为你引入了数百行汇编代码。
1.1 软浮点 (Soft-Float) 的代价
大多数低端MCU没有硬件浮点运算单元 (FPU)。
当你写下 a * b(如果是float)时,编译器会调用一个巨大的库函数(例如 __aeabi_fmul),用一堆整数移位、加减法逻辑来模拟浮点运算。
-
执行速度暴跌:
-
整数乘法:约 1~3 个时钟周期。
-
软浮点乘法:约 50~100 个时钟周期!
-
软浮点除法:高达数百个周期。
如果这段代码在中断服务函数 (ISR) 或 高频控制回路(如电机FOC)里,系统极易过载。
-
1.2 即使有FPU也有坑
即使你用的是带FPU的芯片(如STM32F4/H7),也不要掉以轻心:
-
Double的陷阱 :大多数MCU的FPU只支持单精度 (float) 。如果你写了
3.14(C语言默认是double),FPU会罢工,转而调用慢速的软件库。修正:必须显式加f后缀 ->3.14f。 -
上下文切换:在RTOS中,任务使用FPU会导致上下文切换时需要保存额外的FPU寄存器,增加了中断延迟和内存开销。
2. 深入解剖:浮点数的"肉体" (IEEE 754 存储详解)
在内存里,int32_t a = 12 存的是 0x0000000C,直观易懂。
但是,float f = 12.5 在内存里存的是 0x41480000。
这俩长得完全没关系!为什么?
因为MCU遵循 IEEE 754 单精度浮点数标准,它把 32个 Bit 切成了三块:
-
符号位 (Sign, 1 bit): 最高位 (Bit 31)。0代表正,1代表负。
-
指数位 (Exponent, 8 bits): Bit 23-30。用于存储科学计数法中的 2^N。
- 注意:采用"移码" (Bias) 设计,真实指数 = 存储值 - 127。
-
尾数位 (Mantissa, 23 bits): Bit 0-22。存储小数点后的有效数字。
- 注意:IEEE 754 省略了整数部分的
1,以节省一位空间。
- 注意:IEEE 754 省略了整数部分的
内存透视实战:
当你在调试器 (Memory View) 里看到 00 00 48 41 (小端模式) 时,如何确认它是 12.5?
-
重组 :
0x41480000-> 二进制0100 0001 0100 1000 ... -
符号:0 -> 正数。
-
指数 :
1000 0001(129)。真实指数 129 - 127 = 2。即 2^2 = 4。 -
尾数 :
1.100...(二进制)。1 + 0.5 = 1.5。 -
结果:4 x 1.5 = 6 ... 等等,这里只是简算,实际上尾数解析是 1 + 2^{-1} + 2^{-4} 等的组合。
工程结论:你只需要知道,浮点数在内存里是一堆被编码过的位,不能直接当整数解读。
3. 工程实战:如何传输与存储浮点数?
这是嵌入式中最常见的场景:你采集了一个电压值 3.14159,现在要通过串口发给上位机,或者存进 Flash 里。
方法 A:ASCII 字符串法 (笨办法)
char buf[16];
sprintf(buf, "%.5f", val); // 转成 "3.14159"
HAL_UART_Transmit(&huart1, buf, strlen(buf), 100);
- 缺点:慢(sprintf是耗时大户)、占带宽(发了7个字节)、精度丢失(文本转回数字会有误差)。
方法 B:联合体法 (Union) ------ 优雅且高效 (推荐)
利用 union 共用内存的特性,直接把这 4个 Byte 拿出来。
typedef union {
float f_val;
uint8_t bytes[4];
} FloatConverter_t;
void send_float(float val) {
FloatConverter_t u;
u.f_val = val;
// 直接发送原始字节,速度最快,精度无损
HAL_UART_Transmit(&huart1, u.bytes, 4, 100);
}
方法 C:指针强转法
float val = 3.14f;
uint8_t *p = (uint8_t*)&val; // 欺骗编译器,把float地址当char地址用
HAL_UART_Transmit(&huart1, p, 4, 100);
关键警告:大小端问题
只要传输原始字节,必须考虑大小端!
-
MCU端 (STM32) :小端模式,内存里是
A B C D(低字节在前)。 -
接收端:
-
如果是 Java 上位机 / 网络传输:通常是大端,期待
D C B A。 -
如果是 x86 PC (C++):通常也是小端。
-
-
对策:制定协议时必须明确规定(例如:使用网络字节序/大端)。如果不匹配,发送前要先反转数组顺序。
4. 浮点数的精度噩梦
除了慢和存储复杂,浮点数还有一个致命弱点:不精确。
经典反直觉案例
float a = 1.1f;
if (a == 1.1f) {
// 这里很可能会判断为 false!
}
为什么?因为 1.1 在二进制里是一个无限循环小数(就像十进制里的 1/3),计算机存不下,只能截断。
工程铁律 : 永远不要用 == 去比较两个浮点数! 必须使用"Epsilon"范围比较:
if (fabs(a - 1.1f) < 0.00001f) { ... }
5. 穷人的法拉利:定点数 (Fixed-Point)
如果想要速度快(整数运算),又想要小数(精度),怎么办?
答案是:定点数。本质就是**"缩放"**。
5.1 十进制缩放 (简单易懂)
-
浮点思维 :电压
1.23 V。 -
定点思维 :电压
1230 mV(放大1000倍)。存成int。
5.2 二进制缩放 (Q格式 - 高效之王)
计算机做乘除1000比较慢,但移位 极快。我们把缩放因子设为 2^N。这就是 Q格式。
假设使用 Q15 (缩放 2^15 = 32768):
1.5(浮点) ->1.5 * 32768 = 49152(定点整数)。
运算规则:
-
加减法:直接加减。
-
乘法:结果会"变大",需要右移归一化。
#define Q_SHIFT 10 // 放大 2^10 = 1024 倍
#define TO_FIX(x) ((int32_t)((x) * (1 << Q_SHIFT)))
int32_t a = TO_FIX(1.5); // 1536
int32_t b = TO_FIX(2.0); // 2048
// 乘法:先乘,再右移
// 注意:乘法结果可能超过int32,必须强转int64暂存!
int32_t prod = (int32_t)(((int64_t)a * b) >> Q_SHIFT);
// 结果 3072 -> 代表 3.0
6. 总结:什么时候该用什么?
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 人机交互 (UI) | 浮点数 | 屏幕刷新慢,开发效率优先,代码可读性好。 |
| 复杂算法 (FFT/滤波) | 浮点数 (FPU) | 现代MCU带FPU,不用白不用。 |
| 高频控制 (电机/电源) | 定点数 (Q格式) | 必须在几微秒内算完,且无FPU时唯一的选择。 |
| 数据传输 (串口/CAN) | 原始字节 (Union) | 效率最高,但需注意大小端。 |
| 低功耗设备 | 定点数 | FPU耗电大,定点运算让CPU更快休眠 |