ARTICLE DETAIL

资讯详情

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

计算机为什么用二进制:补码、浮点误差与位运算避坑

计算机为什么用二进制:补码、浮点误差与位运算避坑 面试季里我最喜欢问的一个问题就是为什么计算机要用二进制计算机专业的学生几乎人人能背出电路只有通和断两种状态但再往下追问一句——为什么不是三进制、不是十进制——能答完整的人就少了一大半。二进制这个词听起来像是教科书第一章的常识可它背后压着的是整个计算机系统结构里最底层的一笔账物理器件到底能做到什么、噪声容限能留多宽、逻辑代数能提供什么现成的工具、量产成本能压到多低。这篇文章不打算从定义讲起而是从硬件实现的物理约束、数值转换的手算方法、补码的设计动机、浮点误差的来源一路讲到日常开发和运维里真正会踩到的坑。不管你是刚翻开计算机组成原理的新生还是写了几年代码却从没认真想过位运算的老手这里的内容都值得花二十分钟过一遍。先把结论摆在这儿二进制不是某个人拍脑袋定的规矩而是在器件成本、抗干扰能力、数学工具和工程生态四重约束下几乎唯一的解。1. 从一次面试翻车说起二进制到底解决的是什么问题1.1 为什么不是十进制——先算一笔物理成本账要回答为什么是二进制最直接的办法是先看看为什么不是十进制。十进制对人类友好是因为我们长了十根手指但手指不是电路。电子器件天生就是非线性元件工作在开关状态时最省心晶体管要么截止要么饱和导通中间那段线性区又费电又容易受温度影响。如果非要做十进制计算机硬件上意味着每个存储单元和每条传输线上都要能稳定区分十个电平。假设供电是 5V十个状态平均分下来每个状态只有 0.5V 的窗口再扣掉器件离散性、温度漂移、电源纹波、线间串扰带来的偏差留给设计者的余量几乎归零。而二进制只需要两个状态5V 系统里低和高之间可以留出几伏的死区这个死区就是抗干扰的护城河。历史上确实有人试过十进制这条路。机械时代的计算工具大量采用十进制齿轮因为齿轮的齿数是物理刻出来的做到十个状态不比两个状态贵多少但一旦进入电子时代十进制立刻变成负担。你可以这么理解机械器件刻状态是免费的电子器件分状态是要用电压窗口换的而电压窗口是最贵的资源。这条逻辑链条才是二进制胜出的第一层原因。还有一层常被忽略的收益布尔代数。上世纪三十年代末有人把布尔代数里的真/假和继电器电路的通/断对应起来证明逻辑运算可以用开关电路直接实现。这意味着二进制不只是能实现而是有现成的数学体系可以直接拿来设计电路从逻辑表达式化简到卡诺图全都省了。十进制没有这么一套成熟且廉价的对应代数系统。1.2 二进制、三进制、十进制的横向取舍对比理论上存在一个有意思的结论如果用进制基数来衡量信息密度在器件成本与基数成正比的假设下最经济的基数是自然常数 e约等于 2.718所以三进制在纯理论信息密度上比二进制更优。这个结论经常被拿来当冷知识讲也确实是上世纪有人造三进制计算机的理论动机之一。但工程不是纯理论。下面这张表把三个候选方案摆在一起看取舍就清楚了。对比维度二进制三进制十进制单器件可区分状态2310状态间的判别余量大可留数伏死区中等窗口被压缩小余量极薄抗温度漂移与噪声能力强一般弱逻辑代数支持布尔代数成熟完备三值逻辑工具链稀少无成体系开关代数存储密度理论基准略高高存储密度工程实现高工艺成熟低良率与稳定性差极低编译器、算法、外设生态极其丰富几乎空白空白制造与维护成本低高极高我在实际带新人时经常用一句话总结这张表三进制赢在纸上二进制赢在车间里。上世纪五十年代末确实有研究团队做出过能跑程序的三进制计算机也稳定运行了好些年但它的每一次扩展都要重新解决器件三态判别、指令编码、编译器后端的问题而同一时期二进制阵营的产业链已经滚起了雪球。技术路线之争到最后往往不是谁更优美而是谁的配套更便宜。2. 从电压到加法器0 和 1 是怎么在硅片上长出来的2.1 开关、电平与逻辑门0 和 1 的物理载体到底长什么样把0 和 1落到硬件上它其实是电压区间不是数字本身。以经典的 TTL 电平为例输出低电平大约在 0 到 0.5V输出高电平至少 2.7V输入端把 0.8V 以下认作低2.0V 以上认作高中间那段 0.8V 到 2.0V 是禁区不允许长时间停留。这个设计不是拍脑袋定的它保证了即使信号在传输中衰减、被干扰叠加了几百毫伏接收端依然能判对原来的值。CMOS 工艺把这个思路做得更彻底静态时几乎不耗电只有在翻转瞬间才消耗动态功耗这也是为什么今天手机芯片能塞下上百亿个晶体管还不至于烤手。这里有个容易被忽略的细节噪声容限是可以算的。假设驱动端最差的高电平是 2.7V接收端最低认可的高电平是 2.0V那么高电平方向的噪声容限就是 0.7V低电平方向驱动端最差的低电平 0.5V接收端最高认可的低电平 0.8V容限是 0.3V。这两个数字就是电路的安全垫。如果换成十进制十个状态把 5V 分完每级台阶 0.5V安全垫直接变成负数电路只能在理想环境下工作。理解了这一层你再看为什么是二进制这个问题答案就从因为电路只有通断升级成了因为只有这样才有足够的安全垫。再往上走一层逻辑门就是用这些电平搭出来的基本运算单元。与门实现两个都为高才输出高或门实现有一个为高就输出高非门实现取反。有意思的是非门加与门就能组合出所有其他逻辑功能工程上最常用的反而是与非门因为它用 CMOS 实现时晶体管数量最少、速度最快。这一层是硬件设计者的地盘但对软件工程师来说知道所有复杂运算最终都被拆成与非门这件事能帮你理解为什么位运算在底层会那么快。2.2 二进制算术是怎么长出来的半加器、全加器与进位链数字电路最迷人的地方在于算术不是算出来的是接出来的。先看一位加法两个二进制位相加结果可能是 0、1、10 三种情况。把和位和进位位分别用逻辑门表达就得到了半加器——和位等于两个输入的异或进位位等于两个输入的与。异或门的真值表恰好就是相异为 1这一点和加法规则完全对上这不是巧合而是布尔代数与二进制算术天然同构的体现。半加器只解决一位实际加法需要处理来自低位的进位于是把两个半加器加一个或门组合起来就是全加器。一位全加器有三个输入两个加数位和低位进位两个输出本位和、向高位的进位。把 8 个、32 个、64 个全加器串起来就得到了定长加法器。串行进位的结构简单但慢因为最高位必须等所有低位算完才能确定这个延迟叫进位传播延迟。32 位加法器如果逐级等下去关键路径会很长于是有了超前进位加法器用额外的逻辑提前算出每一位的进位牺牲面积换速度。这也是为什么你在看处理器参数时ALU 的加法延迟通常是个被反复优化的指标。对写代码的人来说这个结构解释了两件事。第一无符号整数的加法溢出是自然发生的因为高位进位直接被丢掉了硬件根本不报错C 语言里 unsigned 溢出是定义良好的回绕行为而 signed 溢出则是未定义行为。第二补码的价值在这里体现得淋漓尽致减法不需要单独的减法器把减数取补码后送进同一个加法器就行。一个加法器同时扛下加减两种运算这是硬件面积上的巨大节省也是补码能统治现代处理器的根本原因。2.3 状态越多越脆弱从三进制计算机的尝试说起前面提到三进制在理论上信息密度更优那为什么现实中没有普及核心在于状态越多容错窗口越窄这条铁律。二进制每个存储单元只需要判断高于阈值还是低于阈值判定一次就够三进制需要判断落在低、中、高哪一段判定两次而且中间那一段的上下边界都必须留足余量。假设供电 5V、三等分每段 1.67V再扣掉器件偏差和温度漂移实际可用余量可能只剩几百毫伏。这意味着同样的工艺条件下三进制存储单元的可靠性明显低于二进制。更麻烦的是状态爆炸带来的连锁反应。存储要重新设计指令编码要重新设计三进制下的位宽和指令长度关系全变了编译器后端的寄存器分配、常量折叠、位运算优化统统要重写连调试器、烧录器、测试设备都要重做。二进制阵营在这些环节积累了几十年的工具链三进制要从零开始追赶。技术史上类似的剧情反复上演不是更优雅的方案赢了而是生态更完整、边际成本更低的方案赢了。还有一个物理层面的现实今天的存储介质几乎都是为两态设计的。磁介质的正负磁化方向、光盘表面的坑与岸、闪存单元里电荷的有与无、DRAM 电容的充与放全都是天然的两态。要在这些介质上塞进第三个稳定状态需要大幅改动材料和工艺收益却只是理论上不到 10% 的密度提升实际还常被可靠性折损抵消这笔账怎么算都不划算。3. 十进制和负数怎么塞进二进制手算方法与精度陷阱3.1 整数转换除 2 取余和按权展开一个都不能少把十进制整数转成二进制标准做法是除 2 取余逆序排列。拿热搜里出现的 217 举例我一步步写出来217 / 2 108 余 1 ← 最低位 108 / 2 54 余 0 54 / 2 27 余 0 27 / 2 13 余 1 13 / 2 6 余 1 6 / 2 3 余 0 3 / 2 1 余 1 1 / 2 0 余 1 ← 最高位 逆序读余数11011001反向验证用按权展开从右往左数位权第 0 位权值是 1第 1 位是 2第 2 位是 4依此类推。11011001 里为 1 的位是第 0、3、4、6、7 位对应权值 1、8、16、64、128加起来正好 217。这两个方向必须都练熟因为它们的用途完全不同正着算按权展开用于人读机器数据反着算除基取余用于人向机器描述数据。我见过太多人只会一个方向结果在做协议解析或者手工改配置的时候卡壳。记住那张权值表——128、64、32、16、8、4、2、1熟练之后看一个字节的二进制基本能一眼读出十进制这比掏计算器快得多。有个实操技巧值得分享如果你的数字不大用凑权值法往往比除 2 取余更快。比如 217先看最大的 128减完剩 89再看 64剩 2532 不够跳过16 减完剩 98 减完剩 14、2 不够跳过1 减完剩 0。写下来就是 1 1 0 1 1 0 0 1和上面的结果一致。这个方法在心算和快速校验时特别好用。3.2 小数转换乘 2 取整以及绕不开的精度限制小数部分的转换规则是乘 2 取整顺序排列。看一个能除尽的例子十进制 0.6250.625 × 2 1.25 取整 1余 0.25 0.25 × 2 0.5 取整 0余 0.5 0.5 × 2 1.0 取整 1余 0 顺序读整数部分0.101二进制 验证1/2 0/4 1/8 0.625但换成 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 ← 循环开始了 ... 无限循环 0011也就是说十进制 0.1 在二进制里是个无限循环小数 0.0001100110011...。有限位宽的浮点数必须截断它于是误差出现了。IEEE 754 单精度下0.1 实际存储的值约等于 0.100000001490116双精度下约等于 0.1000000000000000055511。误差小到几乎无害但它确实存在而且会在连续运算中累积。这就是十进制小数转换为二进制有精度限制时要不要考虑舍入这个问题的答案必须考虑而且要考虑两处——一是转换时的舍入模式就近舍入、向零舍入、向上向下舍入二是运算过程中误差的传播方式。工程上的标准做法是凡是涉及钱、分数、精确计数的场景一律绕开二进制浮点。金额用整数分存储或者用十进制浮点库物理仿真、图形渲染这类允许小误差的场景才放心用 float 和 double。这条分界线守住了能省掉后面无穷无尽的麻烦。顺便说一句判断一个十进制小数能不能被二进制精确表示有个简单规则把它化成最简分数后看分母是不是 2 的幂。0.625 5/8分母 8 是 2 的立方所以能精确表示0.1 1/10分母 10 含因子 5不能精确表示。这个规则我每次讲给新人听他们都觉得比死记0.1 加 0.2 不等于 0.3有用多了。3.3 负数怎么表示原码、反码到补码的演进逻辑光有正数不够用还得表示负数。教科书里的演进路径是原码、反码、补码每一代都是在修补上一代的缺陷。原码最直观最高位当符号位0 表示正、1 表示负剩下的位存绝对值。8 位下 1 是 00000001-1 是 10000001。问题有两个一是出现了 000000000和 -010000000两个零硬件做比较时要写两套判断二是加减法不能统一1 (-1) 按位加会得到 10000010也就是 -2明显不对只能靠软件先判断符号再决定用加法还是减法电路复杂度翻倍。反码的做法是负数按位取反-1 变成 11111110。它把求负数的操作简化成了取反但零的问题依然存在而且加法仍然要单独处理进位回卷。8 位反码里 1 是 00000001-1 是 11111110相加得到 11111111再回卷加 1 变成 00000000这个回卷逻辑额外增加了电路。补码则一劳永逸地解决了这两个问题。规则很简单负数的补码等于其绝对值的按位取反再加一。8 位下 -1 的求法是1 是 00000001取反得 11111110加一得 11111111。现在验证 1 (-1)00000001 11111111 1 00000000最高位的进位溢出被丢弃结果正好是 00000000也就是 0。减法不用单独做零只有一个一套加法器全搞定。补码带来的另一个结果是表示范围不对称8 位有符号整数范围是 -128 到 127而不是对称的 -127 到 127因为 10000000 这个原码里的 -0 被重新定义成了 -128。16 位对应 -32768 到 3276732 位对应 -2147483648 到 2147483647。这个不对称性直接导致了一个经典 bug对最小负数取绝对值或者取负结果还是它自己溢出了。写代码时如果要处理可能为负的绝对值运算记得先判断边界。3.4 二进制除法长除法、移位减与除零陷阱二进制除法在原理上就是十进制竖式除法的翻版只是每一步的商只可能是 0 或 1判断变得极其简单够减就上 1不够减就上 0。看一个例子1001十进制 9除以 11十进制 3被除数1001 除数 0011 1. 取高 4 位 1001与 0011 比较够减 1001 - 0011 0110商位记 1 2. 试商完成商 11二进制即 3 验证3 × 3 9再看一个有余数的1000十进制 8除以 11十进制 31000 ÷ 0011 第 1 步1000 够减1000 - 0011 0101商记 1 第 2 步0101 从最高位起与 0011 比较 这里按标准长除法逐位下移最终 商 10二进制 2余数 10二进制 2 验证2 × 3 2 8硬件实现除法远比加法麻烦因为它涉及试减 恢复或者不恢复余数的迭代一次除法需要多个时钟周期这也是为什么处理器里除法指令的延迟通常是加法指令的十倍以上。现代 CPU 会用查找表加牛顿迭代来加速但代价是面积。写代码时要留心两个坑。第一除数为零。整数除法在任何语言里除零都是错误C 里是未定义行为Java 和 Go 会 panicPython 抛异常。第二负数除法与取模的符号规则各语言不一致C99 起规定向零截断而 Python 用的是向下取整所以 -7 // 2 在 Python 里是 -4在 C 里是 -3。这类差异在跨语言移植算法时最容易出事我的习惯是只要涉及负数就先把符号提出来单独处理再对绝对值做运算最后把符号贴回去。4. 二进制在软件工程里留下的那些隐形坑4.1 文本和二进制的边界strstr 到底能不能查二进制内存这是个非常典型的面试题C 语言的 strstr() 能不能用来在二进制内存里查找子串答案是不能原因不是性能而是语义。strstr 的搜索终止条件是遇到 \0它假设输入是 C 字符串。二进制数据里 0x00 是合法而且常见的字节一旦搜索路径上出现 0x00strstr 会认为字符串结束了后面的内容根本不会被检查。同样的问题也存在于 strlen、strcpy、strcmp 这一整组函数上。正确的做法是用按长度计算的接口GNU 环境下的 memmemC 的 std::search 配合迭代器或者干脆自己写一个滑动窗口比较。关键点是把长度作为显式参数传进去永远不要依赖内容里的终止符。我在做协议解析时踩过一次很深的坑从网络收上来的数据帧里某个字段恰好是 0x00用 strstr 找分隔符时提前截断导致后面所有字段全部错位排查了一整天才定位到。从那以后我给自己定了个规矩凡是处理内存块、文件内容、网络报文一律用带长度参数的函数代码里看到 str 开头的函数就自动警觉三分。这个习惯看起来是小事但它能挡掉一整类难查的偏移错误。4.2 浮点比较与金额计算那些年我们一起写错的小数前面说过 0.1 在二进制里是无限循环小数那 0.1 0.2 会得到什么就顺理成章了。用双精度算结果是 0.30000000000000004不是 0.3。这不是 bug是浮点表示法的必然结果。所以判断两个浮点数相等绝对不能写 a b而要用差值绝对值跟一个容差比较。容差的选取要结合数量级常见做法是相对容差和绝对容差取较大者。金额计算是另一个重灾区。我见过有项目用 double 存商品价格做活动折扣叠加后账单总和跟明细对不上差了几分钱财务那边直接打回来。改成用整数分存储之后问题消失。如果语言支持用十进制浮点类型比如 Python 的 decimal、Java 的 BigDecimal也可以但要注意性能开销和构造方式——从字符串构造不要从 double 构造否则误差在入口就带进来了。数据库设计也有对应要求存金额的字段用定点数类型明确指定精度和小数位数别用浮点类型。这条经验几乎是用真金白银换来的新人问我数据库字段类型怎么选我第一句就是涉及钱和计数的一律定点数。4.3 二进制包、CPU 架构与那句预览文件可能有害的提示日常运维里二进制这个词还有另一个含义预编译好的可执行文件相对于需要现场编译的源码包。下载这类包时架构匹配是第一道门槛。x86_64、arm64也叫 aarch64、armv7 这些架构的指令集完全不同给 x86_64 编译的可执行文件拿到 arm64 机器上根本跑不起来报的错通常是格式错误或者无法执行。所以像内存检测工具这类二进制包下载页面上会明确标出架构版本选错了就只能重下。这背后的原理其实就是本文的主线机器的指令是二进制编码的解码规则由架构决定。同一段比特流在 x86_64 上被解码成一条加法指令在 arm64 上可能被解码成完全不相干的操作甚至直接触发非法指令异常。所以二进制兼容从来不是天然属性而是需要架构一致或者有转译层才能实现。再说说那句很多人见过的提示——你尝试预览的文件可能对你的计算机有害。它出现的原因很朴素系统识别出你要打开的不是文本内容而是可执行文件或有潜在风险的复合格式于是提前拦一道。判断依据常常是文件头部的魔数比如 Windows 可执行文件以 MZ 开头类 Unix 的可执行文件以 0x7F 加 ELF 开头PDF 以 %PDF 开头。系统读几个字节就知道这是什么类型然后按策略决定放不放行。理解这一点之后你再看那些打开就中毒的都市传说就知道真正的防线其实是文件类型识别加访问控制而不是扩展名。顺便说改扩展名骗过系统是个非常糟糕的习惯改了名文件头没变该执行还是执行反而让人放松警惕。5. 想真正吃透二进制我建议这样练5.1 手算练习清单与自查方法光看不动手二进制的直觉永远建立不起来。我自己练过、也推荐给别人的一套练习是这样的第一每天随机挑五个 0 到 255 之间的十进制数只用脑子和纸笔转成二进制再用十六进制写一遍然后用计算器核对第二反过来随机挑五个字节的二进制一眼读出十进制值目标是两秒内报出结果第三把 -1 到 -10 的补码手写一遍检查自己是不是真的会取反加一第四拿几个像 0.625、0.375 这样的十进制小数判断能不能被二进制精确表示不能的话写出前 8 位第五用二进制长除法算几组带余数的除法。这五组练习每天二十分钟坚持两周你对数值的敏感度会有肉眼可见的变化。自查方法也很关键。转换题做完不要只看答案对不对要看过程对不对除 2 取余是不是逆序读了按权展开的权值是不是从 0 开始编的小数乘 2 取整是不是顺序读的。这些步骤错了答案偶尔也能蒙对但换个数字就露馅。我见过太多人栽在逆序和顺序这两个字上包括我自己刚开始学的时候。5.2 工具选型与学习路线别一上来就啃硬书工具方面Python 是最顺手的练习场。bin()、hex()、oct() 三个函数可以把整数直接转成对应进制的字符串int(11011001, 2) 可以转回来位运算符 、|、^、~、、 用起来直观。想练手算的就别用这些函数想验证结果的随时用。查看文件的真实字节Linux 下用 xxd 或 odWindows 下用 PowerShell 的 Format-Hex看几次你就明白文本文件也是二进制这句话是什么意思了。学习路线上我的建议是先建立直觉再补理论最后回到实践中验证。第一步用上面的练习清单建立数值直觉第二步翻计算机组成原理里关于数制与编码的章节重点看补码、定点浮点表示那两节别一上来就啃整本硬书容易劝退第三步找个小项目练比如自己写一个十六进制查看器或者实现一个简单的位图数据结构或者用位运算把一堆布尔状态压缩进一个整数里。我在做用户权限模块时就用了位运算读、写、执行三个权限分别占一位组合起来就是一个 0 到 7 的整数判断权限只要一次与运算比存三个布尔字段清爽得多。Linux 文件权限里 755、644 这些熟悉数字本质上就是同一个思路的产物。6. 常见问题速查与避坑清单6.1 高频疑问速查表常见疑问结论关键原因为什么不用十进制电路判别余量不够十个状态把电压窗口切得太碎抗干扰能力差三进制理论上更优为何不普及生态成本压过高器件三态判别难、工具链全部要重建、判错风险高0.1 加 0.2 为什么不等于 0.3二进制无法精确表示 0.1十进制 0.1 转二进制是无限循环小数必须截断8 位有符号范围为何不对称补码把 -0 用掉了10000000 被定义为 -128因此下界比上界多一个strstr 能否查二进制内存不能遇 0x00 即终止应改用带长度的接口同一个二进制包为何换机器跑不了指令集架构不同x86_64 与 arm64 解码规则不一致判断小数能否精确表示看最简分数分母分母是 2 的幂就能否则不能金额字段该用什么类型整数分或定点数浮点误差在累加和比较时会暴露6.2 我踩过和见过的坑顺手帮你标出来第一类坑是类型边界。有符号整数的最小值取负会溢出这是补码表示不对称带来的必然结果处理绝对值前先判边界。无符号整数减到负数会自动回绕成很大的正数循环里用无符号变量做倒计数经常因此变成死循环这是新手最常见的翻车点之一。我的习惯是循环计数用有符号类型需要位运算的时候再显式转型。第二类坑是浮点比较。除了不能直接判等还要注意累加顺序会影响结果因为每次加法的舍入误差不同。做大数加小数的时候小数的精度可能被直接吃掉。如果必须在浮点下做求和考虑先排序再从小到大加或者用 Kahan 求和这类补偿算法能显著降低误差累积。第三类坑是进制混淆。代码里写 0x10 是十六进制的 16写 010 在某些语言里是八进制的 8Python 3 已经不允许这种写法要用 0o10写 10 才是十进制的 10。这个坑在配置解析和协议实现里特别容易出因为数字来源可能是字符串需要显式指定基数。解析时永远指定进制参数别依赖默认行为。第四类坑是字符串函数处理二进制数据。前面讲过的 strstr 只是代表strlen、strcpy、sprintf 这一组都有同样的问题。凡是数据来源可能包含 0x00一律换成带长度的接口或者在数据结构里显式保存长度。这个习惯看起来啰嗦但它挡掉的是最难定位的那一类 bug——数据在中途悄悄变短错误在几百行之外才爆发。第五类坑是忽视字节序。多字节整数在内存里的排列有大端和小端两种约定网络传输规定用大端本地存储各平台不同。做协议解析时如果忘了转换读出来的数字会诡异得离谱比如本该是 1 的值读成 16777216。这类问题在调试器里看内存时特别直观看几次就记住了。说到底二进制之所以值得花时间搞懂不是因为它出现在考试里而是因为它是所有上层抽象的底座。你写的每一行代码最终都会变成一串电平的高低组合在这块硅片上被加法器、寄存器、缓存一层层搬运。理解了这层底座很多看起来莫名其妙的现象——浮点误差、整数溢出、架构不兼容、字符串截断——都会立刻变得顺理成章。我个人在实际工作中的体会是凡是能把二进制这一层想清楚的工程师排查底层问题时的手感明显不一样他们不是在试错而是在推理。
返回列表