手边这块 MCU 只有整数指令,没有 FPU。现在要存一个温度补偿系数 0.3,还得参与后续计算。
办法很朴素:把小数点钉死在某个位置,剩下的位全部当整数用。这就是定点数, 也是 DSP、电机控制、老式音频通路里沿用了三十年的做法。
0.3 是怎么变成 9830 的
假设用 16 位,规定最高位是符号位,剩下 15 位全部是小数位——这就是常见的
Q15。它的刻度是 2⁻¹⁵ = 0.000030517578125,也就是说
这个寄存器能表示「多少个 2 的负 15 次方」。
存 0.3 就是算一下需要多少个刻度:0.3 ÷ 2⁻¹⁵ = 9830.4,四舍五入得到
9830,换成十六进制是 0x2666。读回来的时候乘以刻度:
9830 × 2⁻¹⁵ = 0.29998779296875。
差了 0.0000122。不是算错了,是 0.3 在这个刻度上根本落不到格点上。
Qm.n 这个名字的三种叫法
同一个格式,不同厂商的手册叫法不一样,这是新手最容易卡住的地方:
- TI 的 DSP 手册写 Q15,指「15 个小数位」
- ARM 的手册写 Q0.15,指「0 个整数位 + 15 个小数位」
- 一些 DSP 资料写 s1.15,把符号位也算进整数部分
三个是同一个东西。本文统一用 Qm.n 这套: 1 个符号位 + m 个整数位 + n 个小数位,总位宽 = m + n + 1。 无符号格式写成 uQm.n,没有符号位。
所以 Q15 就是 Q0.15;Q3.12 是 16 位里有 3 个整数位、12 个小数位; Q31 是 32 位的 Q0.31。换算时只要认准 n,其它都能推出来。
精度和范围是同一枚硬币
位宽固定,小数位给得多,整数位就少。三种 8/16 位格式的对比:
- Q7(8 位):范围 −1 ~ 0.9921875,分辨力 0.0078125
- Q15(16 位):范围 −1 ~ 0.999969482421875,分辨力 0.000030517578125
- Q3.12(16 位):范围 −8 ~ 7.999755859375,分辨力 0.000244140625
Q15 和 Q3.12 同样是 16 位,但 Q15 连 1.0 都存不下(会饱和到 0.999969), 换来的是比 Q3.12 细 8 倍的刻度。所以选格式的第一个问题是: 这个量最大能到多少?答案决定整数位,剩下的才是精度。
一个常被忽略的点:Q15 特意留出「差一格就到 1」的设计,是为了让乘法不出界—— 两个绝对值小于 1 的数相乘,结果一定小于 1,Q15 乘 Q15 永远不会溢出。
量化误差有多大
四舍五入到最近的格点,误差最大是半个 LSB。同样是 0.3:
- Q7:
38 × 2⁻⁷ = 0.296875,误差 0.003125,约 1% - Q15:误差 0.0000122,约 0.004%
- Q3.12:
1229 × 2⁻¹² = 0.300048828125,误差 0.0000488
单次误差不起眼,麻烦在于它会进入后续运算。做 IIR 滤波器时,系数被量化之后极点位置会偏移, 极端情况下滤波器从稳定变成发散;做定点 FFT 时,每一级旋转因子的误差会随级数累积。 这类问题不是「精度不够」那么简单,而是系统对系数的敏感度问题。
工程上的对策通常是:中间累加用更宽的位宽(Q15 的数据配 Q31 的累加器), 只在存储和传输时降精度。
乘法:为什么中间结果要双倍位宽
两个 Q15 相乘,小数位会翻倍:Q15 × Q15 得到 Q30,要再右移 15 位才回到 Q15。
拿 0.5 × 0.5 举例:两个操作数的原始码都是 16384,
16384 × 16384 = 268435456(这是 Q30 的 0.25),
右移 15 位得到 8192,换回 Q15 正好是 0.25。
关键在于中间结果:两个 16 位数相乘,完整乘积有 32 位。如果写在 16 位变量里, 高位直接丢掉,右移之后就是个错数。所以定点乘法的标准写法是:
int16_t a, b; // Q15
int32_t prod = (int32_t)a * b; // Q30,32 位装得下
int16_t c = (int16_t)(prod >> 15); // 回到 Q15 累加同理。把几百个乘积加在一起,int16 的累加器早就爆了, 定点代码里几乎总是用 int32 或 int64 做累加器,最后再饱和回目标位宽。
溢出:饱和还是回绕
Q15 能表示的最大值是 0.999969482421875,原始码 32767。现在给它加 0.0002:
- 饱和:钳在 32767,结果 0.999969,比真值小一点
- 回绕:原始码变成 32774,超出 int16 的表示,回绕成 −32762,结果是 −0.99988
一个是「稍微偏小」,一个是「符号直接反了」。音频里回绕是刺耳的爆音,
电机控制里回绕可能让电流环反相。所以 DSP 一般提供专门的饱和指令,
通用 CPU 上则要自己写 if 判断——这也是定点代码里到处是
if (x > MAX) x = MAX; 的原因。
C 语言里的有符号整数溢出是未定义行为,编译器可以假设它不发生, 于是优化出更难理解的代码。真要依赖回绕,得用无符号整数,或者显式转换。
什么时候还值得用定点
浮点的好处理所当然:不用管小数点在哪,动态范围从 1e−38 到 3.4e38。定点的优势只剩下三条,但都很硬:
- 没有 FPU 的芯片上快一个数量级。软件模拟浮点动辄几十个周期,定点乘法只要一个
- 逐位可复现。同样的输入,定点在任何平台上结果完全一致;浮点的基本运算虽然由 IEEE 754 规定,但 transcendental 函数、FMA 融合、x87 的 80 位中间结果都会让最后几位不同
- 功耗和面积。乘加器的位宽直接决定芯片面积,定点可以用刚好够的位宽,浮点不行
反过来,如果信号里同时有 0.001 和 100000,定点会很痛苦——统一刻度意味着要么精度不够, 要么位宽夸张。这种场景浮点是唯一合理的选择。
和 int8 量化是一回事吗
思路同源,参数不同。神经网络量化里的 scale + zero_point
本质就是「一个刻度 + 一个偏移」,算下来就是 uQ 格式;区别在于零点是可调的,
而且每层、每个通道可以有各自的 scale,所以叫「仿射量化」。
想看具体数字,用Q 格式定点数转换:输入十进制或原始码, 它会同时给出位模式、表示范围、量化误差和饱和提示,还能生成对应的 C 代码。 如果关心的是 FP16 / BF16 那一套浮点格式,用浮点数转换。