ARTICLE DETAIL

资讯详情

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

从补码到浮点数:计算机底层运算为何万物皆加法

从补码到浮点数:计算机底层运算为何万物皆加法 讲减法的时候几乎所有教材都会先补码、再反码最后才告诉你结果是“取反加一”。我不打算这么讲。我带你从一个小学生的笔算过程出发一步步倒推出一台计算机到底是怎么把加法用到极致的。看完这篇你不仅会明白为什么说“万物皆加法”还会顺手把面试里常问的溢出、补码、浮点精度这些坑也一并踩明白。这篇文章写给三类人正在学计算机组成原理但被各种门电路绕晕的学生工作中天天写代码却被0.1 0.2 ! 0.3搞到头大想弄清楚原理的开发还有纯粹好奇手机里那块芯片为什么能算出“112”的普通人。我不绕弯子直接从最底层开始一步步往上搭。1. 为什么计算机非要用0和1从物理开关到布尔代数1.1 一个灯泡和两个开关就是CPU的起点我们小时候都做过一个物理实验把一个灯泡、两节电池、两个开关串起来。当两个开关都按下时灯泡才亮只要有一个开关断开灯泡就灭。这个看似简单的实验其实是整个计算机最底层的本质——电信号只有两种稳定状态有电压和没电压。在芯片里这个“开关”被晶体管替代了。晶体管有一个极小的特性给它一个控制电压它就能让主电路导通或者断开。导通时我们说它是“1”断开时是“0”。你可能会问为什么不能用三个电压来对应0、1、2呢关键在于工程实现。首先电路里的电压不是绝对稳定的会受温度、电磁干扰的影响。如果只区分两个状态只要阈值设计得足够宽比如低于0.8V算0高于2V算1抗干扰能力就极强。而要区分十个电压级别每个级别的“安全窗口”会被压缩得很窄稍微有一点噪声一个“5”就可能被误读成“6”。其次多状态电路功耗会急剧上升晶体管开关速度也会变慢。所以二进制的选择根本不是“数学上最优”而是“物理上最稳”。1.2 布尔代数是工程师的数学翻译机有了0和1之后接下来要解决的是“怎么让它们运算”。1854年布尔发明了布尔代数当时纯粹是逻辑学游戏定义了三种运算与AND、或OR、非NOT。直到1938年香农在他的硕士论文里指出布尔代数的“真”和“假”完全可以对应继电器电路的“通”和“断”。这下数学和电路终于接上了。举个例子两个开关串联的电路做一个“与”操作开关A开关B灯泡000010100111你可以发现这个真值表和“1 AND 1 1”完全一致。于是工程师们把各种门电路做成标准零件与门、或门、非门、异或门。其中异或门特别有意思两个输入不一样时输出1。它后面会成为加法器的核心。2. 加法器万物皆加法的起点2.1 半加器先学会个位数加法现在我们有了一堆门电路怎么搭出加法先从最朴素的一位二进制加法开始。二进制只有0和1所以加法规则很简单000011101110进1。注意到没有只有“11”比较特殊它的个位数是0还要向上进一位。个位数部分其实就是“异或”相同为0不同为1。进位部分则是“与”只有两个输入都是1时进位才是1。把一个异或门和一个与门拼在一起就构成了半加器。它有两个输入A和B两个输出S和和C进位。2.2 全加器把上一位的进位也算进来半加器有个致命缺陷它不考虑来自低位的进位。可我们在做竖式计算时每一位除了本身的两个数相加还要再加上右边那位跑过来的进位。所以需要一个三输入结构的电路全加器。它有三个输入A、B、Cin进位输入两个输出S、Cout进位输出。逻辑关系是这样和S等于A、B、Cin三者的异或即奇偶校验进位Cout则在A和B有两个及以上为1时为1。写成布尔表达式就是S A XOR B XOR Cin Cout (A AND B) OR (Cin AND (A XOR B))用两个半加器加一个或门就能搭出全加器。别看这几个门平平无奇它就是你电脑里所有复杂计算大厦的第一块积木。无论是算光追画面里的矩阵变换还是神经网络里的上亿次浮点乘加最底层都是这个全加器在不停工作。2.3 多位加法纹波进位加法器的串行等待一个全加器只能算一位那你想要算32位的数怎么办很简单把32个全加器首尾相接第0位的进位输出接到第1位的进位输入第1位的接第2位以此类推。这就是经典的纹波进位加法器Ripple Carry AdderRCA。为什么叫“纹波”因为进位要像水波纹一样从最低位一级一级传到最高位。这带来了一个严重的性能问题最高位的计算必须等低位的进位一路传上来。比如算0xFFFFFFFF 1进位要从第0位一直传到最后一位每一位都产生门延迟。32位加法在最坏情况下需要经过 32 × 2 个门延迟这个“串行等待”成了早期CPU的速度瓶颈。我当初学到这里时也有个疑问那我直接算不就行了但硬件没有任何“直接算”的能力它必须按电路时序一步一步来。这也解释了为什么超前进位加法器这么重要——我们后面第7节再展开讲。3. 减法为什么也是加法补码的前世今生3.1 钟表模型一切都可以用“转圈”来理解现在我们会做了加法但减法怎么处理如果专门再造一个减法器电路又多一倍的复杂度。我们回到加减法最原始的关系上去想。想象一个钟表只有12个刻度。现在是3点你想让它回到1点。方法一逆时针拨2格也就是3-21方法二顺时针拨10格也就是31013点而13点在表盘上还是1点。发现了什么减去2 等价于 加上10因为10是“转满一圈12再减2”的那个数。这个“转圈”思维在计算机里极其重要。N位二进制能表示2^N个数想象一个模数M2^N的“数字转盘”。如果把A-B变成A(M-B)结果在转盘上转了一圈后落点和减法完全一致。那个(M-B)就是B的补码。由于M是2的整数次幂M-B又等价于按位取反再加1。3.2 取反加一为什么偏偏是这样有人会问为什么非要取反加一而不是直接减法器因为取反在电路里就是一堆非门成本极低加一则用现成的加法器就行了。这样一来减法就彻底被消灭了CPU里只需要加法器连减法器都不用做了。验证一下4位情况。用-3举例3的二进制是0011取反是1100加1得到1101。现在计算5-35的二进制是0101加上-3的补码11010101 1101 ------- 1 0010最高位产生的进位1扔掉剩下0010正好是2。这个设计精妙得让人拍大腿正数、负数统一走加法器结果完全正确还自动得到了符号位。再往下看补码还有一个隐藏好处0只有唯一表示。反码方案里0会出现0000和1111两个表示会带来额外的判断逻辑而补码彻底规避了这个问题。4位补码的范围是-8到7非对称负数总比正数多一个。-8的补码是1000它没有对应的正数8这也是很多溢出bug的根源。3.3 补码的实际调试心得我在实际排查问题中最常遇到的补码陷阱有两个。第一个是“符号位参与运算”这件事被忽略。补码的加减法和无符号数在硬件电路上完全一样CPU根本不知道你这个数是“正的”还是“负的”它只是把位模式丢进加法器出来的位模式由程序员来解释。所以两个正数相加变成负数或者正数减负数溢出成负数这些都是“解释层”的问题不是硬件问题。第二个是任意扩展时的坑。把8位补码转成16位必须做符号扩展比如8位的11111111-1扩展成16位应该是1111111111111111而不是在后面补0。如果只补0-1会被错误地变成255。这个错误在写解析网络协议或者处理图像像素时特别容易踩。4. 乘法移位加法的艺术4.1 从手算竖式说起小学二年级我们学过多位数乘法例如13×6竖式是先用6乘以13个位3得到18写8进1再算十位1×6再加上进位1得到7。拆开来看本质上就是逐位相乘再把结果累加。二进制乘法更简单因为每一位只有0或1所以中间结果只可能是0或者被乘数本身。以二进制 1101×0110 为例也就是 13×61101 (13) × 0110 ( 6) --------- 0000 (乘数第0位0) 1101 (乘数第1位1左移1位) 1101 (乘数第2位1左移2位) 0000 (乘数第3位0左移3位) --------- 1001110 (78)过程其实就是看乘数的每一位是0还是1如果是1就把被乘数左移对应位数再把所有部分积加起来。这不就是“反复加”吗所以“万物皆加法”在这里最直观——乘法根本没有新的运算只有移位和加法。4.2 硬件乘法器的基本工作过程最早的计算机乘法器真的就走这个笨办法当作“多个加法”来算。一个硬件乘法器的典型实现大致这样初始化被乘数放在寄存器A乘数放在寄存器B部分积寄存器P清零。循环检查乘数最右边一位若是1就执行 P P A然后把A左移一位B右移一位。重复直到乘数的所有位都处理完P里就是最终结果。看起来很简单但有个细节值得注意每处理一位被乘数要左移一次这是为了对齐后面要加的位。这个操作在硬件上就是连线换位置成本极低。早期8位乘法器算一次乘法需要执行8次判断和最多8次加法比起32位加法器那是慢了一大截。4.3 优化思路Booth算法和硬件加速纯粹等在那儿加也太慢了。后来人们发现如果乘数里有连续的一串1比如011110逐个把被乘数加上去很浪费。大数学家Booth提出一个技巧把一串连续的1替换成“一次高位加法和一次低位减法”。本质上还是加减法但把部分积的数量从“1的个数”压缩成“1的段数”。再后来到现代CPU乘法器干脆用硬件树形结构把所有部分积并行加到一起这就是Wallace树。它的思想是用3-2压缩器把三行部分积压成两行再用快速加法器算出最终结果把乘法的延迟降低到几个时钟周期。这就是为什么现代CPU里一条乘法指令比如imul通常也就3~5个周期而不是傻傻地循环加几十次。这里我想给写代码的朋友一个实用建议如果做嵌入式开发没有硬件乘法器的场合例如部分低端MCU写a * 13可以改成(a 3) (a 2) a因为13841。但这种写法可读性差编译器在高优化等级下自己就会做类似转换所以一般情况下直接写乘法把优化交给编译器别瞎折腾。5. 除法本质上还是在做加法5.1 恢复余数法像做竖式除法一样除法算是最麻烦的运算了。但如果你把它看成“连续减法”的封装减法又已经被我们变成了加法所以除法最终还是落脚在加法上。经典的恢复余数法思路和小学竖式一模一样从被除数最高位开始每次向左看一位得到当前的部分余数。尝试“减去除数”如果够减商这位记1如果不够减商这位记0并且把刚才减掉的加回来恢复所以叫“恢复余数法”。重复直到所有位处理完。注意第2步里的“减去除数”实际上是用补码做的加法“不够减时加回来”又是加法。所以除法的底层实际上就是一次次补码加法配合位判断。拿二进制01101010 ÷ 1010也就是 106÷10 来说过程会经历好几次“加除数恢复余数”看起来会比乘法更笨重。5.2 不恢复余数法和现代SRT除法恢复余数法的“恢复”操作很浪费因此出现了不恢复余数法当发现不够减导致余数为负时不恢复而是根据余数是正还是负来决定下一步是加除数还是减除数。这样一来每一步只有一次加/减操作流程固定更容易做成流水线。更高级的SRT除法是现代CPU的主流实现Intel、ARM都在用。它的核心思想是“每次多猜几位商”通过查表快速确定商的可能取值而不是一次一位。这样做的好处是商的位数减少循环次数减少。SRT还允许商在某个范围内有冗余表示后续用加/减来修正所以它的硬件复杂度极高但换来了速度。5.3 为什么除法永远是“慢指令”这应该是我被问过最多的问题之一。很多人在写性能敏感代码时会尽量减少除法指令就是因为除法在x86里可能消耗20~90个时钟周期而加法1个周期、乘法3~5个周期。为什么差这么多根本原因是除法有串行依赖每一步商的确定都依赖上一步的余数没法像乘法那样用树形结构把部分积并行压缩。你不能提前算出“第5位的商”因为它依赖前面4次减法的结果。这就是除法并行化的最大阻碍。另外除零、溢出这些边界条件也需要流水线去处理进一步拖慢速度。我给做高性能计算的读者一句经验之谈循环里如果除数是常量编译器会用乘法加移位来替代除法魔法数乘法所以不用太担心但如果是变量做除数那性能损耗就真实存在了。如果你在一个热循环里反复除以同一个变量可以先算它的倒数inv 1.0 / divisor再乘x * inv。注意这种方式对整数不适用浮点场景才安全。6. 浮点数的加减乘除验证“万物皆加法”的最佳实验场6.1 浮点数是怎么存的符号位、指数、尾数聊到现在我们一直在讨论整数。但你在写金融系统时用double算钱可是会出大事的。要理解这个问题得先看浮点数的结构。IEEE 754 单精度浮点数用32位存储1位符号、8位指数、23位尾数。双精度则用64位指数11位、尾数52位。它本质上是科学计数法的二进制版(-1)^符号 × 1.尾数 × 2^(指数-偏置)。比如十进制的-6.5二进制是-110.1用科学计数法就是-1.101 × 2^2。尾数部分是101指数部分的实际值是1272129单精度偏置127。我提这个结构的重点是二进制浮点数能精确表示的十进制小数只有那些能分解成若干个2的负幂次求和的小数。0.5可以因为它是2^-10.1不行因为0.1 1/10而10含有因子5在二进制里是无穷循环小数。没错0.1在计算机里就像十进制里1/3一样永远写不完只能近似。6.2 0.10.2 为什么不等于0.3既然0.1和0.2在二进制里都是近似值那把它们做加法时误差就被放大了。浮点加法过程是这样的先比较两个数的指数把小的那个往右移位对阶让两个数的尾数对齐然后尾数相加这里又是加法再规格化、舍入回23或52位。0.1的二进制大约是0.000110011001100110011001100110011...无限循环0.2大约是0.001100110011001100110011001100110...。相加后得到的结果在双精度下显示成0.30000000000000004。不是bug是数学的必然。这在金融领域绝不能接受。解决办法在实际开发中有两个方向。第一如果是金额计算用BigDecimal——它本质上是把十进制数拆成“未缩放的值 小数位数”内部用一个整数来精确表示比如123.45存成12345和 2位小数这样运算就回归到了整数加减乘法自然精确。第二如果性能敏感且误差可控就把数值放大成整数运算把所有金额按“分”存成long类型比如1.23元存成123分最后再除以100。我在做账务系统时更倾向于后者快且简单。6.3 写代码时常见的浮点坑举三个我见过最多的浮点误用案例用浮点数直接比较相等if (a 0.3)基本会挂。正确做法是计算绝对误差Math.abs(a - 0.3) 1e-9。大数吃小数1e20 1.0在单精度下结果还是1e20因为1.0被对阶移位后直接被舍入掉了。这在累加大量小数值时尤其危险所以累加建议从大到小或者使用Kahan补偿算法。把浮点数当作循环增量for (float x 0; x 1; x 0.1f)可能导致死循环或次数不对因为每一步都在累积舍入误差。7. 真实CPU里的加法器从8086到现代乱序执行7.1 ALU横在寄存器和存储器之间的“计算心脏”讲道理加法器在真实芯片里不是孤零零存在的。它隶属于CPU的ALU算术逻辑单元ALU负责整数加减、位运算、逻辑比较。ALU的输入来自寄存器堆输出又写回目标寄存器。现代CPU内部有很多个ALU端口分布在不同的执行单元里它们可以并行处理互不依赖的指令。一个典型的现代处理器里加法延迟通常是1个时钟周期这意味着每秒钟可以完成几十亿次加法。这几十亿次的背后靠的是流水线同一时刻第1条指令在第4级流水第2条在第3级第3条在第2级像工厂流水线一样连续吞吐。7.2 流水线背后的功臣超前进位加法器回到第2节留下的问题。纹波进位加法器进位是一层层传的太慢了。**超前进位加法器Carry Look-Ahead Adder, CLA**的核心思路是提前算出每一位的进位是“由这一位自身产生的generate”还是“由低位的进位传播上来的propagate”。一旦进位传播链条被并行计算就不再需要从低到高一级级等下去。用公式表达第i位的进位可以写成C1 G0 | (P0 C0) C2 G1 | (P1 G0) | (P1 P0 C0) C3 G2 | (P2 G1) | (P2 P1 G0) | (P2 P1 P0 C0)这个式子展开后可以看到每一位的进位都可以用最低位的进位C0以及各位的G、P信号直接算出来不再依赖中间进位。代价是电路复杂度呈平方级上升。所以工程上一般用“4位一组”的CLA模块组间再做超前进位取得速度和面积的平衡。现代更极端的实现是Kogge-Stone等并行前缀加法器用类似递归的思路把进位链延迟降到O(log n)在一个时钟周期内完成64位加法绰绰有余。7.3 现代CPU里加法以外的算计如果只是“加法”现代CPU不会这么复杂。为了极致压榨性能CPU内部还有一个隐藏技能宏融合macro-fusion。比如CMP和JZ两条指令会被融合成一条微操作这是因为比较的本质是“做一次减法然后看标志位”而跳转依赖标志位两者融合可以省一次执行。这里的减法依然用的是补码加法。在做性能优化时我建议你了解一点指令延迟数据现代x86上整数加法延迟1周期、乘法延迟3~5周期、除法延迟20~90周期、浮点加法和乘法都可以到2~4周期再借助FMA融合乘加指令一次完成a*bc。这也是为什么深度学习里矩阵乘法性能如此之高——基本上全在用FMA在“乘加”中打转而乘加的底层又是加法。8. 常见问题与排查技巧实录8.1 溢出到底怎么判断做了这么多年开发我相信每个人都被溢出坑过。比如 int 的最大值是 2147483647再加1就变成了 -2147483648。里程碑案例就是波音787的飞机在累计飞行248天后因为计时器溢出导致整机断电重启原因是它的计时变量用了一个16位计数器单位是百分之一秒248天正好溢出。这里我把判断溢出的几条实用经验分享给你在C/C里用__builtin_add_overflow(a, b, result)或C20的std::add_overflow能直接得到溢出标志。判断“两个正数的和是否溢出”可以看符号位标志。更可靠的办法是if (a INT_MAX - b)即把“ab溢出”转换成“b是否大于上界减a”的形式无符号运算不会溢出。使用无符号类型做辅助计算也是常见技巧因为无符号溢出是定义好的模运算不会触发未定义行为。8.2 我看到位模式了怎么“反向验证”它是不是负数调试时在内存里看到11111111你需要结合上下文决定它是255还是-1。这确实很容易让人懵。我的习惯是记住一条CPU不关心符号类型系统关心。如果你把它当作signed char它就是-1当作unsigned char就是255。在调试器里切换类型显示是最直接的办法。另一个容易混淆的概念是算术右移和逻辑右移。对有符号负数右移时高位要补符号位算术右移比如-8 1 -4对无符号数右移高位补0逻辑右移。很多新手用实现“除以2”时遇到负数就整出大问题-7 1 -4但-7 / 2 -3向0取整。这就是为什么我说“用移位替代除法”要极其小心它在正数场景没问题一旦涉及负数和舍入方向就和除法的语义分道扬镳了。8.3 为什么有些加法器设计“看着更慢但被广泛使用”你可能觉得超前进位加法器这么优秀为什么芯片里还保留很多普通加法器答案是面积和功耗。超前进位加法器的晶体管数量与位数平方成正比对64位加法来说代价相当可观。处理器设计通常在一个时钟周期内用快速加法器做部分关键运算而在对延迟不敏感的场合用更小的面积实现普通加法以换取更好地能效。芯片设计永远是“速度、面积、功耗”三者的折衷没有绝对的“最好”。同样这也是为什么有经验的工程师不会在代码里写“自定义加法器”或者“用循环替代乘法”。你写的每一行高级语言最终都要被编译成目标CPU的指令集。过度追求手写底层优化往往适得其反。真正需要优化算法时应该先profile确定热点再做局部的、有把握的改动。9. 实操心得从“看懂原理”到“真正理解”写到这里你可能已经明白标题为什么叫“万物皆加法”了。减法被补码变成了加法乘法是移位加部分积除法是减法的循环而减法又是补码加法浮点运算在对阶、规格化的过程中也是一堆加减法在打底。整个计算机的数学世界最终都收缩到“二进制加法”这一个原点上而加法器又是由最基本的与、或、非门搭建出来的。这条逻辑链是整个计算机组成原理里最值得反复咀嚼的一段。最后再分享一个我这几年给别人讲这个知识点时经常用到的方法如果你真的想把这套逻辑刻进脑子里别只看书去搭一次电路。用 Logisim 或者 Verilog 写一个4位的补码加法器再扩展成8位乘法器亲自动手连一次线、写一次测试向量。当你亲眼看到1011 0101在波形图上的输出真的变成了0000并且进位是1那种“原来如此”的感觉比看十遍教材都管用。我现在遇到很多原理记不清的细节还会去翻自己当年搭的实验波形图一目了然。
返回列表