翻开计算机组成原理教材很多人会被“运算方法与运算器”这一章劝退。它没有项目代码没有炫酷界面摆在你面前的是补码、进位链、真值表和一堆看似重复的加法器电路。但如果把它当成“CPU到底是怎么算加减乘除的”谜底来读这一章其实是整个计算机体系里最讲道理的部分。我这些年带过不少刚接触组成原理的同学大家普遍的感觉是上课能听懂作业不会算考试更加懵。问题往往就出在“运算方法”和“运算器”被当成两个独立章节来背了。实际上前者是算法思想后者是电路实现两者一一对应。一旦你把“补码怎么算”和“加法器为什么这么接”放到一起看知识就会变成一条线。这篇内容就当是个人的学习笔记加实操心得我会把补码、加减乘除的机器实现、ALU进位链、浮点运算这些核心点串起来讲尽量用大白话和带数值的例子让你看完后能动手推演也能知道这些逻辑在真实CPU里长什么样。1. 运算方法与运算器到底在解决什么问题1.1 从“真值”到“机器数”的思维切换我们平时写数学题用的是带正负号的十进制5、-3符号和数值分开写。可机器里只有高电平和低电平也就是0和1没有多余的一条线专门表示正负号。所以必须把“负数”也编码成一串01串这就出现了机器数。最直观的想法是“符号位绝对值”也就是原码。用最高位表示符号0为正、1为负其余位表示数值大小。比如4位原码中3是0011-3是1011。它看起来很美但计算时很痛苦要判断符号位要比较绝对值大小最后还要决定结果符号这相当于逼着电路先做一堆“人类思维”。更麻烦的是原码的0有两种表示0000和1000都表示0判断相等都得多花指令周期。于是补码登场。补码的核心思想是“模运算”在一个固定位数的机器字里数据溢出以后就循环回到起点就和钟表一样。钟面上只有12个小时10点往前拨4小时是2点其实等价于往后拨8小时10818模12就是6不对10-461012-410818模12得6减法可以变成加法。补码就是把减法转换成加法的数学技巧对负数x它的补码就是2的n次方加x其中n是数据位数。实际做题不需要去求模记住“原码取反加一”就行但一定要理解为什么是“取反加一”——它本质上是让符号位也参与运算使得减法变成加上负数的补码最后进位自然丢弃。这也是为什么现代CPU里“减法器”这个部件几乎不存在全用加法器解决。1.2 双符号位与溢出判断用一种计算方式最怕的就是“结果太大装不下”。在补码运算里溢出判断非常重要而最直观的实现就是双符号位也叫变形补码。普通补码只用一个符号位双符号位用两个最高位来表示符号00表示正数11表示负数01表示正溢出10表示负溢出。为什么双符号位好用因为加法的最高位进位和符号位进位是独立出现的。举个例子4位补码中7是01111是0001两者相加得到1000。如果按4位结果看最高位变成1看上去像-8这明显是错的。如果用双符号位7是001111是00001相加得到01000低4位是1000而符号位出现了“01”说明正溢出立刻就能识别。判断溢出的另一种方法是看“最高数值位的进位”和“符号位的进位”是否一致两者不同则溢出。这个规则在很多编程题里会考但在硬件里双符号位直接把两种溢出变成两种编码判断逻辑更简单。所以教材里讲变形补码不只是为了做题它真的会被用在运算器内部防止中间结果悄悄出错。2. 加法运算器全加器与ALU的骨架2.1 一位全加器的逻辑关系运算器最基础的元素是全加器。我们回想一下手算二进制加法的过程第i位相加时除了两个输入Ai和Bi还要考虑从低位来的进位Ci-1输出本位和Si以及向高位的进位Ci。一位全加器就是处理这三输入两输出的“单位格”。全加器的逻辑式可以写成这样Si Ai ⊕ Bi ⊕ Ci-1 Ci Ai·Bi (Ai ⊕ Bi)·Ci-1这里的“⊕”是异或也就是模2加。Si的式子很好理解三个1相加结果为1但奇数个1时Si为1偶数个1时Si为0正好是异或链。Ci的式子则是如果Ai和Bi都是1那不管低位进位是什么高位进位必然为1否则只要Ai和Bi中有一个是1并且低位进位是1也会产生进位。前者叫“进位产生”用G表示后者叫“进位传递”用P表示。这个G和P的命名在后面的快速进位设计里特别重要。很多初学者会在真值表上卡很久我的经验是直接记住两个“看一眼”的结论两个输入不一样Si等于Ci-1取反两个输入一样Si等于Ci-1本身当然这是基于公式观察做题时有奇效。至于Ci只要记住“Ai或Bi有一个1并且低位进1那一定进1”。2.2 串行进位与并行进位把多个一位全加器串起来做多位加法最简单的方法是逐位传递进位叫串行进位加法器。它是电路里最“老实”的方案每个位都等低位的进位算完才动作所以延迟非常长。一位加法器需要两级门延迟4位就要8级32位就要64级在高速CPU里这是不可接受的。并行进位也叫先行进位本质上是用逻辑提前算出高位的进位不让进位一级一级“爬”。以4位加法器为例如果已经得到了每一组的G_i和P_i那么C1可以跳过C0直接算出来C1 G0 P0·C0 C2 G1 P1·G0 P1·P0·C0 C3 G2 P2·G1 P2·P1·G0 P2·P1·P0·C0 C4 G3 P3·G2 P3·P2·G1 P3·P2·P1·G0 P3·P2·P1·P0·C0从公式能看出C4已经不依赖C3、C2这些中间结果了所有进位都可以并行产生延迟从“位数乘2”变成“常数级”。但代价是表达式项数和连线会爆炸式增长位数一多电路面积受不了。所以实际ALU不是全并行而是“组内并行、组间并行/串行”的层次结构。比如4位一组组内用先行进位组间再把每个组的G和P打包给上一级继续展开。这种“分级CLA”的思路和软件里分层调度一模一样关键是控制每层的扇入扇出不要过大。2.3 ALU中的标志位ALU是一个组合逻辑模块能完成加减、与或非、移位等操作。除了结果数据它还会输出几个标志位它们是条件分支指令的“晴雨表”。零标志ZF、符号标志SF、进位/借位标志CF、溢出标志OF这四个几乎所有CPU都有。这几个标志里最容易糊涂的是CF和OF。CF反映无符号数的进位或借位OF反映有符号数是否溢出。做加法时CF等于最高位产生的进位做减法时CF的定义因教材而异在多数系统中表示借位也就是被减数最高位不够减需要向更高位借位时CF1。而OF只看符号位是否被破坏。举个例子两个正数相加最高位进位是1但符号位依然是0那么CF1、OF0这表示无符号数结果超范围了就像十进制991在两位十进制里溢出一样但有符号数结果还是正确的。所以编程时判断无符号数大小看CF判断有符号数大小看SF和OF的比较结果。考试里经常有一道题是“计算后ZF、SF、CF、OF各是多少”做错的人十有八九是把CF和OF混为一谈。3. 乘法运算计算机如何“偷懒”做乘法3.1 原码一位乘法竖式的机器版我们手算乘法是看乘数每一位是否为1为1就把被乘数左移一位相加。比如13乘11竖式会产生两个部分积最后加起来。计算机不能一次保留所有部分积也没那么多加法器所以它改变了一下计算顺序每得到一个部分积就做一次累加然后把部分积右移一位腾出空间给下一位。原码一位乘法的规则很清晰符号位先放一边最后用被乘数和乘数的符号位异或得到结果符号数值位都当成正数处理。具体步骤是被乘数取绝对值乘数取绝对值。设一个部分积初值为0位数要能容纳两倍字长。从乘数最低位开始判断当前位为1则部分积加被乘数然后部分积和乘数联合右移一位当前位为0则不加直接右移。重复n次得到2n位的积再把符号位加到最高位前。这个过程中要注意“右移”是逻辑右移还是算术右移。因为原码数值部分都是正的所以用逻辑右移高位补0就行。如果写成伪代码就是P 0 for i in 0..n-1: if Y[i] 1: P P X P P 1每一步把新的一位乘积结果拼到P的最低位连续n次后P的低n位就是最终积的低半部分。实际操作里常见问题有两个一是忘了“右移”发生在加法之后不是之前二是部分积寄存器宽度不够导致最高位进位丢失。老式教学实验里用4位乘4位得到8位结果很多同学会误以为寄存器只要4位结果高位被截断这种错在硬件仿真里会以未知态出现很难排查。3.2 补码一位乘法Booth算法的核心原码乘法虽然简单但符号位要单独算遇到负数得转换成正数这在硬件上等于多一步“绝对值处理”速度和电路都不划算。补码一位乘法也就是Booth算法可以直接对补码相乘连符号位一起处理。Booth算法的出发点是一个二进制乘数连续的“1”串可以用“进入这一串时加上被乘数离开这一串时减去被乘数”来代替。比如乘数中的“0111”等价于“1000”减去“0001”因为78-1所以看到相邻位从0变1就做加法从1变0就做减法只有末尾需要补一个0作为“右哨兵”。具体操作时把乘数最低位右侧增加一个附加位Y0并置0每一步观察当前乘数最低位Y_{i}和前一位或附加位Y_{i-1}的组合相邻位组合当前操作00 或 11部分积不变01部分积加 [X]补10部分积加 [-X]补即减去X然后做一次算术右移也就是右移时最高位保持符号位不变。重复n1次标准说法是重复n次但最后一次不移位。这个细节不同教材有差异我建议以实际仿真为准。用这个方法做补码乘法符号位自动就对了不用额外判断正负。初学者最易犯错的地方在于减法操作要加的是“[-X]补”这个负号不能顺手写成“[X]补取反加一”忘了在整个字长上扩展右移必须是算术右移如果错用逻辑右移负数符号位就会被0填充结果差得十万八千里。3.3 快速乘法思路从Booth到阵列Booth算法把乘法的部分积数量压缩到大约n/2个对于有连续1的乘数尤其有效。但它还是“逐位迭代”的每一步都要等加法完成和右移完成。现代CPU里要求乘法尽量在几个周期内出结果所以会用更激进的并行结构。最常见的思路是“阵列乘法器”把每个部分积的生成和求和用二维的与门阵列、全加器阵列直接铺开让所有加法同时进行再用超前进位加法器做最后的合并。更进一步可以引入Wallace树和Dadda树它们把部分积按列分组用CSA进位保存加法器把三行压缩成两行最终只剩一个“和”和一个“进位”再做一次快速加法。这里CSA的好处是进位不立刻传播只在每层间传递延迟是log级别的。我个人觉得学习快速乘法时不要一开始就陷进压缩器的细节先理解三个层次逐位串行乘法最慢、阵列乘法并行但面积大、Booth配合Wallace树速度和面积折中。现在芯片里的整数乘法器基本都是“Booth编码 Wallace树 超前进位加法器”的组合。你在仿真里看到的一个周期出乘法结果的IP底层大多数人就是这套思路。4. 除法运算恢复余数与加减交替法4.1 原码恢复余数法除法在硬件里比乘法更难受因为商是“一点点试出来的”。手算十进制除法我们先猜一个商位乘出来看是否够减不够就换小一点的。机器里没有“猜测”能力只能用减法去试。原码恢复余数法的步骤是这样先对被除数和除数取绝对值符号位另算。每次从部分余数中减去除数如果结果非负上商1如果结果为负说明减多了需要把除数加回来恢复原余数然后上商0。之后把部分余数左移一位再继续下一次试商。用伪代码看更直观R 被除数 for i in 0..n-1: R R - D if R 0: 商左移1位末尾补1 else: R R D // 恢复 商左移1位末尾补0 R R 1“恢复”这一步就是它最耗时的原因一旦猜错就要用多余的一拍把余数加回来等于平均每比特多了一次加法。比如做32位除法最坏情况需要64拍以上显然太慢。4.2 加减交替法不恢复余数的优化针对恢复的浪费可以换个思路这次余数不对也没关系把“恢复”和“下一次试商”合并。加减交替法也叫不恢复余数法规则是若当前部分余数R为正上商1下一步做2R-D若当前部分余数R为负上商0下一步做2RD。换句话说余数为负时不是先恢复再加而是直接带着负的余数左移然后加上除数。这样每步固定只有一次加/减法和一次移位没有多余周期。最后如果余数为负还需要再做一次加除数来修正得到正确的正余数。这个方法不仅好用还揭示了除法器电路设计的一条核心思路与其浪费周期去“纠错”不如把纠错融合到下一次计算里。类似的思想在浮点除法的高阶算法里也一直延续。我见过很多同学在作业里把加减交替法写成先恢复再交替这样写错并不大但如果你用HDL去写状态机就会发现多了一个状态时序会明显变差关键路径也不容易收敛。4.3 除法器和乘法器的对称设计从电路结构上看除法器和乘法器有点像“互为逆运算”。乘法器是移位和相加除法器就是移位和相减。两者都可以用一套数据通路配合不同的控制信号来实现这也是很多教学CPU里共用一个算术逻辑单元的原因。除法器的常见实现有“阵列除法器”和“迭代除法器”。阵列除法器通过一串可控加法/减法器完成每一级根据上一位的商决定下一级是加还是减结构规整适合做脱机演示。迭代除法器则更接近加减交替法的状态机硬件量小很多但延迟随位数线性增加。现代CPU里整数除法一般用SRT算法甚至更高基数的算法能在一次状态转移中产生多位商不过那已经超出第三章的范畴了。学习时只要抓住“除法是试商试商靠减法减法是加法加补码”这条主线后面再看什么高级算法都不慌。5. 浮点运算让数字的动态范围更大5.1 浮点数怎么表示定点数能表示的数的范围太小32位无符号整数最大也就42亿而且小数部分表示固定太小的数直接被截断。为了兼顾范围和精度计算机采用类似科学计数法的方式来表示实数也就是浮点数一个数被拆成符号、指数和尾数三部分。通常说的单精度浮点数是32位其中1位符号、8位指数、23位尾数双精度则是64位11位指数、52位尾数。指数为什么用“移码”而不直接用补码因为移码让指数部分可以直接比较大小且全0和全1可以留作特殊值。移码的本质是给指数加一个偏置常数使得最小的负指数变成0最大的正指数变成全1这样指数的硬件比较电路就能用普通无符号比较器来做。这点和补码为了把减法变加法是异曲同工都是为了“让硬件更简单”。5.2 浮点加减法的四个阶段浮点加减法比定点加减法复杂很多但概括起来就是四步对阶、尾数加减、规格化、舍入。对阶的目的是让两个数的指数相同好比两个小数做竖式加减前要先对齐小数点。规则是“小阶向大阶看齐”指数小的那个数尾数右移右移的位数等于两个指数之差。为什么不把大阶向小阶看齐因为如果大阶变小阶尾数得左移高位会丢失损失太大。而小阶变大阶尾数右移只会丢低位相对还能接受。这里就能明白为什么浮点运算里数量级相差很大的两个数相加较小的那个可能会被“吃掉”。尾数加减后结果可能不是规格化形式比如两个同号数相加会发生进位导致尾数溢出两个异号数相减会消掉若干前导0。这时需要规格化右移一位让尾数回到1.x的形式同时指数加1或者左移若干位去掉前导0同时指数减若干。最后要根据舍入模式处理多余的低位常见有就近舍入、朝正无穷、朝负无穷、朝零。这一套流程在硬件里是一条很长的流水线所以浮点运算通常比整数运算慢几个周期。5.3 浮点运算器的组成和定点运算器不同浮点运算器包含两套核心电路指数部分用“阶码运算器”处理指数加减和比较尾数部分用“尾数运算器”处理定点加减乘和移位。两者之间还要有对阶控制、规格化控制和舍入逻辑。很多教材会画一个复杂的浮点运算器框图第一眼看过去会劝退。我的偷懒方法是只画数据流不画控制线先记住四根主轴——输入被拆成符号、阶码、尾数阶码走“对阶-指数加减-指数修正”路径尾数走“右移-加减-规格化移位”路径最后拼起来。只要数据流不错控制逻辑就是给你的数据流接上“什么时候做什么”的阀门而已。看懂这张图你基本就能看懂CPU里浮点运算单元FPU的工作流程了。6. 实操心得与常见坑6.1 几个容易让人失眠的细节在这章里有几个点我几乎每次都会看到有人踩先列成速查表供你对号入座问题原因建议补码减法算完CF总是不对把CF当成无符号加法进位记住减法的CF表示借位A-B时若A的最高位不够减CF1乘法部分积右移用了算术右移对原码和补码的移位要求没分清原码乘法用逻辑右移补码Booth用算术右移Booth算法最后多移了一次对循环次数不明确画状态表记住n位乘法做n步最后一步不右移浮点加法直接把两个尾数硬加忘了先对阶小数点没对齐尾数不能直接相加对阶时大阶向小阶看齐觉得“大的向小的学”永远是小阶变大阶否则高位丢失这些坑在笔试和实验里都特别常见。尤其“对阶方向”这个问题我在多次给学生讲实验课时反复强调你拿小数和整数相加把整数当成1.0和1e-9加如果方向反了1e-9这个数会在右移中直接变0结果等于没加。6.2 用硬件描述语言验证运算逻辑光看理论容易飘建议自己动手写一个小型ALU哪怕只是4位也好。用一个学期中最简单的Verilog代码来验证全加器和补码运算module alu_4bit( input [3:0] A, B, input [2:0] op, output reg [3:0] result, output reg C_out ); always (*) begin case (op) 3b000: {C_out, result} A B; // 加法 3b001: {C_out, result} A - B; // 减法硬件里就是 A ~B 1 3b010: result A B; 3b011: result A | B; 3b100: result A ^ B; 3b101: result A; // 直通 default: result 4b0; endcase end endmodule这段代码里大家可以看到减法分支里完全没有单独写“借位逻辑”底层综合工具会自动把A-B变成A加B取反加一。这就是补码在硬件中“加上负数”的具体体现。写完代码建议做几个全是边缘用例的测试7加1看是否溢出0减1看CF和SF-8加-1看OF。仿真的波形会立刻让你理解理论里的标志位。6.3 为什么这一章值得认真学运算器和运算方法是CPU流水线里一个真真实实的执行部件。你后面学指令集、流水线冲突、甚至编译器优化时很多现象都源于这里为什么无符号数和有符号数比较用不同的跳转指令因为标志位语义不同。为什么乘法指令比加法慢很多因为乘法器要压缩部分积。为什么浮点运算结果有时反直觉因为对阶会丢精度。这些都是这一章给的解释。我个人建议不要只背公式而是拿着纸笔把几个典型例子从头推一遍再从逻辑门角度想一遍。等你能在三分钟内算出一个4位补码乘法结果并且能在纸上画出它的阵列结构时这一章就算真正过关了。如果还有余力可以尝试写一个Booth乘法器的Verilog模块并用随机测试数据对比C语言里的乘法结果那比做十道课后题都有用。