☰
为什么计算机用二进制:从噪声容限、补码到浮点误差
2026/9/30 10:20:28 网站建设 项目流程

刚入行那会儿,带我的老师傅问了一句:为什么计算机用二进制?我脱口而出"因为电路只有通和断两种状态啊"。他笑了笑说,这话对,但只对了三成,剩下那七成才是真正值钱的东西。后来我在调硬件、翻芯片手册、对着文件格式规范一行行啃的时候,才慢慢把这句话补全——二进制从来不是一个数学上的偏好,它是整个工业体系在成本、可靠性、良率和生态之间反复权衡后剩下的那个解。很多人学计算机组成原理、学 C 语言、做进制转换题,卡住的地方其实都不在"怎么算",而在"为什么非得这样算"。这篇东西我想把这七成讲清楚:从电压波形讲到三进制机器的历史,从布尔代数讲到补码,最后落到几个我实际踩过的坑,比如为什么用strstr去搜二进制内存会出事,为什么系统会弹一句"预览这个文件可能对你的计算机有害"。

1. 一盏灯的两种状态,为什么成了整个数字世界的地基

1.1 电平不是理想方波,噪声容限才是关键约束

教科书上画的高低电平是一条干净的方波,0 就是 0V,1 就是 5V。实际拿示波器怼到引脚上一看,你会看到上升沿有坡度、顶部有过冲和振铃、边沿附近还有毛刺。器件手册里从来不会写"0V 就是逻辑 0",而是给一个电压区间。以经典 TTL 为例,输出高电平 VOH 不低于 2.4V,输入识别为高电平的门槛 VIH 是 2.0V;输出低电平 VOL 不高于 0.4V,输入识别低电平的门槛 VIL 是 0.8V。也就是说,一个逻辑 1 从输出端传到输入端,中间允许掉掉 0.4V 还能被认出来,这就是噪声容限。

这个容限是花钱买来的,也是整个数字电路能稳定工作的根基。现在问题来了:如果我想让一根线表示 4 个状态,比如 0V、1.67V、3.33V、5V,那么相邻状态之间只有 1.67V 的间隔,考虑电源波动、温度漂移、器件参数离散、线间串扰,留给噪声的余量可能只剩零点几伏。要把它做可靠,就得加精密参考源、比较器、更严格的匹配走线,成本和面积直接翻几倍。打个比方,在一个嘈杂的车间里喊话,如果只需要区分"是"和"不是"两个词,隔着十米也能听清;如果要区分十个词,那就得走近了喊,或者上对讲机。

1.2 状态数每多一个,工程难度不是线性上涨

有人会算一笔账:三进制表示同一个数,需要的位数是二进制的 log₃2 分之一,大约只要 63% 的位数,看起来省了。信息论里也有个经典结论:如果单个器件能稳定表示 e 个状态(e≈2.718),那么单个器件承载的信息量最大。2 和 3 都很接近这个最优点,所以理论上三进制确实有优势。但理论账要落到工程账上,就得乘上一个系数:每个三进制器件的制造成本,相对于二进制器件要乘多少倍。

答案是乘得非常多。二进制器件只需要区分"导通"和"截止"两种状态,晶体管深饱和导通和完全截止之间的参数差异巨大,工艺容差可以放得很宽,良率极高。多值逻辑器件需要在一个连续量程里精确切分出多个稳定态,每一档都要靠器件参数精确匹配来维持,良率随状态数指数级下滑。再叠加一层:二进制的两个状态天然对应布尔代数的真和假,逻辑运算可以直接用电路实现,而三值逻辑需要额外定义一套代数体系,工具链、验证方法、设计流程全部要重造。所以"位数省了 37%"换来的是"每比特成本涨了三倍以上",这笔账怎么算都亏。

2. 三进制、十进制机器都真造过,最后为什么还是二进制赢了

2.1 平衡三进制的那段历史与现实约束

