刚入行那会儿带我的老师傅问了一句为什么计算机用二进制我脱口而出因为电路只有通和断两种状态啊。他笑了笑说这话对但只对了三成剩下那七成才是真正值钱的东西。后来我在调硬件、翻芯片手册、对着文件格式规范一行行啃的时候才慢慢把这句话补全——二进制从来不是一个数学上的偏好它是整个工业体系在成本、可靠性、良率和生态之间反复权衡后剩下的那个解。很多人学计算机组成原理、学 C 语言、做进制转换题卡住的地方其实都不在怎么算而在为什么非得这样算。这篇东西我想把这七成讲清楚从电压波形讲到三进制机器的历史从布尔代数讲到补码最后落到几个我实际踩过的坑比如为什么用strstr去搜二进制内存会出事为什么系统会弹一句预览这个文件可能对你的计算机有害。1. 一盏灯的两种状态为什么成了整个数字世界的地基1.1 电平不是理想方波噪声容限才是关键约束教科书上画的高低电平是一条干净的方波0 就是 0V1 就是 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 B00000010111001111110实现这些门的技术换了一茬又一茬继电器、真空管、分立晶体管、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 ÷ 210812108 ÷ 2540354 ÷ 2270427 ÷ 2131513 ÷ 26166 ÷ 23073 ÷ 21181 ÷ 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.250.25 × 2 0.5 取 00.5 × 2 1.0 取 1剩 0。结果是 0.101干净利落因为 0.625 5/8分母是 2 的幂。再试 0.10.1 × 2 0.2 取 00.2 × 2 0.4 取 00.4 × 2 0.8 取 00.8 × 2 1.6 取 1 剩 0.60.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 到 127217 这种数在 8 位有符号里是放不下的它会变成 -39。用 Python 验证一下print(bin(217)) # 0b11011001 print(217 - 256) # -398 位有符号的解释方式 print(hex(-100 0xFF)) # 0x9cC 语言里更要注意类型提升和隐式转换#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 0AJPEG 是FF D8 FFPDF 是25 50 44 46ZIP 是50 4B 03 04而 Windows 可执行文件是4D 5A也就是字符 MZELF 可执行文件开头是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比看十遍书都管用。这三步走完再去学定点数、浮点运算、溢出判断就是水到渠成的事。我个人在实际操作中的体会是二进制的很多为什么答案都不在数学里而在成本和可靠性里。噪声容限决定了为什么要二值工艺良率决定了为什么不用三值模运算决定了为什么用补码分母的质因数决定了小数能不能精确表示。搞技术久了会形成一个习惯遇到一个设计决策先别问它对不对先问它在什么约束下这样最划算。二进制能统治整个数字世界靠的不是它更聪明而是它在每一个约束面前都刚好够用而且足够便宜。这个思路比任何一个具体的转换公式都更值得带走。