前言
这篇不再整理 Verilog 语法本身,而是总结一些在 HDLBits 里反复出现、也比较有代表性的电路实现套路。
目标不是把题目抄一遍,而是记住这些问题通常该怎么拆、代码一般长什么样、哪些地方最容易犯错。以后如果再碰到类似题型,应该能更快想到做法。
边沿检测与边沿捕获
这类题的核心思路很统一:先保存输入的上一拍,再把“当前值”和“上一拍值”做比较。
上升沿检测:
module top_module(
input clk,
input in,
output reg pedge
);
reg in_last;
always @(posedge clk) begin
pedge <= ~in_last & in;
in_last <= in;
end
endmodule
下降沿检测:
negedge_detect <= in_last & ~in;
双边沿检测:
edge_detect <= in_last ^ in;
边沿捕获寄存器和普通边沿检测的区别在于:检测到之后不是输出一个脉冲,而是把结果保持住,直到 reset:
always @(posedge clk) begin
if (reset)
out <= 32'd0;
else begin
out <= out | (in_last & ~in);
in_last <= in;
end
end
这类题最值得记住的是“上一拍寄存器 + 当前输入比较”这个模式。
计数器
很多时序题,表面上看是钟表、分频器、模 N 计数,实质上都是计数器加一些条件判断。
最基础的同步计数器:
always @(posedge clk) begin
if (reset)
q <= 4'd0;
else
q <= q + 1'b1;
end
模 10 计数器:
always @(posedge clk) begin
if (reset)
q <= 4'd0;
else if (q == 4'd9)
q <= 4'd0;
else
q <= q + 1'b1;
end
复杂一点的计数器,例如 1-12、1000 分频、12 小时制时钟,本质通常是:
- 当前位计数。
- 到终值时回零。
- 产生一个进位使能给更高位。
所以这类题最有用的不是背答案,而是先问自己:
- 计数范围是多少?
- 什么时候清零?
- 谁给下一级发进位?
移位寄存器、旋转与 LFSR
移位寄存器的本质是:每个时钟边沿,寄存器中的 bit 按固定方向移动。
右移寄存器:
always @(posedge clk) begin
if (reset)
q <= 4'd0;
else if (ena)
q <= {1'b0, q[3:1]};
end
左移寄存器:
q <= {q[2:0], serial_in};
循环移位和普通移位的区别只在于补进来的 bit:
// 左旋
q <= {q[6:0], q[7]};
// 右旋
q <= {q[0], q[7:1]};
算术右移则要保留符号位:
q <= $signed(q) >>> 1;
一维边界怎么处理
很多移位、滑动窗口、邻居比较题,真正容易错的不是“中间部分怎么写”,而是最左边和最右边怎么处理。
常见处理方式有四种:
- 边界外补 0
- 边界外补 1
- 边界环回
- 边界复制原值或符号位
补 0 最常见,普通逻辑右移就是这种:
q <= {1'b0, q[3:1]};
补 1 也只是把补进去的 bit 换成 1'b1:
q <= {1'b1, q[3:1]};
环回就是把移出去的一端接回另一端:
// 左旋
q <= {q[6:0], q[7]};
// 右旋
q <= {q[0], q[7:1]};
符号位扩展常见于算术右移,本质上也是“边界外补固定值”,只不过这个固定值来自当前最高位:
q <= {q[7], q[7:1]};
所以遇到这类题时,我觉得最先该问的一句是:
- 超出边界的那个 bit,到底应该来自哪里?
一旦这个问题想清楚,代码通常就是一条拼接语句。
LFSR 的关键不是“会不会移位”,而是“反馈 bit 从哪里来”。例如一个简单的 Galois LFSR,本质上就是:
- 某些位正常移位。
- 某些 tap 位和输出 bit 做 XOR。
- 全零态通常要避开。
所以 LFSR 题目真正要先看的是反馈拓扑,而不是代码形式。
多路选择器、比较器与优先级逻辑
组合逻辑题里经常出现 mux、比较器、优先编码器。
简单 mux:
assign out = sel ? b : a;
多路 mux:
always @(*) begin
case (sel)
2'd0: out = a;
2'd1: out = b;
2'd2: out = c;
default: out = d;
endcase
end
优先编码器:
always @(*) begin
casez (in)
8'b???????1: pos = 3'd0;
8'b??????10: pos = 3'd1;
8'b?????100: pos = 3'd2;
8'b????1000: pos = 3'd3;
8'b???10000: pos = 3'd4;
8'b??100000: pos = 3'd5;
8'b?1000000: pos = 3'd6;
8'b10000000: pos = 3'd7;
default: pos = 3'd0;
endcase
end
这里最容易踩坑的是忘记优先级。优先编码器不是普通编码器,必须确保“最低位/最高位谁先匹配”是明确的。
状态机
状态机是 Sequential Logic 里最值得单独总结的一类题,因为它几乎总是在考“如何拆结构”,而不是某个单独语法。
最常见的拆法:
- 状态寄存器。
- next-state 组合逻辑。
- 输出逻辑。
典型二段式:
reg [1:0] state, next_state;
always @(posedge clk or posedge areset) begin
if (areset)
state <= A;
else
state <= next_state;
end
always @(*) begin
case (state)
A: next_state = in ? B : A;
B: next_state = in ? B : C;
C: next_state = in ? A : C;
default: next_state = A;
endcase
end
assign out = (state == C);
做状态机题时,我觉得最有效的顺序是:
- 先画状态图或者抄状态转移表。
- 先写状态定义。
- 先保证 next-state 对。
- 最后再写输出逻辑。
不要一上来就直接敲 case,很容易把状态和输出搅在一起。
热独码 One-Hot 状态机
One-hot 编码很适合单独记一下,因为它和普通二进制状态机的写法明显不同。
普通二进制编码可能是:
localparam A = 2'd0, B = 2'd1, C = 2'd2, D = 2'd3;
One-hot 则是:
localparam A = 4'b0001;
localparam B = 4'b0010;
localparam C = 4'b0100;
localparam D = 4'b1000;
one-hot 的好处是 next-state 逻辑往往可以直接从状态图“按入边”写出来:
assign next_state[A] = state[A] & ~in
| state[C] & in;
assign next_state[B] = state[A] & in
| state[B] & in
| state[D] & in;
这种题目的重点不是背公式,而是学会这个观察角度:
- 我现在要写的是“谁能转移到这个状态”。
- 所以要看这个状态有哪些入边。
one-hot 在 HDLBits 里非常适合练“从状态图直接写逻辑方程”。
串口接收器与 FSM + Datapath
这一类题很有意思,因为它开始体现“控制路径”和“数据路径”的分离。
比如串口接收器常常会分成两部分:
- FSM:负责判断现在处于 idle、start、data、stop 还是 error。
- Datapath:负责把收到的 bit 移进寄存器,最后组成一个字节。
典型 datapath:
always @(posedge clk) begin
if (shift_ena)
out_byte <= {in, out_byte[7:1]};
end
FSM 则负责在正确时机拉高 shift_ena、done、reset_parity 之类的控制信号。
这种题的价值在于提醒自己:复杂设计不一定要塞进一个 always,而是可以拆成“状态控制 + 数据处理”两个层次。
康威生命游戏
这个题我觉得很有意思,因为它把“时序电路”和“二维邻域计算”结合起来了。
康威生命游戏的核心规则很短:
- 周围活细胞数小于 2,死亡。
- 等于 2,保持原状。
- 等于 3,变活。
- 大于 3,死亡。
难点不在规则本身,而在于:
- 如何表示 16x16 的棋盘。
- 如何找到每个格子的 8 个邻居。
- 这里还是环面,边界要回绕。
这类题最自然的拆法通常是:
- 用一个 256 bit 寄存器保存当前棋盘。
- 在组合逻辑里,按
(row, col)遍历每个格子。 - 计算 8 邻域活细胞数。
- 根据规则生成 next board。
- 在时钟边沿更新棋盘。
它很像一个“二维版的 next-state 计算”。所以这题虽然看起来像算法题,本质还是:
- 当前状态
q - 组合逻辑算出
q_next - 时钟边沿
q <= q_next
这也是为什么它特别适合拿来练“先想电路结构,再写代码”。
二维边界怎么处理
生命游戏这一类题,经常会出现“坐标走到边界外怎么办”。这个问题其实很通用,不只生命游戏会遇到,很多二维卷积、邻域统计、棋盘类题都会遇到。
常见处理方式同样可以分成几类:
- 越界算 0
- 越界算 1
- 行列回绕成环面
- 边界复制最近的合法格子
如果是“越界算 0”,判断邻居时通常要先检查坐标是否合法:
if (nr >= 0 && nr < 16 && nc >= 0 && nc < 16)
count = count + board[nr][nc];
如果越界直接算 0,那么非法坐标就直接跳过,不计入邻居数。
如果题目要求“越界算 1”,那就反过来:
if (nr < 0 || nr >= 16 || nc < 0 || nc >= 16)
count = count + 1'b1;
else
count = count + board[nr][nc];
生命游戏这题则是环回,也就是 row 和 col 超出边界后要绕回另一侧。最常见的写法是把上下左右坐标先算好:
up = (r == 0) ? 15 : r - 1;
down = (r == 15) ? 0 : r + 1;
left = (c == 0) ? 15 : c - 1;
right = (c == 15) ? 0 : c + 1;
然后 8 个邻居就是:
count = board[up][left] + board[up][c] + board[up][right]
+ board[r][left] + board[r][right]
+ board[down][left] + board[down][c] + board[down][right];
这种写法的好处是:边界处理只做一次,后面数邻居时就不用每个方向都重新写一遍判断。
所以对于二维题,一个很好用的思路是:
- 先决定越界该怎么算。
- 再把“合法坐标 / 回绕坐标”预先算出来。
- 最后再写核心逻辑。
这样代码会干净很多,也不容易在八个方向里漏掉某个边角。
我觉得最值得反复回看的几类题
如果以后要复习,我会优先回看这几类:
- 边沿检测与边沿捕获:练“上一拍 vs 当前拍”的思路。
- 计数器与多位进位:练时序优先级和分层计数。
- 移位寄存器、旋转、LFSR:练拼接和 next-state 写法。
- 普通状态机与 one-hot 状态机:练状态转移逻辑。
- 串口接收器这类 FSM + Datapath:练模块化拆分。
- 康威生命游戏:练大规模并行 next-state 计算。
小结
写到这里会感觉,很多看起来完全不同的题,最后都能归到几个熟悉的模式里:
- 组合逻辑:当前输入直接算输出。
- 时序逻辑:当前状态经过组合逻辑得到下一状态。
- 结构分解:控制路径和数据路径分开。
- 编码选择:普通状态编码还是 one-hot 编码。
所以刷题真正积累下来的,不应该只是“这道题答案长什么样”,而是“我看到这个电路时,第一反应该怎么拆它”。
参考资料
- HDLBits: Problem sets
- HDLBits: Edge capture register
- HDLBits: 4-bit shift register
- HDLBits: 64-bit arithmetic shift register
- HDLBits: 5-bit LFSR
- HDLBits: Simple FSM 3
- HDLBits: One-hot FSM
- HDLBits: Serial receiver and datapath
- HDLBits: Serial receiver with parity checking
- HDLBits: Conway's Game of Life 16x16