三进制计算机不是纸上谈兵,是真造出来过的。上世纪五十年代末,莫斯科国立大学由 Nikolay Brusentsov 带队做出了一台平衡三进制计算机,用的数码是 -1、0、1 三个值,一共生产了几十台,在气象计算、工程计算上跑过实际业务,据说性价比一度相当可观。再往前,早期的电子计算机里也有用十进制计数环的,用真空管或者计数管一圈圈地表示 0 到 9 十个状态,编程和维护都很麻烦。

那为什么没走下去?很多人喜欢把原因归结为"某个技术选择失误",但真实情况是一组很朴素的约束。第一是器件供应链:当时整个半导体工业的产能、封装、测试设备都是围绕二值器件铺开的,你想做三值器件,等于要重建一整条上游。第二是规模效应:产量上不去,单价就下不来,单价下不来就更没产量,这是个死循环。第三是软件与生态:已有的汇编器、编译器、数值库、教学体系全是为二进制写的,换进制等于把几十年的积累推倒重来。技术史上这类事情反复出现——不是最优解胜出,而是在正确的时间点能形成规模效应的那个解胜出。

2.2 存储密度、功耗、良率和生态的四本账

我把当时需要算的账整理成一张表,这样看得更清楚。

维度二进制方案三进制/多值方案
器件成本开关两态,工艺容差大,良率高需精确区分多档,匹配要求高,良率低
单比特信息量1 bit/器件约 1.58 bit/器件
表示同一数值的位数基准约 63%
逻辑代数支撑布尔代数,与电路同构需重建三值代数体系
工具链与生态成熟,编译器/EDA 完备几乎从零开始
抗噪声能力噪声容限大,长链路可恢复容限小,需精密基准

四本账一算就明白:**信息密度那点优势,被器件成本、良率和生态三重劣势吃干净还倒欠。**这里有个思维上的坑我特别想提醒,很多刚学的同学会觉得"越省位数越先进",这是把工程问题当成数学题了。工程上的最优解永远是在给定约束下求成本最小值,而不是在单一指标上求极值。想通这一点,后面看缓存为什么分级、为什么用十六进制而不是二进制来写地址、为什么浮点数不直接存十进制,就都顺了。

3. 0 和 1 不是数字,是逻辑:布尔代数与开关电路的同构

3.1 从继电器到 CMOS:同一张真值表的不同落地方式

二进制真正厉害的地方,不是它能表示数,而是它同时能表示逻辑。布尔代数里与、或、非三种运算,和串联、并联、反相三种电路结构,本质上是一一对应的。这个对应关系在香农 1937 年的硕士论文里被系统地写出来,之后所有的数字电路设计都是在这张对应表上做文章。

我列一下最核心的几个门,你看它们的真值表就能理解为什么电路和逻辑是一回事:

ABA AND BA OR BA XOR B
00000
01011
10011
11110

实现这些门的技术换了一茬又一茬:继电器、真空管、分立晶体管、TTL 芯片,到今天的 CMOS。技术形态变了,真值表一个字没变。这也是为什么高校的计算机组成原理课,讲完逻辑门就能直接往上盖加法器、译码器、寄存器,中间不需要任何"翻译层"。CMOS 里最常用的是 NAND 和 NOR,因为它们用最少的晶体管就能实现,而且 NAND 是功能完备的——理论上只用 NAND 门就能搭出整个 CPU。实际做芯片当然不会这么干,但"完备集"这个概念在化简逻辑、降低门数的时候非常有用。

3.2 加法器与触发器:CPU 是怎么从逻辑门里长出来的

先说加法。最容易理解的是半加器,两个输入 A、B,输出本位和 S 和进位 C:和就是 A 异或 B,进位就是 A 与 B。把两个半加器加一个或门拼起来就是全加器,能处理来自低位的进位输入:本位和是 A⊕B⊕Cin,进位输出是 AB + Cin(A⊕B)。全加器再按位串联,就是行波进位加法器。

这里有个很实用的细节:行波进位加法器的延迟和位宽成正比,因为最高位的进位要一级一级传上去。32 位加法器最坏情况下要等 32 级门延迟,主频就上不去。所以真实 CPU 里用的是超前进位加法器,用额外的逻辑提前算出每一位的进位,用面积换延迟。这个"面积换速度"的取舍在计算机里到处都是,从加法器到缓存到流水线,思路是同一个。

