关于这篇文章: 这篇文章最初收录了我做过的全部 HDLBits 题解。182 道题放在同一个 Markdown 文件中,查找和维护都很麻烦,因此现在已经将代码单独整理到:
仓库按 HDLBits 的题目目录组织,每道题对应一个文件。这篇文章则保留一些入门说明,以及做题时比较容易遇到的问题。
建议先独立完成题目,遇到问题时根据波形定位;确实无法解决,再将仓库中的代码作为思路参考。直接复制答案可以通过测试,但对理解 Verilog 没有什么帮助。
这篇文章主要面向刚开始在 HDLBits 上练习 Verilog 的读者。如果你刚接触 Verilog,建议按顺序阅读“先建立硬件的概念”“解题流程”和“常见问题”;如果已经做到状态机或读取仿真等相关题目,可以直接跳到对应小节。
HDLBits 是什么
HDLBits 是一个用于练习 Verilog 的在线平台。题目提供固定的模块接口,提交代码后,平台会进行编译和仿真,并将输出与参考设计比较。整个过程不需要配置本地环境,打开网页即可开始。
题目从简单的导线连接开始,之后依次涉及向量、组合逻辑、触发器、计数器、有限状态机和较完整的数据通路。它不能代替数字电路教材,但很适合用来检验自己是否真正理解了某个知识点。代码能够看懂,与代码能够正确运行,通常还是两回事。
先建立硬件的概念
Verilog 的语法与 C 有不少相似之处,但不能按普通程序的方式理解它。程序通常强调语句的执行顺序,而 Verilog 描述的是电路结构和信号随时间的变化。
例如,多条彼此独立的 assign 语句表示同时存在的组合逻辑,不是依次执行的几个步骤。它们在文件中的顺序一般不会改变电路。不过,在同一个过程块中,阻塞赋值的先后关系仍然可能影响结果;仿真时也存在事件调度。因此,“硬件是并行的”是基本认识,并不意味着所有代码顺序都可以忽略。
reg 这个名字也容易引起误解。它只表示信号可以在 always 等过程块中被赋值,并不说明最终一定会得到寄存器:
- 在完整赋值的
always @(*)中,它可以表示组合逻辑; - 在
always @(posedge clk)中,它通常表示触发器; - 在组合块中漏掉赋值路径时,它可能使综合工具推导出锁存器。
遇到难以判断的语法时,可以先考虑它对应什么电路、是否需要保存旧值,以及输出应当在什么时刻变化。
解题流程
一般可以按下面的顺序处理题目:
1 | 理解题意 → 画出逻辑关系、真值表或状态图 → 编写代码 → 编译 → 仿真 |
提交后常见的结果包括:
- Compile Error:代码存在语法、端口或类型问题;
- Simulation Error:代码通过编译,但仿真过程出现错误;
- Incorrect:仿真能够运行,但输出与参考设计不一致;
- Success:通过测试。
比较值得花时间的是动手之前的分析。先确定组合逻辑还是时序逻辑、复位方式、信号位宽,以及输出属于当前周期还是下一个周期。状态较多时先画状态转移图,通常比直接写 case 更省时间。
推荐学习路径
HDLBits 按主题组织题目,同一主题内的难度大致递增。Verilog 语言题与电路题可以交叉练习:学习一种语法后,再用电路题确认自己是否理解了它对应的硬件。下面列出与本文章节对应、比较能体现某个知识点的题目,作为参考:
| 知识点 | 推荐题目 | 说明 |
|---|---|---|
| 导线与向量 | Step one / Vectors | 熟悉模块接口和位宽 |
| 组合逻辑与锁存器 | Always_nolatches | 练习默认值避免锁存器 |
| 阻塞与非阻塞赋值 | Alwaysblock2 | 对比连续赋值、组合块和时钟块 |
| 位宽与拼接 | Vector100r / Adder100i | 练习拼接、循环和 generate |
| 计数器与分频 | Count1to10 / Count_clock | 理解时钟使能和复位 |
| 有限状态机 | Exams/review2015_fsm | 三段式拆分与 Moore/Mealy |
| 读取仿真波形 | Verification: Reading Simulations | 10 道还原电路 + 5 道找 bug |
| 编写 testbench | Verification: Writing Testbenches | 5 道 testbench 题 |
不需要每道题都做完再看下一节,但遇到对应知识点卡壳时,可以优先挑表中的题目集中练习。
常见问题
1. 组合逻辑与时序逻辑
always @(*) 用于描述组合逻辑,输出由当前输入决定;always @(posedge clk) 用于描述时序逻辑,输出在时钟边沿更新并保存状态。二者描述的是不同的硬件。
题目中出现 clk,说明模块通常包含时序逻辑,但不代表所有逻辑都应写进时钟块。例如状态机的状态寄存器属于时序逻辑,次态计算一般仍然属于组合逻辑。
2. 阻塞赋值与非阻塞赋值
入门阶段可以先遵循下面的规则:
- 组合块
always @(*)使用阻塞赋值=; - 时钟块
always @(posedge clk)使用非阻塞赋值<=。
例如:
1 | always @(posedge clk) begin |
在同一个时钟沿,q1 和 q2 都根据更新前的值进行计算,因此这段代码表示两级寄存器。若在时钟块中随意混入阻塞赋值,仿真结果可能受到语句顺序影响。
3. 意外生成锁存器
组合 always 块需要在所有执行路径上为输出赋值。如果某条路径没有赋值,输出便需要保留原值,综合工具可能据此生成锁存器。这类问题不一定会成为编译错误。
常见的处理方法是在块开头设置默认值,之后再覆盖特殊情况:
1 | always @(*) begin |
这里即使 case 没有匹配,四个输出也已经有确定值。对应的 HDLBits 题目是 Always_nolatches。另一种方式是完整处理所有 if / else 和 case / default 分支。
4. wire、reg、assign 与 always
在传统 Verilog 中,由 assign 驱动的信号使用 wire;在 always 或 initial 中被赋值的信号使用 reg。reg 是否对应实际寄存器,仍然取决于过程块的写法。
使用 SystemVerilog 时,许多单驱动信号可以声明为 logic。HDLBits 的题目模板同时可以见到传统 Verilog 和少量 SystemVerilog 写法,按题目给定的接口完成即可。
本地编写 Verilog 时,还可以在文件开头加入:
1 |
这样,拼错的信号名会直接报错,而不是自动成为隐式连线。如果源码会与其他文件一起编译,可以在文件末尾使用 `default_nettype wire 恢复默认设置。
5. 位宽
Verilog 会根据表达式和目标信号的宽度进行截断或扩展。有些工具会给出警告,有些情况下则不会,因此不能只看代码是否通过编译。
例如,两个 100 位无符号数相加并保留进位时,可以先显式扩展操作数:
1 | wire [100:0] sum_ext; |
符号扩展也可以明确写成 {{24{in[7]}}, in}。处理 [N-1:0]、无尺寸常量和有符号运算时,应当确认中间表达式的实际宽度,而不只是输出信号的宽度。
常用写法
下面几种写法在 HDLBits 中经常出现。对每种写法,先掌握它的硬件含义,再考虑是否用它来简化代码。
拼接与循环移位
{a, b, c} 用于组合或重新排列信号。例如 100 位循环右移:
1 | q <= {q[0], q[99:1]}; |
对应仓库中的 Rotate100.v。
重复
{N{x}} 将某个信号重复 N 次,常用于符号扩展和掩码:
1 | wire [31:0] a_ext = {{16{a[15]}}, a}; // 16 位有符号数扩展到 32 位 |
归约运算符
&in、|in、^in 对向量中的所有位进行 AND、OR 或 XOR。^in 常用于奇偶校验:
1 | assign all_ones = ∈ // 所有位为 1 时输出 1 |
三目运算符
cond ? a : b 对应一个 2 选 1 多路选择器:
1 | assign out = sel ? a : b; |
for 循环
循环边界在综合时可确定时,会展开为重复硬件。Popcount255.v 使用循环统计 255 位输入中 1 的数量:
1 | integer i; |
generate / genvar
用于重复实例化模块。Adder100i.v 使用多个全加器构成 100 位加法器。假设已经定义 full_adder 模块,可以将第 0 位单独处理,再用前一位的 cout 作为下一位的进位输入:
1 | assign {cout[0], sum[0]} = a[0] + b[0] + cin; |
索引式部分选择
[base +: width] 与 [base -: width] 适合处理分段总线和 BCD 数字:
1 | localparam integer DIGIT_INDEX = 2; |
这些写法可以减少重复代码,但没有必要为了缩短行数而把结构写得难以辨认。HDLBits 只检查功能,代码最短并不表示实现最好。
有限状态机
有限状态机在 HDLBits 中占有较多题目。状态少时可以使用较紧凑的写法,状态、计数器和控制信号增多以后,将它们拆开通常更容易检查。
常见的三部分写法为:
- 次态逻辑:组合逻辑,根据当前状态和输入计算
next_state; - 状态寄存器:时序逻辑,在时钟边沿执行
state <= next_state,复位也在这里处理; - 输出逻辑:Moore 型输出只取决于当前状态,Mealy 型输出同时取决于当前状态和输入。输出逻辑可以单独编写,也可以与次态逻辑放在同一组合块中。
下面是一个检测 101 序列的 Moore 型状态机示例,包含异步复位:
1 | localparam IDLE = 2'b00; |
状态转移可以概括为:从 IDLE 开始,收到 1 进入 S1;再收到 0 进入 S10;再收到 1 进入 S101 并输出 1。其它输入则回到合适的中间状态。如果输出依赖 in,则变成 Mealy 型,输出可能提前一个周期变化。
仓库中的 Exams_review2015_fsm.v 是一个十状态的序列检测定时器,采用了这种拆分方式。
另外需要注意:
- 同步复位与异步复位:异步复位出现在敏感列表中,例如
always @(posedge clk or posedge reset);同步复位只在@(posedge clk)内判断。还要留意高有效和低有效。 - 当前状态与下一状态:输出应当依据题目的时序要求设计。Moore 型输出通常由
state决定;直接改用next_state会让输出间接依赖当前输入,可能改变状态机类型并产生组合毛刺。波形恰好相差一个时钟周期时,应先检查输入采样边沿和输出是否需要寄存,不要只为对齐波形而替换二者。 - 独热编码:每个状态使用一位,某个次态位可以写成所有入边条件的或。对应示例见
Exams_review2015_fsmonehot.v。
波形与 testbench
输出不正确时,先找到实际输出与期望输出第一次不一致的位置,再向前检查输入、状态、复位和使能。后续的大段错误经常只是第一个错误的连锁结果。
如果输出恰好提前或延后一个周期,应检查阻塞与非阻塞赋值、state 与 next_state,以及输出是否多经过或少经过一级寄存器。若出现 x,则检查寄存器是否初始化、组合逻辑是否遗漏赋值路径,以及信号是否存在多个驱动源。
题解仓库中有 15 道读取仿真的题,其中 10 道根据波形还原电路,5 道根据波形寻找 bug。这部分题目不多,但对之后自己调试电路很有帮助。
另外还有 5 道 testbench 题,内容包括生成时钟、施加输入和实例化待测模块。HDLBits 会提供参考设计,而在自己的工程中,需要由 testbench 判断设计是否满足预期,因此这一部分不建议跳过。
延伸阅读
- HDLBits:练习平台本身,可以按目录顺序完成。
- ChipVerify 的 Verilog 教程:用于查询语法和常见模块。
- TutorialsPoint 的数字电路教程:用于补充组合电路和时序电路的基础概念。
- 相关主题:LFSR、有限状态机、元胞自动机、分支预测器、竞争冒险。
题解仓库
全部 182 道题解收录在:
GitHub:felixtensor/hdlbits-solutions
其中包括 2 道入门题、41 道 Verilog 语言题、115 道电路题、15 道读取仿真题、5 道 testbench 题和 4 道附加题。目录结构与 HDLBits 的导航基本一致;文件名取自题目 wiki 的 slug,文件头部链接回原题,部分复杂题目附有额外说明。仓库使用 MIT License。
这些代码写于不同阶段,其中一些只是当时第一个通过测试的版本。整理仓库时补充了注释、统一了部分格式,并做过基础静态检查,但它们仍然只是参考题解,不保证在面积、时序或代码风格上最优。