如果你写过几年代码,或者啃过几本计算机基础的书,一定绕不开“二进制运算”这四个字。哪怕是现在编程语言越来越高级,一条语句能搞定一堆事,但只要你碰到底层调试、性能优化、网络协议解析,甚至只是排查一个诡异的整数越界 bug,二进制运算都会从课本角落跑出来,实实在在地卡你一下。这篇文章我自己梳理了一遍二进制的加减乘除、移位、位掩码这些核心运算,结合这些年工程里踩过的坑,尽量把原理和实用场景揉在一起写,项目需要对着文档硬啃的朋友也可以直接看实操部分抄作业。
二进制运算说白了就是计算机“脑子里”最底层的算术和逻辑操作。它能解决什么问题?最直观的是:你为什么能用 int 表示负数、为什么位运算能高效判断奇偶、为什么网络掩码能划分子网、为什么权限系统通常用 1、2、4、8 这种数字。如果你正在学计算机基础、刷算法题,或者做嵌入式、网络编程、游戏开发这类离硬件比较近的方向,这部分内容不只是考试重点,更是排错必备的底层直觉。
1. 二进制运算的底层原理与整体认知
1.1 为什么计算机非要“二”不行
先聊一个最根本的问题:为什么不是十进制?毕竟人类从小学算盘就是用十进制的。核心原因不在数学层面,而在物理实现层面。计算机底层是电压信号,一个电路节点上,最可靠的状态就是“高电平”和“低电平”两种,对应 1 和 0。你要是硬搞十个电压等级,工艺难度高不说,噪声干扰一上来根本分不清是 3 还是 4。
从这个物理事实出发,二进制就变成一种必然。每个二进制位叫一个 bit,8 个 bit 凑成一个 byte。你在代码里写的 int、float、指针,最终都化成了一大串 0 和 1 在寄存器、内存和总线上跑来跑去。二进制运算就是这套底层体系下最基础的“动作”,比如把两个 8 位数字加起来,CPU 里的加法器硬件就专门干这个事。
还有一个容易忽略的点:二进制运算不只是算术运算,还包括逻辑运算。与、或、非、异或,这些不是数学课上教的那种加减乘除,而是按位操作,而且是很多高级特性的基石。比如你做一个标志位开关,一个 int 变量的 32 个 bit 就能存 32 个布尔值,这在资源紧张的嵌入式环境里极其常见。理解到这一层,二进制运算的“能做什么”才算真正打开。
1.2 进制转换:所有运算前的第一道坎
任何二进制运算,第一步都逃不掉进制转换。我见过不少朋友在纸上算得飞快,一上机写代码就懵,本质上是没吃透“位权”这个概念。
先看二进制转十进制。二进制数 1011,从右往左每位的位权分别是 1、2、4、8,所以计算就是:
1×1 + 1×2 + 0×4 + 1×8 = 11
再看十进制转二进制。最常用的办法是短除法,不断除以 2 记录余数,最后倒过来写。比如把 45 转成二进制:
45 ÷ 2 = 22 余 1
22 ÷ 2 = 11 余 0
11 ÷ 2 = 5 余 1
5 ÷ 2 = 2 余 1
2 ÷ 2 = 1 余 0
1 ÷ 2 = 0 余 1
余数从下往上读就是 101101,验证一下:32 + 8 + 4 + 1 = 45,没错。
转换本身不难,但真正在二进制运算里容易出事的,是位数限制。比如你用 8 位存数字,二进制 11111111 就代表 255,再加 1 就溢出了,回到 00000000。这就像汽车里程表,跑满了归零。很多新手第一次看到某个数突然变成负数或者变成 0,就是这个溢出机制在起作用。
2. 加减法与补码:计算机里的“正负”真相
2.1 加法器只会做加法,但减法也能用加法实现
很多教材会直接告诉你:计算机里的减法是通过补码转成加法来做的。这里我想把这个事拆得更透一些,因为“为什么需要补码”是理解二进制运算最关键的坎之一。
想象你只有一个 4 位寄存器,能表示 0~15 这 16 个数。如果直接在这套系统里定义减法,硬件得额外做一套减法器,成本高、电路复杂。但如果把减法变成加一个“负数”,那硬件只需要加法器就够了。问题是:负数怎么表示?
方案一叫原码,就是最高位当符号位,0 是正、1 是负。比如 0101 是 5,1101 是 -5。这听起来很直观,但实际用起来有两个麻烦:一是 0 会有两种表示(0000 和 1000),浪费编码空间;二是做加法时符号位和数值位要分开处理,电路设计复杂。
方案二叫补码,规则是正数不变,负数等于原码取反加 1。为什么这么绕?因为这样设计以后,一个最关键的性质出现了:任何一个数和它的相反数相加,结果正好溢出为 0。比如 4 位下,5 是 0101,-5 是 1011(取反 1010,再加 1),相加等于 10000,截断后是 0000。这不就完美解决了“加法器做减法”的问题吗?
2.2 实际计算:补码做减法的完整推演
我们用 8 位二进制做个完整例子,计算 45 - 18。
先把 45 转成二进制:00101101
18 转成二进制:00010010
要求 -18 的补码。先取反:11101101,再加 1:11101110。
然后做加法:
00101101
11101110
相加后从右往左算,结果是 100011011。注意这个多出来的第 9 位,在 8 位寄存器里会被丢弃,剩下的 8 位是 00011011。
00011011 转十进制:16 + 8 + 2 + 1 = 27。45 - 18 = 27,完全正确。
这里有一个非常容易踩的坑:很多人以为溢出的 1 丢掉以后结果就不对,其实在这种补码运算里有符号溢出和无符号溢出是两种情况。无符号数溢出意味着结果超出了表示范围,有符号数溢出则要看进位和符号位的关系,后面我会专门讲。
2.3 溢出判断:你最该掌握的排错本领
在 C、C++、Java、Go 这些语言里,整型溢出不会直接报错,而是默默地循环。这种“静默行为”最容易埋 bug,所以我单独把溢出判断拎出来说。
对于无符号数,判断很简单:最高位向上有进位,就是溢出了。比如 8 位无符号数 255 + 1,结果本应是 256,但 8 位装不下,只保留了 00000000。
对于有符号数,情况不同。溢出发生在“两个正数相加变成负数”或“两个负数相加变成正数”时。比如 8 位有符号数范围是 -128 到 127,那 100 + 50 就会溢出,因为结果 150 超过 127,表现出来反而变成了负数。
具体到代码层面,我建议做加减法之后,马上检查边界条件。比如:
int a = 100; int b = 50; if (a > INT_MAX - b) { // 这里说明 a + b 会溢出 }这个写法不需要算完再判断,而是预先比较,能避免不确定行为。Python 因为整型是任意精度,没有这种问题,但如果你写 C/C++、Rust(release 模式下不做溢出检查)或者 Go,一定要有这个意识。Rust 在 debug 模式下会 panic,release 模式下就是静默回绕,搞得很多从 Rust 入门的人第一次碰到还以为是自己逻辑错了,其实是溢出行为不一致。
3. 乘法、除法与移位:性能与陷阱并存
3.1 二进制乘法的竖式原理
二进制乘法和十进制竖式乘法逻辑相似,但因为只有 0 和 1,过程反而更简单。看一个例子,计算 5 × 3,即 0101 × 0011:
0101
× 0011
0101 (这是 0101 × 1,最低位)
0101 (这是 0101 × 1,第二位,需要左移一位)
0000 (这是 0101 × 0,左移两位)
0000 (这是 0101 × 0,左移三位)
相加:001111 = 15
看到没有,乘数的每一位乘完被乘数之后,要么是原数,需要移位,要么是 0。最后把中间结果全部加起来。硬件乘法器就是按这个逻辑用移位器和加法器堆出来的。
不过在代码层面,乘法通常建议直接写成*,编译器会帮你优化。但有些场景下,我们故意不用*,而是用移位,这就引出二分一倍和位移的关系了。
3.2 左移右移:乘 2 除 2 的最快路径
二进制运算里,左移一位等于乘以 2,右移一位等于除以 2。这个规律在整数运算里非常精准,前提是没发生溢出或者舍入。
比如 6 的二进制是 00000110,左移一位得 00001100,也就是 12;右移一位得 00000011,也就是 3。这个操作在底层特别快,因为它只是改变位的读取位置,不需要经过复杂的运算电路。
工程上用移位做乘除法的经典场景是图像处理里的像素计算。比如你要把一个像素的 RGB 值整体压暗一半,常规写法是value / 2,但很多 C 语言图像库里直接写value >> 1,因为移位比除法快,尤其在嵌入式处理器上差别非常明显。
但是这里有一个重要区别:右移分逻辑右移和算术右移。无符号数做右移,高位移 0,这叫逻辑右移;有符号数做右移,高位移的是符号位,这叫算术右移。比如 8 位下,-16 的二进制是 11110000,算术右移一位得到 11111000,也就是 -8,这符合“除以 2”的预期。逻辑右移则不管符号,高位移 0,得到 01111000,也就是 120,完全是另一码事。
这个区别在实际编程里非常容易翻车。用 C/C++ 时,对 int 做右移,编译器用的是算术右移;对 unsigned int 做右移,则用逻辑右移。如果你拿一个有符号负数去做位运算,又期望它是无符号行为,结果就可能是天文数字。
3.3 除法的难点:为什么除法这么慢
相比乘法,除法在二进制里要麻烦得多。它的经典算法是长除法,类似十进制除法,但每一步都涉及比较、减法、移位,需要更多时钟周期。所以硬件电路设计者通常会尽量避免做除法,能转成乘法就转成乘法,能移位就移位。
在实际编程里,“除以 2 的幂”可以轻松转成右移,但除以任意数怎么办?编译器会做“魔数乘法”,也就是把除法转成乘以一个精心选择的常量,再用移位修正。你写a / 7时,很多编译器在汇编层面并不是真的做除法,而是做一次乘法和几次移位,因为乘法器比除法器快得多。
这个细节平时写高级语言注意不到,但在性能敏感代码里就很重要。比如你要在循环里大量求平均值,sum / n如果 n 恰好是 2 的幂,写成sum >> k会快很多。不过,对于可读性和维护性,我建议非必要不到处手写优化,毕竟现代编译器的优化能力已经很强,乱优化反而可能干扰它的判断。
4. 位运算:与、或、非、异或的工程魔法
4.1 四个基本操作的规则和直觉
位运算的规则不复杂,但很多人只是背下来,没有真正形成直觉。
与运算(&):相同位都是 1,结果才是 1。这个操作用于“提取”或“保留”某些位。它本质上像一个过滤器。
或运算(|):相同位只要有一个是 1,结果就是 1。这个操作用于“设置”某些位,把特定位置 1。
非运算(~):按位取反,0 变 1,1 变 0。注意它不只是改数值位,符号位也会跟着变,所以 ~5 在 32 位环境下不等于 -5,而是 -6,因为 ~000...0101 = 111...1010。这个点初学者特别容易踩。
异或运算(^):相同位取 0,不同位取 1。这个操作有几个极强的性质:一个数异或自己等于 0,异或 0 等于自己,且满足交换律和结合律。这些性质后面实战阶段非常有用。
我把这四个操作集中在一张表里,方便对照:
| 操作 | 规则 | 典型用途 |
|---|---|---|
| 与 & | 两个 1 才是 1 | 提取位、清除位、判断某一位 |
| 或 | | 有 1 就是 1 | 设置位、组合标志 |
| 异或 ^ | 不同才为 1 | 翻转位、无临时变量交换 |
| 非 ~ | 0 变 1,1 变 0 | 取反屏蔽字 |
4.2 位掩码:用 int 管理 32 个开关
位掩码是我在实际项目中用到最多的二进制运算技巧,在权限系统、配置项管理、网络协议标志位里到处都是。核心思路就是用整数的每个 bit 代表一个开关或标志,位运算来设置、清除、判断。
举个例子。一个用户权限系统,用低 4 位表示四种权限:
- 第 1 位(值 1):可读
- 第 2 位(值 2):可写
- 第 3 位(值 4):可执行
- 第 4 位(值 8):可删除
如果一个用户的权限值是 7,二进制就是 0111,说明他拥有可读、可写、可执行,但没有删除权限。现在要判断他能不能写,做法很简单:
if (perm & 2) { // 有可写权限 }要给他加上删除权限:
perm = perm | 8; // 第 4 位置 1要取消他的可写权限:
perm = perm & ~2; // 先把 2 取反变成 111...1101,保住其他位,只把第 2 位清 0这个模式的好处是:一个 int 就能搞定几十个权限项,数据库里也只存一个整数,传输和存储开销极小。我在游戏服务器项目里见过用 8 个 int 管理两百多个功能开关的,读配置、同步给客户端,一套位运算就搞定,比用几十个布尔变量优雅太多。
4.3 异或的三大经典应用
异或在工程里的应用很有意思,单独拎出来说一说。
第一个是交换两个整数,不用临时变量:
a = a ^ b; b = a ^ b; a = a ^ b;这个技巧在很多面试题里出现过。原理就是异或的性质:a ^ b ^ b = a。第二行b = a ^ b,此时左侧的 a 已经是原来的 a^b,再异或 b,等于 a;第三行a = a ^ b,左侧 a 是原来 a^b,右侧 b 已经是原来的 a,异或结果等于 b。看起来巧妙,但实际工程中我不太推荐这么写,因为可读性差,而且如果 a 和 b 指向同一个内存地址,结果会直接变 0。不过理解它,能帮你深刻记住异或性质。
第二个是找“只出现一次的数”。给定一个数组,所有数都出现两次,只有一个数出现一次,用异或一次遍历就能找到:
int result = 0; for (int num : nums) { result ^= num; } // 最终 result 就是那个唯一的数因为相同的数异或为 0,0 异或其他数等于那个数自身。
第三个是原地加密。数据块异或一个密钥,再进行一次异或就能还原。这是很多简单对称加密的雏形,也是 CRC 校验、RAID 磁盘阵列里校验盘数据恢复的基础原理。RAID 5 写数据时计算校验,某块盘挂了,把其他盘和校验盘异或就能恢复数据,底层就是这个简单的位运算。
5. 实战:从算法题到工程排错
5.1 经典技巧速查:奇偶判断、2 的幂、位计数
这里整理几个我在笔试、实际代码里反复用到的二进制运算技巧,每个都值得背下来:
- 判断奇偶:
if (n & 1)。因为二进制最低位为 1 一定是奇数,0 一定偶数。这个方法比n % 2快,而且更直观。 - 判断一个数是不是 2 的幂:
n > 0 && (n & (n - 1)) == 0。原理是 2 的幂只有一位是 1,减 1 后所有低位变 1,二者相与必然为 0。我第一次看到这个技巧时有一种“原来如此”的感觉,它比循环除 2 高效太多。 - 取最低位的 1:
n & (-n)。这个在树状数组(Fenwick Tree)里非常核心。负数的补码取反加 1,会让最低位的 1 保持不变,而其他位全部相反,相与就能把这一位单独提出来。 - 统计二进制中 1 的个数:最朴素的方法是逐位移位判断,但更经典的技巧是
n = n & (n - 1)每次消掉最低位的 1,循环次数等于 1 的个数。
这里我特别想强调n & (n - 1)这个式子。它几乎是我解决位运算问题最常用的利器:消掉最低位的 1。判断 2 的幂、统计 1 个数、判断两个整数是否只有一位不同,全都能用它。
5.2 真实场景:权限系统与标志位管理
前面讲过权限系统的基本思路,这里补充一个我在一个后端服务里实际用过的例子。当时服务要管理多个推送渠道:短信、邮件、站内信、Webhook,每个用户都可以配置接收哪些渠道。我用一个 int 存储推送偏好:
#define CHANNEL_SMS (1 << 0) // 1 #define CHANNEL_EMAIL (1 << 1) // 2 #define CHANNEL_INAPP (1 << 2) // 4 #define CHANNEL_WEBHOOK (1 << 3) // 8用户设置时收到的是一个组合值,比如 5 表示短信 + 站内信。发送通知时判断:
if (user.channels & CHANNEL_SMS) { send_sms(...); } if (user.channels & CHANNEL_EMAIL) { send_email(...); }这套做法的好处不仅是省存储,更关键的是序列化简单。前端传一个整数,后端存一个整数,数据库查询时一个字段搞定,不需要为每个渠道单独建列或建关联表。等到需求新增一个渠道,只需要增加一个宏,不需要改表结构。
还有一点容易被忽视:当你用一个 int 管理多个选项时,初始化值一定要明确。很多人喜欢直接把配置字段初始化为 0,但在权限系统里,0 往往表示“没有任何权限”,这是有意义的。真正要警惕的是默认值全开导致的越权风险。我在一个项目里就遇到过测试环境默认权限字段没初始化,导致本地调试时所有接口全都放行,上线前排查才被发现。
5.3 二进制运算在图像与音频处理中的应用
如果只把二进制运算用在算法题里,格局有点小了。在图像和音频处理里,位运算可以说是性能优化的老朋友。
比如一张 32 位的像素,常见的 ARGB 格式,一个整数里同时存了透明度(Alpha)、红色、绿色、蓝色四个通道,每个通道占 8 位。你要把红色通道的值取出来:
int pixel = 0xA5B6C7D8; int red = (pixel >> 16) & 0xFF;这里先右移 16 位把红色通道移到最低 8 位,再用& 0xFF把高 24 位全部清零。注意0xFF本身就是二进制掩码00000000000000000000000011111111,这又是一个位掩码的实战场景。
如果要单独修改蓝色通道的值,可以先把旧的蓝色清掉,再放新的进去:
pixel = (pixel & ~0xFF) | (newBlue & 0xFF);左边清零,右边塞入新值,两个位运算一次搞定。
在音频处理里,混音也经常用位运算。比如两个采样值相加可能会溢出,常见的做法是取平均值,也就是(a + b) >> 1,保证结果落在有效范围内。这段代码虽然简单,但在实时音频流处理里,每个采样点省几个时钟周期,累积起来对延迟的影响就很可观了。
6. 常见错误与调试技巧实录
6.1 和算术运算混淆的坑
二进制运算最常见的问题,就是把位运算和逻辑运算、算术运算搞混。最典型的是把&&和&混用。在 C 语言里,&&是逻辑与,只返回 0 或 1;&是按位与,返回的是逐位操作的结果。比如判断奇偶,有人写成:
if (n && 1) // 这个是判断 n 是否为真,再和 1 逻辑与,几乎永远为真正确的写法必须是n & 1。这个错误在编译时不报错,逻辑结果又很难一眼看出来,属于隐蔽性比较高的 bug。
另一个常见误区是把加法当成位运算。很多人知道x << 1相当于乘 2,就以为所有位运算都能替代算术运算,这是不准确的。移位虽然快,但它忽略了进位,比如 3 << 1 结果是 6,因为 3×2 恰好是 6,但如果是 3 + 1 这种非移位能表达的运算,就必须老老实实用加法器。
6.2 符号位与位扩展问题
位扩展是另一个高频坑。把一个 8 位整数赋值给 16 位整数时,高 8 位怎么填?如果是无符号数,直接补 0;如果是有符号数,要扩充符号位,也就是所谓的符号扩展。
举个例子,一个有符号 8 位数0b10000000表示 -128,扩展成 16 位后,应该变成0b1111111110000000,而不是0b0000000010000000。如果你用的是 Java 或 C 语言,编译器默认处理了这件事,但如果你在网络协议里手动拆包组包,从一个字节读出一个有符号数后再存入 int,就很有可能会出现符号扩展导致的巨大数值。
我在一个网络协议解析的项目里就遇到过这个。当时从字节流里读出一个温度值,低字节直接赋给了 int,结果高字节全是 1,温度值变成了一个很大的负数。排查到最后,就是忘了做符号扩展或者强制转换。解决方案是先把字节变量提升到 int,然后判断符号位,需要时手动填充高位。
6.3 一次变量交换引发的血案
前面提到过异或交换变量的技巧,我要用一个真实教训强调它的危险性。当时在一个 C 语言排序算法里,我用了异或交换两个数组元素:
a[i] ^= a[j]; a[j] ^= a[i]; a[i] ^= a[j];看起来没问题,但当 i == j 时,三个表达式操作的是同一个内存地址。第一次执行后,这个位置的值就变成 0;第二次异或还是 0;第三次依然是 0。结果数组里那个元素直接被清零,排序结果彻底乱了。
从那以后我在工程代码里再也没用过异或交换,一律用临时变量。这个技巧的价值在于帮助理解异或性质,而不是用在生产代码里。类似的“炫技”写法还有很长的三元运算符嵌套,能把人绕晕,调试时更是噩梦。
6.4 调试位运算问题的实用工具
如果你正在排查一个位运算相关的 bug,我强烈建议按这个顺序做:
- 先把十进制数打印成二进制看。C 语言里可以自己写一个打印函数,用
printf("%d", (n >> i) & 1)逐位输出。Python 则直接bin(n)。 - 缩小范围。给定一个固定输入,手动在纸上推到某个中间结果,再和代码实际运算结果对比,定位是哪个操作出了问题。
- 检查类型。确认无符号和有符号是否混用,尤其是负数右移和位扩展。
- 警惕编译器优化。在 release 模式下列印语句可能被优化掉,可以用
volatile或者临时文件输出中间结果。
7. 二进制运算的心智模型与个人体会
写到这里,我想分享一个这几年积累下来的习惯。每次需要分析一段位运算代码时,我不会直接盯着十进制的数值想,而是先在草稿纸上把关键值写成固定位数的二进制形式,然后按位对齐看。比如 5 和 3 的与运算,你写0101 & 0011一眼看出结果是 0001,比在脑子里算十进制的 5 & 3 要直观无数倍。位运算本质上就是“逐位游戏”,你对齐越工整,越不容易出错。
另外,我建议那些做上层业务开发的朋友,也不要觉得二进制运算离自己很远。很多看似高级的问题,比如哈希冲突调优、布隆过滤器、分布式 ID 生成、数据库索引设计,底层都离不开位运算的思想。你多花一点时间把 AND、OR、XOR、移位这些基础概念练到条件反射的程度,再看这些技术资料,会发现底层原理一下子贯通了。
最后再说一个实操建议。平时刷题或者写代码时,凡是遇到整数取半、乘 2、判断奇偶、取模这类操作,都试着先想一下能不能用位运算表达,再对照普通写法判断可读性和性能差异。这样练久了,二进制运算就不再是需要刻意背的公式,而是构建在直觉上的一种计算方式。等你哪天真要调一个诡异的底层问题,脑子里能瞬间蹦出好几种排查思路,这种积累就值回票价了。