Verilog HDL课后习题精讲:从仿真综合到参数化自检
2026/9/20 3:43:45 网站建设 项目流程

简介:这份《数字系统设计与Verilog HDL课后习题》PDF面向电子信息、自动化及计算机相关专业的在校生,以及准备转入FPGA与数字IC设计的自学者,主要用于配合高校课程复习、作业核对与期末备考。内容按章节编排,覆盖EDA技术特点、Top-down设计方法、ASIC与FPGA/CPLD实现方式、IP复用、综合与功能/时序仿真等理论题,也包含8位加法器、模60 BCD计数器、4选1数据选择器、移位寄存器、串并转换、状态机交通灯、彩灯控制及UDP描述等Verilog编程练习,并涉及Synplify、ModelSim、Quartus II等工具的使用思路,较完整地串联起数字系统设计的基础概念与编码要点。资源包共1个PDF文件,约126KB,轻量便于打印、批注与分章练习;目前已有1363人学习下载。读者可借助题目逐章检验概念掌握程度,把阻塞与非阻塞赋值、任务与函数、状态机写法等易错点落到代码与仿真中,形成从原理到工具流程的完整练习路径。

1. 课后习题里的 Verilog HDL:为什么仿真过了,综合却报错

很多人拿到《数字系统设计与 Verilog HDL》这套课后习题,第一反应是照着课本把 module 敲进编辑器,波形一出来就以为过关了。真正卡人的地方在后面:同一段代码,iverilog 跑得挺欢,综合工具却冒出一堆 latch 推断、位宽截断、无法综合的警告。课后习题的价值从来不是答案本身,而是它把数字系统设计里最基础的三件事——组合逻辑的完备描述、时序逻辑的边沿与复位、状态机的状态转移——压缩成几十行可以反复推敲的 Verilog HDL 代码。这些代码短到能背下来,却又足够暴露你对赋值语义、位宽规则、综合边界的真实理解程度。这篇面向正在啃这套习题的人:先把题型分类讲清楚,再用仿真跑通,最后落到参数化和排错,给的都是能直接抄进自己工程的做法。

2. 数字系统设计课后题的题型拆解:Verilog HDL 的组合、时序与状态机写法

课后习题看着五花八门,归到 Verilog HDL 层面其实只有三类骨架:纯组合逻辑、边沿触发的时序逻辑、带状态转移的有限状态机。把这三类写对,八成的题目就稳了。判断题目属于哪一类有个简单办法:问自己「输出是否只取决于当前输入」。只取决于当前输入就是组合逻辑,用assignalways @(*);输出跟历史有关、需要跨时钟保持,就是时序逻辑;输出依赖于一组有限个内部状态,才上状态机。

2.1 组合逻辑题:assign 与 always @(*) 的选择边界

课本里最常见的组合逻辑题是多路选择器、译码器、优先编码器。这类题目用assign写数据流描述最直观,综合结果也最好预测:

// 4 选 1 多路选择器:纯组合逻辑,数据流描述 module mux4to1 #(parameter W = 8) ( input wire [W-1:0] d0, d1, d2, d3, input wire [1:0] sel, output wire [W-1:0] y ); assign y = (sel == 2'b00) ? d0 : (sel == 2'b01) ? d1 : (sel == 2'b10) ? d2 : d3; endmodule

parameter W让位宽可配,后面 8 位改 16 位不用动逻辑;sel的 2 位刚好覆盖四种选择,最后一级三元表达式的:后面直接给d3,等价于补上了default。综合出来是一层 MUX 加一个小的选择译码,延迟可估算。

换成always @(*)case也能写同一道题,但代价是必须处理「所有分支都赋值」这件事,漏一个分支就会推断出锁存器。我一般这样分工:题目本质是并行的数据选择就用assign;题目带优先级语义(比如优先编码器、中断仲裁)就用always @(*)里的if-else if链,因为优先级在if-else里是天然的,在assign里要嵌套三元表达式,可读性差。

2.2 时序逻辑题:always @(posedge clk) 的三个必设参数

计数器、移位寄存器、分频器这类题,模板几乎一致:时钟、复位、使能三个信号缺一不可。

// 带同步使能、异步复位的加法计数器 module counter #(parameter W = 4) ( input wire clk, input wire rst_n, // 低有效异步复位 input wire en, output reg [W-1:0] cnt, output wire carry ); always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= {W{1'b0}}; else if (en) cnt <= cnt + 1'b1; end assign carry = (cnt == {W{1'b1}}); // 进位是组合输出,不要放进时序块 endmodule

