关于这篇文章: 这篇文章最初收录了我做过的全部 HDLBits 题解。182 道题放在同一个 Markdown 文件中,查找和维护都很麻烦,因此现在已经将代码单独整理到:

felixtensor/hdlbits-solutions

仓库按 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
2
3
4
always @(posedge clk) begin
q1 <= d;
q2 <= q1;
end

在同一个时钟沿,q1q2 都根据更新前的值进行计算,因此这段代码表示两级寄存器。若在时钟块中随意混入阻塞赋值,仿真结果可能受到语句顺序影响。

3. 意外生成锁存器

组合 always 块需要在所有执行路径上为输出赋值。如果某条路径没有赋值,输出便需要保留原值,综合工具可能据此生成锁存器。这类问题不一定会成为编译错误。

常见的处理方法是在块开头设置默认值,之后再覆盖特殊情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
always @(*) begin
up = 1'b0;
down = 1'b0;
left = 1'b0;
right = 1'b0;

case (scancode)
16'he06b: left = 1'b1;
16'he072: down = 1'b1;
16'he074: right = 1'b1;
16'he075: up = 1'b1;
endcase
end

这里即使 case 没有匹配,四个输出也已经有确定值。对应的 HDLBits 题目是 Always_nolatches。另一种方式是完整处理所有 if / elsecase / default 分支。

4. wireregassignalways

在传统 Verilog 中,由 assign 驱动的信号使用 wire;在 alwaysinitial 中被赋值的信号使用 regreg 是否对应实际寄存器,仍然取决于过程块的写法。

使用 SystemVerilog 时,许多单驱动信号可以声明为 logic。HDLBits 的题目模板同时可以见到传统 Verilog 和少量 SystemVerilog 写法,按题目给定的接口完成即可。

本地编写 Verilog 时,还可以在文件开头加入:

1
`default_nettype none

这样,拼错的信号名会直接报错,而不是自动成为隐式连线。如果源码会与其他文件一起编译,可以在文件末尾使用 `default_nettype wire 恢复默认设置。

5. 位宽

Verilog 会根据表达式和目标信号的宽度进行截断或扩展。有些工具会给出警告,有些情况下则不会,因此不能只看代码是否通过编译。

例如,两个 100 位无符号数相加并保留进位时,可以先显式扩展操作数:

1
2
wire [100:0] sum_ext;
assign sum_ext = {1'b0, a} + {1'b0, b};

符号扩展也可以明确写成 {{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
2
wire [31:0] a_ext = {{16{a[15]}}, a};  // 16 位有符号数扩展到 32 位
wire [7:0] mask = {8{enable}}; // enable 为 1 时全 1,否则全 0

归约运算符

&in|in^in 对向量中的所有位进行 AND、OR 或 XOR。^in 常用于奇偶校验:

1
2
assign all_ones = &in;      // 所有位为 1 时输出 1
assign parity = ^in; // 偶校验位:与输入拼接后,1 的总数为偶数

三目运算符

cond ? a : b 对应一个 2 选 1 多路选择器:

1
assign out = sel ? a : b;

for 循环

循环边界在综合时可确定时,会展开为重复硬件。Popcount255.v 使用循环统计 255 位输入中 1 的数量:

1
2
3
4
5
6
7
8
integer i;
reg [7:0] count;

always @(*) begin
count = 0;
for (i = 0; i < 255; i = i + 1)
count = count + in[i];
end

generate / genvar

用于重复实例化模块。Adder100i.v 使用多个全加器构成 100 位加法器。假设已经定义 full_adder 模块,可以将第 0 位单独处理,再用前一位的 cout 作为下一位的进位输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
assign {cout[0], sum[0]} = a[0] + b[0] + cin;

genvar i;
generate
for (i = 1; i < 100; i = i + 1) begin : adder_block
full_adder fa (
.a(a[i]),
.b(b[i]),
.cin(cout[i-1]),
.sum(sum[i]),
.cout(cout[i])
);
end
endgenerate

索引式部分选择

[base +: width][base -: width] 适合处理分段总线和 BCD 数字:

1
2
localparam integer DIGIT_INDEX = 2;
wire [3:0] digit = bcd[4*DIGIT_INDEX +: 4]; // 等价于 bcd[11:8]

这些写法可以减少重复代码,但没有必要为了缩短行数而把结构写得难以辨认。HDLBits 只检查功能,代码最短并不表示实现最好。

有限状态机

有限状态机在 HDLBits 中占有较多题目。状态少时可以使用较紧凑的写法,状态、计数器和控制信号增多以后,将它们拆开通常更容易检查。

常见的三部分写法为:

  1. 次态逻辑:组合逻辑,根据当前状态和输入计算 next_state
  2. 状态寄存器:时序逻辑,在时钟边沿执行 state <= next_state,复位也在这里处理;
  3. 输出逻辑:Moore 型输出只取决于当前状态,Mealy 型输出同时取决于当前状态和输入。输出逻辑可以单独编写,也可以与次态逻辑放在同一组合块中。

下面是一个检测 101 序列的 Moore 型状态机示例,包含异步复位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
localparam IDLE = 2'b00;
localparam S1 = 2'b01; // 已收到 1
localparam S10 = 2'b10; // 已收到 10
localparam S101 = 2'b11; // 已收到 101

reg [1:0] state, next_state;
reg out;

// 1. 次态逻辑
always @(*) begin
case (state)
IDLE: next_state = in ? S1 : IDLE;
S1: next_state = in ? S1 : S10;
S10: next_state = in ? S101 : IDLE;
S101: next_state = in ? S1 : S10;
default: next_state = IDLE;
endcase
end

// 2. 状态寄存器
always @(posedge clk or posedge reset) begin
if (reset)
state <= IDLE;
else
state <= next_state;
end

// 3. 输出逻辑(Moore 型)
always @(*) begin
out = (state == S101);
end

状态转移可以概括为:从 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

输出不正确时,先找到实际输出与期望输出第一次不一致的位置,再向前检查输入、状态、复位和使能。后续的大段错误经常只是第一个错误的连锁结果。

如果输出恰好提前或延后一个周期,应检查阻塞与非阻塞赋值、statenext_state,以及输出是否多经过或少经过一级寄存器。若出现 x,则检查寄存器是否初始化、组合逻辑是否遗漏赋值路径,以及信号是否存在多个驱动源。

题解仓库中有 15 道读取仿真的题,其中 10 道根据波形还原电路,5 道根据波形寻找 bug。这部分题目不多,但对之后自己调试电路很有帮助。

另外还有 5 道 testbench 题,内容包括生成时钟、施加输入和实例化待测模块。HDLBits 会提供参考设计,而在自己的工程中,需要由 testbench 判断设计是否满足预期,因此这一部分不建议跳过。

延伸阅读

题解仓库

全部 182 道题解收录在:

GitHub:felixtensor/hdlbits-solutions

其中包括 2 道入门题、41 道 Verilog 语言题、115 道电路题、15 道读取仿真题、5 道 testbench 题和 4 道附加题。目录结构与 HDLBits 的导航基本一致;文件名取自题目 wiki 的 slug,文件头部链接回原题,部分复杂题目附有额外说明。仓库使用 MIT License。

这些代码写于不同阶段,其中一些只是当时第一个通过测试的版本。整理仓库时补充了注释、统一了部分格式,并做过基础静态检查,但它们仍然只是参考题解,不保证在面积、时序或代码风格上最优。