算术逻辑单元ALU设计:从全加器进位链到标志位与Verilog实现
算术逻辑单元这几个字第一次出现在课表上的时候我以为这一章讲的是怎么算加减乘除直到用门电路把一个四位加法器真正搭出来、波形跑通的那一刻才反应过来它讲的是处理器里最不能出错的那块地砖所有指令最后都要踩在它身上。算术逻辑单元Arithmetic Logic Unit下文统一简称 ALU是数据通路上负责整数运算与位运算的组合逻辑部件两路操作数进来一组功能选择信号进来结果和若干状态标志出去。它决定了处理器一次能算多宽、进位要等多久、条件跳转凭什么成立。这篇笔记写给三类人正在啃计算机组成原理、被进位链和溢出判断绕晕的同学准备用硬件描述语言手写一个 ALU 交课程设计的人以及写了几年上层代码、想回头把底层补上的开发者。下面这些内容基本都是我在实验台前和仿真器里一点点磨出来的不是课本目录的复述。1. 先把ALU在指令执行链路里的位置钉死很多人学完这一章仍然说不清 ALU 到底“服务于谁”因为课本往往直接从半加器真值表开始讲跳过了它在整条数据通路中的角色。搞清楚它的上下游关系后面所有的设计取舍才有依据。1.1 一条加法指令到底经过了哪些部件以最简单的寄存器加法为例取指阶段指令从存储器取出译码阶段控制器识别出这是一条加法指令于是把两个源寄存器的内容送到 ALU 的两个输入端同时把功能选择信号设为“加”ALU 算完之后把结果写回目的寄存器顺带把进位、溢出、零、符号这几个标志位丢给状态寄存器。整条链路里ALU 是唯一一个“纯组合”的环节——它没有时钟输入变了输出立刻跟着变一个时钟周期内必须稳定下来。这一点非常关键ALU 的延迟直接决定了处理器主频的上限后面讲进位链的时候你会反复回到这句话。从这个视角看ALU 的接口其实非常朴素两路数据输入通常是 A 和 B、一路功能码、一路结果输出、一组状态标志。复杂的地方全在内部——功能码有几位、支持哪些运算、进位怎么传播、标志怎么产生每一项都要在面积、延迟、可扩展性之间做取舍。1.2 输入输出端口的设计取舍我在第一版设计里犯过一个典型错误把功能码直接做成独热码one-hot八种运算用八根线。仿真没问题但综合之后的面积报告很难看译码逻辑比运算本体还大。后来改成三位二进制编码加一个译码器面积直接降下来一截。这个经验值得记下来功能码的编码方式属于设计决策不是随手定的独热码在控制信号少、追求速度的场合合适运算种类多的时候二进制编码更划算。另一处取舍是标志位的产生时机。是每个功能都产生全部四个标志还是按功能选择性产生课本上通常默认“全都产生”但实际实现里逻辑运算不产生进位、移位运算的进位含义又和加法不同。我的做法是标志位统一在模块输出端产生但内部对不同功能做屏蔽用功能码控制哪些标志有效这样上层软件读状态寄存器时语义清晰不会读到一堆无意义的位。还有位宽问题。课程设计常见的是 8 位或 16 位但如果你想让这个设计有移植价值最好一开始就用参数化位宽写把W做成 parameter。我吃过亏第一版硬编码成 8 位后来老师要求扩到 16 位验证进位链所有常量、循环边界、测试向量全要改改完还引入了一个隐藏 bug。2. 半加器到全加器一位加法是怎么被“推”出来的这一段是整章的地基很多人觉得简单就跳过去结果到了多位进位和溢出判断的时候完全接不上。我建议哪怕觉得自己懂了也把真值表和表达式亲手推一遍。2.1 半加器为什么单独用不了半加器只处理两个一位输入输出和与进位。它的局限在于没有考虑低位送来的进位输入。单独用它做一位运算没问题但要做多位加法时第二位必须把第一位的进位吃进去半加器做不到。这就是为什么实际电路里用的都是全加器——三个输入A、B、进位输入 Cin两个输出和 S、进位输出 Cout。真值表只有八行但推导过程值得写一遍三个输入里奇数个 1 时和为 1这个性质直接对应异或两个及以上为 1 时进位为 1。所以S A ⊕ B ⊕ CinCout AB A·Cin B·Cin。这两条式子看起来简单但它是后面所有优化讨论的起点。2.2 进位输出的两种等价写法及其代价Cout AB A·Cin B·Cin是最直观的与或式两级门延迟。还有一种是Cout AB (A ⊕ B)·Cin看着绕但它把“A 和 B 是否同时为 1”生成进位和“A、B 是否相异”传播进位这两个概念显式拆开了。这个拆法就是后面超前进位的基础也是我建议你在笔记里单独标红的地方生成信号 G A·B传播信号 P A ⊕ B这两个信号一旦引入进位链的延迟分析立刻从“一级一级等”变成“可以并行算”。我当时在纸上把两种写法都展开成门级图对比了一下延迟第一种是 A 与 B、A 与 Cin、B 与 Cin 三路并行算与再一个三输入或共两级第二种是异或一级、与一级、或一级看着更深但 P 和 G 可以提前算出来供高位使用做多位加法时优势就出来了。2.3 用异或搭出来的可级联结构实际写代码的时候我不会真的去实例化一堆门而是用行为级描述加显式的进位串联让综合工具去映射。但理解结构很重要因为你得知道综合出来的东西是不是你想要的。一个稳妥的写法是把一位全加器写成独立模块或者函数多位加法用它循环例化进位明确地从低位连到高位。这样写的好处是可读性极强波形上每一位的进位都能单独看调试的时候能一眼定位是哪一位的进位链断了。我第一次做的 8 位加法器就出过问题写循环的时候把索引写反了高位进位接到了低位结果仿真时低四位的和永远是对的、高四位全是错的。这个 bug 用肉眼读代码很难发现但在波形上把每一位的 Cin 和 Cout 展开两分钟就定位了。所以搭加法器一定要把进位单独立信号观察不要只看最终结果。3. 进位链才是性能命门从串行到超前如果说全加器是 ALU 的细胞进位链就是它的血管。位宽一大进位传播的延迟会迅速吃掉整个周期预算这也是为什么这一节值得花最多笔墨。3.1 串行进位到底慢在哪串行进位加法器行波进位就是把 N 个全加器首尾相连第 i 位的进位输出接第 i1 位的进位输入。结构简单、面积小但延迟随位宽线性增长。粗算一下一位全加器的进位路径大约是两到三级门延迟取决于工艺库和写法32 位串起来就是六十到九十级门延迟在高速时钟下根本来不及。我在综合报告里对比过 8 位和 32 位的串行进位加法器关键路径延迟差不多是四倍关系符合线性预期。这个数字的意义在于它告诉你为什么早期的处理器位宽都不大也告诉你为什么现代处理器一定要用更聪明的进位结构。3.2 超前进位的核心思路与代价超前进位Carry Lookahead的本质是把“等进位”变成“算进位”。利用前面提到的 G 和 P第 i 位的进位可以写成只依赖各级 G、P 和最低位进位的表达式不需要逐级等待。四位一组的时候展开式还在可接受范围内再往上展开表达式的项数会爆炸门数也跟着爆炸。这里有一个非常实用的经验不要试图一次性展开整个 32 位。工程上的通行做法是分组四位或八位一组组内用超前进位组间再用一级超前进位或者干脆串行。我做过三组对比实验条件都是 32 位、同一工艺库、同样的时序约束进位结构关键路径延迟相对值面积相对值可读性全串行进位4.01.0最好4 位分组超前进位1.61.9中等全超前进位展开1.15.2最差这张表不是标准答案不同工艺、不同综合策略下数字会变但趋势是稳定的延迟的收益是递减的面积的代价是递增的。选哪一档取决于你的时钟频率目标和面积预算而不是“越先进越好”。3.3 位宽扩展时该保留哪些接口做位片式扩展的时候一个容易忽略的点是进位输入输出的处理。如果你打算把四片 4 位 ALU 拼成 16 位每片必须把进位输出完整地交给下一片并且要能接收外部的初始进位用于减法时加一。我第一次拼的时候忘了把片间进位接上结果低位片算完了高位片完全不知道加法在 4 和 7 之间莫名其妙出错。教训是扩展位宽时先把进位通路画成一张图标清每一段的进从哪里来、到哪里去再动手连线。另外超前进位会引入额外的进位生成逻辑这部分逻辑通常放在一个独立模块里位宽扩展时只换模块、不动数据通路。这种分层的好处是你可以在不改变运算单元的情况下换掉进位实现做性能对比实验非常方便。4. 补码加减法用一套加法器干掉减法这一节是我觉得整章最优雅的地方。理解了补码和模运算你会意识到“减法器”这个东西在实际硬件里根本不存在减法就是变形的加法。4.1 补码的本质是模运算下的取反在 W 位字长下所有运算都是在模 2^W 意义下进行的。一个数 x 的相反数 -x 在模 2^W 下等于 2^W - x而 2^W - x 恰好等于 x 按位取反再加一。这个等式不是技巧是定义。所以 A - B 可以写成 A (~B) 1把 B 取反送进加法器同时把最低位进位强制置 1。这个设计的价值在面积上体现得最直接加法器和减法器共用一套数据通路只需要在 B 的输入端加一排异或门做“取反或不取反”的选择再加一根控制线接到最低位进位。一排异或门的成本远低于一个完整的减法器。4.2 控制信号怎么串起来控制逻辑通常是一个加法/减法选择信号接到两处第一处是 B 输入端的每个异或门的一个输入0 表示不取反1 表示取反第二处是最低位进位输入同样的信号。两处必须同步否则会出现“取反了但没加一”或者“加一了但没取反”的情况结果差一。我在实验里专门验证过这个差一错误的波形A5、B3正确结果应该是 2但如果只取反不加一得到的是 -7 的补码形式看起来像一个很大的正数。这个现象非常有迷惑性因为结果不是随机的而是一个稳定的错值。所以一旦发现减法结果总是差一第一反应应该是检查进位输入有没有接对。4.3 溢出的三种判断方式与它们的坑溢出的判断有好几种写法每种都有自己的适用场景和陷阱。第一种是看最高位进位和次高位进位是否一致不一致就溢出。第二种是用符号位判断两个操作数同号而结果异号就是溢出。第三种是对结果做符号扩展再比较。第一种写法在纯手工搭电路的场景下最直观但要求你能拿到内部的两根进位线。第二种写法在写代码时最方便只需要三个符号位。第三种适合在高层语言里模拟。坑在于减法运算的溢出判断不能用“两操作数同号”这个条件直接套因为减法在做的时候 B 已经被取反了。正确的写法是减法的溢出条件是“两个操作数符号不同且结果符号与被减数不同”。这个细节我至少见过三次别人写错包括我自己第一版。5. 标志位ALU最容易被低估的输出初学者往往只关心结果对不对把标志位当成附属品。等学到条件跳转、循环控制、多字节运算的时候才会发现标志位才是真正决定程序行为的东西。5.1 四个常见标志各自的定义边界零标志 ZF 表示结果全零这个最没有歧义。符号标志 SF 取结果的最高位在补码语义下表示正负也没有争议。进位标志 CF 表示最高位产生的进位或者借位取决于体系结构的约定这里开始出现分歧。溢出标志 OF 表示有符号运算溢出和 CF 完全是两回事。分歧点在于CF 到底是“进位输出”还是“借位”。有的架构在减法时把 CF 定义成借位即减法的进位输出取反有的直接给进位输出。这个差异会让同一段代码在不同架构上判断出相反的结果。我在写模拟器的时候就踩过这个坑实现完之后跑一个比较两个无符号数的测试用例结果和预期完全相反查了半天才发现是减法时 CF 的语义搞反了。5.2 进位和溢出为什么必须分开这是最经典的一个混淆点。用一个例子说清楚8 位有符号数 100 100。二进制是 0110_0100 0110_0100 1100_1000最高位没有进位输出所以 CF 0但结果是负数两个正数加出负数OF 1。反过来无符号数 200 100 300超出了 8 位能表示的范围 255产生进位CF 1但如果把它们当有符号数看这个结果本身没有“符号错误”意义上的溢出。一句话总结CF 服务于无符号语义OF 服务于有符号语义。所以同一份加法硬件必须同时给出这两个标志编译器根据变量的类型决定读哪一个。这个设计决策解释了一个常被问的问题为什么不能只留一个标志位。5.3 条件跳转是怎么“消费”这些标志的所有条件跳转指令本质上都是对标志位做布尔判断。等于跳转看 ZF大于跳转看 SF 和 OF 的组合无符号比较看 CF。指令集里那些看起来五花八门的跳转助记符展开之后无非是几个标志的组合逻辑。理解这一层之后你会明白为什么 ALU 的功能码和标志位必须严格对应——如果某条指令执行完标志位是错的后面所有依赖它的跳转全部失效而且这种 bug 极难定位因为出错的地方和表现出来的地方隔着好几条指令。我在写一个简易处理器的时候遇到过比较指令之后的条件跳转偶尔跳错最后发现是比较指令没有更新标志位标志还停留在上一条指令的结果上。6. 把一块ALU真正拼起来功能选择与位宽扩展单个加法器不是 ALU。ALU 的关键在于“多功能复用同一套数据通路”这就涉及功能选择、结果选择和位宽扩展三件事。6.1 用多路选择器组织运算功能最直接的组织方式是把所有运算并行算出来然后用一个多路选择器根据功能码挑一个输出。这种写法延迟低所有运算同时进行选择器只加一级延迟面积大每个功能都要一套硬件。另一种是资源共享比如加减法共用一个加法器、逻辑运算共用一组与或非门面积小但控制逻辑复杂。我的建议是分档处理加减法和比较必须共用一套它们本来就是同一套逻辑运算共用一套移位单独一套。剩下的冷门功能可以串行复用。这样既不会面积爆炸也不会延迟失控。具体到实现上就是在模块内部先算出几个中间结果再用一个按功能码索引的选择结构输出最终值。6.2 位片式扩展与分组的思想位片bit-slice是早期 ALU 芯片的核心思想做一片 4 位的 ALU通过级联拼成任意位宽。这种设计的价值在于复用和可扩展。现在虽然很少直接买位片芯片但这个思想在写代码时依然适用——把一位或四位的运算单元写成参数化模块外层循环例化位宽就是个参数。级联时要注意两点。一是进位必须完整传递包括片间的进位输入输出。二是标志位不能简单地从最后一片取比如零标志需要所有片的结果或起来符号标志取最高片的最高位溢出标志需要最高片的内部进位信息。我见过有人把零标志直接接成最高片结果的零判断结果低位全零高位非零的时候判断错误。零标志必须是全局的不是局部的。6.3 移位和逻辑运算怎么塞进同一条通路移位运算的特殊性在于它不满足“两路操作数对称”这个假设。左移右移、算术右移逻辑右移方向不同结果差别很大。常见做法是把移位器做成独立的一级插在 ALU 输出之前或者做成一个可选的数据通路分支。算术右移和逻辑右移的区别在于高位补什么逻辑右移补 0算术右移补符号位。这个区别在硬件上就是一根选择线控制高位填充源。我在实现的时候一开始只做了逻辑移位跑负数右移的测试用例时结果全错才意识到漏了算术移位。这个坑很典型测试用例里一定要有负数参与移位的场景否则测不出来。7. 动手实现一个参数化ALU的完整代码与验证纸上谈兵到此为止下面是我实际用过的一版实现参数化位宽可综合配套测试平台。代码不复杂重点在注释里标注的取舍理由。7.1 端口定义与功能编码module alu #( parameter W 32 )( input wire [W-1:0] a, input wire [W-1:0] b, input wire [3:0] op, output reg [W-1:0] y, output wire zf, output wire cf, output wire of, output wire sf ); localparam OP_ADD 4d0; localparam OP_SUB 4d1; localparam OP_AND 4d2; localparam OP_OR 4d3; localparam OP_XOR 4d4; localparam OP_SLL 4d5; localparam OP_SRL 4d6; localparam OP_SRA 4d7; localparam OP_SLT 4d8; // 有符号比较 localparam OP_SLTU 4d9; // 无符号比较功能码用四位二进制而不是独热码理由前面说过。比较运算单独列出有符号和无符号两个版本是刻意的——它们在硬件上是两个不同的判断混用一个会出事。7.2 运算主体与标志产生wire [W:0] add_ext {1b0, a} {1b0, b}; wire [W:0] sub_ext {1b0, a} {1b0, ~b} 1b1; wire [W-1:0] add_res add_ext[W-1:0]; wire [W-1:0] sub_res sub_ext[W-1:0]; wire add_cf add_ext[W]; wire sub_cf sub_ext[W]; // 有符号溢出同号相加得异号 wire add_of (a[W-1] b[W-1]) (add_res[W-1] ! a[W-1]); // 减法溢出异号相减得与被减数异号 wire sub_of (a[W-1] ! b[W-1]) (sub_res[W-1] ! a[W-1]); always (*) begin case (op) OP_ADD: y add_res; OP_SUB: y sub_res; OP_AND: y a b; OP_OR: y a | b; OP_XOR: y a ^ b; OP_SLL: y a b[4:0]; OP_SRL: y a b[4:0]; OP_SRA: y $signed(a) b[4:0]; OP_SLT: y ($signed(a) $signed(b)) ? {{(W-1){1b0}}, 1b1} : {W{1b0}}; OP_SLTU: y (a b) ? {{(W-1){1b0}}, 1b1} : {W{1b0}}; default: y {W{1b0}}; endcase end assign zf (y {W{1b0}}); assign sf y[W-1]; assign cf (op OP_ADD) ? add_cf : (op OP_SUB) ? sub_cf : 1b0; assign of (op OP_ADD) ? add_of : (op OP_SUB) ? sub_of : 1b0;这里有两个细节值得单独说。第一加减法用了扩展一位的方式算进位而不是从最高位单独推断这样写法统一位宽参数化之后也不会出错。第二移位量取了低五位这是 32 位下的约定如果你的位宽不是 32这个切片范围要跟着改否则会综合出奇怪的东西。7.3 测试平台与必须覆盖的用例测试平台的思路是穷举加定向。8 位版本可以穷举一部分组合32 位版本只能定向构造。必测的几类零加零、最大正数加一溢出、最小负数减一溢出、正数减负数、同数相减零标志、负数算术右移、负数逻辑右移应该变成大正数、无符号比较和有符号比较在同一组输入上的不同结果。// 定向用例片段 a 8h7F; b 8h01; op OP_ADD; #1; // 期望 y 8h80, of 1, cf 0, sf 1验证的时候不要只看最终结果把 cf、of、sf、zf 全部打印出来逐个核对。我调试时就靠这个办法抓到过一个“结果对但标志错”的问题——加法结果是对的但进位用的是一位截断后的信号导致 CF 永远为 0。8. 我自己踩过的坑和一份排查清单这一节是纯经验都是仿真器和实验台上摔出来的按发生频率排序。8.1 有符号与无符号混用这是最高频的错误没有之一。Verilog 里默认是无符号做比较和移位的时候如果不显式声明负数会被当成大正数。上面代码里$signed的用法就是为此。更隐蔽的是位宽扩展把一个 8 位的有符号数直接赋给 32 位信号高位补 0 而不是补符号位数值直接错掉。正确做法是先用$signed转换再赋值或者手工做符号扩展。8.2 组合逻辑环与仿真发散ALU 是纯组合逻辑一旦反馈路径接错仿真会挂死或者波形全红。常见诱因是功能选择信号自己依赖运算结果或者进位链首尾接成了环。排查方法很原始但有效把模块内部的每个中间信号都拉到波形上看哪个信号一直在 0 和 1 之间跳。如果综合工具报了组合环警告别忽略一定要找到环路位置。8.3 功能仿真通过但门级仿真失败这个坑我遇到过一次现象是功能仿真全绿门级仿真结果偶发错误。原因是代码里有组合逻辑的竞争冒险某条路径上出现了毛刺被下游的时序单元采到了。解决办法是在关键路径上加寄存器打拍或者用格雷码之类的方式避免多位同时跳变。凡是组合逻辑输出直接驱动时序单元的地方都要警惕毛刺。下面这张表是我自己总结的排查顺序基本上按这个顺序走能覆盖九成问题现象优先排查常见根因结果整体偏移一位位宽与索引循环边界写反、切片范围错减法结果差一进位输入取反与加一未同步高位结果全错进位链片间进位未连接标志位不更新控制逻辑功能码未覆盖该指令负数比较出错符号处理未做有符号转换偶发错误竞争冒险组合输出直连时序单元8.4 一个值得养成的习惯每次改完 ALU 的任何一个功能都把全部定向用例重跑一遍。我吃过亏只测了刚改的移位功能结果改动影响了功能码的译码逻辑加法悄悄坏掉等到集成到处理器里才发现回头定位花了一整天。ALU 这种东西功能之间耦合度比看上去高得多回归测试的成本远低于事后排查的成本。说个我自己的体会ALU 这一章真正难的不是把加法器搭出来而是把“为什么这么搭”想清楚。进位链的取舍、标志位的语义、有符号与无符号的分野这些东西在写代码的时候不会报错但会在你调试别人代码或者自己集成处理器的时候以最难受的方式跳出来。所以我建议代码写完先别急着跑拿张纸把进位通路和标志产生路径各画一遍画得出来再仿真能省掉至少一半的调试时间。