再说"记忆"。纯粹的组合逻辑没有记忆,输入一变输出就变。加上时序逻辑才有的。RS 锁存器用两个交叉耦合的 NOR 门就能锁住一个状态,D 触发器在时钟边沿采样输入并保持到下一个边沿。有了触发器,才有了寄存器、计数器、状态机,CPU 才有"当前指令地址"这个概念。我个人的体会是,理解触发器是理解"计算机为什么能顺序执行"的分水岭:组合逻辑负责算,时序逻辑负责"记住算到哪了"。

4. 手算一遍:整数、小数、负数在二进制里长什么样

4.1 除二取余与位权展开

拿 217 这个数当例子。最通用的方法是除二取余,一直除到商为 0,然后把余数从下往上读:

步骤除法商余数
1217 ÷ 21081
2108 ÷ 2540
354 ÷ 2270
427 ÷ 2131
513 ÷ 261
66 ÷ 230
73 ÷ 211
81 ÷ 201

从下往上读就是 11011001。验算一下:128 + 64 + 16 + 8 + 1 = 217,对上了。另一种更快的方法是位权展开,先把 2 的幂次列出来:256、128、64、32、16、8、4、2、1,从大到小看能不能减。217 减 256 不够,减 128 剩 89,减 64 剩 25,减 32 不够,减 16 剩 9,减 8 剩 1,减 4 不够,减 2 不够,减 1 剩 0。所以 128、64、16、8、1 这几位是 1,其它是 0,同样是 11011001。

这两种方法各有用途。除二取余适合写程序实现,位权法适合心算和快速估算。做嵌入式开发的时候,看数据手册里的寄存器位定义,用的全是位权思维:第 3 位是使能、第 7 位是中断标志,一眼就能算出来掩码是0x08和0x80。

4.2 小数的精度天花板:0.1 为什么天生存不准

整数转二进制很干净,小数就不是了。方法叫乘二取整:小数部分不断乘 2,取整数部分作为当前位,直到小数部分变 0 或者达到所需位数。

以 0.625 为例,0.625 × 2 = 1.25 取 1,剩 0.25;0.25 × 2 = 0.5 取 0;0.5 × 2 = 1.0 取 1,剩 0。结果是 0.101,干净利落,因为 0.625 = 5/8,分母是 2 的幂。

再试 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;0.6 × 2 = 1.2 取 1 剩 0.2……到这儿你发现 0.2 又回来了,开始循环。所以 0.1 的二进制是 0.0001100110011……无限循环。这不是精度不够,而是表示法的边界:二进制只能精确表示那些分母是 2 的幂的分数,其它一律是近似值。

所以那个"十进制小数转二进制有精度限制时要不要考虑舍入"的问题,答案是必须考虑,而且要在设计阶段就定好策略。金融计算里我从来不用浮点存金额,一律用整数以分为单位存,或者用定点数/Decimal 类型。用 float 存金额再乘以 100 取整,偶尔会少一分钱,对账的时候能查到你想砸键盘。

4.3 补码:把减法改写成加法的那一步

负数怎么表示,历史上试过好几种方案。符号位加绝对值最直观,但有个致命问题:+0 和 -0 两个零,而且加法电路要单独处理符号,硬件复杂。反码也有双零问题。最后胜出的是补码。

补码的规则是:正数保持不变,负数取反加一。举个例子,8 位下求 -100 的补码。100 的二进制是 01100100,按位取反得 10011011,加一得 10011100,也就是十六进制的 0x9C。验算:0x9C 是无符号的 156,而 156 = 256 - 100,正好对得上。

为什么这么设计?用模运算一看就明白。8 位寄存器的模是 256,溢出自然丢弃。那么 x - y 在模 256 的意义下等于 x + (256 - y),也就是 x 加上 y 的补码。**减法就这么消失了,加法器一个电路通吃加减法。**这是硬件的巨大简化,一个 ALU 加法器加一个取反加一的控制逻辑就够了。

