如果你写过几天代码,大概率撞见过这几件事:程序里0.1 + 0.2算出来是0.30000000000000004;一个正整数左移一位后莫名变成了负数;格式化一块标称 1TB 的硬盘,系统显示可用空间只有约 931GB。这些麻烦看起来互不相干,但只要顺着现象往底层挖,最后都会撞上同一个老朋友——二进制数、位和字节。
坦白说,我刚学编程那会儿也没把这些当回事,觉得能跑就行。直到后来排查一个溢出 bug 到凌晨,才老老实实回头把计算机的数字底层逻辑从头啃了一遍。这篇保姆级教程就是那次补课的沉淀:从一根电线为什么只能表达 0 和 1,到 8 个位为什么组成一个字节,再到补码、位运算、容量换算里的各种坑,一次讲透。不管你是编程新手、自学转行的非科班,还是写了好几年代码却总觉得根基不牢的开发者,这篇文章都值得静下心来读一遍。
1. 计算机为什么天生用二进制:从一根电线说起
1.1 人类用十进制,是“手指文明”的遗产
一个很自然但值得追问的问题:我们从小到大用十进制,为什么计算机偏偏不用?最朴素的解释是,人类有十根手指,计数的时候掰手指数,“逢十进一”对我们来说是与生俱来的习惯。但计算机没有手指,它生活在电的世界里,电路里流动的是电压和电流,而不是抽象的数位。
如果让一台机器去区分“0 到 9”这十种符号,它必须有能力判断十种不同的物理状态。这在技术上不是做不到,但代价极高。最简单的对比:让电路识别“有电”和“没电”两种状态,一个电压比较器加一点噪声容限就搞定了;要让电路稳定地区分十个电压等级,对制造工艺、供电稳定性和抗干扰能力的要求都会急剧上升。
所以二进制的第一个理由非常务实:在物理实现上,两态是最可靠、最便宜的方案。这个结论不是数学家的偏好,而是工程师在成本和可靠性之间权衡出来的结果。
1.2 电的世界里,稳定状态只有“通”和“断”
在数字电路中,我们约定:电压高于某个阈值记为 1,低于另一个阈值记为 0。两个阈值之间有足够的间隔,哪怕信号受到噪声干扰,只要不跨越阈值,就能被正确还原。这种“整形”能力,正是数字电路比模拟电路抗干扰的根本原因。
如果换成十进制,就要把电压分成十档,每一档之间留给噪声的余量就非常小,同样的工艺水平下误判率会显著上升。更何况,二值逻辑还有一个额外福利:0 和 1 可以直接映射到“真”和“假”,于是布尔代数里的与、或、非运算可以原封不动地落到电路上。CPU 内部成千上万个晶体管,本质上就是一群受电压控制的开关,这些开关组成逻辑门,逻辑门再组成加法器、乘法器、寄存器。可以说,从晶体管到整个 CPU,“开关”这个意象贯穿始终。
稍微扯远一点:二进制的抗噪能力还有一个典型体现——长途传输。因为只有 0 和 1 两种电平,信号衰减后可以通过中继器重新整形、放大,传多远都不容易失真;而多电平模拟信号一旦被噪声污染,就很难无损恢复。这也是为什么现代通信系统普遍采用数字编码,而不是直接传模拟量。
2. 二进制数到底怎么数:位权、进制转换与十六进制
2.1 一张表看懂进位规则
二进制只有 0 和 1 两个数字符号,遇到 2 就进位。所以二进制里的“10”读作“一零”,不是“十”,它表示的数值是十进制的 2;“100”则是十进制的 4。很多新手在这里犯晕,其实只要记住“逢二进一”就够了。下面这张表可以帮你建立直觉:
| 十进制 | 二进制 | 十进制 | 二进制 |
|---|---|---|---|
| 0 | 0 | 8 | 1000 |
| 1 | 1 | 9 | 1001 |
| 2 | 10 | 10 | 1010 |
| 3 | 11 | 11 | 1011 |
| 4 | 100 | 12 | 1100 |
| 5 | 101 | 13 | 1101 |
| 6 | 110 | 14 | 1110 |
| 7 | 111 | 15 | 1111 |
| 16 | 10000 | - | - |
盯着这张表看一会儿,你会发现规律:最低位每两次变化就进一位,倒数第二位每四次变化进一位。位的“权重”开始浮现了。
2.2 位权展开:二进制转十进制的万能公式
十进制数字 234 拆开是 2×100 + 3×10 + 4×1,这里的 100、10、1 是十进制各个位的“权”。二进制也一样,从右往左,每一位的权值分别是 1、2、4、8、16……也就是 2 的幂。
二进制 1011 转十进制就按位权展开:1×8 + 0×4 + 1×2 + 1×1 = 11。只要会乘法和加法,任何人都能心算。再练一个:1100 = 1×8 + 1×4 = 12;1111 = 8+4+2+1 = 15;10000 = 16。一旦习惯了这套位权,二进制数在你眼里就不再是乱码,而是一串“开了哪些位”的开关列表。
一个实用心算技巧:二进制从右到左每一位若为 1,就把对应的权值 1、2、4、8、16、32、64、128 加上。这串数字本身就是 2 的幂,记熟它们,很多和时间有关的二进制换算(比如子网掩码、内存对齐)都能口算出来。
2.3 十进制转二进制:两种方法任选
第一种是教科书里常见的“除 2 取余法”。以 13 为例:
13 ÷ 2 = 6 余 1
6 ÷ 2 = 3 余 0
3 ÷ 2 = 1 余 1
1 ÷ 2 = 0 余 1
余数从下往上读:1101。也就是说 13 的二进制是 1101。这个方法逻辑严谨,适合写程序循环实现,也适合做复杂的进制转换。
第二种是“减法拆权法”,更适合心算。13 = 8 + 4 + 1,拆成 2^3、2^2、2^0,所以这三个位写 1,剩下的位写 0,得到 1101。再比如 100 = 64 + 32 + 4,对应二进制就是 1100100。这个方法对靠近 2 的幂的数字特别快,我平时做转换基本都用它。
2.4 八进制与十六进制:二进制的“压缩包”
二进制写一长串 0/1 很容易看花眼。比如 255 的二进制是11111111,八个位满屏都是 1,写出来还行,再大一点就非常痛苦。于是计算机先驱们想了个办法:用更大的进制给二进制分组。三个二进制位恰好能表达 0~7,于是有了八进制;四个二进制位恰好能表达 0~15,于是有了十六进制。
十六进制的一位对应四个二进制位,所以1111就是 F,1001就是 9,1010就是 A。程序员看到0xFF,实际就是 8 个 bit 全为 1;看到0x80,就是10000000。内存地址、网络协议、颜色值里到处都是十六进制的影子。一个非常经典的例子是网页颜色#FF0000:FF 是红色通道,8 个 bit 全满,00 是绿色通道,00 是蓝色通道,三个字节就定义了一种纯红色。没有十六进制,这种表达会冗长到没法看。
3. 位与字节:信息世界的原子和分子
3.1 比特:只有两种状态的“原子”
位(bit,binary digit 的缩写)是信息世界最小的单位,只有 0 和 1 两个取值。单个位的表达能力极其有限,但位的数量一多,“组合爆炸”就来了。两个位能组合出 00、01、10、11 四种状态;三个位能组合出 8 种状态;n 个位能组合出 2 的 n 次方种状态。反过来,如果要编码 N 种不同的信息,至少需要 log₂(N) 个位。这个关系是整个信息论的起点。
用一个更生活化的比喻:一个二进制位就像一枚硬币的正反面,8 枚硬币一起扔,能出现 2^8 = 256 种组合。如果事先约定某一种组合代表字母 A、某一种代表数字 1,那 8 枚硬币理论上就能编码 256 个不同的符号。所有的文本、图片、声音,在计算机里最终都是这样一组组“硬币组合”。
3.2 为什么偏偏是 8 个位组成一个字节
字节(byte)这个概念不是一开始就长成现在的样子。早期计算机出现过 6 位字节、7 位字节等不同规格。后来 ASCII 字符集需要编码 128 个字母和符号,7 位勉强够用,但要容纳更多欧洲字符和扩展符号,大家开始用 8 位。8 是 2 的幂,对内存对齐、寻址都友好,于是“8 位 = 1 字节”逐渐成为事实标准,沿用至今。
字节的意义在于:它成了计算机存储和传输的最小可寻址单位。内存按字节编址,磁盘按字节读写,文件大小按字节计算。虽然单个位也能存信息,但实际系统里很难单独取一个位出来处理,所以位总是成群结队,以字节的身份干活。
顺带一提,我们常说的“32 位系统”“64 位系统”,这里的“位”指的是 CPU 一次能处理的二进制位数,也叫字长,和“8 位一个字节”不在一个层面。64 位系统一次能啃 8 个字节,处理大数和大内存地址时自然比 32 位有优势。
3.3 KB、MB、GB 与 1024 的纠葛
在纯二进制语境下,1KB = 2^10 = 1024 字节,1MB = 1024KB,1GB = 1024MB,1TB = 1024GB。但因为“千”的十进制习惯,很多存储设备厂商按 1000 来计算容量。于是同一个 1TB 硬盘,厂商说的是 1,000,000,000,000 字节,操作系统却按 1024 来分组,最终显示约 931.3GB。
算一下:1,000,000,000,000 ÷ 1,073,741,824 ≈ 931.32。所以所谓“容量缩水”,本质是两种进制标准在打架。更严谨的命名是:二进制单位叫 KiB、MiB、GiB,十进制单位叫 KB、MB、GB。但 Windows 等系统把 GiB 也标成 GB,于是混乱延续至今。以后再有人抱怨“1TB 硬盘怎么只有 931GB”,你可以直接告诉他:不是厂商偷工减料,也不是系统算错,是 1000 和 1024 的区别。
4. 字节里的数字家族:无符号数、补码与溢出
4.1 无符号整数:0 到 2^n-1
一个字节 8 个位,能表示 2^8 = 256 种组合。如果作为无符号整数,范围就是 0 到 255。为什么最大值是 255 而不是 256?因为 0 占了一个位置。这个“少一个”的问题,几乎每个初学者都会问一遍。
常见的整数类型按字节数分,大致如下(以主流平台为例,具体由语言和硬件决定):
| 类型 | 字节数 | 位宽 | 无符号范围 | 有符号范围 |
|---|---|---|---|---|
| char(C 语言) | 1 | 8 | 0 ~ 255 | -128 ~ 127 |
| short | 2 | 16 | 0 ~ 65535 | -32768 ~ 32767 |
| int(32 位平台常见) | 4 | 32 | 0 ~ 4294967295 | -2147483648 ~ 2147483647 |
| long long(C 语言) | 8 | 64 | 0 ~ 约 1.8×10^19 | -9.2×10^18 ~ 9.2×10^18 左右 |
注意,C 语言标准只规定 char 至少 8 位、int 至少 16 位,具体大小取决于平台;而 Java 是固定长度。写跨平台代码时,别想当然地认为 int 一定是 4 字节。
4.2 补码:把减法变成加法的魔法
有符号数怎么表示?最直白的想法是拿一个位当符号位:0 正 1 负。但这样会有两个问题:一是 0 会有“正零”和“负零”两种写法,浪费编码;二是加法和减法得为符号位单独设计一套电路,硬件复杂度上升。
补码方案更优雅。一个 n 位有符号整数,正数用普通二进制表示;负数用“2^n 减去它的绝对值”来表示。比如 8 位环境下,-1 对应 256 - 1 = 255,即11111111;-127 对应 256 - 127 = 129,即10000001。换一种等价的口诀,就是“按位取反再加一”。比如 3 是00000011,取反得11111100,加 1 得11111101,这就是 -3 的 8 位补码。验证一下:3 + (-3) =00000011 + 11111101 = 1_00000000,最高位的进位溢出丢掉,剩下00000000,正好是 0。
用钟表类比更好理解:12 小时制里,“往前拨 3 小时”和“往后拨 9 小时”效果一样,因为 -3 等价于 +9(模 12)。8 位整数的所有运算都发生在一个“模 256”的圈上,溢出就是跨过零点。这套设计的最大好处是:CPU 完全不需要专门的减法器,减去一个数等于加上它的补码,加法器通吃加减法,硬件简单,速度更快。
补码“取反加一”不是凭空规定的,它本质是模 2^n 意义下的同余运算。理解这一点,负数相关的位运算就不容易出错。
4.3 溢出:当数字装不下的时候
有符号 8 位整数能表示的范围是 -128 到 127。如果你给一个 byte 赋 127 再 +1,在 Java 里会“环绕”成 -128;Integer.MAX_VALUE + 1也会秒变负数。在 C/C++ 里,有符号整数溢出是未定义行为,轻则环绕,重则在开启优化后出现完全超出直觉的结果。
这种 bug 在真实项目里非常隐蔽:一个看起来正常的计数器,跑到边界瞬间变成负数,后面的数组下标、循环判断、内存计算全部错乱,而且往往只在极端数据下才触发。我的经验是:写代码前先估算数据范围,该用 long 就用 long,该用无符号就用无符号,不要在溢出边缘反复横跳。溢出不是“偶尔出错”,而是确定性的数学规律,只是你没算到位数边界而已。
5. 动手实验:用 Python 亲手摸一遍二进制与位运算
5.1 三秒打印任意数字的二进制
Python 自带bin()函数,f-string 也支持二进制格式化。哪怕你电脑上只有最基础的 Python 环境,也能立刻做下面这个实验:
for n in range(16): print(f"{n:2d} decimal = {n:08b} binary")输出会整齐地展示 0 到 15 的 8 位二进制补零形式。这里的08b意思是“以二进制格式输出,总宽度 8 位,不足补 0”。跑一遍,再回头看前面那张十进制对照表,你会对进位规则有更直观的感受。
补码也可以亲手验证。注意 Python 的整数没有固定位数,直接bin(-3)会得到-0b11,看起来和补码没有关系。想看到“真·补码”,把负数和一个字节掩码做按位与,强制截断成 8 位:
x = 3 neg = (~x) + 1 print(bin(neg & 0xFF)) # 0b11111101,这就是8位补码 print(neg & 0xFF) # 253,本质上是 256 - 3这个实验特别能帮助理解“取反加一”到底在做什么。补码不是一个抽象符号,而是一个模 256 下的同余结果。
5.2 位运算实验:与、或、异或、左移、右移
位运算在底层语言里随处可见,理解了它们,后面的权限模型、掩码解析、状态压缩都不再神秘。先看一段最基础的操作:
a = 0b1010 # 10 b = 0b1100 # 12 print(bin(a & b)) # 0b1000,位与:两边都是1才为1 print(bin(a | b)) # 0b1110,位或:有一边是1就为1 print(bin(a ^ b)) # 0b0110,异或:两边不同才为1 print(bin(a << 1)) # 0b10100,左移1位,10变20 print(bin(a >> 1)) # 0b101,右移1位,10变5左移一位等于乘以 2,右移一位等于整除 2,这在无符号数上非常直观。位运算之所以重要,是因为哈希、权限、网络协议、图形学、加密算法里全是它的身影。现代 CPU 对普通乘法的优化已经足够快,但位运算的思维仍然必不可少——很多数据格式的设计逻辑就是按位来的。
5.3 一个贴近实战的小例子:用位做权限组合
假设一个系统有读、写、执行三种权限,最经典的做法就是每位一个开关:
READ = 0b100 # 4 WRITE = 0b010 # 2 EXEC = 0b001 # 1 perm = READ | WRITE # 0b110,6 if perm & READ: print("有读权限") if perm & READ and perm & WRITE: print("有读写权限") if not (perm & EXEC): print("没有执行权限") perm |= EXEC # 加执行权限,变成 0b111 perm &= ~WRITE # 去掉写权限,变成 0b101 print(bin(perm)) # 0b101看懂这几行,再看 Linux 文件权限的rwx、各种 SDK 里FLAG_A | FLAG_B的写法,都会豁然开朗。一个整数可以同时当多个布尔开关用,存储紧凑、判断迅速,这就是位运算在工程中最典型的应用。
6. 常见误区排雷:小数、负数移位与容量缩水
6.1 0.1 的二进制是一个无限循环小数
很多人第一次写代码遇到0.1 + 0.2 != 0.3,百思不解。问题根源就在进制转换。十进制小数 0.1 转二进制,用“乘 2 取整法”:
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;
0.6 × 2 = 1.2,取 1,余 0.2;
然后就开始循环:0.4、0.8、1.6、1.2……
所以 0.1 的二进制是0.0001100110011...(无限循环),根本写不完。计算机里的浮点数只有有限位数(float 大约 7 位十进制精度,double 大约 15~16 位),只能截断存储。于是 0.1 存进去就已经是近似值,再加一个近似值,结果自然和你的数学直觉对不上。
实战建议:不要用==直接比较浮点数,改用“差的绝对值小于某个极小值”;涉及金额等精确计算时,用十进制类型或者直接用整数最小单位。这个坑几乎每个人都踩过,理解了背后的二进制逻辑,至少不会再一脸茫然。
6.2 负数右移:算术右移与逻辑右移的翻车现场
在 C/C++ 和 Java 中,有符号整数的>>是算术右移,左端补符号位;无符号整数是逻辑右移,左端补 0。Java 还专门提供了>>>做逻辑右移。看一个例子:
int x = -8; System.out.println(x >> 1); // -4,算术右移,高位补1 System.out.println(x >>> 1); // 2147483644,逻辑右移,高位补0为什么会差这么多?因为 -8 的 32 位补码是11111111 11111111 11111111 11111000,算术右移一位得到1111...111100,也就是 -4;逻辑右移一位得到0111...111100,变成一个巨大的正数。Python 的>>对有符号数也是算术右移,所以-8 >> 1等于 -4。
一个隐蔽的陷阱是:很多人默认“右移一位等于除以二”,但负数右移得到的是“向下取整”的结果。比如 -3 数学上除以 2 有两种取法:向零取整得 -1,向下取整得 -2;而-3 >> 1的结果是 -2。如果你在解析协议、拼接掩码时没注意符号位,这种差异会直接产生隐蔽 bug。
6.3 容量“缩水”到底该找谁
关于硬盘、U 盘、手机存储的“缺斤少两”,前文已经算过一次账,这里用表格再收一下尾:
| 对象 | 标称容量 | 按 1024 换算后的显示 | 原因 |
|---|---|---|---|
| 1TB 硬盘 | 1,000,000,000,000 字节 | 约 931 GiB | 厂商按 1000,系统按 1024 |
| 16GB 内存条 | 16,000,000,000 字节左右 | 约 14.9 GiB | 同上 |
| 128GB 手机 | 128,000,000,000 字节左右 | 约 119 GiB | 同上 |
以后再看到“缩水”,别急着骂厂商。理解了二进制和十进制的换算差异,你会知道这其实是两种标准并存带来的必然结果。
最后分享一点个人习惯:我啃完这些底层知识后,写代码时明显会多想一层——变量会不会溢出、小数比较要不要容差、位运算的结果在负数上会不会跟直觉相反。这些意识不是靠背公式得来的,而是在调试器里一遍遍看二进制的过程中养成的。建议你读完这篇文章后,也用 Python 的bin()把自己常用的数字都打印出来看看,或者在调试器里查看变量的内存布局。看多了,二进制就不再是一堆抽象符号,而是你脑子里最自然的一种底层语言。