8位多功能ALU设计:Verilog可综合、标志位与边界验证

发布时间:2026/9/17 20:38:52
8位多功能ALU设计:Verilog可综合、标志位与边界验证 简介这份文档是杭州电子科技大学计算机组成原理与系统结构课程设计的实验三报告围绕多功能ALU设计展开适合正在做计组课设、需要用Verilog HDL完成运算器建模与仿真的本科生参考。报告记录了实验目的、仪器、步骤方法与结果分析从ISE建工程、新建Verilog Module、语法检查与综合、查看RTL视图到编写Test Fixture激励代码观察仿真波形脉络清晰可对照。源码涵盖ALU_OP译码的与、或、异或、同或、加、减、小于比较、移位八种运算以及AB_SW测试用例、ZF零标志与OF溢出标志的生成逻辑并附激励代码与RTL图。思考题部分讨论了指令集覆盖不足、slt比较中符号与无符号的歧义可增设SIGctr控制信号等排错思路。包内共1个doc文件约89KB轻便易查阅已有2286人学习下载。1. 从一次减法结果错位的排查说起多功能ALU要解决的到底是什么第一次做这个实验时我把8h80减去8h01的结果读出来屏幕上是0x7F符号位从 1 翻成 0可溢出指示灯毫无反应。折腾了半天才想明白自己把有符号溢出和无符号借位当成同一个信号在判断减法里 CF 反映的是无符号借位OF 才反映有符号溢出两者在0x80 - 0x01这组输入上恰好给出相反的结论。多功能 ALU 设计实验要解决的就是把这套「按控制码切换运算、同时把标志位说清楚」的逻辑从纸面功能表变成可仿真、可综合、能上板的电路。它是计组课程里第一次把算术单元、多路选择、标志生成三件事塞进同一个组合模块的练习也是后面数据通路和基本模型机中运算器的原型。不管你是跟着课程做实验还是备考 408 计组运算器那一章又或者想从数字逻辑补到 RTL 这一步这块 8 位 ALU 都是绕不过去的第一个完整例子。2. 多功能ALU的功能表怎么定运算集合、控制码与标志位三件套2.1 先定数据宽度和运算集合再谈电路很多同学一拿到题目就打开 Logisim 开始拖元件拖到一半发现运算不够用、控制码位数不够分、标志位语义对不上只能推翻重来。常见做法是先在纸上把三件事定死数据位宽、运算种类、每个运算对标志位的影响。位宽上8 位是最合适的起步值。它足够让溢出、进位、符号这几件事都真实发生4 位太快就跑到边界了又不会让波形图和真值表长到看不完。如果你打算把实验继续做到 16 位或者 32 位那就从一开始就把位宽写成参数而不是在代码里到处硬编码 7、15、31 这些下标。运算集合按「指令集里最常见的几类」来选就够了算术类 ADD、SUB、INC、DEC逻辑类 AND、OR、XOR、NOT移位类 SHL、SHR比较类 SLT有符号小于置 1。这 11 条覆盖了后续数据通路里绝大多数需要运算器配合的指令。想多加也行比如带上算术右移、带进位加但每多一条控制码和测试向量都要跟着改一遍收益不大。2.2 4 位操作码的分配表控制码位数直接决定运算条数上限。3 位只能放 8 条11 条运算就装不下所以直接上 4 位留出余量给以后扩展。下面这张表是整个实验的「合同」代码、testbench、Logisim 电路三者都必须和它严格对齐op[3:0]助记符运算结果来源更新标志0000ADDy a b9 位和的低 8 位ZF / CF / OF / SF0001SUBy a - b9 位差的低 8 位ZF / CF / OF / SF0010ANDy a b逐位与ZF / SF0011ORy a | b逐位或ZF / SF0100XORy a ^ b逐位异或ZF / SF0101NOTy ~a逐位取反ZF / SF0110SHLy a 1移出的最高位进 CFZF / CF / SF0111SHRy a 1移出的最低位进 CFZF / CF / SF1000INCy a 1自增ZF / CF / OF / SF1001DECy a - 1自减ZF / CF / OF / SF1010SLTy (ab) ? 1 : 0有符号比较ZF / SF1011~1111保留y 0全零全部清 0这张表有两个容易被忽略的设计决策。第一保留编码的默认行为是输出全零、标志全清而不是「输出不定」——组合逻辑必须给每条路径一个确定值否则综合出来会挂锁存器。第二SLT 的结果只占最低 1 位高位补零因此 SF 在 SLT 下必然为 0这一点在写测试向量时要写对。2.3 四个标志位的语义边界ZF/CF/OF/SF 分别在什么时候置 1标志位是这块 ALU 里最容易出错的部分因为它不是「算出来」的而是「解释出来」的。四个标志的边界建议这样定ZF零标志只要结果 y 全零就置 1逻辑运算也参与更新。它不看运算类型只看结果是最简单也最不该出错的一个。CF进位/借位标志无符号语义。加法时它表示「和超出了位宽」减法时它表示「不够减发生了借位」。注意这两件事在位级上取的都是扩展位的最高位但含义刚好相反——加法里 CF1 是「多了」减法里 CF1 是「欠了」。OF溢出标志有符号语义只对 ADD、SUB、INC、DEC 有意义。判断依据是「两个同号数相加得到异号结果」或「两个异号数相减得到与减数符号不同的结果」。逻辑运算和移位不更新它保持 0。SF符号标志直接取结果最高位。它对算术有意义对逻辑运算通常没有语义但硬件上照样输出属于「如实反映结果位模式」。这里有个真实踩过的坑INC 和 DEC 的 CF。0xFF 1会归零并产生进位0x00 - 1会变成0xFF并产生借位这两条如果没在图里标出来测试一定会挂。判断规则很简单——INC 的 CF 就是(a 全1)DEC 的 CF 就是(a 全0)。2.4 从 74181 到可综合 Verilog选型上的取舍如果实验箱上给的是 74181 这类 4 位 ALU 芯片那走的是「查功能表、拼位片、接进位链」的路线74181 用 4 位功能选择加 1 位模式选择确定运算做 8 位要靠两片级联还得自己补标志位逻辑。这条路的好处是看得见门级结构坏处是改动成本高加一条运算可能要重画半个图。用 Verilog 描述行为则是另一条路把功能表写成 case交给综合器映射成 LUT。它灵活、好改、好测缺点是「看不见门」。两种路线在实验报告里都要交功能表和测试结果所以先把编码固化成常量两边引用同一份是省事的关键// 先把编码固化成常量case 和 testbench 都引用同一份避免改一处漏一处 localparam OP_ADD 4b0000; localparam OP_SUB 4b0001; localparam OP_AND 4b0010; localparam OP_OR 4b0011; localparam OP_XOR 4b0100; localparam OP_NOT 4b0101; localparam OP_SHL 4b0110; localparam OP_SHR 4b0111; localparam OP_INC 4b1000; localparam OP_DEC 4b1001; localparam OP_SLT 4b1010;localparam在编译期就展开成字面量不占任何硬件资源但它让「操作码 4b0110 代表什么」这件事在代码里只出现一次。后面 testbench 里写激励时直接用OP_SHL比写裸的4b0110可读得多也避免抄错。3. 用 Verilog 写出一个可综合的 8 位多功能ALU3.1 模块接口与参数化位宽接口设计的原则是「输入控制、输出结果和状态」ALU 本身不存任何状态所以全部端口都是组合语义。位宽用参数 WIDTH 表示默认 8timescale 1ns/1ps module alu #( parameter WIDTH 8 // 数据位宽改成 16 可整体复用 )( input wire [WIDTH-1:0] a, // 操作数 A input wire [WIDTH-1:0] b, // 操作数 B input wire [3:0] op, // 4 位操作码 output reg [WIDTH-1:0] y, // 运算结果 output reg zf, // 零标志y 0 output reg cf, // 进位/借位标志 output reg of, // 有符号溢出标志 output reg sf // 符号标志y 的最高位 );WIDTH一旦参数化后面所有位选都必须写成WIDTH-1、WIDTH-1:0这种形式不能出现7、[7:0]。这个习惯看着啰嗦但改位宽时只需要动一个数字是性价比最高的参数化写法。3.2 主体case 分发与「多扩一位」的进位技巧整个运算逻辑放在一个always (*)里用 case 分发。进位和溢出的关键技巧是加减法先把两个操作数扩一位再算进位直接取扩展位的最高位省掉事后补算的麻烦。wire [WIDTH:0] add_ext {1b0, a} {1b0, b}; // 多扩一位进位自然落在最高位 wire [WIDTH:0] sub_ext {1b0, a} - {1b0, b}; // 同上借位落在最高位 reg carry; // 内部进位/借位 reg ovf; // 内部溢出 always (*) begin y {WIDTH{1b0}}; // 先给默认值杜绝锁存器 carry 1b0; ovf 1b0; case (op) OP_ADD: begin y add_ext[WIDTH-1:0]; carry add_ext[WIDTH]: // 同号相加得异号 有符号溢出 ovf (~a[WIDTH-1] ~b[WIDTH-1] y[WIDTH-1]) | ( a[WIDTH-1] b[WIDTH-1] ~y[WIDTH-1]); end OP_SUB: begin y sub_ext[WIDTH-1:0]; carry sub_ext[WIDTH]; // 借位a b 时为 1 // 异号相减结果符号与被减数不同 有符号溢出 ovf ( a[WIDTH-1] ~b[WIDTH-1] ~y[WIDTH-1]) | (~a[WIDTH-1] b[WIDTH-1] y[WIDTH-1]); end OP_AND: y a b; OP_OR : y a | b; OP_XOR: y a ^ b; OP_NOT: y ~a; OP_SHL: begin y a 1; carry a[WIDTH-1]; end OP_SHR: begin y a 1; carry a[0]; end OP_INC: begin y a {{(WIDTH-1){1b0}}, 1b1}; carry (a {WIDTH{1b1}}); // 全 1 自增才进位 end OP_DEC: begin y a - {{(WIDTH-1){1b0}}, 1b1}; carry (a {WIDTH{1b0}}); // 全 0 自减才借位 end OP_SLT: y {{(WIDTH-1){1b0}}, ($signed(a) $signed(b))}; default: y {WIDTH{1b0}}; endcase // 标志位统一在这里生成保证和 y 用的是同一份结果 zf (y {WIDTH{1b0}}); sf y[WIDTH-1]; cf carry; of ovf; end endmodule这段代码有三处值得说明。第一{1b0, a} {1b0, b}里的拼接把 8 位操作数变成 9 位和不丢进位a b如果先赋值给 8 位变量进位就丢了再想补算会写得很别扭。第二overflow 的两个条件式分别对应加法和减法的符号判定它只看符号位不关心位宽所以参数化后依然成立。第三default分支必须有否则 op 落到保留编码时 y 保持上一次的值综合器会推断出锁存器波形上表现为「结果偶尔不动」。3.3 标志位生成为什么不要用事后补算一个高频错误写法是先把结果算出来再用结果反推 CF// 反例先算结果再靠比较反推进位——位宽截断后已经无从判断 always (*) begin y a b; // 进位在这里就丢了 cf (y a); // 只在部分情况下等价于真实进位 endy a这种写法在「a b 刚好回绕」时看起来能对上但遇到 INC、SUB 就立刻失效而且它没法给出正确的 OF。正确的顺序是先扩位算拿到完整的高位再从高位里取进位标志位全部在同一个 always 块里、基于同一份 y 生成。这样仿真和综合的行为一致也不会出现「y 更新了但标志位慢一拍」的错觉——组合逻辑在同一时刻全部稳定。还要注意一点减法的 CF 语义。有些教材把 SUB 的 CF 定义成「借位的反」也就是a b时 CF1目的是让后续「带借位减」的指令统一处理。实验里两个定义都能自圆其说但必须在报告里写清楚用了哪一个并且 testbench 的期望值要跟着改。3.4 在 Logisim 里搭同一张功能表如果走画图路线结构其实和代码一一对应两片 4 位 74181 级联成 8 位做算术和逻辑一个 4 选 1 或者 8 选 1 多路选择器负责在「加减结果、逻辑结果、移位结果、比较结果」之间切换标志位单独用一个小的组合块生成。移位用桶形移位器或者直接把每一位接到下一级输入上比较用减法结果加符号位异或来判a b。控制码就是多路选择器的选择端和上面那张 op 表保持同一套编码。画完之后把 3.2 节 testbench 里的测试向量按同样的输入顺序在 Logisim 里跑一遍两边的结果应该逐位一致——这是验证手画电路和 RTL 是否等价的最快办法。4. 仿真验证用自检 testbench 把边界用例跑穿4.1 自检式 testbench 的骨架不要靠眼睛盯波形图。把期望值写进测试任务里让它自己比对、自己报错才是能反复跑的做法// tb_alu.v —— 自检式 testbench任意一位不符就打印详细信息 timescale 1ns/1ps module tb_alu; reg [7:0] a, b; reg [3:0] op; wire [7:0] y; wire zf, cf, of, sf; integer pass_cnt 0, fail_cnt 0; alu #(.WIDTH(8)) dut ( .a(a), .b(b), .op(op), .y(y), .zf(zf), .cf(cf), .of(of), .sf(sf) ); task expect; input [7:0] ea, eb; // 激励 input [3:0] eop; input [7:0] ey; // 期望结果 input ezf, ecf, eof, esf; // 期望标志 begin a ea; b eb; op eop; #5; // 组合逻辑留出稳定时间 if ({y, zf, cf, of, sf} ! {ey, ezf, ecf, eof, esf}) begin fail_cnt fail_cnt 1; $display(FAIL op%b a%0d b%0d | got y%h zf%b cf%b of%b sf%b, eop, ea, eb, y, zf, cf, of, sf); $display( expect | y%h zf%b cf%b of%b sf%b, ey, ezf, ecf, eof, esf); end else begin pass_cnt pass_cnt 1; end end endtask initial begin expect(8h7F, 8h01, 4b0000, 8h80, 0,0,1,1); // 正溢1271 越界 expect(8hFF, 8h01, 4b0000, 8h00, 1,1,0,0); // 满进位归零 expect(8h00, 8h01, 4b0001, 8hFF, 0,1,0,1); // 借位0-1 回绕 expect(8h80, 8h01, 4b0001, 8h7F, 0,0,1,0); // 负溢-128-1 越界 expect(8hF0, 8h0F, 4b0010, 8h00, 1,0,0,0); // 逐位与得零 expect(8hF0, 8h0F, 4b0011, 8hFF, 0,0,0,1); // 逐位或全 1 expect(8hAA, 8h55, 4b0100, 8hFF, 0,0,0,1); // 异或全 1 expect(8h81, 8h00, 4b0110, 8h02, 0,1,0,0); // 左移最高位进 CF expect(8h81, 8h00, 4b0111, 8h40, 0,1,0,0); // 右移最低位进 CF expect(8h80, 8h01, 4b1010, 8h01, 0,0,0,0); // -128 1 成立 expect(8h01, 8h80, 4b1010, 8h00, 1,0,0,0); // 1 -128 不成立 $display(done: pass%0d fail%0d, pass_cnt, fail_cnt); $finish; end endmoduletask expect的 8 个入口参数把「输入」和「期望」分开写读起来一目了然比对时用!而不是!是为了让 x、z 这类不确定值也能被判为失败而不是被当成相等悄悄放过。#5是给组合逻辑留的稳定时间纯组合模块其实#1就够但留宽一点更省心。最后的$display会把通过和失败的数量都打出来改一行代码就跑一次回归成本几乎为零。4.2 边界向量表这几组不跑通不要上板上面 11 组是精简版实际验收建议按类别补全。下表列出的是必须覆盖的类别和判断依据类别典型输入关注点期望标志特征加法无溢出0x10 0x20普通路径CF0 OF0加法进位0xFF 0x01无符号溢出CF1 OF0 ZF1加法正溢0x7F 0x01有符号溢出CF0 OF1 SF1减法借位0x00 - 0x01无符号下溢CF1 OF0 SF1减法负溢0x80 - 0x01有符号溢出CF0 OF1 SF0逻辑归零0xF0 0x0FZF 更新ZF1移位出位0x81 1CF 取移出位CF1有符号比较0x80 vs 0x01SLT 用 signedy0x01保留编码任意 0b1111默认分支y0 标志全 0注意0x81 1的结果是0x02而不是0x102因为结果位宽只有 8 位被移出的最高位不进入 y只进入 CF。这一条如果期望值写错会误判成模块有 bug。4.3 波形看不出来时$display 与打印断言波形图对组合逻辑的排查其实不友好——信号太多、跳变太密定位一个标志位要看很久。更快的办法是在always (*)里临时加一条$display把输入和中间量打出来// 排查期临时插入正式提交前删掉 always (*) begin $display([%0t] a%h b%h op%b - y%h carry%b ovf%b, $time, a, b, op, y, carry, ovf); end这条打印会把每次输入变化后的内部状态都吐出来比在波形里一格格找要快得多。定位完之后一定删掉否则组合逻辑的$display会在每次信号抖动时都触发仿真日志会爆炸。另外carry和ovf是内部 reg波形窗口里默认可能不显示需要手动加到观察列表或者干脆声明成wire便于查看。4.4 综合与上板Quartus / Vivado 里要看的三个报告仿真通过不等于能上板。综合之后重点看三处一是综合警告里有没有「inferred latch」出现就说明某个分支没给默认值回到 3.2 节的默认赋值检查二是资源报告里的 LUT 和进位链用量8 位 ALU 正常应该只用几十个 LUT如果数字大得离谱通常是 case 被综合成了超大的优先选择网络可以尝试用parallel_case综合属性或者改写编码让综合器识别成并行多路三是时序报告纯组合 ALU 本身没有时钟但接进数据通路后会有一段组合延迟如果实验要求跑在某个时钟频率上这条路径的延迟必须小于时钟周期。上板验证时把拨码开关接到 a、b 和 opLED 接 y 和四个标志位手工把 4.2 节表格里的几组输入拨一遍是最直观的验收方式。5. 进阶把ALU接进数据通路之前的三道检查5.1 组合环路与时序ALU 不该有自己的状态很多同学在实验后期会把 ALU 直接连到寄存器堆和 PC 上这时候最常见的问题是「结果一直抖」。原因通常是 ALU 的输入里混进了它自己的输出或者某个中间信号被错误地声明成了 reg 并在多个 always 块里赋值。检查方法很直接把 ALU 单独拿出来做一次综合看它有没有任何跨时钟的路径如果报告里出现了反馈环路那就说明组合逻辑里藏了状态。一个干净的 ALU 应该满足「同样的 a、b、op任何时候输出都一样」任何违反这点的实现都要回退检查。5.2 位宽参数化之后标志位还对不对把WIDTH改成 16 重跑一遍 testbench是检验参数化质量最快的手段。多数情况会挂在这几处SLT 里{{(WIDTH-1){1b0}}, ...}的补零位数写错INC/DEC 的进位判断常量没有跟着位宽变overflow 表达式里用了硬编码的[7]。正确的写法是三处都用WIDTH-1改完位宽只需要重跑测试不用改逻辑。如果 16 位下 11 组向量全部通过基本可以确认参数化是干净的。5.3 用 Python 生成黄金向量回填 testbench手工写几十组期望值很容易抄错尤其是带符号比较和移位这类需要脑内换算的。稳妥做法是用一段脚本算一遍把结果直接生成成 Verilog 的expect(...)调用步骤做法说明1Python 里用整数模拟 ALU结果按掩码截断逐位算标志2随机生成 a、b、op覆盖全编码含保留码3输出expect语句直接粘进 testbench 的 initial 块4重跑仿真失败项回看是哪一位对不上# gen_vectors.py —— 生成黄金期望值直接粘贴进 testbench import random M 0xFF # 8 位掩码改 0xFFFF 即可生成 16 位向量 def golden(a, b, op): if op 0b0000: # ADD s a b y, cf s M, (s 8) 1 of 1 if (~(a ^ b) (a ^ y)) 0x80 else 0 elif op 0b0001: # SUBCF 定义为借位 d a - b y, cf d M, 1 if d 0 else 0 of 1 if ((a ^ b) (a ^ y)) 0x80 else 0 elif op 0b0110: # SHL y, cf (a 1) M, (a 7) 1 of 0 else: y, cf, of 0, 0, 0 # 其余分支按表补全 return y, int(y 0), cf, of, (y 7) 1 random.seed(1) for _ in range(20): a, b, op random.randrange(256), random.randrange(256), random.randrange(16) y, zf, cf, of, sf golden(a, b, op) print(fexpect(8h{a:02X}, 8h{b:02X}, 4b{op:04b}, f8h{y:02X}, {zf},{cf},{of},{sf});)把这段输出粘进 4.1 节的 initial 块就得到一批随机但带黄金参照的回归向量。注意 Python 里的~是无限位宽的按位取反所以判断溢出时必须先和0x80相与再取值否则~(a ^ b)会得到负数判断结果整个反过来——这是从 Python 生成向量时最常踩的一个坑。生成完之后把种子固定住同一批向量每次跑的结果就可复现改一次 RTL 跑一次回归比每次重新想测试用例省事得多。本文还有配套的精品资源点击获取