Q 格式定点运算详解
笔记 / 嵌入式
引言
Cortex-M0/M3 没有 FPU,浮点运算靠软件库模拟,一次乘除动辄上百个时钟周期;就算 M4/M7 带了硬件 FPU,定点运算依然无处不在:CMSIS-DSP 的滤波器族是 Q15/Q31 的,很多电机控制库里电流环的内核也是。原因不复杂:定点运算的耗时是确定的,每条指令数固定,中断里跑控制环时这一点比单纯的”快”更重要。
定点数常被当成低精度的浮点数,实际上它是一套独立的表示法:小数点的位置由使用者自己规定,CPU 并不知道它的存在。理解了这一点,Q 格式的所有规则都能自己推出来。
一、虚拟小数点
一个 16 位整数 0x4000,在 CPU 眼里就是 16384。但如果规定低 15 位是小数,它就代表 0.5,因为第 14 位的权重是 2⁻¹:
| 位 | b15 | b14 | b13 | … | b1 | b0 |
|---|---|---|---|---|---|---|
| 权重 | −2⁰ | 2⁻¹ | 2⁻² | … | 2⁻¹⁴ | 2⁻¹⁵ |
| 0.5 | 0 | 1 | 0 | … | 0 | 0 |
这套记法叫 Qm.n(或直接写 Qn):n 是小数位数,m 是整数位数(不含符号位)。16 位下最常用的是 Q15(1 个符号位 + 15 个小数位),32 位下常用 Q31、Q24。TI 的写法 A(a,b) 是一回事,a 个整数位、b 个小数位。
关键认知:小数点只存在于程序员的约定里。同一串比特,按 Q15 读它是 0.5,按 Q14 读它就是 1.0,硬件没有变,变的只是解释方式。
二、范围与分辨率:此消彼长
总共 32 位,小数位多一位,整数位就少一位。TI 的 IQmath 库文档(SPRC990)给出了完整的对照表,摘录如下:
| 数据类型 | 最小值 | 最大值 | 分辨率 |
|---|---|---|---|
| _iq30 | −2 | 1.999999999 | 0.000000001 |
| _iq28 | −8 | 7.999999996 | 0.000000004 |
| _iq26 | −32 | 31.999999985 | 0.000000015 |
| _iq24 | −128 | 127.999999940 | 0.000000060 |
| _iq22 | −512 | 511.999999762 | 0.000000238 |
| _iq20 | −2048 | 2047.999999046 | 0.000000954 |
| _iq18 | −8192 | 8191.999996185 | 0.000003815 |
| _iq16 | −32768 | 32767.999984741 | 0.000015259 |
| _iq15 | −65536 | 65535.999969482 | 0.000030518 |
| _iq12 | −524288 | 524287.999755859 | 0.000244141 |
每降一档 Q 值,范围翻倍、分辨率减半。选 Q 格式要回答两个问题:这个变量的物理量程是多少,能容忍的最小刻度是多少。两个问题都答出来,Q 值就没有选择余地了。
有个经典教训值得单独说:TI 的 IQsin/IQcos/IQatan2 不支持 Q30。原因见上表:Q30 最大只能表示 1.999999999,而角度的范围是 −π~π,得用 Q29(最大 3.999…)才装得下。把数学函数的值域提前想清楚,能省掉很多”为什么结果突然变成负数”的调试时间。

