ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

二进制计算与进制转换:从补码到位运算,彻底搞懂计算机底层原理

二进制计算与进制转换:从补码到位运算,彻底搞懂计算机底层原理 老读者都知道我平时聊的大多是工程落地层面的东西但今天想回头补一块所有人都绕不开的基础二进制计算及转换。起因是上周有朋友在群里问我为什么0.1 0.2在很多语言里不等于0.3也有人问 Linux 权限里的755到底是怎么来的我答完之后发现大家并不是不会用工具而是对“数在机器里到底长什么样”缺一套完整直觉。这篇文章就把二进制相关的计算和转换掰开揉碎讲一遍适合刚接触编程的学生、写业务逻辑的开发者、偶尔碰主机的运维朋友以及所有想搞懂底层原理的人。二进制看起来很简单无非是0和1但把它彻底想清楚之后后面再看补码、位运算、浮点数精度、CRC 校验这些概念都会顺很多。下面我不按教科书顺序讲而是从“为什么计算机非要选二进制”开始一步步带你把二进制加减乘除、进制互转、负数表示、位运算、代码实战全过一遍。1. 先从“为什么是二进制”说起1.1 计算机为什么认二进制很多人第一反应是“二进制简单”但简单并不是真正理由。更本质的原因是计算机底层硬件是由数不清的晶体管组成的晶体管工作在开关状态要么导通、要么截止天然就适合表达两种状态。你可以把每个晶体管想象成一个只有“开”和“关”两个档位的灯泡一个灯泡只能表示0或1但把一排灯泡组合起来就能表达很大的数。为什么不直接用十进制让电路表示 0 到 9 十个数字技术上不是完全做不到但代价非常高。要让一个电路可靠地区分十个电压等级对制造工艺、噪声容限、功耗都是巨大挑战而只要区分“低电压”和“高电压”两个状态误判概率就低得多。所以二进制不是数学家拍脑袋定的而是物理实现里性价比最高的选择。再加上布尔代数里的与、或、非恰好能映射成电路逻辑计算机用二进制从硬件到算法都是顺理成章的事。1.2 二进制的四则运算规则既然数是二进制存的那计算也要遵循二进制规则。这里把加减乘除都过一遍尤其是除法很多人接触少后面看 CRC 校验时会用到。加法规则是“逢二进一”000011110同时向高位进1。比如1011 0111 ------ 10010从最低位开始110进1第二位1111再进1第三位0110再进1第四位1010进1最后得10010换算成十进制就是18跟117的结果一致。减法规则是“借一当二”0-001-011-100-1不够减要向高位借1借来的1当作2来用。比如1001 - 00111001 - 0011 ------ 0110最低位1-10第二位0-1不够从第三位借1变成10-11第三位被借走后就剩0第四位1-01最后结果是0110也就是6和十进制9-36对得上。乘法本质是“移位相加”。比如101 × 11101 × 11 ------ 101 101 × 最低位的1 101 101 × 次低位的1左移一位 ------ 1111把每一位乘数单独拿出来跟被乘数相乘后左移对应的位数最后加起来1111就是十进制15。二进制除法跟十进制长除法类似但试商更简单每位数只有0和1两种可能。我以1101 ÷ 0101为例也就是十进制13 ÷ 51101 ÷ 0101 第一步取被除数最高位 1小于 101商位写 0 第二步取前两位 11仍小于 101商位写 0 第三步取前三位 110大于等于 101商位写 1做减法 110 - 101 001 第四步把被除数最后一位 1 拉下来得到 0011仍小于 101商位写 0 最后得到商 00102余数 00113注意这里每一步比较的都是“当前剩余的被除数片段”和“除数”的大小够除就上1不够就上0。后面的 CRC 校验核心用的就是这种二进制除法不过它用的是不进位的模 2 除法本质可以理解成逐位异或后面再细说。2. 进制转换绕不开的基本功2.1 整数转换从“除基取余”到“按权展开”十进制整数转二进制最常用的方法是“除 2 取余逆序排列”。比如把25转成二进制25 ÷ 2 12 余 1 12 ÷ 2 6 余 0 6 ÷ 2 3 余 0 3 ÷ 2 1 余 1 1 ÷ 2 0 余 1把所有余数从下往上排得到11001。验算一下11001按权展开是1×16 1×8 0×4 0×2 1×1 25正确。反向操作“二进制转十进制”就是“按权展开”从最低位开始权重分别是1, 2, 4, 8, 16...也就是2^0, 2^1, 2^2...。拿到一个二进制数先写上每一位的权重再相乘累加就行。比如101101×16 0×8 1×4 1×2 0×1 22负数转换时需要注意不能直接把负号丢掉再转。通常要先确定用几位存储然后转成补码形式这部分我放到后面专门讲。做题时如果题目没规定位数一般只说“二进制表示”就默认是绝对值但一旦涉及计算机存储必须考虑符号位和补码。2.2 小数转换乘 2 取整以及精度陷阱小数部分和整数不一样十进制小数转二进制用“乘 2 取整顺序排列”。以0.375为例0.375 × 2 0.75 取整数位 0 0.75 × 2 1.5 取整数位 1 0.5 × 2 1.0 取整数位 1顺序写出整数位得到0.011。验算一下0×1/2 1×1/4 1×1/8 0.375正确。问题在于不是所有十进制小数都能用有限位二进制小数表示。比如0.1转成二进制后是一个无限循环小数0.1 × 2 0.2 → 0 0.2 × 2 0.4 → 0 0.4 × 2 0.8 → 0 0.8 × 2 1.6 → 1 0.6 × 2 1.2 → 1 0.2 × 2 0.4 → 0 ...会一直循环下去。而计算机内存里的浮点数字长有限必须截断或按规则舍入。所以十进制小数转换为二进制有精度限制时确实需要考虑舍入IEEE 754 标准默认采用“就近舍入”规则。这也是0.1 0.2不等于0.3的根本原因不是计算 bug是二进制表示天生就存不满那个小数。2.3 二进制、八进制、十六进制三位和四位的“压缩”二进制写起来又长又容易看错所以程序员常用八进制和十六进制作为二进制的“缩写”。二进制转八进制每3位一组从最右侧开始分组左边不足三位补0。二进制转十六进制每4位一组同样从右侧开始。比如10110011转八进制010 110 011 → 2 6 3所以是 263 转十六进制1011 0011 → B 3所以是 0xB3这里要注意八进制每一位对应三位的范围是0~7十六进制每位对应四位范围是0~15。为了熟练最好把0~15的二进制表示记牢。我整理了一个常用速查表十进制二进制八进制十六进制00000001000111200102230011334010044501015560110667011177810001089100111910101012A11101113B12110014C13110115D14111016E15111117F反过来八进制或十六进制转二进制就是把每一位展开成三位或四位二进制。比如十六进制0x7F展开是0111 1111也就是127。很多寄存器文档、内存地址、颜色值、网络报文都是十六进制表示能快速展开成二进制去分析是调试基本功。2.4 手工转换的小技巧如果两个非十进制进制之间互转我建议先转成二进制这个“中间层”再转目标进制。比如八进制转十六进制不要直接硬算先每位八进制展开成三位二进制再按四位一组拼成十六进制正确率高很多。另外要熟记几个关键幂次2^0 1 2^4 16 2^8 256 2^10 1024 2^16 65536 2^20 1048576 2^32 4294967296这些数字在内存大小、IP 地址、文件大小、哈希计算里频繁出现。比如看到0x400能立刻反应出是1024看到0x10000知道是65536。这里还有一个很实用的例子IP 地址和子网掩码其实就是二进制的按位与运算。比如192.168.1.130/26/26表示子网掩码前26位是1换算成点分十进制是255.255.255.192。把 IP 展开成二进制后跟掩码做按位与就能算出网络地址是192.168.1.128。这种计算在排查网络问题时特别有用而它的本质就是二进制逐位与。3. 负数到底怎么存从原码反码到补码3.1 原码和反码为什么不流行上学时最早接触的是原码最高位当符号位0表示正1表示负其余位存绝对值。比如 8 位里5是0000 0101-5是1000 0101。原码看起来直观但计算机做减法会非常麻烦比如5 - 3电路要去判断两个数的符号再决定是加还是减甚至还要比较绝对值大小硬件复杂度直线上升。反码是对负数除符号位外按位取反比如-5的反码是1111 1010。反码解决了一部分问题但还存在“正零0000 0000”和“负零1111 1111”这种令人尴尬的表示零不唯一判断相等也麻烦。补码就是把这些坑都填掉的方案补码下的零只有一种表示而且加减法可以统一用加法电路实现不需要单独设计减法器。3.2 补码的计算与减法实例求补码最快的方法是“取反加一”符号位不参与取反逻辑其他位取反再加1。比如 8 位下求-5的补码5 0000 0101 按位取反 1111 1010 再加 1 1111 1011所以-5的补码是1111 1011。还有一个更直观的观察法从二进制数的最低位往左看找到第一个1这个1以及它右边的位保持不变更左边的位全部取反也能得到同样的结果。例如0000 0101从右往左第一个1是最低位本身保持1不变左边取反得到1111 1011。补码的最大优势是“减法变成加法”。以7 - 5为例在 8 位里可以写成0000 0111 7 1111 1011 -5 的补码 ---------- 10000 0010 最高位进位溢出丢弃丢掉超出 8 位的进位后结果是0000 0010也就是十进制2答案完全正确。这就是为什么计算机里只有加法器不单独造减法器所有a - b都可以翻译成a (-b)来做。但补码也有边界问题。8 位有符号数能表示的范围是-128 ~ 127。如果用127 10111 1111 0000 0001 ---------- 1000 0000结果1000 0000在补码规则里是-128而不是128。这是因为加法结果超出了可表示范围发生了溢出。这个例子值得记一辈子有符号整数溢出不是随机 bug而是二进制位数不够导致的自然结果。3.3 有符号无符号与常见误区同样一串二进制解释成有符号还是无符号得到的值可能差别巨大。比如0xFF作为无符号数255 作为有符号数8位补码-1日常开发里最常见的坑是把无符号类型和有符号类型混在一个表达式里算。C 语言里的隐式类型转换规则比较多容易把人绕晕。我的建议是涉及位操作、协议解析、内存布局时尽量用显式类型例如uint8_t、int32_t看到0x开头的魔数先想清楚它是有符号还是无符号再去做运算和比较。4. 位运算把二进制用于实战4.1 与或非异或的直观理解位运算直接作用在二进制位上的速度比普通加减乘除还快很多底层优化都靠它。四个基本运算按位与两个位都是1结果才是1用来取指定位。按位或|只要有一位是1结果就是1用来把某些位置成1。按位异或^两个位不同结果才是1相同就是0用来翻转位。按位取反~0变11变0。举个例子如果只想取一个字节的低 4 位可以用value 0x0F高 4 位被掩码挡掉。想把一个字节的最高位置成1可以value | 0x80。想翻转一个字节的所有位直接~value。4.2 移位比乘除法更快的乘 2 除 2左移一位等于乘以2右移一位等于整除以2。比如x 5x 1 10x 1 2。因为二进制里每向左移动一位所有位的权重都翻倍所以结果是乘2向右移动则是缩小权重。不过右移要分逻辑右移和算术右移。逻辑右移高位补0适用于无符号数算术右移高位补符号位适用于有符号数。比如 8 位下-4是1111 1100算术右移一位得到1111 1110也就是-2如果是逻辑右移结果会是0111 1110也就是126完全不是一回事。所以处理有符号负数时不能随手右移。4.3 二进制思维的小算法有几个位运算技巧写代码时经常用面试也爱考。判断一个数是不是奇数不用取模if (x 1) { // 奇数 }判断一个正整数是不是 2 的幂if (x 0 (x (x - 1)) 0) { // 是 2 的幂 }原理是2 的幂在二进制里只有一个1比如1000减1变成0111两者相与结果一定是0。用异或交换两个变量不需要临时变量a ^ b; b ^ a; a ^ b;这个技巧初看神奇原理就是异或的“自反性”a ^ b ^ b等于a。统计一个整数二进制里1的个数可以反复清除最低位的1int count 0; while (x) { x x (x - 1); count; }每次x (x-1)都会把最低位的1变成0循环次数就是1的个数。这个思路在计算汉明距离、判断二进制表示甚至做空间复杂度分析时都很有用。另外二分查找的时间复杂度是O(log n)为什么那么快因为每比较一次就排除一半最多只需要比较“二进制位数”那么多次。比如n1000log2(1000)≈10确实只需要十来次这就是二进制规模的直觉。4.4 真实场景权限位、编码和校验Linux 文件权限是理解“二进制到权限”最直观的例子。rwx三种权限分别对应一个二进制位r是4w是2x是1。所以7就是111表示可读可写可执行5是101表示可读可执行但不能写。755拆开看成7 → 111 → rwx文件拥有者 5 → 101 → r-x同组用户 5 → 101 → r-x其他用户理解这个之后就不会再把666随意当作万能权限了。很多权限问题归根结底是二进制位没有搞对。字符大小写转换也能用二进制。ASCII 码里大写字母和小写字母刚好相差32也就是二进制的0b00100000。所以对英文字母执行char ^ 0x20可以在大小写之间切换执行char | 0x20则统一变成小写。这里的关键就是看懂了 ASCII 码在二进制位上的排列规律。说到校验最典型的二进制除法应用是 CRC 校验。CRC 把一段数据当成一个很大的二进制数再用一个固定的生成多项式对数据做模 2 除法也就是逐位异或不进位最后得到的余数就是校验码。接收方用同样方式重新计算如果余数不为零说明数据在传输过程中发生了改动。这个过程中“二进制除法”不是课本上的东西而是实实在在的工程应用。5. 手把手实战从手算到代码到工具5.1 常用命令行工具进制转换不需要每次都手算命令行和解释器就能快速完成。在 Python 里bin(10) # 0b1010 oct(10) # 0o12 hex(10) # 0xa int(1010, 2) # 10 int(ff, 16) # 255 format(10, b) # 1010不带前缀 format(255, x) # ff在 Shell 里也有简单办法echo $((2#1010)) # 二进制转十进制输出 10 printf %x\n 255 # 十进制转十六进制输出 ff printf %o\n 255 # 十进制转八进制输出 377我自己的习惯是手算一遍之后再用这些命令验证。不要觉得多此一举因为手算训练的是对二进制结构的直觉而命令验证防止的是粗心算错。如果想练习可以把日期、IP、端口号、颜色代码都拿来转一圈。5.2 二进制包、源码包到底啥区别很多朋友做部署时经常看到“二进制包”和“源码包”的说法这里的“二进制”和我们前面讲的二进制数其实是一回事。源码包是给人看的 C/C 源码需要本机编译器生成机器码二进制包则是有人帮你提前编译好的可执行文件和依赖库里面存的已经是指令集对应的 0/1 机器码拿过来直接安装或解压就能用。以常见的主机部署为例用 nginx 二进制包部署和用源码编译部署是完全不同的路径。二进制包的好处是省去编译时间不用装一堆构建依赖开箱即用源码包的好处是可以精确裁剪模块、打进自定义补丁但需要更长的编译等待和更高的维护成本。选择哪个不绝对但理解“二进制包”里的内容是机器可直接执行的二进制指令遇到“缺动态库”“运行报错”这类问题时思路会更清晰。5.3 浮点数计算里的二进制精度问题回到开头那个0.1 0.2的问题。我用 Python 演示一下 0.1 0.2 0.30000000000000004这不是某门语言的 bug而是所有遵循 IEEE 754 的语言都会出现的现象。0.1和0.2的二进制表示都是无限不循环小数存储时被截断或舍入到 53 位有效数字加法的误差就显现出来了。如果做金额、金融类计算不能直接拿浮点数相加。常见做法有两个使用十进制高精度类型比如 Python 的decimal.Decimal或者把“元”换成“分”用整数计算最后再除回。比较浮点数时也不要直接判断相等而是判断差的绝对值是否小于一个很小的误差范围abs(a - b) 1e-9遇到这种问题真正有用的不是背答案而是要能联想到“十进制的 0.1 在二进制里根本表示不精确”这就回到了第二节的小数转换原理。5.4 用几行代码实现和校验与位扩展校验计算也是二进制很好的练习场。最简单的“和校验”可以这样写data bhello checksum sum(data) 0xFF把每个字节当成整数相加再取低 8 位作为校验值。这里 0xFF本质上就是一个二进制掩码目的是只保留低 8 位也就是一个字节。更复杂的 CRC 校验可以自己拿多项式模拟模 2 除法写出来之后你会在代码里看到“二进制除法”从纸面变成真正能跑的算法理解会深很多。再看一个位扩展的例子把 4 位二进制数1010扩展成 8 位无符号数直接高位补0得到00001010如果是补码负数比如 8 位11111010扩展成 16 位有符号数要高位补符号位1变成1111111111111010这样数值仍等于-6。这个操作叫“符号扩展”不理解补码的人很容易在数据拼接时算错值。6. 常见问题与排查技巧实录6.1 新手最容易踩的坑我整理了一个速查表都是以前答疑时反复遇到的高频问题现象原因排查思路十进制整数转二进制结果多一位/少一位余数排列顺序搞反从下往上读余数写完回算一遍小数转换后验证对不上乘 2 取整后顺序写反小数是从上往下读和整数相反二进制加法算错忘了逢二进一按十进制习惯处理逐位列竖式标出进位负数运算结果不对没转补码直接用原码算先求补码再做加法0x加法结果不一致有符号无符号混用统一类型注意打印格式移位后值不对有符号负数用了逻辑右移确认是算术右移还是逻辑右移权限设置不符合预期755每一位对应关系不熟拆成三位二进制再判断十六进制展开时位数不对分组后左侧补 0 被忽略不足 4 位的分组一定补 0这些坑的共同点都是“太信任手算或直觉”没有用工具或反向验算。我建议在做任何进制转换后都花几秒用 Python 验证一下成本极低收益极大。6.2 排查思路与自检方法遇到二进制相关的 bug我一般按这个顺序查先确认数据解释方式对不对是有符号还是无符号是大端还是小端然后看每一位的权重对不对最后把结果转回十进制看是否符合预期。比如从一段网络报文里解析两个字节得到0x12 0x34。如果按照大端拼接是0x1234 4660如果按小端拼接就是0x3412 13330。很多初学者看到答案不一样就蒙了其实只要老老实实把二进制位写出来拼完之后再转十进制问题立刻清楚。Windows 自带的计算器切到“程序员”模式也支持二进制、八进制、十进制、十六进制互转和位运算临时验证很方便。6.3 我的调试习惯最后分享一个我自己的习惯遇到二进制相关的问题第一件事不是猜而是把数值统一打印成十六进制因为十六进制和二进制之间的转换几乎可以心算能一眼看出哪些位是1、哪些位是0。调试字节流、文件头、寄存器状态时我用十六进制看的时间远多于二进制因为同样一堆位十六进制更紧凑也不容易看花眼。把数据按字节拆分、位掩码提取、再按位组装这种能力需要刻意练习。以前我踩过最深的坑是写代码时把0x0F当成了无符号小数值然后跟int直接比较结果符号扩展把整个条件判断带偏查了半天才发现是类型和位宽的问题。从那以后凡是涉及位运算、类型转换、掩码操作的代码我都会先写清楚每个变量的宽度和有无符号再动手。这个习惯帮我省下大量排查时间。
返回列表