位运算与进制转化:从原理到工程落地的实战指南
发布时间:2026/9/7 19:59:33 作者:尧图编辑部 阅读量:1,286

2. 开篇为什么今天我非要聊位运算先说个我自己的经历。前两年在调一个底层图像解码的模块数据按位打包一个像素占 10 bit跨字节边界又是大端存储。当时排查一个问题日志打到怀疑人生最后发现就是一处右移操作没考虑符号位一个把高位补成了 1色彩通道直接错乱。那次之后我悟了一件事位运算这东西看起来是面试题实际上它藏在协议解析、图像处理、权限系统、性能优化这些日常业务的每一个角落。我当时在项目里做的最多的事情之一就是跟二进制数据打交道。你别看平时写业务 CRUD 用不上几个、|但只要你碰到底层 SDK、嵌入式逻辑、网络协议包解析或者哪怕只是用一个开关配置项位运算都会主动来找你。更不用说进制转化你发现没有网上随手搜“位运算”第一屏出现的几乎都是a ^ a 0这类花活但真正能落地的案例少之又少。这篇文章我打算换个讲法不整虚的。我会从位运算的底层逻辑讲起把它掰开揉碎然后带你看清楚进制转化到底在转什么东西最后全部落到你能直接抄走的实操代码里。不管你是刚学数据结构的在校生还是工作几年想补基础的开发这篇内容都能帮你在最短时间内把这些概念焊死在脑子里。2. 先搞清楚位运算的本质2.1 位运算到底在算什么位运算之所以让很多人觉得“绕”是因为我们平时用的十进制思维根深蒂固。你看5 3 8脑子里自动反射出十进制加法的过程。但位运算不是它操作的直接就是二进制位每一位只有 0 和 1 两种状态。举个例子5 3这个表达式你要先把它转换成二进制5的二进制是01013的二进制是0011按位与的规则是两个位都为 1 时结果才为 1否则为 0所以0101 0011的结果就是0001也就是十进制1。这个过程你一旦在脑子里形成画面整个位运算就豁然开朗了。它不是在“算数”它是在“按规则逐位比较”。这就像你在处理两个开关面板每个面板上有 4 个开关按位与就是你同时观察两个面板的同一个开关位只有当两个开关都是闭合状态时输出才是 1。这个类比虽然简单但非常本质。再比如左移运算num n本质是把num的所有二进制位整体向左移动n位低位空出来的位置补 0。右移分两种逻辑右移高位补 0算术右移高位补符号位。这个区别在设计底层协议时尤其关键——你解析到一个有符号整数时如果用了逻辑右移正负数一反转数值就全错了。2.2 六种基本位运算逐一拆解我们把这六种操作符逐个过一遍注意这里我会把底层行为和实际应用场景绑定在一起这样记起来才不容易忘。按位与规则是“同 1 才 1”。它的经典应用场景是提取特定位。比如你想拿到一个整数低 8 位的数据直接num 0xFF就搞定了。这在处理 RGB 颜色值时特别常用一个 32 位颜色值中R、G、B、A 各占 8 位你想单独拿绿色通道就(color 8) 0xFF。这个操作的本质是先右移把目标位段移到最低位然后通过与操作把高位置零。按位或|规则是“有 1 就 1”。它的核心用途是把某些位置为 1同时不影响其他位。比如num | 0x0F就是把低 4 位置为 1。在权限系统里这种操作非常常用。比如读权限是1 0写权限是1 1执行权限是1 2你给用户授权时直接把这些值或起来一个整数就存下了三种权限的组合。按位异或^规则是“相同为 0不同为 1”。很多人觉得异或是位运算里最花哨的一个其实不然。异或最大的特性是自反性a ^ b ^ b a。这意味着用a ^ b可以加密数据再用b异或回去就能解密。在嵌入式通信里CRC 校验也大量使用异或运算。另一个常用技巧是不用临时变量交换两个数a a ^ b; b a ^ b; a a ^ b;。这个技巧面试爱考但实际工程里我建议还是老老实实用临时变量因为可读性太差了。按位取反~规则是把 0 变 11 变 0。注意它操作的是补码表示下的所有位。很多人第一次接触会愣住~5为什么结果是-6因为5的二进制是0000 0101逐位取反后是1111 1010。在补码表示法里最高位是符号位为 1 表示负数。负数用补码表示所以1111 1010要转成十进制需要先减 1 再取反得到0000 0110也就是 6加上负号就是 -6。这个特性在做掩码翻转时经常用到。左移num n等价于num * 2^n但要注意溢出。比如一个 8 位无符号数1111 1111左移 1 位变成1111 1110等于 254原来的 255 变成 254 相当于乘以 2 后溢出低 8 位。左移不会保留符号位也就是说负数左移可能变成正数这取决于溢出的情况。右移num n等价于num / 2^n向下取整。但正如前面说的对于有符号整数不同语言对高位补位的行为不同。C/C 和 Java 的是算术右移高位补符号位而 JavaScript 的是逻辑右移高位补 0。在 Python 中只有但在部分场景下它的行为更接近算术右移不过因为 Python 的整数是任意精度的左移右移的表现跟固定宽度语言会有细微差异。这些语言差异是很多 bug 的来源后面我会专门讲。3. 进制转化的核心逻辑3.1 为什么是二进制、八进制、十六进制进制转化的本质是把同一个数值在不同计数制下的表示形式进行转换。你口袋里有 10 块钱你可以说“我有十块钱”也可以说“我有 1010 块钱”二进制说得都没错只是计数方式不同。计算机为什么用二进制因为物理上最容易实现两个稳定状态高电平和低电平。但二进制写出来太长了比如十进制255二进制要写 8 位11111111如果数值大一点比如65535二进制就是111111111111111116 位眼都看花。所以工程里引入了八进制和十六进制作为二进制的“缩写形式”。八进制每 3 位二进制对应 1 位十六进制每 4 位二进制对应 1 位。为什么是 3 位和 4 位因为 2^382^416数学上刚好可以一一映射、无歧义地转换。所以你在看 Linux 权限、内存地址、颜色值、文件标志位时基本都是十六进制表示因为一个十六进制位就能精确表达 4 个比特位效率提升立竿见影。3.2 十进制转 N 进制通用的办法只有这一个网上教进制转化时经常列一堆规则什么“除 2 取余法”“乘 2 取整法”本质其实是一个东西整数的 N 进制转化用短除法小数的 N 进制转化用乘法取整法。整数部分的规矩是不断除以 N倒序读余数。比如十进制的29转二进制29 ÷ 2 14 余 114 ÷ 2 7 余 07 ÷ 2 3 余 13 ÷ 2 1 余 11 ÷ 2 0 余 1从下往上读余数得到11101所以29的二进制是11101。这个方法对任何 N 进制都通用你把 2 换成 8、16过程一模一样。小数部分的规矩是不断乘以 N顺序读整数位。比如十进制0.625转二进制0.625 × 2 1.25取整数 1小数部分剩下 0.250.25 × 2 0.5取整数 0小数部分剩下 0.50.5 × 2 1.0取整数 1小数部分为 0从上往下读整数位得到101所以0.625的二进制是0.101。很多人会背口诀“整数往右写小数往左写”但我建议你理解背后的原因整数部分每次除以 N本质是看这个数能拆成多少个 N 的幂小数部分每次乘以 N本质是看这个数里包含了多少个 N 的负数次方。3.3 二进制转十六进制工程里最常用为什么我特别强调二进制转十六进制因为在协议分析、内存调试、算法日志打印时十六进制几乎是无处不在的。你打印一个二进制数据包时如果按二进制打印一行几百位根本没法看但按十六进制打印每 4 位合并成一个字符瞬间就清爽了。转换规则极其简单从右往左每 4 位二进制一组直接映射到十六进制字符。不足 4 位的在高位补 0。例如10111011拆成两组1011和10111011对应十六进制的B所以结果是BB反向转换也一样每个十六进制字符拆成 4 位二进制。二进制转十六进制的映射表我建议直接背下来虽然你说“我能算”但实际调试时看到0x3F能马上反映出来是0011 1111会快很多。这张表你在无数跟网络协议和编码相关的代码里都会用到。十六进制二进制十进制000000100011200102300113401004501015601106701117810008910019A101010B101111C110012D110113E111014F1111154. 位运算与进制转化的实战应用4.1 权限系统的经典实现一个整数存多个开关权限系统大概是位运算最经典的落地场景了。很多刚工作的同学看到一堆1 2这样的代码会觉得莫名其妙其实背后的逻辑非常清晰。假设你有一个系统需要管理用户的读、写、执行三种权限。最笨的办法是定义三个布尔变量canRead、canWrite、canExecute。但这样存储和传参都很麻烦尤其是在数据库里存权限列表时三个字段和一个字段的区别是明显的。用位运算你只需要一个整数public class Permission { public static final int READ 1 0; // 1 public static final int WRITE 1 1; // 2 public static final int EXECUTE 1 2; // 4 public static void main(String[] args) { // 授权读 写 int perm READ | WRITE; // 检查是否有读权限 boolean hasRead (perm READ) ! 0; // 追加执行权限 perm | EXECUTE; // 移除写权限 perm ~WRITE; System.out.println(perm); } }注意这几个关键操作perm | READ把读权限对应的位置为 1其他位不受影响。因为 READ 的二进制位只有一个位置是 1其余是 0OR 操作等价于“把这个位置成 1”。perm ~WRITE先取反 WRITE得到一个“除了写权限位为 0其他位全为 1”的掩码然后做 AND写权限位就被清零了。(perm READ) ! 0判断某位是否为 1。这是权限校验的核心逻辑也是 Linux 系统权限模型的原型。通过这种方式权限信息可以只占一个 integer 字段数据库里存一个数字业务层用位运算解析性能高、存储省、逻辑统一。你去看很多开源项目里的配置项比如Options参数基本都是这种玩法。4.2 用位运算做奇偶判断和 2 的幂判断n % 2和n 1在功能上等价但n 1的底层执行速度更快因为它直接跳过除法指令。现代编译器对于n % 2在编译器层面也会优化成位运算所以性能差异并不明显但在某些解释型语言或嵌入式中位运算写法仍然有优势。判断一个数是不是 2 的幂位运算一行解决bool isPowerOfTwo(int n) { return n 0 (n (n - 1)) 0; }这个技巧的原理也值得记住一个 2 的幂的二进制表示恰好只有一位是 1其余全是 0。比如8是0000 10008 - 1是0000 0111。两者做 AND刚好每一位都不同结果是 0。而任何非 2 的幂的数二进制中至少有两个 1n (n - 1)的结果不可能为 0。顺带一提n (n - 1)本身也是一个非常常用的操作——它能把n最右边的那个 1 清除掉。这在统计二进制中 1 的个数时特别高效每执行一次就去掉一个 1统计次数等于 1 的个数。4.3 使用异或寻找唯一出现一次的数字这是面试题里被问烂了的题目但每一次我都会推荐它因为它是异或自反性的绝佳演示。题目是这样的一个数组中只有一个数字出现一次其他数字都出现两次找出这个数字。常规解法是用哈希表计数时间复杂度 O(n)、空间复杂度 O(n)。但用异或空间复杂度直接降到 O(1)def find_single(nums): result 0 for num in nums: result ^ num return result原理就是a ^ a 0和0 ^ a a。所有成对出现的数字会互相抵消最后剩下的就是唯一出现一次的那个数字。这个操作的执行过程在二进制层面特别直观你可以拿[2, 3, 2]手动推一遍0 ^ 2 2二进制0000 ^ 0010 00102 ^ 3 1二进制0010 ^ 0011 00011 ^ 2 3二进制0001 ^ 0010 0011多推几次你就能体会到异或在位层面的互换和抵消逻辑。这个技巧在协议校验、数据恢复、磁盘阵列校验等场景中都有直接应用不只是面试题。4.4 RGB 颜色通道的拆解与合成图像处理里位运算和进制转化几乎是标配操作。最常见的例子是对 32 位 ARGB 颜色值的解析与合成。一个像素颜色通常用一个 32 位整数表示从高到低依次是 Alpha、Red、Green、Blue每通道 8 位。从整数中拆出各通道int color 0xAABBCCDD; // Alpha0xAA, Red0xBB, Green0xCC, Blue0xDD int alpha (color 24) 0xFF; int red (color 16) 0xFF; int green (color 8) 0xFF; int blue color 0xFF;这里 0xFF的作用就是只保留低 8 位。因为右移之后目标通道正好落在最低 8 位而高 24 位还是旧数据必须用按位与把高位置零。反向合成时用按位或和左移int newColor (alpha 24) | (red 16) | (green 8) | blue;这个过程本质上就是进制转化的一个实际应用把 4 个 8 位数值拼接成一个 32 位数值。如果你把每个通道当成一个“两位十六进制数”那合成过程就是把AA、BB、CC、DD拼接成AABBCCDD。搞懂这个你再看图像编码、音频采样数据打包思路都是一样的。4.5 位图去重布隆过滤器的应用位运算在大数据场景还有一个非常重要的应用位图Bitmap和布隆过滤器Bloom Filter。布隆过滤器的核心思想是设计一个位数组每个元素通过多个哈希函数映射成多个位置将这些位置置 1。判断某个元素是否存在时看映射位置是否全部为 1只要有一个为 0肯定不存在如果全部为 1大概率存在。位运算在布隆过滤器中承担的是什么工作就是给位数组的某个位赋值和取值。假设底层是一个byte[]数组你要把第N位设为 1byte[] bits new byte[1024]; // 把第 n 位置为 1 void setBit(int n) { int byteIndex n / 8; int bitIndex n % 8; bits[byteIndex] | (byte) (1 bitIndex); } // 判断第 n 位是否为 1 boolean testBit(int n) { int byteIndex n / 8; int bitIndex n % 8; return (bits[byteIndex] (1 bitIndex)) ! 0; }这里的byteIndex和bitIndex计算本质上就是一次十进制到二进制位号的转化。n / 8表示这个位落在哪个字节里n % 8表示它在字节内的第几位。你看进制转化和位运算是非常紧密地结合在一起的。5. 避免位运算写法的那些坑5.1 有符号数和右移导致的符号扩展问题我在开篇提到的图像解码 bug就是符号右移引起的。C/C 里对于有符号数是算术右移二进制高位补符号位对于无符号数则是逻辑右移高位补 0。Java 也一样是有符号右移是无符号右移。在实际业务中最容易出现的问题就是把一个byte类型先右移再与0xFF做 AND。很多人会写成byte b (byte) 0x80; // b 的二进制是 1000 0000但因为符号位为1实际值是 -128 int result b 4; // 这里会得到 0xF8而不是 0x08原因就是b先被提升为int类型符号扩展为0xFFFFFF80再右移 4 位高位补 1得到0xFFFFFFF8转成十六进制就是F8。如果希望得到不含符号扩展的结果必须先做b 0xFF再右移或者直接用。这个坑几乎每个写底层代码的人都踩过一次。我的排查习惯是一旦遇到跟字节解析相关的异常数据第一反应就是看符号扩展。这也是为什么我建议在 C/C 中解析协议数据时有符号和无符号要分得清清楚楚能声明无符号就声明无符号。5.2 运算符优先级比优先级低这是另一个高频踩坑点。在 C/C、Java 里的优先级高于所以你写if (perm READ 1) { ... }编译器会先执行READ 1然后拿perm去 AND 这个布尔结果。这行代码实际的行为完全不是你想要的。正确写法是if ((perm READ) READ) { ... }我见过很多新手在这个问题上栽跟头甚至一些老手在代码 review 时也会盯着看两秒。最保险的解决方案是位运算表达式一律加括号不要依赖优先级。尤其在混合了!、、、|的表达式里括号多一点不是坏事可读性更好也避免隐患。5.3 不同语言的整数宽度差异C/C 的int通常是 32 位Java 的int固定 32 位Java 的long是 64 位Python 的整数是任意精度。这意味着同样一段位运算代码在不同语言里的溢出行为可能完全不同。比如左移溢出int a 0x7FFFFFFF; int b a 1; // 结果变成负数因为最高位被置 1Java 里也同样会得到负数。但在 Python 中a 0x7FFFFFFF b a 1 print(b) # 4294967294不会溢出因为 Python 不会限制整数宽度它自动扩展。这个差异在日常写算法题时影响不大一旦涉及底层协议实现或跨语言对接就很容易出问题。我建议在涉及固定位宽的协议实现时统一使用 Python 的struct模块或者 C 语言的uint32_t、int8_t等明确类型尽量不要隐式依赖语言的默认宽度。5.4 进制转化浮点的精度陷阱小数转二进制时容易产生无限循环。比如十进制的0.1转成二进制是0.000110011001100...无限循环。这也就是为什么你在 Python 里执行0.1 0.2得到0.30000000000000004而不是0.3。这个问题的根源在于二进制无法用有限位数精确表示 0.1。计算机里的浮点数是有限位数的它只能取近似值。理解了进制转化的这一层你就能理解浮点数误差的真正来源而不是每次遇到0.1 0.2的问题就只会背“不要直接用浮点数比较相等”的结论。处理方案不外乎几种用Decimal、用整数表示最小单位、比较时允许误差范围。但重点是知道为什么会出现这样的误差比记住解决方案更有价值。6. 工具与调试技巧6.1 在代码中快速打印二进制调试位运算问题时最快的办法就是把数值以二进制形式打出来。不同语言各有语法# Python print(bin(29)) # 0b11101 print(f{29:08b}) # 00011101// Java System.out.println(Integer.toBinaryString(29)); System.out.println(String.format(%08d, Integer.parseInt(Integer.toBinaryString(29))));// C printf(%08b\n, 29); // 注意%b 不是标准 C99GCC 支持但可移植性差 // 更稳妥的方式是手动循环实际调试时我习惯直接用一个十六进制数字做输入比如0xF0打印二进制后一眼就能看出位的位置。如果你在处理一个 32 位数值建议格式化打印时把高位 0 补齐否则0101和101容易看混。6.2 我常用的一个换算了表工具人在现场时没有 IDE 也能算但出错概率太高。我自己的方法是平时写代码时把二进制到十六进制到十进制的映射表记熟但真到调试时直接用系统自带的计算器切到“程序员”模式。macOS 的计算器、Windows 的计算器都有这个模式可以直接输十进制看二进制、十六进制、八进制也可以反向输入。省时省力避免心算出错。6.3 快速心算技巧几个常见值有些数值的二进制形态出现频率太高我建议直接背下来0xFF1111 1111 2558 位全 1可以当 8 位掩码用0x0F0000 1111低 4 位掩码0x801000 0000最高位为 1常用来探测符号位0x80000000 32 位整数的符号位(1 n) - 1 低 n 位全是 1其中(1 n) - 1这个公式尤其好用。比如你想生成一个低 8 位全为 1 的掩码(1 8) - 1 255直接就能得出0xFF完全不用手动写 8 个 1。7. 从位运算和进制转化延伸出去位运算和进制转化不是孤立的知识点它们像地基上面盖着很多高楼。我把几个常见的延伸方向列出来你顺着这个脉络往下学会特别顺。网络协议解析IP 地址、端口、TCP 头、UDP 头全部是按位定义的。比如一个 16 位的端口号在字节流里拆分时就要考虑大端小端。你解析一个 IPv4 头版本号占 4 位IHL 占 4 位这两个字段甚至共用一个字节必须用位运算分别取出来。这就是最典型的“位级协议”应用。图像与音视频编码JPEG、PNG、H.264 这些编码格式里大量数据是按位打包的。一个 10 bit 的像素值可能横跨两个字节解码时要用移位和掩码拼接。我之前做的那个图像解码模块核心工作就是拆位和装位。压缩算法与哈希布隆过滤器、位图索引、Zstd、LZ4 这些算法底层都是对二进制位的高效操作。你理解了位运算再看这些算法的核心代码会有一种“原来如此”的感觉。国密算法与加密SM2、SM3、SM4 这些密码算法大量使用循环移位、异或、按位与等操作。密码学对位运算的依赖程度极高你去看 SM4 轮的轮函数几乎全是位操作。当然实际工程中不要自己实现加密算法直接用现成库但理解原理时位运算是必修课。嵌入式与单片机寄存器操作基本就是位操作。置位、清零、翻转、连续赋值每一行都是在跟具体位打交道。嵌入式工程师写代码时脑子里几乎时刻有一张寄存器的位分配图。这些方向都指向同一个结论位运算和进制转化不是面试用的花架子而是底层开发绕不开的核心能力。我个人还有一个体会当你真正熟练了二进制思考方式之后解决问题的速度会明显加快。以前我排查一个问题需要各种打印和推理现在看到某一段数据异常脑子里会本能地猜测“是不是位偏移了一位”“是不是符号扩展错了”“是不是字节序搞反了”定位速度比之前快了很多。这就像学骑自行车没学会之前总觉得难学会之后它就成了你身体的一部分想忘都忘不掉。希望这篇文章能帮你跨过这个拐点。