HDL学习记录(2):Verilog常见电路实现

成元 2026-04-25 { 教程, 笔记 } [ HDL, Verilog, FSM ]

前言

这篇不再整理 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 小时制时钟,本质通常是:

  1. 当前位计数。
  2. 到终值时回零。
  3. 产生一个进位使能给更高位。

所以这类题最有用的不是背答案,而是先问自己:

移位寄存器、旋转与 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;

一维边界怎么处理

很多移位、滑动窗口、邻居比较题,真正容易错的不是“中间部分怎么写”,而是最左边和最右边怎么处理。

常见处理方式有四种:

  1. 边界外补 0
  2. 边界外补 1
  3. 边界环回
  4. 边界复制原值或符号位

补 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]};

所以遇到这类题时,我觉得最先该问的一句是:

一旦这个问题想清楚,代码通常就是一条拼接语句。

LFSR 的关键不是“会不会移位”,而是“反馈 bit 从哪里来”。例如一个简单的 Galois LFSR,本质上就是:

  1. 某些位正常移位。
  2. 某些 tap 位和输出 bit 做 XOR。
  3. 全零态通常要避开。

所以 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 里最值得单独总结的一类题,因为它几乎总是在考“如何拆结构”,而不是某个单独语法。

最常见的拆法:

  1. 状态寄存器。
  2. next-state 组合逻辑。
  3. 输出逻辑。

典型二段式:

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);

做状态机题时,我觉得最有效的顺序是:

  1. 先画状态图或者抄状态转移表。
  2. 先写状态定义。
  3. 先保证 next-state 对。
  4. 最后再写输出逻辑。

不要一上来就直接敲 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

这一类题很有意思,因为它开始体现“控制路径”和“数据路径”的分离。

比如串口接收器常常会分成两部分:

典型 datapath:

always @(posedge clk) begin
    if (shift_ena)
        out_byte <= {in, out_byte[7:1]};
end

FSM 则负责在正确时机拉高 shift_enadonereset_parity 之类的控制信号。

这种题的价值在于提醒自己:复杂设计不一定要塞进一个 always,而是可以拆成“状态控制 + 数据处理”两个层次。

康威生命游戏

这个题我觉得很有意思,因为它把“时序电路”和“二维邻域计算”结合起来了。

康威生命游戏的核心规则很短:

难点不在规则本身,而在于:

  1. 如何表示 16x16 的棋盘。
  2. 如何找到每个格子的 8 个邻居。
  3. 这里还是环面,边界要回绕。

这类题最自然的拆法通常是:

  1. 用一个 256 bit 寄存器保存当前棋盘。
  2. 在组合逻辑里,按 (row, col) 遍历每个格子。
  3. 计算 8 邻域活细胞数。
  4. 根据规则生成 next board。
  5. 在时钟边沿更新棋盘。

它很像一个“二维版的 next-state 计算”。所以这题虽然看起来像算法题,本质还是:

这也是为什么它特别适合拿来练“先想电路结构,再写代码”。

二维边界怎么处理

生命游戏这一类题,经常会出现“坐标走到边界外怎么办”。这个问题其实很通用,不只生命游戏会遇到,很多二维卷积、邻域统计、棋盘类题都会遇到。

常见处理方式同样可以分成几类:

  1. 越界算 0
  2. 越界算 1
  3. 行列回绕成环面
  4. 边界复制最近的合法格子

如果是“越界算 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];

这种写法的好处是:边界处理只做一次,后面数邻居时就不用每个方向都重新写一遍判断。

所以对于二维题,一个很好用的思路是:

  1. 先决定越界该怎么算。
  2. 再把“合法坐标 / 回绕坐标”预先算出来。
  3. 最后再写核心逻辑。

这样代码会干净很多,也不容易在八个方向里漏掉某个边角。

我觉得最值得反复回看的几类题

如果以后要复习,我会优先回看这几类:

小结

写到这里会感觉,很多看起来完全不同的题,最后都能归到几个熟悉的模式里:

所以刷题真正积累下来的,不应该只是“这道题答案长什么样”,而是“我看到这个电路时,第一反应该怎么拆它”。

参考资料