三个参数值得逐条说。复位写进敏感列表or negedge rst_n表示异步复位,上板时复位释放和时钟沿无关,调试更省事;如果题目明确要求同步复位,就把rst_n从敏感列表去掉,只在if (!rst_n)里判断,代价是多占一点复位树的资源。复位值写{W{1'b0}}而不是裸的0,是为了让位宽显式对齐,避免工具在不同位宽规则下给出意料之外的结果。enelse if挂上,未使能时计数器保持,这条隐含的「保持」路径正是时序逻辑和组合逻辑最大的区别——组合逻辑没有「保持」这个概念。

carryassign单独拉出来,不让它进时序块。进位是当前计数值的纯函数,打进寄存器会晚一个周期,答非所问。

2.3 状态机题:三段式描述与状态编码

序列检测是这类教材的常客,比如检测输入流里的1101。三段式写法在这个场景里最好维护:第一段管状态寄存,第二段管次态组合逻辑,第三段管输出。

module seq_det_1101 ( input wire clk, rst_n, din, output reg dout ); localparam S0 = 3'd0, S1 = 3'd1, S2 = 3'd2, S3 = 3'd3, S4 = 3'd4; reg [2:0] cur, nxt; always @(posedge clk or negedge rst_n) // 第一段:状态寄存器 if (!rst_n) cur <= S0; else cur <= nxt; always @(*) begin // 第二段:次态组合逻辑 nxt = S0; // 先给默认值,杜绝 latch case (cur) S0: nxt = din ? S1 : S0; S1: nxt = din ? S2 : S0; S2: nxt = din ? S2 : S3; S3: nxt = din ? S4 : S0; S4: nxt = din ? S2 : S0; default: nxt = S0; endcase end always @(posedge clk or negedge rst_n) begin // 第三段:输出寄存器 if (!rst_n) dout <= 1'b0; else dout <= (nxt == S4); end endmodule

localparam定义状态编码,比define安全,作用域限于本模块;第二段开头nxt = S0;是防止锁存器的关键,case里没覆盖到的分支都会落到这个默认值;第三段用nxt而不是cur决定输出,输出因此和状态同步跳变,不会出现毛刺。状态数只有 5 个,3 位编码够用,default分支还能兜住上电时的非法状态。

三类题型的对照关系可以整理成一张表,做题前先定位题型,再套模板:

题型特征典型题目描述方式高频错误
输出只依赖当前输入译码器、MUX、比较器assign/always @(*)casedefault推断 latch
输出跨时钟保持计数器、移位寄存器、分频器always @(posedge clk)用阻塞赋值、复位值位宽不明
输出依赖有限状态序列检测、交通灯、电梯三段式状态机次态未赋默认值、输出用cur

3. 用 iverilog 和 GTKWave 把一道 Verilog HDL 课后习题跑通

写完代码只是第一步,课后习题真正的训练量在「跑起来、看到波形、对上期望值」。iverilog 加 GTKWave 这套组合免费、启动快,特别适合一道题一个目录反复折腾。

3.1 最小可编译工程与命令行

把设计文件和 testbench 放在同一目录,三条命令走完编译、仿真、看波形:

# 编译:设计文件在前,testbench 在后,一起交给 iverilog iverilog -g2012 -o sim.out mux4to1.v tb_mux4to1.v # 运行:vvp 解释执行,产生 vcd 波形文件 vvp sim.out # 打开波形,观察各信号随时间的变化 gtkwave dump.vcd &

-g2012打开 IEEE 1800-2012 的一部分语法,logic$clog2、循环里声明变量这些写法才认;不写这个开关,很多教材上的新写法会直接报语法错误。-o sim.out指定输出文件名,避免默认的a.out在多个题目之间互相覆盖。如果一个 testbench 文件里塞了多个模块做对比,用-s tb_top显式指定顶层,否则 iverilog 会自己挑一个,挑错了波形是空的。

3.2 testbench 里时钟、复位、激励的写法

组合逻辑题的 testbench 相对简单,把输入逐个试一遍就行:

`timescale 1ns/1ps module tb_mux4to1; reg [7:0] d0, d1, d2, d3; reg [1:0] sel; wire [7:0] y; integer i; mux4to1 u_mux (.d0(d0), .d1(d1), .d2(d2), .d3(d3), .sel(sel), .y(y)); initial begin $dumpfile("dump.vcd"); // 波形输出文件 $dumpvars(0, tb_mux4to1); // 0 表示 dump 全部层级 d0 = 8'h11; d1 = 8'h22; d2 = 8'h33; d3 = 8'h44; for (i = 0; i < 4; i = i + 1) begin sel = i[1:0]; #10; // 等 10ns 让组合逻辑稳定 $display("sel=%0d y=%h", sel, y); end $finish; end endmodule

timescale 1ns/1ps决定了#10是 10 纳秒,以及仿真精度到皮秒;不写这行,延时单位在不同工具里默认值不一样,波形时间轴会对不上。$dumpvars(0, tb_mux4to1)的第一个参数 0 表示递归 dump 下面所有子模块的信号,调时序题时不写这个只能看到顶层端口。sel = i[1:0]把整数截成 2 位,是 Verilog 里很常见的技巧,省去手写四段赋值的麻烦。

时序题的 testbench 要多一个时钟发生器:

// 周期 20ns 的时钟,初始为 0,每 10ns 翻转一次 initial clk = 1'b0; always #10 clk = ~clk; initial begin rst_n = 1'b0; // 上电先拉低复位 #25 rst_n = 1'b1; // 25ns 后释放,跨过时钟沿更真实 en = 1'b1; #200 $finish; end

复位的释放时刻我一般故意不放在时钟沿上,而是错开几个纳秒。这样跑出来的波形能看出复位释放和时钟沿的竞争关系,比「刚好在沿上释放」更接近真实情况。

3.3 仿真报错与波形异常的对照排查表

跑不通的时候,报错信息和波形症状基本能对应到固定几类问题:

报错 / 现象常见原因处理方式
syntax error near ...用了-g2012才支持的语法-g2012或改成 Verilog-2001 写法
Unknown module type设计文件没加进编译命令编译时补上对应的.v文件
波形全是x信号未初始化、复位没生效检查initial赋值与复位极性问题
波形全是z输出被多个驱动源驱动检查是否重复例化或漏接端口
波形停在 0 时刻$dumpvars$finish没触发$dumpvars(0, tb)与结束条件

4. Verilog HDL 习题里最常翻车的参数:位宽、赋值方式与综合结果

代码能编译、波形有变化,跟代码写对之间还隔着几道坎。这几道坎在课后习题里出现频率极高,而且每一条都会在后面的综合阶段放大。

4.1 位宽不匹配与隐式扩展

Verilog 的表达式位宽由上下文决定,赋值语句里左侧的位宽会反过来影响右侧的计算,这一点极其反直觉:

reg [3:0] a, b; reg [3:0] sum4; reg [4:0] sum5; initial begin a = 4'd9; b = 4'd7; sum4 = a + b; // 右侧按 4 位计算:9+7=16 溢出成 0 sum5 = a + b; // 右侧扩展到 5 位计算:得到 16 end

sum4拿到的不是 16,而是 0,因为加法在 4 位宽度下做完才赋过去。习题里的加法器、计数器进位判断经常踩这个坑。稳妥做法是把中间结果和跨模块的比较都按可能的最大位宽声明,或者显式用{1'b0, a} + {1'b0, b}先把操作数扩宽。

场景危险写法建议写法
求和可能溢出sum = a + b;位宽未扩结果位宽取max(W)+1
计数到最大值判断cnt == 4'b1111cnt == {W{1'b1}}
拼接后位数不对{a, b}未核对总宽注释里标出拼接后位宽
有符号比较reg声明却按有符号比较显式用$signed()或改wire signed

4.2 阻塞赋值与非阻塞赋值的边界

一句话原则:时序逻辑一律用<=,组合逻辑一律用=。违反这条原则最典型的症状是移位寄存器。

// 错误:阻塞赋值,q1 先更新,后面两级的输入已经是新值 always @(posedge clk) begin q1 = din; q2 = q1; // 此时 q1 已经等于 din q3 = q2; end // 正确:非阻塞赋值,三条语句在同一时钟沿统一取值 always @(posedge clk) begin q1 <= din; q2 <= q1; q3 <= q2; end

第一个版本编译通过、波形也不为全零,但只移位了一级——q1q2q3在同一时刻都变成din。这种错误在单比特序列题里看不出来,一旦做并行数据移位就会暴露。

4.3 用 parameter 和 generate 把习题做成参数化模块

课后习题的位宽往往是 4 位、8 位,但真实工程里位宽是配置项。把generate用起来,同一段代码可以覆盖多种位宽:

// 参数化行波进位加法器:W 改大不用动逻辑 module ripple_adder #(parameter W = 8) ( input wire [W-1:0] a, b, input wire cin, output wire [W-1:0] sum, output wire cout ); wire [W:0] c; assign c[0] = cin; genvar i; generate for (i = 0; i < W; i = i + 1) begin : gen_adder assign sum[i] = a[i] ^ b[i] ^ c[i]; assign c[i+1] = (a[i] & b[i]) | (a[i] & c[i]) | (b[i] & c[i]); end endgenerate assign cout = c[W]; endmodule

genvar只能出现在generate块里,它是编译期变量,不占硬件资源。begin : gen_adder的标签不能省,否则综合出来的层次名是自动生成的,波形里定位某一级进位时非常麻烦。c声明成[W:0],多出一位专门放最终进位,避免和sum抢位宽。

4.4 latch 推断:组合块里的隐形陷阱

综合日志里出现「inferred latch」警告,几乎都出在组合 always 块。根因是某个输入组合下输出没有被赋值,工具只能让它保持上一个值,而「保持」在硬件上就是锁存器。

// 有 latch 风险:sel 等于 2'b11 时 y 无赋值 always @(*) begin case (sel) 2'b00: y = a; 2'b01: y = b; 2'b10: y = c; endcase end // 修复:进 case 之前先给默认值,一行解决 always @(*) begin y = 1'b0; case (sel) 2'b00: y = a; 2'b01: y = b; 2'b10: y = c; endcase end

默认值写在case之前比写default:分支更稳,因为它对if-else链同样生效。修复之后综合日志干净,时序分析也有了确定的组合路径延迟。

综合日志关键词含义处理方向
inferred latch组合块存在未赋值分支块首加默认赋值
width mismatch两侧位宽不一致显式对齐或扩宽
is not a constant把变量用在了需要常量的位置localparamgenvar
multiple drivers同一信号被多处驱动合并驱动源或改用wire

5. 把课后习题改造成自检 testbench:断言、随机激励与批量回归

肉眼对波形只适合第一道题,做到第十道题就受不了了。自检 testbench 的核心思路是:把期望值写进代码,让 testbench 自己判断对错并累计错误数。

task把一组激励加校验打包,每道题只需要重复调用:

integer errors = 0; task check; input [1:0] s; input [7:0] exp; begin sel = s; #10; if (y !== exp) begin $display("FAIL sel=%0d exp=%h got=%h", s, exp, y); errors = errors + 1; end end endtask initial begin d0 = 8'h11; d1 = 8'h22; d2 = 8'h33; d3 = 8'h44; check(2'b00, 8'h11); check(2'b01, 8'h22); check(2'b10, 8'h33); check(2'b11, 8'h44); if (errors) $fatal(1, "errors=%0d", errors); else $display("PASS"); $finish; end

比较用!==而不是!=,这样波形里出现xz也能被判定为失败,而不是悄悄放过。$fatal(1, ...)的第一个参数是退出码,非零退出码会被 shell 捕获,这是后面做批量回归的基础。errors声明成integer放在模块级,task内部可以直接累加,不用来回传参。

有了自检,批量回归就是一行 shell:

# 每个题目一个目录,目录里放 design.v 和 tb.v for d in ex01 ex02 ex03 ex04; do iverilog -g2012 -o /tmp/sim.out "$d/design.v" "$d/tb.v" || { echo "$d 编译失败"; continue; } if vvp /tmp/sim.out | grep -q "FAIL"; then echo "$d 未通过" else echo "$d 通过" fi done

|| { ...; continue; }处理编译失败,避免一个题目挂掉整轮回归;grep -q "FAIL"只看有没有失败行,不关心输出多少。想把随机激励也加进来,在initial块里用$random生成激励,同时用同一份参考模型算出期望值做对比,覆盖率比穷举法高得多。真要把这套东西挂到持续集成上,靠的是$fatal的非零退出码和grep的返回状态,两处都对上,一条make test就能把整套课后习题变成可重复执行的验证用例。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询