拓冰建站拓冰建站
首页 / 资讯中心 / 正文

嵌入式开发必知:整数补码与定点数Q格式全解析

搞嵌入式、写DSP算法、或者做神经网络推理优化的人迟早都得跟“整数表示”和“定点数表示”这两个概念正面碰上。我自己第一次被定点数折磨是在一个音频采集项目里用C语言写了个FIR滤波器浮点版本在电脑上跑得飞快一放到主频几十兆的MCU上直接卡成幻灯片当时就意识到底层计算资源不等人明白数据在寄存器里到底怎么摆、怎么算比什么都重要。这篇内容就来好好梳理一下整数与定点数的表示方式覆盖补码原理、Q格式定义、定点数的加减乘除、溢出与舍入、以及在DSP、PID控制和神经网络量化里的实际应用。不管你是刚接触单片机的新手还是做嵌入式算法优化的老手读完都能有一份可以直接拿去用的“速查手册”。1. 整数表示补码为什么能成为绝对主流1.1 无符号数与有符号数的原始形态先从最基本的说起。无符号整数表示方式最简单每一位都代表正的幂次比如一个8位无符号数1010_1101计算方式就是1*2^7 0*2^6 1*2^5 0*2^4 1*2^3 1*2^2 0*2^1 1*2^0 173一个N位无符号数能表示的范围是0到2^N - 18位就是0到255。这个规则没有任何争议硬件实现也最简单直接按位加权累加就行。但现实世界里的数据不全是正数温度、速度、误差、增量都是带符号的。于是前辈们设计了三种常见的有符号表示原码、反码、补码。原码很好理解最高位当符号位0代表正1代表负剩下的位表示绝对值。比如4位的1011意思是-3。看似直观但原码有个致命缺点存在0000和1000两个零而且做加减法时必须单独判断符号位电路逻辑复杂速度还慢。反码是在原码基础上把负数各位取反比如-3的4位反码是1100。反码依然存在正负零两个编码运算时还会出现循环进位的特殊处理所以现代CPU基本都不用了。1.2 补码的运算优势与溢出判断补码的定义一句话一个负数-K的补码等于2^N - K的二进制表示。N位补码的表示范围是-2^(N-1)到2^(N-1)-1比如8位就是-128到127。为什么是这个范围因为1000_0000在补码规则下表示-128而1111_1111表示-1没有一个浪费的编码。补码最漂亮的地方在于符号位可以像普通位一样直接参与加法运算。举个4位的例子计算3 (-2)3是0011-2的补码是1110直接相加得到10001丢掉最高位的进位结果是0001也就是1。整个过程不需要判断符号硬件电路只需要一个加法器就能同时处理正数和负数这是补码成为主流的最重要原因。实际写代码时溢出是绕不开的坑。两个正数相加变成负数或者两个负数相加变成正数都是溢出。比如8位补码下120 500111_1000 0011_0010 1010_1010最高位变成1结果是-86完全错乱。在MCU里很多芯片的SR寄存器会有V标志位专门记录这种情况但在C语言里有符号整数溢出在标准看来是未定义行为所以要自己做好类型提升和范围检查。最简单也最稳妥的做法是把中间结果放到更宽的类型里比如两个int8_t相加先转成int16_t再算然后再饱和回int8_t。1.3 符号扩展与算术右移的细节除了溢出日常开发里符号扩展也特别容易出错。把一个int8_t的-11111_1111转成int16_t如果只在高8位补0得到0000_0000_1111_1111变成255那就大错特错了。正确做法是复制符号位得到1111_1111_1111_1111仍然是-1。在C语言里从窄类型转宽类型编译器默认会做符号扩展但如果你手动拼接数据比如从传感器寄存器里把两个字节拼成一个int16_t就必须自己考虑高位字节是直接左移8位再相加还是先转成int8_t再扩展。与之配套的是算术右移和逻辑右移的区别。对有符号数x 1在大多数编译器里是算术右移空出来的高位用符号位填充比如0b1000_0000 1得到0b1100_0000仍然是-64除以2等于-32方向是对的。对无符号数右移就是逻辑右移高位补0。在定点数运算里这个区别直接决定乘法结果缩放的准确性后面会专门说。2. 定点数表示的Q格式一个约定好的小数点2.1 为什么要抛弃浮点选择定点浮点数的动态范围很大用起来方便但代价是硬件资源一个浮点乘法单元的面积和功耗比整数乘法大很多倍很多MCU干脆没有硬件浮点单元只能用软件模拟库速度直接下降一个数量级。以常见的Cortex-M0内核为例连单精度浮点指令都没有你写一个float a b * c;编译器会生成一长串软浮点调用算一次乘法可能要几十个周期。定点数的思路很简单不记录浮点小数点的位置而是把小数点“固定”在某个二进制位上所有数据都按整数来存储和运算只是在换算回真实世界的数值时内心知道小数点的位置在哪里。这样既保留了小数的表达能力又能完全复用整数运算单元和指令在MCU、DSP、FPGA上都能获得极高效率。这项技术不是老古董现在大模型推理用的INT8量化本质上也是一种定点表示。所以我说搞懂定点数是能直接变现为工作产出的技能。2.2 Qm.n的关键参数与范围精度计算Q格式的命名规则很直接用Qm.n表示一个数其中m是整数部分位数包括符号位n是小数部分位数总位数就是mn。在某些资料里也会有Q15这种表示法实际上指的是Q1.15就是1位符号位加15位小数总共16位专门用在16位DSP上。还有一种Q8.8的记法就是8位整数加8位小数总共16位最高位还是符号位实际可表示的最大正数是127.99609375。定点数的精度取决于小数部分的位数n精度最小可以分辨的量就是2^(-n)。范围则取决于整数部分的位数m最大值是2^(m-1) - 2^(-n)最小值是-2^(m-1)。比如Q1.15的范围是-1到0.999969482421875精度是1/32768约等于0.0000305足够表示大部分传感器数据和音频PCM信号了。这里有个常见的公式转换从真实值到定点值fixed (int)(real * (1 n))从定点值到真实值real (double)fixed / (1 n)以Q1.15为例0.5对应的定点数是0.5 * 32768 16384-0.25对应的定点数是-0.25 * 32768 -8192。只要脑子里记住这个“定点值除以2^n就是真实值”的规则大多数格式转换就不会晕。Q格式总位数范围精度Q1.1516-1 ~ 0.9999693.05e-5Q8.816-128 ~ 127.9963.9e-3Q0.3132-1 ~ 0.99999999954.66e-10Q16.1632-32768 ~ 32767.999981.53e-5选择Q格式的核心原则是先估算数据范围确定整数位数m再根据分辨率需求确定小数位数n最后让mn不超过可用位宽。工程里往往需要牺牲一部分范围来换精度或者反过来这就是一个典型的“够用就好”的取舍问题。2.3 浮点转定点的量化和误差写代码时经常要用一个宏或工具函数把浮点常量转成定点数比如#define FLOAT_TO_Q15(x) ((int16_t)((x) * 32768.0f))但这里要注意直接截断转int16_t会有量化误差也就是把真实值映射到最近的可表示定点值时产生的误差最大是半个精度单位。举个例子0.1转Q150.1 * 32768 3276.8如果直接取整到3276真实值是0.0999756误差约0.0000244在可接受范围内。但如果做大量累加这种误差会累积所以更稳妥的做法是四舍五入比如(int16_t)((x) * 32768.0f 0.5f)正数情况。固定偏移造成的系统误差在某些反馈控制系统里会被积分器放大最后表现为静态误差。所以定标的时候不要只盯着单次转换精度还要看整个算法的误差传播路径。3. 定点数运算实战加、减、乘、除的完整套路3.1 加减法对齐小数点和防止溢出两个相同Q格式的定点数相加减直接当成整数相加就行因为小数点位置是同一个不用做任何对齐。比如两个Q15数相加就是两个int16_t相加。但这里最容易出问题的是结果的位宽。int16_t int16_t可能溢出比如32767 1得到-32768这在编译器和运行时都不会报错静默就错了。我的习惯是中间计算一律用int32_t。两个Q15的int16_t相加先转成int32_t再加结果仍然是Q15格式但位宽足够不会溢出。如果计算结果最终要存回int16_t再考虑饱和处理clamp到-32768到32767。很多DSP库都提供__SSAT指令或者饱和加减函数C语言标准里虽然没有直接支持但在部分编译器的内置函数里能找到。3.2 乘法位宽翻倍和缩放还原定点数乘法是最容易犯迷糊的地方。两个Q15数相乘结果应该是什么格式答案是Q30。因为两个15位小数相乘小数部分位数变成30位。但这里有个陷阱两个int16_t相乘C语言默认会做整数提升结果会得到一个32位的int32_t。比如0.5转Q15是163840.25转Q15是8192两者相乘算出16384 * 8192 134217728这个数对应Q30格式真实值是134217728 / (2^30) 0.125是对的。但如果你把Q30结果直接当作某个Q格式用就会出错。实际工程中通常希望对结果再右移n位把Q30变回Q15。以上面的例子134217728 15 4096对应Q15就是0.125。这个右移n位的操作就是为了把“多出来的小数位”丢弃掉回到预定的Q格式。千万注意右移时如果是有符号数要用算术右移否则负数结果会变成很大的正数。3.3 除法先放大再除小心除数为0定点数除法比乘法更麻烦。两个Q15数相除结果是Q0格式也就是纯整数而且小数信息全丢了。想保留小数精度必须先对被除数做左移n位的放大处理再做整数除法。公式是result (dividend n) / divisor得到的结果是商默认保留了n位小数。还是用例子说话0.5除以0.25期望结果是2.0。Q15下0.5 163840.25 8192直接16384 / 8192结果是2这个刚好没问题但如果是0.4除以0.3期望结果是1.3333直接整数除法得到1小数部分全丢。所以正确做法是把被除数左移15位再看成Q30然后做除法(16384 15) / 9830 10923再除以32768得到0.3333这个精度就比较理想了。这里最核心的坑是除数为0必须在代码里显式判断否则会直接触发硬件异常。3.4 舍入模式与饱和处理整数右移会丢弃低位的二进制位这在算术上是“截断”会产生向负无穷方向的偏置误差。比如-0.9转Q15是-29491取整如果用截断方式缩放到Q14误差可能比直接四舍五入大。工程上常用的舍入方式是在右移前给结果加上一个补偿项(a (1 (n-1))) n这就相当于四舍五入到最近值。注意如果a是负数这个加法方式要做相应处理不然方向会反。饱和处理也是定点数的保命手段当计算结果超出Q格式能表示的范围时把它拉回最大或最小值而不是让它溢出后产生不合理的随机数。举个PID控制的例子积分项如果无限累积最后输出变量变成错误值执行器一顿乱抖加了饱和才稳定下来。static inline int16_t saturate_q15(int32_t x) { if (x 32767) return 32767; if (x -32768) return -32768; return (int16_t)x; }4. 定点数在真实系统里的三个经典场景4.1 DSP滤波器中的累加溢出问题FIR滤波器的核心是乘累加操作y[n] sum(coeff[i] * x[n-i])。如果把系数和输入都用Q15表示每个乘法结果是Q30累加时如果用int32_t存储精度合适但可能溢出。比如滤波器长度为64每个乘法结果最大接近0.25两个不超过1的数相乘64次累加总值最大也可能到16超出int16_t范围但还在int32_t范围内。所以工程里会使用Q15系数与Q15输入乘后用int32_t累加最后再统一缩放回Q15输出。这个过程中乘法结果右移多少位、累加器用多宽都要根据滤波器长度和系数最大值预先估算。如果累加器选小了结果就是“数据都变成乱码”而且很难查。我调试过一个64抽头的滤波器症状是输入越大输出越错查了半天发现累加器用的int16_t早就溢出了换成int32_t之后一切正常。这类问题不用示波器直接打印中间累加值就能定位。4.2 PID控制器中的增量式与定点积分PID控制器里比例项、积分项、微分项各有各的数值范围。如果直接用浮点实现在STM32F4这类带FPU的芯片上还算轻松但在低端MCU上浮点PID的每个周期都可能耗掉几百个周期。换成定点后一般先把系数转换成Q15或Q12格式再把输入误差也转成定点然后套用整数公式。积分项尤其要注意饱和不然就会产生“积分饱和”现象导致系统响应超调严重。另一个常见方案是增量式PID只计算输出的增量delta_u Kp*(e_k - e_{k-1}) Ki*e_k Kd*(e_k - 2*e_{k-1} e_{k-2})这样本身就不需要单独累加积分项可以避免一部分积分饱和问题。但增量式PID计算中有多个乘法同样要注意中间结果的位宽。我的做法是Kp、Ki、Kd都用Q15的定点表示误差用int16_t中间的临时变量用int32_t最后再缩放并饱和回int16_t输出。4.3 神经网络推理的INT8量化聊到定点的现代应用神经网络量化是最典型的。训练阶段模型都是float32的权重和激活值部署到端侧时为了省内存、加速推理会把它量化成INT8。对称量化的公式是q clamp(round(r / scale), -128, 127)其中scale是浮点缩放因子等于最大绝对值除以127。这里的q就是定点数而推理时的矩阵乘法本质上是大量的INT8乘法加INT32累加最后再通过scale还原输出。整个过程就是一次大号定点数运算跟上面说的FIR滤波器乘累加一脉相承。非对称量化则多了一个zero_point零点的偏移量适合数据不是以0为对称中心的情况。实际在部署过程中如果量化参数定得不好误差会非常大比如激活值范围选太小很多数据会都被饱和截断精度掉得厉害。这就是为什么做量化部署的人必须理解定点数的范围和精度表达。5. 常见问题与排查技巧实录5.1 编译器警告integer operation result cant fit in type很多嵌入式编译器在开启高警告级别时会报类似warning: #61-D: integer operation result cant fit in type这样的信息我在CW32L012的工程里就经常见到。这个警告看起来只是提示实际上暗示你的整数运算结果可能放不进声明的类型里。比如两个int16_t相乘如果你把它赋值给int16_t编译器就会报这个警告。处理方式不是去屏蔽警告而是把接收变量改成int32_t或者显式做一个强制转换并确认丢弃高位的后果。5.2 符号问题的位操作陷阱调试中经常发生类似syntax error - token integer deleted这种编译报错往往跟代码里误用了中文符号、或者宏定义中的整数常量写法不合法有关。而运行时层面的符号问题则更隐蔽比如在写int32_t result (a * b) 15时如果a和b都是int16_t乘积在int16_t范围里已经溢出了先溢出再右移结果自然不对。正确写法是(int32_t)a * b 15先把一个因子扩展成32位再算。5.3 定点调试推荐思路调试定点代码我一般遵循三个步骤第一步先用浮点模型跑一遍算法得到参考结果第二步把定点版本对同一组输入跑一遍输出两者差异第三步如果误差超出预期就用“定点打印法”排查把一个定点数除以2^n打印成浮点或者用十六进制格式直接打印位模式快速定位是哪一步的缩放或者舍入出了问题。这里给一个有用的C宏#define Q_PRINT(x, n) printf(%ld.%05ld\n, (int32_t)(x) / (1 (n)), (int32_t)((x) ((1 (n)) - 1)) * 100000 / (1 (n)))这个宏可以把Q格式的定点数打印成可读的小数注意负数需要改造但作为调试工具已经很好用。我屡试不爽大多数定点问题在打印出中间量之后都能迅速定位。5.4 参数计算要留足裕量做定点化设计时最忌讳的是把范围卡得太死。实际数据往往会受噪声、瞬态冲击影响超出你的静态估算范围。我一般会把最大值留出1.2到1.5倍的裕量再换取更多的整数位。精度不够可以后续再用更高位宽的定点数但溢出一次整个输出都会乱定位成本远高于多花一点位宽。6. 结尾一个建议先写死Q格式再优化速度说了这么多最后分享一个实际体会。很多人一开始做定点化时总想把Q格式定得很完美无缺结果花了大把时间调参。我的做法是先在浮点上把算法跑通再整体选一个比较保守的Q格式比如Q15或Q8.8把整数运算替换进去跑通后再根据实际误差情况微调小数位数。不要在第一步就追求最优先让系统跑起来拿到可观测的中间量再逐步压榨精度整个过程会顺畅很多。定点数的本质就一句话用一个整数去表达一个小数只要在乘法和位移时记住“小数点的位置”你就掌握这门手艺了。以后不管是写音频滤波器、电机控制还是移植AI模型遇到性能瓶颈时你都会比别人多一张底牌。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门