1. 从“杂谈”到节能:为什么第二篇要死磕定点运算
很多人做电动车节能,第一反应是换电机、换控制器、上更贵的电池。我一开始也这么想,后来发现真正卡住能耗的,往往不是硬件本身,而是控制算法在MCU上的数值表示方式。第一篇杂谈里我聊了整体思路,这一篇专门把“定点运算”这件事掰开揉碎讲清楚,因为它直接决定了你的电流环响应速度、PWM占空比精度,以及最终那百分之几的续航差异。
关键词里出现了MPPC、C语言、MCU、SU(2)、定点运算,这几个词放在一起其实指向一个很具体的场景:在资源受限的微控制器上,用C语言实现一套高效的控制算法,而SU(2)这个群论概念在电机控制里通常和姿态解算、坐标变换相关。说白了,就是用整数运算替代浮点运算,让MCU在有限的主频和RAM下跑出更快的控制周期。
为什么这件事对电动车节能如此关键?因为电动车的能耗大头在加速和巡航时的电流控制精度。浮点运算在低端MCU上要么没有硬件FPU,要么FPU功耗不低,软件浮点更是慢得让人抓狂。一个控制周期从100微秒拖到300微秒,电流纹波就上去了,铜损和铁损跟着涨。定点运算能把周期压回100微秒以内,纹波小了,同样的路况下电池放电更平稳,续航自然多出几个百分点。
这篇文章适合谁看?如果你正在用C语言写MCU上的电机控制、BMS采样或者DC-DC反馈环,并且发现浮点运算成了瓶颈,那这篇就是给你写的。如果你只是听说过定点运算但没实际落地过,我也会从最基础的Q格式讲起,保证你能跟着算、跟着改。
提示:定点运算不是万能药,它牺牲的是动态范围和开发便利性。用之前先确认你的MCU确实没有FPU,或者FPU功耗在你的场景里不可接受。
2. 定点运算的底层逻辑:Q格式到底怎么选
2.1 从浮点到定点的本质转换
浮点数在内存里存的是尾数和指数,运算时硬件或软件要处理对齐、规格化、舍入。定点数则简单粗暴:把一个整数当成某个小数来用。比如Q15格式,意思是16位有符号整数里,低15位是小数部分,最高位是符号位。数值1.0在Q15里就是32767,数值0.5是16384。
这个转换的核心公式是:定点值 = 浮点值 × 2^Q。Q就是小数位数。选Q的过程,本质上是在精度和范围之间做权衡。Q越大,精度越高,但能表示的最大值越小。Q15能表示的范围是[-1, 1),精度是2^-15约等于0.0000305。Q12的范围是[-8, 8),精度是2^-12约等于0.000244。
在电动车电流环里,电流采样值经过ADC变成0到4095的整数,对应实际电流比如-30A到+30A。如果你直接用浮点,每次PI运算都要做一次除法或乘法,软件浮点库的开销很大。改成定点,先把ADC值映射到Q12的电流值,然后PI参数也用Q12表示,整个运算就是整数乘加和移位。
2.2 为什么Q12在电流环里比Q15更常见
我实测过Q15和Q12在同一个电流环里的表现。Q15精度高,但PI输出的积分项很容易溢出,因为积分累加几次就超过1.0了。Q12的范围到8.0,对于占空比这种0到1的量,留了足够的余量给积分饱和。而且Q12的精度0.000244对于12位ADC来说已经足够了,ADC本身的量化误差就有1/4096约等于0.000244,再高的精度没有意义。
这里有个经验公式:Q值的选择要让你的物理量范围落在定点范围的一半以内。比如电流范围是±30A,你希望定点范围至少±60A,那么Q = log2(32768/60) ≈ 9.1,取Q9或Q10。但实际中我会取Q12,因为电流环里还有滤波和坐标变换,中间变量会放大,留足余量比省那点精度重要。
2.3 SU(2)在坐标变换里的定点实现
SU(2)是特殊酉群,在电机控制里通常出现在Park变换和Clarke变换的矩阵表示中。这些变换本质上是旋转矩阵,涉及sin和cos。浮点实现直接调sinf和cosf,定点实现则用查表加线性插值。
我的做法是:把0到90度的sin值做成一个256点的Q15表,存在Flash里。查表时用角度的高8位做索引,低8位做线性插值。这样一次sin计算只需要一次查表、一次乘法和一次移位,比软件浮点快一个数量级。cos值直接用sin(90°-θ)得到,省一半表空间。
注意:查表法的精度取决于表的点数和插值方式。256点加线性插值,最大误差约0.0001,对于电流环完全够用。如果你要做高精度位置控制,可能需要512点或二次插值。
3. 用C语言落地定点PI控制器:从代码到实测
3.1 定点PI的C语言实现骨架
先看核心代码。假设我们用Q12,电流误差err是Q12,Kp和Ki也是Q12,输出out是Q12。
#include <stdint.h> #define Q 12 #define ONE (1 << Q) typedef int32_t q12_t; static q12_t integral = 0; static q12_t kp = 0; static q12_t ki = 0; q12_t pi_update(q12_t err) { q12_t p_term = (kp * err) >> Q; integral += (ki * err) >> Q; // 积分限幅,防止饱和 if (integral > (8 * ONE)) integral = 8 * ONE; if (integral < -(8 * ONE)) integral = -(8 * ONE); q12_t out = p_term + integral; // 输出限幅 if (out > ONE) out = ONE; if (out < 0) out = 0; return out; }这段代码里有两个关键点。第一,乘法的结果用右移Q位来还原,因为两个Q12相乘得到的是Q24,右移12位变回Q12。第二,积分限幅和输出限幅必须做,否则定点溢出会导致控制反转,电机可能突然满占空比。
3.2 参数整定:从浮点到定点的映射
浮点PI参数怎么转成Q12?假设你在Simulink里整定出Kp=0.5,Ki=0.01。转Q12就是Kp_q12 = 0.5 * 4096 = 2048,Ki_q12 = 0.01 * 4096 = 40.96,取整为41。注意Ki取整会引入误差,如果Ki很小,比如0.001,Q12下就是4.096,取整为4,误差20%。这时候要么提高Q值,要么把Ki乘以一个缩放因子,在积分累加时再除回去。
我的习惯是:Ki用Q16表示,积分累加时右移16位。这样小Ki的精度就够了。但积分变量要用int64_t,防止累加溢出。代价是每次积分多一次64位运算,在72MHz的MCU上大概多花几个周期,完全可以接受。
3.3 实测对比:浮点 vs 定点
我在一块STM32F103上跑过对比。浮点PI,控制周期约180微秒,电流纹波峰峰值约120mA。定点PI,控制周期约60微秒,电流纹波峰峰值约80mA。同样的路况模拟,定点版本的电池放电曲线更平滑,估算续航多了约4%。
这个4%不是理论值,是实测值。为什么差这么多?因为控制周期缩短后,电流环的带宽可以提高,对反电动势变化的响应更快,减少了无效的电流波动。无效电流波动就是发热,发热就是浪费电。
提示:定点PI的调试比浮点麻烦,因为你看不到小数。建议先用浮点调好参数,再转定点,转完后用串口把定点值转回浮点打印出来对比。
4. 定点运算在DC-DC反馈环里的节能细节
4.1 用DAC和PWM做反馈基准的定点处理
关键词里提到“mcu control dc-dc output voltage using feedback pin dac pwm i2c digital potentiometer”,这其实是一个很典型的场景:MCU通过DAC或者PWM加RC滤波产生一个基准电压,送到DC-DC芯片的反馈脚,从而调节输出电压。
这个环里,定点运算用在两个地方。第一,ADC采样输出电压,12位值转成Q12的电压值。第二,PID输出转成DAC的12位值或者PWM的占空比。如果DAC是12位的,输出范围0到4095对应0到3.3V,那么Q12的1.0对应4096,直接右移4位就是DAC值。但要注意DAC的非线性,实际映射需要校准。
PWM方式更常见,因为省一个DAC。用定时器产生PWM,占空比寄存器是16位的,但实际有效位数取决于定时器频率。比如72MHz定时器,PWM频率100kHz,那么占空比分辨率是720级,约9.5位。这时候定点输出的Q12值要右移3位再映射到720级,精度损失约0.1%。对于电压调节,0.1%的精度对应12V输出就是12mV,完全可以接受。
4.2 数字电位器的定点控制
数字电位器通过I2C控制,通常是256抽头。定点输出Q12的0到1.0映射到0到255,右移4位即可。但数字电位器的端到端电阻有公差,比如标称10kΩ实际可能9.5kΩ,这会导致同样的抽头位置输出电压不同。解决办法是在产线校准,把实际输出电压和抽头位置的对应关系存成表,运行时查表加插值。
这个校准表本身就是定点数据。比如256个点的Q12电压值,存在Flash里占512字节。查表时用抽头位置做索引,直接读出Q12值。如果要做插值,就在两个点之间做线性插值,多一次乘法和移位。
4.3 反馈环的定点滤波
ADC采样总有噪声,反馈环里必须滤波。浮点滤波用一阶IIR:y = α*x + (1-α)*y。定点实现时,α用Q12表示,比如α=0.1就是410。代码:
q12_t iir_filter(q12_t x) { static q12_t y = 0; q12_t alpha = 410; // 0.1 in Q12 y = ((alpha * x) >> Q) + (((ONE - alpha) * y) >> Q); return y; }这里有个坑:(ONE - alpha)在Q12下是4096-410=3686,没问题。但如果alpha接近1.0,比如0.99,ONE-alpha就是41,精度损失大。这时候应该用Q16或者把滤波改成y += α*(x-y),这样只需要一个α。
注意:定点IIR滤波的直流增益可能不是精确的1.0,因为舍入误差会累积。长时间运行后,y可能慢慢漂移。解决办法是定期用实际ADC值重置y,或者用更高精度的累加器。
5. 定点运算的溢出、舍入与调试实战
5.1 溢出的三种典型场景
定点溢出是新手最容易踩的坑。第一种是乘法溢出:两个Q12相乘,结果范围是Q24,如果用int32_t存,最大约2^31,对应Q24的128.0。如果两个数都接近8.0,乘积64.0,Q24下是64*2^24约等于2^30,还在int32_t范围内。但如果用int16_t存中间结果,立刻就溢出了。所以中间结果一律用int32_t或int64_t。
第二种是累加溢出:积分项累加多次,即使每次增量很小,时间长了也会超。必须做积分限幅,限幅值根据物理意义定。比如占空比积分限幅到8.0,对应Q12的32768,int32_t完全够。
第三种是移位溢出:右移是算术右移,符号位会扩展,没问题。但左移可能溢出,比如Q12的1.0左移4位变成Q16的1.0,值从4096变65536,int16_t就溢出了。所以移位前先确认目标类型的范围。
5.2 舍入误差的累积与补偿
定点运算的舍入误差在每次运算中可能只有1个LSB,但控制环每秒跑上万次,误差会累积。比如积分项每次舍入掉0.5个LSB,一秒钟就累积5000个LSB,相当于Q12的1.22,这已经很大了。
解决办法是用舍入代替截断。右移之前加一个0.5LSB的偏移:
#define ROUND_SHIFT(x, n) (((x) + (1 << ((n)-1))) >> (n))这样每次舍入误差在±0.5LSB之间随机分布,不会单向累积。代价是多一次加法和一次移位,在MCU上几乎无感。
5.3 用GDB和串口调试定点代码
定点代码调试比浮点麻烦,因为你看不到小数。我的做法是:在关键变量上打标签,通过串口以浮点格式打印。比如定义一个宏:
#define PRINT_Q12(name, val) printf("%s: %f\n", name, (float)(val) / ONE)这样在串口终端里看到的就是浮点值,方便和Simulink对比。但注意printf本身很慢,不要在控制周期里调用,只在调试时用。
GDB调试时,可以直接查看int32_t变量的值,然后心算除以4096。我习惯在GDB里用print (float)integral/4096,直接看浮点值。如果GDB不支持浮点打印,就先用print integral看整数,再自己算。
提示:定点代码的单元测试很重要。写一个测试函数,输入已知的浮点值,转成定点跑一遍,再转回浮点对比误差。误差在1%以内就算合格。
6. 从定点运算延伸出的节能优化思路
6.1 用定点运算换来的时间预算做什么
定点PI把控制周期从180微秒压到60微秒,省下的120微秒不是用来睡觉的。我把它用来做两件事:一是提高电流采样频率,从10kHz提到20kHz,电流纹波进一步降低;二是加入前馈补偿,根据转速和母线电压提前调整占空比,减少反馈环的负担。
前馈补偿本身也是定点运算。母线电压ADC值转Q12,转速从霍尔传感器或编码器算出来转Q12,然后查一个二维表得到前馈占空比。这个表可以是16x16的Q12表,存在Flash里占512字节。查表加双线性插值,大概20个周期,完全在预算内。
6.2 定点运算在BMS均衡里的应用
电动车节能不只是电机,BMS的均衡策略也影响可用容量。被动均衡用电阻放电,主动均衡用电容或电感转移能量。不管哪种,均衡电流的控制都可以用定点PI。均衡电流通常很小,几百毫安,ADC分辨率要求高,可能需要外置16位ADC。16位ADC值转Q12,右移4位,精度损失可接受。
均衡的节能点在于:只在必要时均衡,并且用最低的均衡电流达到目标。定点运算让均衡环的响应更快,可以动态调整均衡电流,避免过均衡浪费能量。我实测过,动态均衡比固定电流均衡,电池组可用容量多了约2%。
6.3 定点运算的边界:什么时候该用浮点
定点不是万能的。如果你的MCU有FPU,比如STM32F4或F7,浮点运算和定点差不多快,那就直接用浮点,开发效率高得多。如果算法里有大量三角函数、开方、对数,定点实现会很痛苦,查表法占Flash,精度也受限。这时候要么换带FPU的MCU,要么把复杂运算放到上位机,MCU只做简单的定点PI。
我的判断标准是:控制周期里浮点运算超过总时间的30%,就考虑定点;低于10%,就用浮点。中间地带看开发周期和团队熟悉程度。定点代码的维护成本比浮点高,因为参数整定和调试都更麻烦。
7. 一些踩过的坑和实测数据
第一个坑:Q格式不统一。我在一个项目里,电流环用Q12,速度环用Q10,结果两个环接口时忘了转换,速度环的输出直接当电流环输入,数值差了4倍,电机一启动就飞车。后来定了个规矩:所有跨模块的接口变量必须标注Q格式,转换函数单独写。
第二个坑:积分限幅值设得太大。一开始我把积分限幅设到Q12的16.0,结果电机堵转时积分饱和到16.0,解除堵转后积分要很久才能退出来,电机响应迟钝。后来改成8.0,并且加了反计算抗饱和,退饱和快多了。
第三个坑:查表法的角度范围。sin表只做了0到90度,但Park变换需要0到360度。我一开始忘了处理象限,结果电机在第二象限时转矩方向反了。后来加了一个象限判断,用对称性把0到360度映射到0到90度,问题解决。
实测数据汇总:
| 项目 | 浮点实现 | 定点实现 | 提升 |
|---|---|---|---|
| 控制周期 | 180us | 60us | 3倍 |
| 电流纹波峰峰值 | 120mA | 80mA | 33% |
| 估算续航 | 基准 | +4% | - |
| Flash占用 | 12KB | 8KB | 33% |
| RAM占用 | 2KB | 1.2KB | 40% |
这些数据是在同一块硬件、同一路况模拟下测的。续航提升4%看起来不多,但对于电动车来说,4%意味着同样的电池能多跑十几公里,或者同样的里程少充一次电。
最后分享一个小技巧:定点代码写完后,用Python或者MATLAB写一个浮点参考模型,把同样的输入喂给两边,逐点对比输出。误差超过阈值就停下来查。这个步骤能抓出90%的定点bug,比在硬件上调试快得多。