这张图区分了两个常被混用的词。分辨率是刻度的粗细(红色点群),准确度是离真值多远(绿色点群)。Q 格式决定分辨率;准确度还取决于标定、量化方案和算法。分辨率高不等于准确,一个 Q30 的变量配上错误的定标,结果照样是错的。
三、加减法:先对齐,再运算
只有定标相同的数才能直接加减。Q15 加 Q12,相当于把厘米读数与米读数直接相加,数值算对了,量纲也是错的。
不同 Q 格式相加,必须先把一方移位到另一方的定标上。Q12 转 Q15 左移 3 位,左移不损失精度;反过来 Q15 转 Q12 右移 3 位,就会丢掉低 3 位:
int16_t a = q15_value; // Q15
int16_t b = q12_value; // Q12
int32_t sum = (int32_t)a + ((int32_t)b << 3); // 统一到 Q15
注意 int32_t 中间变量:同格式相加也可能溢出。Q15 下 0.9 + 0.9 的真值是 1.8,超出 Q15 上限 0.99997;直接用 16 位相加,29491 + 29491 = 58982,回绕成 −6554,一次加法把正数加成了负数,而且不报任何错。
Randy Yates 的教程里给了一个更完整的例子(直流抵消器):信号 x[n] 是 16 位 A(15,0)(纯整数),直流估计 w[n] 是 32 位 A(15,16)(16 个小数位,估计值需要精细刻度):
![[qformat-dc-canceller.webp|Randy Yates《Fixed-Point Arithmetic: An Introduction》Figure 2:直流抵消器——输入 x[n] 分两路,一路送 DC 估计器得到 w[n],再与原信号相减输出 y[n];两条支路定标不同,相减前必须对齐]]
要算 y[n] = x[n] − w[n],先把 x[n] 左移 16 位变成 32 位 A(15,16)(低位补零,数值不变),两边定标就一致了。但直接相减仍有溢出风险(正满量程减负满量程),所以两个数再各右移 1 位,降到 A(16,15),腾出 1 位余量,代价是 w[n] 损失 1 个小数位。这套”对齐定标 → 留出余量 → 付出一点精度”的思路,是处理混合定标的通用解法。
四、乘除法:Q 值相加,位宽翻倍
乘法的规则一句话:结果的 Q 值 = 两个操作数的 Q 值之和。Q15 × Q15 = Q30。数例:Q15 的 0.5 是 16384,16384 × 16384 = 268435456(0x1000_0000),按 Q30 读正好是 0.25。
实际中往往要乘回 Q15,就右移 15 位。但右移是向零截断,正数最多偏差 1 个 LSB,负数同理,信号处理里累积起来就是直流偏置。习惯做法是移位前加上半个 LSB 做四舍五入:3.7 用 Q4 表示是 59,直接 >> 4 得 3,加 8 再移得 4。
int32_t q15_mul_q15(int16_t a, int16_t b)
{
return (int32_t)((int32_t)a * b + 0x4000) >> 15; // 加 2^14 再移位:四舍五入
}
中间量用 int32_t 是必须的:16 位 × 16 位,积最大 32 位,用 16 位接就会截断。CMSIS-DSP 的 Q15 乘法因此在 64 位累加器里做(1.15 × 1.15 = 2.30,还得防累加溢出,额外留了保护位)。
除法反过来:Q15 ÷ Q15 若直接整除,商变成 Q0,小数全部丢失。要先左移再除:
int16_t q15_div(int16_t a, int16_t b)
{
return (int16_t)(((int32_t)a << 15) / b); // Q15 / Q15 -> Q15
}
左移 15 位同样要求 32 位中间变量。除法在无除法器的核上开销很大(几十到上百周期),定点代码里常见的优化是把”除以常数”改写成”乘以倒数”,倒数在初始化时算好,存成 Q31。
五、饱和:抑制溢出
回绕(wrap-around)是定点运算中最难排查的故障模式:不置标志、不报警,符号随时可能翻转。PID 的积分项尤其典型(见 PID 算法的基本原理和数学描述):误差长时间同号,积分一路加到满量程,回绕后输出反向,系统失去控制。
饱和操作把越界值钳到边界上,输出顶在边界值上而不是翻转符号,至少单调性还在:
static inline int16_t sat16(int32_t x)
{
if (x > 32767) return 32767;
if (x < -32768) return -32768;
return (int16_t)x;
}
TI 的 IQmath 把这件事做成了三档 API(SPRUGG9):
| 函数 | 行为 |
|---|---|
_IQmpy(A, B) | 直接乘,溢出回绕 |
_IQrmpy(A, B) | 乘后舍入,溢出仍回绕 |
_IQrsmpy(A, B) | 乘后舍入 + 饱和 |
默认不饱和是为了性能,饱和版本会多几条指令。控制环里输出路径上的乘法建议一律用饱和版本,这点开销换来的是故障模式从输出反向变为输出钳位。
六、实战:从 ADC 读数到电流环
电机控制是把 Q 格式用得最系统的地方。ADC 给的是 12 位无符号数(04095),对应双极性电流 −I_max+I_max。归一化到 Q15 只要两步:减掉中点变成有符号数,再左移 4 位对齐到 15 个小数位:
// ADC 12 位原始值 -> Q15 标幺电流
int16_t current_q15 = (int16_t)(((int32_t)adc_raw - 2048) << 4);
// 0 -> -2048 << 4 = -32768 (对应 -I_max)
// 2048 -> 0 << 4 = 0 (对应 0)
// 4095 -> 2047 << 4 = 32752 (对应 +I_max)
此后的整个电流环(Clarke/Park 变换、PI 调节器、反 Park、SVPWM)都可以在 Q15 或 Q14 域里闭环(角度例外,见下)。这套算法的 C 实现见 FOC 磁场定向控制的 C 语言实现,SVPWM 调制部分见 电机控制的空间矢量调制 SVPWM。标幺化的好处是所有量都在 ±1 附近,Q15 的分辨率正好覆盖这个量程;换成安培做单位,量程一到几十安,Q15 就不够分了。
角度 θ 的处理要单独考虑:sin/cos 的参数是弧度,范围 −ππ,Q15 装不下(原因见第一节第二张表)。常见做法有两个:用 Q13 或更粗的定标存弧度;或者干脆存每转计数(065536 对应 0~2π),配合查表法取 sin,后者在无 FPU 的核上用查表加线性插值,比 CORDIC 迭代还快。
库生态这边,两条路线各有成熟方案:TI C2000 的 IQmath 库以 GLOBAL_Q(默认 Q24)为中心,一套函数名对应一种定标;ARM 的 CMSIS-DSP 用纯 Q15/Q31 的”分数定标”(1.15、1.31),靠 64 位累加器抗溢出。理解了本文的规则,两套库的手册都只是细节差异。
七、踩坑速查
| 症状 | 原因 | 解法 |
|---|---|---|
| 输出周期性尖刺或符号翻转 | 加法/乘法溢出回绕 | 32 位中间变量 + 饱和 |
| 结果比预期小一半 | 乘法后忘了右移 | 检查每步的 Q 值账本 |
| 小量永远为零 | 右移截断吞掉了小数 | 移位前加半 LSB 舍入 |
| 两数相加莫名错位 | 定标不同直接相加 | 先移位对齐再运算 |
| sin 输入正常输出错 | 角度定标超出表示范围 | 弧度用 Q13 或改每转计数 |
| PID 积分”越调越偏” | 积分项溢出回绕 | 积分限幅 + 抗饱和 |
| 换编译器结果变了 | 依赖了 char 符号 / 移位实现细节 | 显式用定宽类型,算术右移自己写 |
总结
Q 格式的全部规则可以压缩成三句话:小数点是约定,运算按整数做,每一步记好 Q 值账。加减对齐定标,乘法 Q 值相加,移位前想想舍入,可能溢出的地方加饱和。这套纪律换来的是无 FPU 平台上确定性的实时性能,也是很多电机控制库至今坚持定点实现的原因。