资讯详情

深入理解二进制运算:从精度误差到位运算实战

📅 2026/9/29 4:20:21 | 华诺云谱 👁 阅读
深入理解二进制运算:从精度误差到位运算实战
去年我给一个电商后台排查订单金额问题后台反馈有两笔订单的优惠分摊总是差一分钱而且不是偶发是稳定复现。我一开始以为是数据库字段精度设置有问题查了一圈发现存储层完全正常最后定位到是 Java 里 double 做加减法时产生了精度误差。那一刻我突然意识到很多人包括我自己平时写代码习惯了高级语言遇到这类问题第一反应是查框架、查缓存、查数据库很少有人会真的去算一遍“0.1 在内存里到底长什么样”。这道题说到底绕不开的就是二进制运算。这篇文章我想把二进制运算这件事从头到尾讲透进制转换、加法和补码、位运算、浮点数的底层表示以及最容易踩坑的边界场景。适合刚接触计算机原理的新手适合写过两三年代码但没系统整理过底层知识的开发也适合准备技术面试、需要快速把基础概念理清楚的朋友。我不会堆数学公式而是尽量用我实际排查问题时的思路来拆争取让你读完以后不只是“知道”而是真的能用二进制的方式去思考问题。1. 二进制运算不是“计算机入门课”而是定位 BUG 的显微镜1.1 一次“差一分钱”的排查把我拉回最基础的概念那个金额问题的现象是这样的订单里有三件商品要按金额比例分摊一个满减优惠数据库里存的是 decimal(10,2)但业务代码里先用 double 计算每件商品的分摊金额再四舍五入保留两位小数最后把三件的分摊加起来有时候会比优惠总额多一分或者少一分。表面看这是“舍入策略”的问题改成 BigDecimal 就能解决。但真正让我背后发凉的是为什么 double 计算 0.1 0.2 的结果不是 0.3而是 0.30000000000000004这个问题不搞清楚今天躲过了金额分摊明天还会在别的场景里踩同样的坑。我后来的排查过程其实很简单写了一段测试代码分别用 double、float、BigDecimal 计算同样的算式把每一步的二进制表示打印出来。看到 0.1 在 IEEE 754 标准下对应的那一长串尾数时我才真正理解了什么叫“二进制运算的精度边界”。从那以后我再看浮点数相关的代码都会多问一句这个数用二进制能精确表示吗1.2 计算机为什么非要选二进制开关、电压和噪声容限要理解二进制运算先得回答一个看似很“蠢”的问题为什么计算机不用我们最熟悉的十进制毕竟人类数数从十进制开始算盘也是十进制连钟表都是六十进制。工程上的原因非常现实数字电路里最容易做得又快又稳的电子元件是“开关”。晶体管导通或者截止对应一条线路上的电压是“高电平”还是“低电平”。如果用十进制你得把一个电压范围精确切成十份给每一份代表 0 到 9制造工艺稍微有一点点波动温度一变化芯片就可能把 3 读成 4。而二进制的世界只有两种状态高和低、有和无只要电压超过某个阈值就当作 1低于某个阈值就当作 0中间存在很大的容错空间也就是俗称的“噪声容限”。很多人还会接着问那为什么不干脆用三进制理论上三进制在信息表示上确实比二进制更“紧凑”前苏联还造过三进制计算机 Setun。但现实是二进制的电路设计最简单可靠布尔代数提供了完整的数学工具所有复杂的逻辑运算都可以分解成“与、或、非”三种基本操作。再加上过去几十年的产业积累整个生态都建立在二进制之上想换也换不掉了。所以结论很清楚计算机选二进制不是因为它“高级”而是因为它在工程上最稳、最省、最容易大规模集成。2. 进制转换理解位权而不是记口诀2.1 整数转换除2取余的本质是拆分位权我见过很多人学进制转换的时候老师给了一句口诀“除2取余倒序排列”。于是大家照着做也能算对但完全不知道自己在干什么。等到题目变成“把十进制 587 转成十六进制”发现口诀变成“除16取余”还是能算。但如果问一句“为什么 13 的二进制是 1101”很多人就愣住了。关键在于位权。十进制数 13 可以拆成 (1 \times 10^1 3 \times 10^0)同理二进制数 1101 代表 (1 \times 2^3 1 \times 2^2 0 \times 2^1 1 \times 2^0)也就是 8 4 0 1。所谓“除2取余”其实就是在反复拆“除以 2 之后余下的那一位”每次除以 2整个数就向右移动一位余数就是你当前阶段要提取的最低位。实际操作时我建议你口算时用“位权拆解”而不是机械地除。比如 45先找不超过它的最大 2 的幂是 3245 - 32 1313 里面最大的 2 的幂是 813 - 8 55 里面最大的 2 的幂是 45 - 4 1最后剩下 1。所以 45 32 8 4 1写成二进制就是从高位到低位依次填位(2^5) 位是 1(2^4) 位是 0(2^3) 位是 1(2^2) 位是 1(2^1) 位是 0(2^0) 位是 1结果是 101101。这个方法比连续除 2 快得多而且不容易错。十六进制同理只是把 4 个二进制位合成一组比如 101101 从右往左分成 0010 1101对应十六进制 0x2D。2.2 小数转换与精度损失0.1 的真实面目整数转二进制靠除 2小数转二进制则相反靠乘 2 取整。十进制的 0.375乘以 2 得到 0.75整数部分是 0记下 00.75 乘以 2 得到 1.5整数部分是 1记下 1去掉整数部分剩 0.50.5 乘以 2 得到 1.0整数部分是 1结束。所以 0.375 的二进制是 0.011也就是 (0 \times 2^{-1} 1 \times 2^{-2} 1 \times 2^{-3})。但问题来了并不是所有十进制小数都能用有限位的 2 的负幂加出来。0.1 乘以 2 是 0.2记 00.2 乘以 2 是 0.4记 00.4 乘以 2 是 0.8记 00.8 乘以 2 是 1.6记 10.6 乘以 2 是 1.2记 10.2 乘以 2 是 0.4记 0……你会发现它进入了无限循环。所以 0.1 在二进制里相当于十进制里的“三分之一”根本写不完。这就是“0.1 0.2 ! 0.3”的根源不是计算机笨而是计算机能表示的浮点数本来就只有有限多个0.1 根本不在其中。你看到的 0.1其实是二进制下无限循环小数被截断后的近似值。2.3 几个我常用的快速心算和验证方法做二进制运算久了我总结了一些很好用的心算技巧。首先是记住 2 的幂表至少要滚瓜烂熟地记到 (2^{10} 1024)这个表不仅能用于进制转换后面做位运算和内存大小估算也到处用。(2^n)值(2^n)值(2^0)1(2^6)64(2^1)2(2^7)128(2^2)4(2^8)256(2^3)8(2^9)512(2^4)16(2^{10})1024(2^5)32(2^{11})2048其次是验证结果我自己写代码的时候经常用 Python 的 bin() 函数和 hex() 函数来做即时校验print(bin(45)) # 0b101101 print(hex(45)) # 0x2d print(float.hex(0.1)) # 0x1.999999999999ap-4看到0x1.999999999999ap-4这样的十六进制浮点表示你就知道 0.1 在机器里是一个很复杂的近似值而不是干净的 0x1.ap-4。这种“打印出来亲眼看一遍”的习惯比背多少理论都要管用。还有一个我自己常用的验证思路把同一个数分别在二进制、八进制、十六进制里写一遍然后检查它们的位模式是否相互对应。比如二进制 110100101101从右往左三位一组是 110 100 101 101对应八进制 6455从右往左四位一组是 1101 0010 1101对应十六进制 0xD2D。三个结果能对得上说明没算错。3. 二进制的算术运算加法器、补码与溢出3.1 加法运算从半加器到全加器的递推二进制加法本身很简单规则跟十进制一样逢二进一0 0 01 0 10 1 11 1 0 且向高位进 1。这个“向高位进 1”是整个加法电路设计的核心。只看一位的加法我们需要输出一个“和位”和一个“进位位”。和位就是异或逻辑两个输入不一样时结果为 1进位位就是与逻辑两个输入都是 1 时才产生进位。这种不考虑来自低位的进位的电路叫半加器。但真实的多位加法里每一位都要接收来自低位的进位所以完整的电路叫全加器它有三个输入本位的 a、本位的 b、低位的进位 carry_in两个输出和 s、向高位的进位 carry_out。从二进制运算的角度理解多位加法就是从最低位开始把每一位的运算结果和进位一级一级传递上去。你会看到 CPU 里的加法器本质上是把一串全加器串联起来所以存在“进位传播延迟”。这也是为什么后来会有超前进位加法器它把进位的计算并行化不再等低位算完再算高位。这些属于计算机组成原理的内容但我建议你至少知道你写的a b在硬件里就是这样一格格算出来的。3.2 减法为什么要用补码一个关于“模”的解释二进制的减法有一个经典问题电路里做“借位”比做“进位”麻烦得多。为了让加法器同时处理加法和减法计算机采用了一种很聪明的方案把减法变成加法也就是用补码表示负数。理解补码最直观的方式是“模”的概念。时钟是一个模 12 的系统10 点往前拨 2 小时是 8 点往后拨 10 小时也是 8 点所以减去 2 相当于加上 10因为 10 是 -2 对模 12 的“补数”。对于一个 n 位的二进制系统模是 (2^n)那么一个负数 (-x) 的补码就是 (2^n - x)。以 8 位为例-1 的补码是 (256 - 1 255)也就是二进制 11111111。看起来“全是 1”但其实它就是 -1因为 1 (-1) 在 8 位空间里等于 (1 255 256)最高位溢出后剩下 0结果正好是 0。这就是为什么你经常看到“补码等于原码取反加一”的口诀因为 (2^n - x (2^n - 1 - x) 1)而 (2^n - 1 - x) 恰好是把 x 的每一位取反。口诀本身没错但如果你只记住口诀遇到像“-128 的补码是什么”这种问题就会犯迷糊。-128 在 8 位里的补码是 10000000。你可以验证-128 和 128 对模 256 是同余的而 128 在 8 位里无法用原码表示但它的补码 10000000 刚好被用来表示 -128。这就导致一个有符号 8 位整数的范围是 -128 到 127而不是对称的 -127 到 127。这个不对称性就是很多溢出 Bug 的源头。3.3 乘法与除法移位是第一公民如果你写过底层代码一定听说过“乘法和除法是慢指令”慢不是因为它们的数学定义复杂而是因为硬件实现上乘法本质上是重复的移位和加法。一个 n 位二进制数乘以 2就是把每一位向左移动一位最低位补 0。乘以 3 呢就是先左移一位变成 2x再加上原来的 x所以 (3x (x 1) x)。推广到任意乘法CPU 里最原始的乘法器就是把乘数的每一位拆开如果当前位是 1就把被乘数左移对应的位数后加到结果上。这个过程跟你小学做十进制乘法竖式一模一样只不过十进制每一步乘 9 以内二进制每一步只乘 0 或 1逻辑更简单但步骤更多。除法稍微复杂一点常见的是恢复余数法从高位开始每次尝试用除数减当前余数减得动商的对应位就是 1减不动就恢复原状商的对应位是 0。这个过程对应到十进制除法就是“试商”。虽然现代 CPU 有更快的 SRT 除法、甚至用查表法加速但如果你在二进制层面手动算过一次恢复余数法再看到视频里那些“位运算实现除法”的代码就不会觉得玄学了。3.4 溢出与位宽你可能一直在用“错误”的结果这是二进制运算里最让我头疼、也最值得单独拎出来讲的部分。先看一个非常简单的实验用 8 位无符号整数计算 255 1。二进制看是 11111111 00000001 100000000但 8 位寄存器只保留低 8 位结果是 00000000。也就是说如果代码里用无符号 8 位整数做这个加法得到的是 0。这不是“算错了”而是数学结果超出了 8 位能表达的位宽产生了缠绕。有符号数的情况更微妙。8 位有符号整数里127 1 等于多少用补码算01111111 00000001 10000000而这个二进制模式在有符号解释下是 -128。所以在 C 语言里这对某些编译器来说是“未定义行为”在 Java 里你总会直接得到 -128Python 由于整数无限精度又不会这样。不同语言对溢出的处理策略不同这是实际开发中最容易踩的暗坑。我后来写代码时会特别留意自己选的数据类型位宽状态位、协议解析、嵌入式寄存器读写这些场景里数据宽度是固定的一旦溢出就会产生隐性问题。调试的时候除了看结果还要看 CPU 的标志位——进位标志 CF 能告诉你无符号运算有没有向更高位借出或进满溢出标志 OF 能告诉你带符号运算的结果是否超出了符号位的解释范围。这两个标志就是硬件在“悄悄告诉你这里已经不对了”。4. 位运算二进制运算里最值钱的实战部分4.1 与、或、异或、取反四个操作的应用场景如果说加、减、乘、让计算机“会算数”那与、或、异或、取反这四个位运算就是让计算机“会判断”的肌肉记忆。它们每一位之间互相独立不产生进位所以速度快得惊人而且天生适合做掩码、合并、翻转这些操作。按位与最典型的用法是取掩码。比如你想拿到一个 8 位数据的低 4 位直接data 0x0F就行。想判断一个数的第 3 位是不是 1可以用data (1 2)是否非零来判断。按位或最典型的用法是设置位。权限系统里常见的二进制标志就是这样定义一个读写权限0b110想给某个人加上写权限就把它和0b010做或运算结果变成0b110等于把对应位“点亮”了。异或则更灵活。两个相同的位异或得 0不同的位异或得 1。所以x ^ 1可以把最低位翻转x ^ 0xFF可以把低 8 位全部取反。经典技巧“不用临时变量交换两个变量”a a ^ b; b a ^ b; a a ^ b;虽然现代编译器一般不需要这么写但这个例子能帮你理解异或的可逆性一个数异或同一个数两次会变回原来的数。按位取反~x在实战中常用于构造“全 1 掩码”然后再配合与运算比如x ~0x0F可以把低 4 位清零、高 4 位保留。需要注意的是不同语言里~的隐式类型提升规则和符号扩展行为不一样写之前最好先确认一下变量的无符号类型。4.2 移位运算乘以2除以2兼谈算术移位与逻辑移位左移运算x n的语义比较统一不管有符号还是无符号都是把每一位向左移动 n 位右边补 0相当于乘以 (2^n)只要没溢出。右移就要小心了它分逻辑右移和算术右移两种。逻辑右移x n以 Java 为例把高位一律补 0适合无符号数。算术右移x n则把高位补成原来的符号位正数补 0负数补 1所以-8 1的结果是-4而不是一个大正数。这个差异在 C/C 里尤其危险因为对“有符号整数的右移是算术右移还是逻辑右移”标准并未完全统一当前主流的编译器都会在绝大多数平台上做算术右移但你最好别写这种依赖平台行为、看起来像能跑的代码。有个实际场景帮我彻底分清了这两种移位处理音频 PCM 数据时采样点经常是 16 位有符号数要把两个单声道样本合成一个立体声样本就必须小心用它做算术右移因为负数补 1 才能保持原值若用逻辑右移负样本会变成一个很大的正数音质直接毁掉。移位虽简单但“移位不改变原变量值”这一点也容易忘x 1不会修改 x必须写成x x 1才有效。4.3 实战技巧状态标志、权限组合、RGB通道提取位运算最爽的地方在于它能用一个整数装下很多个“开关”。我自己维护过一个内部配置系统以前用四个布尔字段分别表示是否启用缓存、是否开启调试日志、是否允许覆盖、是否发送通知后来改成用一个整数的不同位来存#define FLAG_CACHE (1 0) // 0001 #define FLAG_DEBUG (1 1) // 0010 #define FLAG_OVERRIDE (1 2) // 0100 #define FLAG_NOTIFY (1 3) // 1000 uint8_t config 0; config | FLAG_DEBUG | FLAG_NOTIFY; // 开启调试日志和通知 config ~FLAG_DEBUG; // 关闭调试日志 bool isNotify (config FLAG_NOTIFY) ! 0;这样不仅省内存更重要的是可以用一个memcmp或者一个整型值快速比对整组状态是否一致排障时直接打印一个数字就能看出哪些位亮了。类似的位打包思路在嵌入式底层协议、数据库存储引擎里到处都是。另一个很常见的实战是 RGB 颜色通道提取。一个 32 位像素值通常按0xAARRGGBB排布要取红色通道就是先右移 16 位再和0xFF做与运算。用位运算写出来是uint32_t pixel 0xFF336699; uint8_t r (pixel 16) 0xFF; // 0x33 uint8_t g (pixel 8) 0xFF; // 0x66 uint8_t b pixel 0xFF; // 0x99如果不做这种二进制层面的拆分而是用pixel / 65536 % 256这类算术方式逻辑是一样的但多绕了一圈。我在性能敏感的图形处理代码里这类反复执行的操作能差出不少时间。位运算不神秘它只是把“把数字当成一个二进制位串”去思考一旦你习惯这种视角很多代码会变得非常简洁。5. 浮点数在二进制世界里的“妥协”5.1 IEEE 754 怎么把一个数塞进32位整数在二进制里是“精确”的因为每一位都能对应确定的 2 的幂只要位宽够整数就能无误差表示。但浮点数不行尤其在 32 位浮点数float)里只有 32 个 bit要表示极大范围的小数甚至大整数必然要牺牲精度。IEEE 754 标准的做法是科学计数法的二进制版。以单精度 float 为例它把 32 位分成三部分1 位符号位 S8 位指数位 E23 位尾数位 M。实际表示的数值是 ((-1)^S \times 1.M \times 2^{E - 127})其中尾数前面的那个 1 是隐含的被称为“隐藏位”。指数采用偏移量表示法是为了让指数可以同时表示正负范围便于比较大小。我之前写过一个极简的浮点数打印工具把 0.1 的 32 位表示拆分出来结果是符号位 0指数位 01111011也就是 123减去偏移 127 得到 -4尾数部分是 10011001100110011001101。所以 0.1 实际上被存成 (1.10011001100110011001101_2 \times 2^{-4})也就是一个被截断到 23 位尾数的近似值。5.2 0.1 0.2 的完整计算过程演示很多人知道 0.1 0.2 不等于 0.3但不清楚到底差在哪。我们实际算一遍 double 的情况。double 用 64 位尾数有 52 位所以 0.1 的近似精度比 float 高很多但依然不是精确值。0.1 在 double 里大约等于 0.10000000000000000555111512312578270211815834045410156250.2 大约等于 0.200000000000000011102230246251565404236316680908203125。这两个数相加结果的精确值大约等于 0.3000000000000000444089209850062616169452667236328125但 double 只能保存 53 位有效二进制位最终四舍五入成打印时我们熟悉的 0.30000000000000004。这个过程可以用 Python 精确地验证from decimal import Decimal print(Decimal(0.1 0.2)) # 0.300000000000000044408920985006...你会发现问题不在于“最后一位 4”而在于 0.1 和 0.2 本身就不是它们字面上的十进制值。每一次浮点运算都会引入一次舍入当运算次数变多误差就会累积所以浮点数比较不能用而是要比较差的绝对值是否小于某个容差或者使用math.isclose。5.3 解决浮点误差的几种常见做法与取舍面对浮点误差不同领域有不同解法我来分析一下我常用的几类方案。第一类是货币计算用十进制类型。Java 用BigDecimalPython 用decimal.Decimal数据库用DECIMAL。它们的本质是把十进制小数当成字符串或者大整数来存储而不是转成二进制浮点数所以能精确表示 0.1。代价是速度慢不适合高并发的数值计算所以只在金额等需要“人看得到的结果必须精确”的场景使用。第二类是全部转成整数运算。比如把金额单位从元改成“分”所有计算都用整数在最终展示时才除以 100。这个方案速度快也精确但前提是你得知道所有数值的固定小数位数而且乘除法时要自己管理小数点位置容易在比例计算里出错。第三类是接受浮点近似但在比较时用容差。比如游戏里的坐标、物理引擎里的位置这些数值本身来自连续世界有一点误差没关系只要别在判断时踩到边界即可。abs(a - b) 1e-9是常见的写法。我的建议是不要迷信任何一种方案先问清楚数值的语义。如果业务要求“账必须平”直接上十进制类型如果只是可视化浮点加容差足够。因为我在生产环境里见过有人为了追求 BigDecimal 的安全把一个每秒计算上万次的实时行情模块拖慢了好几倍最后换回浮点加容差处理性能显著提升业务上也没有任何问题。6. 学习二进制运算的几个常见误区与一条自学路径6.1 误区一觉得补码就是“取反加一”“补码就是取反加一”这个说法流传太广以至于很多人面试被问到“-5 的补码怎么算”时脱口而出“5 的二进制取反加一”这样确实能算对答案但说明不了“为什么”。我建议你从这个角度去理解补码的本质是模 (2^n) 下的补数。拿 8 位来说-5 表示 (256 - 5 251)换成二进制 11111011。而“5 的二进制 00000101 取反得到 11111010再加一得到 11111011”是模运算的一个快速计算技巧两者并不矛盾但后者只是手段前者才是本质。理解到这一层你才能解释为什么 10000000 表示 -128也能明白为什么补码的加减法规则对符号位同样有效因为整个过程根本没把符号位当特殊的东西处理。6.2 误区二把逻辑右移和算术右移搞混这个误区在实战里非常隐蔽因为很多脚本语言甚至不区分两种右移比如 Python 的对负数做的是算术右移而部分动态语言的移位规则又各有不同。到了 C/C、Java、Go 里如果不注意很容易写出“看起来对但只在正数时对”的代码。我处理过一个和图形编码有关的 Bug编码器读入一批像素值其中有负数做右移归一化时用了逻辑右移结果所有的暗部细节全部变成了一片亮白。从二进制看负数最高位是 1逻辑右移在左侧补 0相当于把一个负数变成了大正数数值方向全反了。排查时我把错误输出转成二进制立刻看到左侧多出来的 0 位问题一目了然。所以我给新手一个很实用的建议凡是涉及有符号数的右移先明确“我到底是想保留符号还是当成无符号位串来处理”。最好的办法是让变量类型本身把意图表达清楚有符号就用算术右移无符号就用逻辑右移不要在代码里依赖默认行为。6.3 误区三认为二进制运算只跟底层开发有关我经常听到有人说“我又不写驱动、不做嵌入式学二进制运算干嘛”。但实际上Web 开发里很多算法题和工程技巧都会用到位运算。一个很常见的例子是判断一个整数是不是 2 的幂n 0 (n (n - 1)) 0这个判断比循环除以 2 快得多也是很多缓存库计算容量时的惯用写法。再比如状态机里用位掩码表示一组开关前端权限列表用位字段判断某个角色能不能访问某个按钮甚至算法题里统计一个整数二进制中 1 的个数也有n n - 1这种经典做法。这些都是二进制运算在不同领域的直接应用。你可以不写驱动但你不能不看懂别人提交的这类代码。6.4 我的练习路径从手算到写一个小工具最后分享一条我自己验证过、也推荐过不少朋友的学习路径核心原则是“动手算动眼查再动手写”。第一步手算 20 以内的十进制整数到二进制的转换同时用“位权拆解法”写出每个数的展开式比如 13 8 4 1。这个阶段的目的不是算得快而是让位权成为直觉。第二步用 Python 的bin()、oct()、hex()验证自己的结果遇到不一致就回头看错在哪。第三步写一个十进制转二进制的小函数支持小数部分比如输入 0.375 输出 0.011输入 0.1 输出一个前 30 位的近似值这会逼着你真正理解“乘 2 取整”的边界在哪里。第四步给自己出几道常见面试题8 位无符号 200 100 等于多少补码 11000000 和 10000000 分别表示什么把0xF0算术右移 4 位和逻辑右移 4 位分别得到什么。这些题目不需要背拿出纸笔算一遍再用代码验证。等这四步都走完你再看任何二进制运算相关的代码感觉会完全不一样因为你已经能在心里把数字展开成一条条位串而不是只能看着十进制的表象发愣。我自己踩坑踩到现在最大的心得就是遇到二进制相关的 Bug永远不要急着改代码先在脑子里或草稿纸上把数字的位模式画出来。很多问题一经展开答案自己就跳出来了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