这里有个必须注意的坑:8 位有符号数的范围是 -128 到 127,217 这种数在 8 位有符号里是放不下的,它会变成 -39。用 Python 验证一下:

print(bin(217)) # 0b11011001 print(217 - 256) # -39,8 位有符号的解释方式 print(hex(-100 & 0xFF)) # 0x9c

C 语言里更要注意类型提升和隐式转换:

#include <stdio.h> #include <stdint.h> int main(void) { int8_t a = -100; uint8_t b = (uint8_t)a; printf("%02x\n", b); // 9c printf("%d\n", (int)a); // -100 return 0; }

5. 二进制在真实系统里的几张面孔

5.1 文件魔数:系统为什么警告"预览这个文件可能有害"

很多人第一次看到"你尝试预览的文件可能对你的计算机有害"这句提示会懵,其实背后就是二进制在起作用。操作系统和很多软件判断文件类型,不是看扩展名,而是读文件开头几个字节的固定特征,也就是魔数。Linux 下file命令干的就是这件事。常见的几个:PNG 是89 50 4E 47 0D 0A 1A 0A,JPEG 是FF D8 FF,PDF 是25 50 44 46,ZIP 是50 4B 03 04,而 Windows 可执行文件是4D 5A,也就是字符 "MZ",ELF 可执行文件开头是7F 45 4C 46。

你可以在终端里直接看:

file example.png xxd -l 16 example.png

顺手说个冷知识,PNG 魔数里带一个1A,那是 DOS 时代的文件结束符。当年有人在 DOS 下用type命令误打 PNG 文件,屏幕会疯狂滚乱码,加上1A之后,命令读到这个字节就停下,能起到一点保护作用。所以当系统提示某个文件"可能有害",通常是它读到的魔数和可执行文件特征吻合,或者扩展名和实际内容对不上——文件名可以随便改,开头那几字节改不了。

5.2 CPU 架构与二进制包:ARM、ARM64 和 x86 为什么不通用

二进制可执行文件不只是"一些 0 和 1",它是特定 CPU 指令集的编码。ARM 的指令编码、x86 的指令编码、它们各自的字节序和调用约定(ABI)都不一样。所以你在 x86 机器上编译出来的程序,拷到 ARM 设备上直接跑,内核第一步就会拒绝,报一个格式错误。

这个坑在实际运维里很常见。比如给嵌入式板子或树莓派部署测试工具,包名里带着arm和arm64的区分;armhf是 32 位硬浮点,arm64是 64 位,别下错。判断方法很简单,装之前先看一眼:

uname -m # 输出 aarch64 或 x86_64 file ./memtester # 看 ELF 头部声明的架构

同样的道理,容器镜像现在普遍做多架构清单,拉镜像的时候客户端会自动挑和你机器匹配的那一份。如果你的构建机是 x86,目标机是 ARM,那就得交叉编译或者用目标架构的构建环境,别指望docker build出来的东西能直接搬过去跑。

5.3 IEEE 754 与 0.1 + 0.2 的经典误差

浮点数是二进制世界里最容易被误解的一块。IEEE 754 单精度浮点分三段:1 位符号、8 位阶码、23 位尾数;双精度是 1 + 11 + 52。0.1 在双精度下的位模式是3fb999999999999a,注意末尾那个a,说明它是个近似值,不是精确的 0.1。

import struct print(struct.pack('>d', 0.1).hex()) # 3fb999999999999a print(0.1 + 0.2) # 0.30000000000000004 print(0.1 + 0.2 == 0.3) # False

这个现象在 Python、Java、JavaScript、C 里完全一致,因为它们遵循同一套标准。所以浮点数比较永远不要用==,要用一个误差容限,比如abs(a - b) < 1e-9。涉及金额、计费、库存数量这类要求精确的场景,用整数或十进制类型。我见过线上事故就是积分累加用了浮点,用户攒了很久的积分莫名其妙少了一点,排查了半天才定位到浮点误差。

