简介:这份文档是杭州电子科技大学计算机组成原理与系统结构课程设计的实验三报告,围绕多功能ALU设计展开,适合正在做计组课设、需要用Verilog HDL完成运算器建模与仿真的本科生参考。报告记录了实验目的、仪器、步骤方法与结果分析,从ISE建工程、新建Verilog Module、语法检查与综合、查看RTL视图,到编写Test Fixture激励代码观察仿真波形,脉络清晰可对照。源码涵盖ALU_OP译码的与、或、异或、同或、加、减、小于比较、移位八种运算,以及AB_SW测试用例、ZF零标志与OF溢出标志的生成逻辑,并附激励代码与RTL图。思考题部分讨论了指令集覆盖不足、slt比较中符号与无符号的歧义(可增设SIGctr控制信号)等排错思路。包内共1个doc文件,约89KB,轻便易查阅;已有2286人学习下载。
1. 从一次减法结果错位的排查说起:多功能ALU要解决的到底是什么
第一次做这个实验时,我把8'h80减去8'h01的结果读出来,屏幕上是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] | 助记符 | 运算 | 结果来源 | 更新标志 |
|---|---|---|---|---|
| 0000 | ADD | y = a + b | 9 位和的低 8 位 | ZF / CF / OF / SF |
| 0001 | SUB | y = a - b | 9 位差的低 8 位 | ZF / CF / OF / SF |
| 0010 | AND | y = a & b | 逐位与 | ZF / SF |
| 0011 | OR | y = a | b | 逐位或 | ZF / SF |
| 0100 | XOR | y = a ^ b | 逐位异或 | ZF / SF |
| 0101 | NOT | y = ~a | 逐位取反 | ZF / SF |
| 0110 | SHL | y = a << 1 | 移出的最高位进 CF | ZF / CF / SF |
| 0111 | SHR | y = a >> 1 | 移出的最低位进 CF | ZF / CF / SF |
| 1000 | INC | y = a + 1 | 自增 | ZF / CF / OF / SF |
| 1001 | DEC | y = a - 1 | 自减 | ZF / CF / OF / SF |
| 1010 | SLT | y = (a<b) ? 1 : 0 | 有符号比较 | ZF / SF |
| 1011~1111 | 保留 | y = 0 | 全零 | 全部清 0 |
这张表有两个容易被忽略的设计决策。第一,保留编码的默认行为是输出全零、标志全清,而不是「输出不定」——组合逻辑必须给每条路径一个确定值,否则综合出来会挂锁存器。第二,SLT 的结果只占最低 1 位,高位补零,因此 SF 在 SLT 下必然为 0,这一点在写测试向量时要写对。
2.3 四个标志位的语义边界:ZF/CF/OF/SF 分别在什么时候置 1
标志位是这块 ALU 里最容易出错的部分,因为它不是「算出来」的,而是「解释出来」的。四个标志的边界建议这样定:
- ZF(零标志):只要结果 y 全零就置 1,逻辑运算也参与更新。它不看运算类型,只看结果,是最简单也最不该出错的一个。
- CF(进位/借位标志):无符号语义。加法时它表示「和超出了位宽」,减法时它表示「不够减,发生了借位」。注意这两件事在位级上取的都是扩展位的最高位,但含义刚好相反——加法里 CF=1 是「多了」,减法里 CF=1 是「欠了」。
- 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 = 4'b0000; localparam OP_SUB = 4'b0001; localparam OP_AND = 4'b0010; localparam OP_OR = 4'b0011; localparam OP_XOR = 4'b0100; localparam OP_NOT = 4'b0101; localparam OP_SHL = 4'b0110; localparam OP_SHR = 4'b0111; localparam OP_INC = 4'b1000; localparam OP_DEC = 4'b1001; localparam OP_SLT = 4'b1010;localparam在编译期就展开成字面量,不占任何硬件资源,但它让「操作码 4'b0110 代表什么」这件事在代码里只出现一次。后面 testbench 里写激励时直接用OP_SHL,比写裸的4'b0110可读得多,也避免抄错。
3. 用 Verilog 写出一个可综合的 8 位多功能ALU
3.1 模块接口与参数化位宽
接口设计的原则是「输入控制、输出结果和状态」,ALU 本身不存任何状态,所以全部端口都是组合语义。位宽用参数 WIDTH 表示,默认 8:
`timescale 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 = {1'b0, a} + {1'b0, b}; // 多扩一位,进位自然落在最高位 wire [WIDTH:0] sub_ext = {1'b0, a} - {1'b0, b}; // 同上,借位落在最高位 reg carry; // 内部进位/借位 reg ovf; // 内部溢出 always @(*) begin y = {WIDTH{1'b0}}; // 先给默认值,杜绝锁存器 carry = 1'b0; ovf = 1'b0; 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){1'b0}}, 1'b1}; carry = (a == {WIDTH{1'b1}}); // 全 1 自增才进位 end OP_DEC: begin y = a - {{(WIDTH-1){1'b0}}, 1'b1}; carry = (a == {WIDTH{1'b0}}); // 全 0 自减才借位 end OP_SLT: y = {{(WIDTH-1){1'b0}}, ($signed(a) < $signed(b))}; default: y = {WIDTH{1'b0}}; endcase // 标志位统一在这里生成,保证和 y 用的是同一份结果 zf = (y == {WIDTH{1'b0}}); sf = y[WIDTH-1]; cf = carry; of = ovf; end endmodule这段代码有三处值得说明。第一,{1'b0, a} + {1'b0, 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时 CF=1,目的是让后续「带借位减」的指令统一处理。实验里两个定义都能自圆其说,但必须在报告里写清楚用了哪一个,并且 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(8'h7F, 8'h01, 4'b0000, 8'h80, 0,0,1,1); // 正溢:127+1 越界 expect(8'hFF, 8'h01, 4'b0000, 8'h00, 1,1,0,0); // 满进位归零 expect(8'h00, 8'h01, 4'b0001, 8'hFF, 0,1,0,1); // 借位:0-1 回绕 expect(8'h80, 8'h01, 4'b0001, 8'h7F, 0,0,1,0); // 负溢:-128-1 越界 expect(8'hF0, 8'h0F, 4'b0010, 8'h00, 1,0,0,0); // 逐位与得零 expect(8'hF0, 8'h0F, 4'b0011, 8'hFF, 0,0,0,1); // 逐位或全 1 expect(8'hAA, 8'h55, 4'b0100, 8'hFF, 0,0,0,1); // 异或全 1 expect(8'h81, 8'h00, 4'b0110, 8'h02, 0,1,0,0); // 左移,最高位进 CF expect(8'h81, 8'h00, 4'b0111, 8'h40, 0,1,0,0); // 右移,最低位进 CF expect(8'h80, 8'h01, 4'b1010, 8'h01, 0,0,0,0); // -128 < 1 成立 expect(8'h01, 8'h80, 4'b1010, 8'h00, 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 | 普通路径 | CF=0 OF=0 |
| 加法进位 | 0xFF + 0x01 | 无符号溢出 | CF=1 OF=0 ZF=1 |
| 加法正溢 | 0x7F + 0x01 | 有符号溢出 | CF=0 OF=1 SF=1 |
| 减法借位 | 0x00 - 0x01 | 无符号下溢 | CF=1 OF=0 SF=1 |
| 减法负溢 | 0x80 - 0x01 | 有符号溢出 | CF=0 OF=1 SF=0 |
| 逻辑归零 | 0xF0 & 0x0F | ZF 更新 | ZF=1 |
| 移位出位 | 0x81 << 1 | CF 取移出位 | CF=1 |
| 有符号比较 | 0x80 vs 0x01 | SLT 用 signed | y=0x01 |
| 保留编码 | 任意 + 0b1111 | 默认分支 | y=0 标志全 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 和 op,LED 接 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){1'b0}}, ...}的补零位数写错;INC/DEC 的进位判断常量没有跟着位宽变;overflow 表达式里用了硬编码的[7]。正确的写法是三处都用WIDTH-1,改完位宽只需要重跑测试,不用改逻辑。如果 16 位下 11 组向量全部通过,基本可以确认参数化是干净的。
5.3 用 Python 生成黄金向量,回填 testbench
手工写几十组期望值很容易抄错,尤其是带符号比较和移位这类需要脑内换算的。稳妥做法是用一段脚本算一遍,把结果直接生成成 Verilog 的expect(...)调用:
| 步骤 | 做法 | 说明 |
|---|---|---|
| 1 | Python 里用整数模拟 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: # SUB,CF 定义为借位 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(f"expect(8'h{a:02X}, 8'h{b:02X}, 4'b{op:04b}, " f"8'h{y:02X}, {zf},{cf},{of},{sf});")把这段输出粘进 4.1 节的 initial 块,就得到一批随机但带黄金参照的回归向量。注意 Python 里的~是无限位宽的按位取反,所以判断溢出时必须先和0x80相与再取值,否则~(a ^ b)会得到负数,判断结果整个反过来——这是从 Python 生成向量时最常踩的一个坑。生成完之后把种子固定住,同一批向量每次跑的结果就可复现,改一次 RTL 跑一次回归,比每次重新想测试用例省事得多。
本文还有配套的精品资源,点击获取