6. 我踩过的坑:处理二进制数据时最容易翻车的几件事

6.1 strstr 处理二进制内存的致命缺陷

这个坑我必须重点说,因为它非常隐蔽。C 语言的strstr是查找子串的标准函数,直觉上应该能用来在内存里找一段数据吧?不行。它的原理是判断字符串结束符\0,也就是字节 0x00。而二进制数据里出现 0x00 太正常了,一个 32 位整数 1000 存成小端就是E8 03 00 00,中间两个 0x00 会让strstr认为字符串到此结束,后面的内容全部看不到。

正确做法是用长度已知的搜索函数。GNU 环境有memmem,需要定义_GNU_SOURCE:

#define _GNU_SOURCE #include <string.h> #include <stdio.h> int main(void) { unsigned char buf[] = {0x41, 0x00, 0x42, 0x43, 0x00}; unsigned char needle[] = {0x42, 0x43}; void *p = memmem(buf, sizeof(buf), needle, 2); if (p) printf("found at offset %ld\n", (unsigned char *)p - buf); return 0; }

如果平台没有memmem,可以自己写一个朴素搜索,或者先用memchr找到首字节的所有位置,再用memcmp逐个比对。数据量大的话就上 KMP 或 Boyer-Moore,别用暴力双层循环去扫几百兆的数据。这个坑的本质是:**凡是处理二进制数据,就必须显式携带长度,永远不要依赖任何以空字节为终止符的函数。**同一类的还有strlen、strcpy、strcmp,一个都不能用在二进制数据上。

6.2 用文本工具打开二进制文件的后果

第二个坑是工具误用。用 VS Code 打开一个二进制文件,它会弹"文件似乎是二进制文件,是否仍要打开",你点确定,看到一屏乱码,还可能因为一行太长让编辑器卡住。真要查看,用xxd、hexdump或者带 Hex Editor 扩展的编辑器,并且加上长度限制:

head -c 256 sample.bin | xxd xxd -l 64 -g 1 sample.bin

绝对不要用cat把二进制文件直接输出到终端。有些字节序列会被终端解释成控制字符,把终端状态搞乱,字符集错乱、光标乱跳,这时候敲reset回车就能恢复。

还有一个老派的坑:用 FTP 或者某些文件传输工具时,一定要选二进制模式而不是 ASCII 模式。ASCII 模式会做换行符转换,把0D 0A里的0D吃掉,传过去的二进制文件就废了,校验和永远对不上。现在的传输工具大多默认二进制模式,但老系统和脚本里还是要显式指定。

6.3 学习顺序上的坑:别把进制题和原理课割裂开

最后说个学习路径上的体会。很多人准备计算机组成原理或者考研复试的时候,把进制转换当成纯粹的数学题去背,除二取余、乘二取整背得滚瓜烂熟,但一到"为什么补码能简化电路""为什么浮点数有精度限制"就答不上来。原因就是进制和电路、数据类型是割裂学的。

我的建议是三步走。第一步,手算打底,把整数、小数、补码的转换练到不用想,这是基本功。第二步,写代码验证,用bin()、hex()、struct.pack()、C 的联合体去把自己手算的结果打印出来对照,手算错了立刻能发现。第三步,用调试器看内存,在 C 代码里打断点,把变量地址和内存窗口打开,亲眼看着-100在内存里是9c ff ff ff,比看十遍书都管用。这三步走完,再去学定点数、浮点运算、溢出判断,就是水到渠成的事。

我个人在实际操作中的体会是,二进制的很多"为什么",答案都不在数学里,而在成本和可靠性里。噪声容限决定了为什么要二值,工艺良率决定了为什么不用三值,模运算决定了为什么用补码,分母的质因数决定了小数能不能精确表示。搞技术久了会形成一个习惯:遇到一个设计决策,先别问它"对不对",先问它"在什么约束下这样最划算"。二进制能统治整个数字世界,靠的不是它更聪明,而是它在每一个约束面前都刚好够用,而且足够便宜。这个思路,比任何一个具体的转换公式都更值得带走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询