📝

第三阶段:时序逻辑

学习时钟、复位、寄存器、移位寄存器、计数器和有限状态机,并掌握时序电路的仿真方法。

返回 FPGA 与 Verilog 学习目录

第三阶段:时序逻辑讲义

对应课程目录第 7~10 章。建议学习时间:6~9 天。

本阶段学习目标

完成本阶段后,你应该能够:

  • 区分组合逻辑与时序逻辑,说明时钟在时序电路中的作用
  • 使用 always @(posedge clk) 描述上升沿触发的寄存器
  • 在时序逻辑中正确使用非阻塞赋值 <=
  • 区分同步复位与异步复位,并说明各自的触发时机
  • 实现带复位、使能和并行装载功能的多位寄存器
  • 实现串入串出、串入并出和并入串出移位寄存器
  • 实现加法、减法、可逆和模 N 计数器
  • 根据最大计数值计算计数器所需位宽
  • 理解 Moore 型与 Mealy 型有限状态机的区别
  • 使用“状态寄存器 + 次态逻辑 + 输出逻辑”结构编写状态机
  • 为时序电路编写自检式 Testbench,并避免时钟边沿竞争

第 7 章:时钟、复位与触发器

7.1 什么是时序逻辑

组合逻辑的输出只取决于当前输入;时序逻辑的输出不仅取决于当前输入,还取决于过去保存的状态:

1
2
当前输出 = f(当前输入, 当前状态)
下一状态 = g(当前输入, 当前状态)

时序逻辑需要存储信息。常见时序电路包括:

  • 触发器
  • 寄存器
  • 移位寄存器
  • 计数器
  • 有限状态机
  • 存储器

以一个带使能的寄存器为例:当 enable = 1 时,它在时钟边沿保存新数据;当 enable = 0 时,它保持之前保存的值。这里的“保持”是真正的状态记忆,与组合逻辑中因为输入不变而使输出不变不同。

7.2 时钟信号

时钟 clk 是周期性变化的数字信号:

1
2
3
4
5
      上升沿        上升沿
         |         |
clk  ____/‾‾‾‾\____/‾‾‾‾\____
              |         |
            下降沿       下降沿

常用参数包括:

  • 周期 T:相邻两个同类边沿之间的时间
  • 频率 f:每秒的周期数,f = 1 / T
  • 高电平时间
  • 低电平时间
  • 占空比:高电平时间占整个周期的比例

例如,周期为 20 ns 的时钟频率为:

1
f = 1 / 20 ns = 50 MHz

Testbench 中可以这样产生周期为 10 ns 的时钟:

1
2
3
4
reg clk;

initial clk = 1'b0;
always #5 clk = ~clk;

always #5 和延时 #5 只用于仿真,不能综合成普通 FPGA 内部逻辑。真实电路中的时钟通常来自开发板晶振或专用时钟资源。

7.3 边沿触发的过程块

上升沿触发的时序过程块写作:

1
2
3
always @(posedge clk) begin
    q <= d;
end

只有当 clk 从 0 变为 1 时,这个过程块才执行。d 在两个上升沿之间发生变化,不会立即改变 q

下降沿触发写作:

1
2
3
always @(negedge clk) begin
    q <= d;
end

一个普通寄存器通常只使用一种边沿。不要为了“反应更快”而同时使用上升沿和下降沿,除非目标器件和设计方法明确支持双边沿触发资源。

7.4 非阻塞赋值 <=

时钟触发的时序逻辑通常使用非阻塞赋值:

1
2
3
4
always @(posedge clk) begin
    q1 <= d;
    q2 <= q1;
end

在同一个时钟边沿:

  1. 先读取所有右侧表达式的旧值
  2. 再统一更新所有左侧寄存器

因此,上面的电路是两级寄存器:q1 保存 dq2 保存边沿到来前的 q1。数据每经过一个时钟边沿向后移动一级。

如果错误地使用阻塞赋值:

1
2
3
4
always @(posedge clk) begin
    q1 = d;
    q2 = q1;
end

仿真中第二行可能立即读到刚写入 q1 的新值,使两级寄存器表现得像一级逻辑,不能正确表达真实的并行触发行为。

本课程采用以下规则:

电路类型 过程块 赋值方式
组合逻辑 always @(*) 阻塞赋值 =
时序逻辑 always @(posedge clk) 非阻塞赋值 <=

7.5 D 触发器

D 触发器在有效时钟边沿保存输入 d

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
module dff (
    input  wire clk,
    input  wire d,
    output reg  q
);

always @(posedge clk) begin
    q <= d;
end

endmodule

这段 RTL 通常综合为一个 D 触发器。q 是在过程块中赋值的,因此声明为 reg。这里的 reg 表示 Verilog 变量类型,同时这段时钟边沿逻辑也确实需要物理存储单元。

没有复位时,仿真开始阶段的 q 通常为 x,实际 FPGA 上电值则取决于器件、初始化配置和综合工具。需要确定初始状态的控制逻辑应设计明确的复位或初始化方案。

7.6 同步复位

同步复位只在有效时钟边沿被采样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
module dff_sync_reset (
    input  wire clk,
    input  wire reset,
    input  wire d,
    output reg  q
);

always @(posedge clk) begin
    if (reset)
        q <= 1'b0;
    else
        q <= d;
end

endmodule

即使 reset = 1,如果时钟边沿还没有到来,q 也不会立刻改变。同步复位的优点是所有状态变化都受同一个时钟控制,便于进行同步时序分析。

7.7 异步复位

异步复位不需要等待时钟边沿。低电平有效异步复位常写成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
module dff_async_reset (
    input  wire clk,
    input  wire reset_n,
    input  wire d,
    output reg  q
);

always @(posedge clk or negedge reset_n) begin
    if (!reset_n)
        q <= 1'b0;
    else
        q <= d;
end

endmodule

敏感列表中包含 negedge reset_n,所以 reset_n 从 1 变为 0 时,过程块立即执行。名称后缀 _n 通常表示低电平有效。

异步复位的释放需要谨慎。如果复位信号在时钟边沿附近解除,可能违反触发器的恢复时间或移除时间要求。实际工程常采用“异步置位或复位、同步释放”的结构,这会在第 13 章可靠设计中进一步学习。

同步复位与异步复位的区别:

项目 同步复位 异步复位
复位触发条件 有效时钟边沿且复位有效 复位有效边沿立即触发
敏感列表 只有时钟 时钟和复位
状态变化 完全同步 复位可以异步改变状态
设计关注点 必须保证复位能被时钟采样 解除复位时需要特别谨慎

7.8 实践:带复位和使能的 8 位寄存器

新建 register8.v

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
module register8 (
    input  wire       clk,
    input  wire       reset,
    input  wire       enable,
    input  wire [7:0] d,
    output reg  [7:0] q
);

always @(posedge clk) begin
    if (reset)
        q <= 8'b0000_0000;
    else if (enable)
        q <= d;
end

endmodule

这个过程块的优先级是:

  1. reset = 1:复位优先,q 清零
  2. reset = 0enable = 1:保存 d
  3. reset = 0enable = 0q 保持原值

时序过程块中没有给 enable = 0 编写 else 是有意的,它表示触发器保持状态,不会像组合过程块缺少赋值那样推断出意外锁存器。

7.9 寄存器自检式 Testbench

新建 register8_tb.v

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
`timescale 1ns/1ps

module register8_tb;

reg        clk;
reg        reset;
reg        enable;
reg  [7:0] d;
wire [7:0] q;

integer errors;

register8 dut (
    .clk(clk),
    .reset(reset),
    .enable(enable),
    .d(d),
    .q(q)
);

initial clk = 1'b0;
always #5 clk = ~clk;

task check_q;
    input [7:0] expected;
    begin
        #1;
        if (q !== expected) begin
            $display("ERROR: expected q=%h, got q=%h", expected, q);
            errors = errors + 1;
        end
    end
endtask

initial begin
    errors = 0;
    reset = 1'b1;
    enable = 1'b0;
    d = 8'h00;

    @(posedge clk);
    check_q(8'h00);

    @(negedge clk);
    reset = 1'b0;
    enable = 1'b1;
    d = 8'hA5;
    @(posedge clk);
    check_q(8'hA5);

    @(negedge clk);
    enable = 1'b0;
    d = 8'h3C;
    @(posedge clk);
    check_q(8'hA5);

    @(negedge clk);
    reset = 1'b1;
    enable = 1'b1;
    @(posedge clk);
    check_q(8'h00);

    if (errors == 0)
        $display("PASS: register8 tests passed");
    else
        $display("FAIL: %0d register8 error(s)", errors);

    $finish;
end

endmodule

Testbench 在下降沿修改输入,使输入能够在下一个上升沿之前稳定。检查任务在上升沿后等待 #1,让非阻塞赋值完成更新,避免 Testbench 与 DUT 在同一个仿真时间片中发生竞争。

7.10 波形分析

观察 register8 波形时应确认:

  1. q 只在上升沿发生变化
  2. 同步复位在上升沿才使 q 清零
  3. enable = 1 时,上升沿后的 q 等于边沿前的 d
  4. enable = 0 时,即使 d 变化,q 仍保持原值
  5. 复位与使能同时有效时,复位优先

真实触发器要求数据在时钟边沿前后保持稳定一小段时间,分别称为建立时间和保持时间。RTL 功能仿真通常不会自动表现真实器件的建立时间和保持时间违例,相关时序约束将在第 15 章学习。

7.11 本章易错点

  • 在时钟过程块中使用阻塞赋值,导致级联寄存器仿真行为错误
  • 把同步复位误认为会立即生效
  • 异步复位写进逻辑判断,却忘记加入敏感列表
  • 低有效复位的条件写反,例如把 if (!reset_n) 写成 if (reset_n)
  • 在同一个寄存器上使用多个过程块驱动
  • Testbench 恰好在上升沿修改输入,造成仿真竞争
  • 在可综合模块中写 #10always #5 等仿真延时
  • 认为时序过程块中的“不赋值”一定是错误;对触发器而言,这通常表示保持状态

7.12 本章练习

  1. 编写一个带同步高电平复位的 1 位 D 触发器。
  2. 把同步复位改成异步低电平复位,并通过 Testbench 验证复位不需要等待时钟。
  3. 编写一个 16 位带使能寄存器,使能为 0 时保持原值。
  4. register8 增加同步 load 输入,并规定优先级为复位、装载、使能。
  5. 使用两个寄存器实现两级数据流水线,并观察输入经过几个时钟到达最后一级。
  6. 故意把两级流水线中的 <= 改成 =,比较仿真结果并解释原因。

7.13 本章检查点

  • 组合逻辑与时序逻辑的根本区别是什么?
  • posedge clk 表示什么事件?
  • 为什么时序逻辑推荐使用非阻塞赋值?
  • 同步复位和异步复位分别在什么时候生效?
  • 为什么带使能寄存器在 enable = 0 时可以不写 else
  • Testbench 为什么适合在下降沿改变待采样输入?

第 8 章:寄存器与移位寄存器

8.1 多位寄存器

多个共享同一时钟和控制信号的触发器可以组成多位寄存器:

1
2
3
4
5
6
7
8
reg [7:0] data_reg;

always @(posedge clk) begin
    if (reset)
        data_reg <= 8'b0;
    else
        data_reg <= data_in;
end

这会保存完整的 8 位数据。寄存器不仅用于暂存数据,还用于:

  • 对齐不同数据路径的时钟周期
  • 隔离较长的组合逻辑路径
  • 保存计数值和状态机状态
  • 构成串并转换电路
  • 构成流水线

8.2 移位寄存器的基本原理

移位寄存器在每个有效时钟边沿把数据向某个方向移动一位。例如:

1
q <= {q[6:0], serial_in};

边沿到来前:

1
q = abcdefgh

边沿到来后:

1
q = bcdefghS

其中 S 是边沿到来时的 serial_in。由于使用非阻塞赋值,右侧的 q[6:0] 是移位前的旧值。

移位方向的名称容易混淆。判断时不要只看“数据在图上往哪边走”,而要看位编号:

  • q <= q << 1:数据向高位移动,称为左移
  • q <= q >> 1:数据向低位移动,称为右移

8.3 串入串出移位寄存器

串入串出寄存器每个时钟输入一位,同时从另一端输出一位:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
module siso8 (
    input  wire clk,
    input  wire reset,
    input  wire serial_in,
    output wire serial_out
);

reg [7:0] shift_reg;

always @(posedge clk) begin
    if (reset)
        shift_reg <= 8'b0;
    else
        shift_reg <= {shift_reg[6:0], serial_in};
end

assign serial_out = shift_reg[7];

endmodule

一个输入位需要经过 8 个有效时钟边沿才能从 serial_out 移出。这个结构可以延迟串行数据,也可以看作一条 8 级流水线。

8.4 串入并出移位寄存器

串入并出寄存器接收串行数据,并允许同时读取已经保存的全部位:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
module sipo8 (
    input  wire       clk,
    input  wire       reset,
    input  wire       serial_in,
    output reg  [7:0] parallel_out
);

always @(posedge clk) begin
    if (reset)
        parallel_out <= 8'b0;
    else
        parallel_out <= {parallel_out[6:0], serial_in};
end

endmodule

连续输入 8 位后,parallel_out 保存这 8 位数据。必须事先约定串行数据是最高位先传还是最低位先传,否则接收结果可能位序相反。

8.5 并入串出移位寄存器

并入串出寄存器先一次装入多位数据,再逐位输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
module piso8 (
    input  wire       clk,
    input  wire       reset,
    input  wire       load,
    input  wire [7:0] parallel_in,
    output wire       serial_out
);

reg [7:0] shift_reg;

always @(posedge clk) begin
    if (reset)
        shift_reg <= 8'b0;
    else if (load)
        shift_reg <= parallel_in;
    else
        shift_reg <= {shift_reg[6:0], 1'b0};
end

assign serial_out = shift_reg[7];

endmodule

这里规定最高位先输出。load 的优先级高于移位:装载边沿只保存并行数据,不同时执行移位。

8.6 双向和循环移位

双向移位寄存器根据控制信号选择方向:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
always @(posedge clk) begin
    if (reset)
        q <= 8'b0;
    else if (enable) begin
        if (direction)
            q <= {q[6:0], serial_right};
        else
            q <= {serial_left, q[7:1]};
    end
end

循环左移不会丢弃最高位,而是把它送回最低位:

1
q <= {q[6:0], q[7]};

循环右移则把最低位送回最高位:

1
q <= {q[0], q[7:1]};

循环移位适合产生重复灯光图案、轮转优先级和固定宽度数据旋转。

8.7 实践:8 位流水灯控制器

新建 running_led.v

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
module running_led (
    input  wire       clk,
    input  wire       reset,
    input  wire       enable,
    input  wire       direction,
    output reg  [7:0] led
);

always @(posedge clk) begin
    if (reset)
        led <= 8'b0000_0001;
    else if (enable) begin
        if (direction)
            led <= {led[6:0], led[7]};
        else
            led <= {led[0], led[7:1]};
    end
end

endmodule

复位后只有 led[0] 为 1。每次 enable = 1 的时钟边沿,有效位循环移动一位:

1
2
direction = 1:
0000_0001 -> 0000_0010 -> 0000_0100 -> ... -> 1000_0000 -> 0000_0001

如果直接连接 50 MHz 时钟,人眼无法观察灯光移动。实际使用时应由较慢的时钟使能脉冲控制 enable,而不是在普通逻辑中随意产生新的时钟。第 9 章将实现这种时钟使能。

8.8 流水灯自检式 Testbench

新建 running_led_tb.v

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
`timescale 1ns/1ps

module running_led_tb;

reg        clk;
reg        reset;
reg        enable;
reg        direction;
wire [7:0] led;

integer errors;

running_led dut (
    .clk(clk),
    .reset(reset),
    .enable(enable),
    .direction(direction),
    .led(led)
);

initial clk = 1'b0;
always #5 clk = ~clk;

task step_and_check;
    input [7:0] expected;
    begin
        @(posedge clk);
        #1;
        if (led !== expected) begin
            $display("ERROR: expected led=%b, got led=%b", expected, led);
            errors = errors + 1;
        end
    end
endtask

initial begin
    errors = 0;
    reset = 1'b1;
    enable = 1'b0;
    direction = 1'b1;
    step_and_check(8'b0000_0001);

    @(negedge clk);
    reset = 1'b0;
    enable = 1'b1;
    step_and_check(8'b0000_0010);
    step_and_check(8'b0000_0100);

    @(negedge clk);
    enable = 1'b0;
    step_and_check(8'b0000_0100);

    @(negedge clk);
    enable = 1'b1;
    direction = 1'b0;
    step_and_check(8'b0000_0010);
    step_and_check(8'b0000_0001);
    step_and_check(8'b1000_0000);

    if (errors == 0)
        $display("PASS: running_led tests passed");
    else
        $display("FAIL: %0d running_led error(s)", errors);

    $finish;
end

endmodule

8.9 波形分析

观察移位寄存器波形时,重点确认:

  1. 数据只在有效时钟边沿移动
  2. 复位值是否符合预期
  3. 关闭使能后是否保持当前图案
  4. 改变方向后,下一个有效边沿是否按新方向移动
  5. 循环移出的位是否正确回到另一端
  6. 串行输入位经过多少个时钟到达指定位置

不要只观察最后的十六进制值。对移位寄存器,使用二进制显示更容易看清每一位的位置变化。

8.10 本章易错点

  • 把左移和右移方向写反
  • 拼接时少写或多写一位,导致宽度不匹配
  • 没有约定串行传输是最高位先传还是最低位先传
  • 并入串出寄存器在 load 时又执行了一次移位
  • 使用阻塞赋值,使多级数据在一个边沿穿过多级寄存器
  • 循环移位时用常数 0 填充,实际变成普通逻辑移位
  • 流水灯直接使用高频系统时钟,导致肉眼看不出移动
  • 为了减慢流水灯而用普通寄存器输出直接当作新时钟,增加不必要的时钟域

8.11 本章练习

  1. 编写 4 位串入串出移位寄存器,并验证一个输入位经过 4 个时钟后输出。
  2. 编写 8 位串入并出寄存器,依次输入 8'b1010_0110
  3. 编写最低位先输出的并入串出寄存器。
  4. running_led 增加并行装载输入,可以装入任意初始灯光图案。
  5. 实现逻辑左移、逻辑右移、循环左移和循环右移四种模式。
  6. 设计一个 4 级流水线,记录一个输入脉冲经过每一级的时间。

8.12 本章检查点

  • 移位寄存器为什么属于时序逻辑?
  • q <= {q[6:0], serial_in} 使用的是新 q 还是旧 q
  • 串入并出和并入串出的数据方向分别是什么?
  • 普通移位与循环移位有什么区别?
  • 流水灯为什么需要较慢的使能脉冲?

第 9 章:计数器

9.1 计数器的基本原理

计数器本质上是“每个有效时钟边沿更新一次数值”的寄存器。最简单的 8 位加法计数器为:

1
2
3
4
5
6
always @(posedge clk) begin
    if (reset)
        count <= 8'b0;
    else
        count <= count + 1'b1;
end

固定宽度寄存器会自然回绕:

1
8'hFE -> 8'hFF -> 8'h00 -> 8'h01

这不是有符号溢出,而是只保留低 8 位的模 2^8 计数。

9.2 加法、减法和可逆计数器

带使能的加法计数器:

1
2
if (enable)
    count <= count + 1'b1;

减法计数器:

1
2
if (enable)
    count <= count - 1'b1;

可逆计数器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
always @(posedge clk) begin
    if (reset)
        count <= 8'b0;
    else if (enable) begin
        if (direction)
            count <= count + 1'b1;
        else
            count <= count - 1'b1;
    end
end

这里规定 direction = 1 向上计数,direction = 0 向下计数。接口必须明确方向定义,不能只依赖信号名称猜测。

9.3 模 N 计数器

模 N 计数器只经过 N 个不同状态。例如模 10 计数器依次经过 0~9:

1
0 -> 1 -> 2 -> ... -> 8 -> 9 -> 0

它不能依赖 4 位寄存器的自然溢出,因为 4 位寄存器会计数到 15。必须显式判断终值。

9.4 实践:0~9 循环计数器

新建 counter_mod10.v

 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
module counter_mod10 (
    input  wire       clk,
    input  wire       reset,
    input  wire       enable,
    output reg  [3:0] count,
    output reg        tick
);

always @(posedge clk) begin
    if (reset) begin
        count <= 4'd0;
        tick  <= 1'b0;
    end else begin
        tick <= 1'b0;
        if (enable) begin
            if (count == 4'd9) begin
                count <= 4'd0;
                tick  <= 1'b1;
            end else begin
                count <= count + 1'b1;
            end
        end
    end
end

endmodule

tick9 -> 0 的时钟周期输出一个周期宽度的脉冲,可以作为更高位计数器的使能信号。每个非复位周期先执行 tick <= 0,只有回绕时再执行 tick <= 1。同一个过程块中对同一变量有多个非阻塞赋值时,后执行的赋值生效,因此 tick 不会一直保持为 1。

9.5 模 10 计数器 Testbench

新建 counter_mod10_tb.v

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
`timescale 1ns/1ps

module counter_mod10_tb;

reg        clk;
reg        reset;
reg        enable;
wire [3:0] count;
wire       tick;

integer cycle;
integer errors;
reg [3:0] expected_count;

counter_mod10 dut (
    .clk(clk),
    .reset(reset),
    .enable(enable),
    .count(count),
    .tick(tick)
);

initial clk = 1'b0;
always #5 clk = ~clk;

initial begin
    errors = 0;
    reset = 1'b1;
    enable = 1'b0;
    expected_count = 4'd0;

    @(posedge clk);
    #1;
    if (count !== 4'd0 || tick !== 1'b0)
        errors = errors + 1;

    @(negedge clk);
    reset = 1'b0;
    enable = 1'b1;

    for (cycle = 1; cycle <= 20; cycle = cycle + 1) begin
        @(posedge clk);
        #1;
        expected_count = cycle % 10;

        if (count !== expected_count) begin
            $display("ERROR: cycle=%0d expected count=%0d got=%0d", cycle, expected_count, count);
            errors = errors + 1;
        end

        if (tick !== ((cycle % 10) == 0)) begin
            $display("ERROR: cycle=%0d incorrect tick=%b", cycle, tick);
            errors = errors + 1;
        end
    end

    @(negedge clk);
    enable = 1'b0;
    repeat (2) begin
        @(posedge clk);
        #1;
        if (count !== 4'd0 || tick !== 1'b0)
            errors = errors + 1;
    end

    if (errors == 0)
        $display("PASS: counter_mod10 tests passed");
    else
        $display("FAIL: %0d counter_mod10 error(s)", errors);

    $finish;
end

endmodule

这个 Testbench 连续检查两个完整的 0~9 周期,并验证:

  • 每个使能时钟只增加 1
  • 9 之后回到 0
  • tick 只在回绕周期为 1
  • 关闭使能后计数值保持不变

9.6 计数器位宽计算

能够表示 N 个不同状态的最小位宽 W 满足:

1
2^W >= N

常见例子:

状态数量 最小位宽 可表示范围
2 1 0~1
10 4 0~15
16 4 0~15
17 5 0~31
1000 10 0~1023

数学上可以写成:

1
W = ceil(log2(N))

SystemVerilog 和较新的 Verilog 工具常支持 $clog2(N) 计算这个位宽。参数化设计将在第 11 章系统学习。即使寄存器位宽足够,模 N 计数器仍需在 N-1 时主动回绕,否则会进入多余状态。

9.7 终值判断与非阻塞赋值

考虑下面的代码:

1
2
3
4
if (count == 4'd9) begin
    count <= 4'd0;
    tick  <= 1'b1;
end

条件判断读取的是时钟边沿前的旧 count。因此,当旧值为 9 时,本次边沿把 count 更新为 0 并置位 tick。不要误以为判断会读取刚更新后的 count

这种“所有寄存器同时读取旧值、同时写入新值”的行为正是同步时序电路能够可靠级联的基础。

9.8 分频计数与时钟使能

假设开发板时钟为 50 MHz,希望每 0.5 秒让流水灯移动一次,需要计数:

1
50,000,000 × 0.5 = 25,000,000 个时钟周期

计数范围是 0~24,999,999,需要 25,000,000 个状态。因为:

1
2
2^24 = 16,777,216
2^25 = 33,554,432

所以需要 25 位计数器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
module tick_500ms (
    input  wire clk,
    input  wire reset,
    output reg  tick
);

reg [24:0] count;

always @(posedge clk) begin
    if (reset) begin
        count <= 25'd0;
        tick  <= 1'b0;
    end else if (count == 25'd24_999_999) begin
        count <= 25'd0;
        tick  <= 1'b1;
    end else begin
        count <= count + 1'b1;
        tick  <= 1'b0;
    end
end

endmodule

tick 连接到其他模块的 enable

1
2
3
4
5
6
always @(posedge clk) begin
    if (reset)
        led <= 8'b0000_0001;
    else if (tick)
        led <= {led[6:0], led[7]};
end

所有寄存器仍使用同一个 50 MHz 系统时钟,tick 只是每 0.5 秒出现一次的同步使能脉冲。这比直接把计数器某一位当作新时钟更容易进行时序约束,也避免产生不必要的时钟域。

9.9 波形分析

观察计数器波形时,应检查:

  1. 复位后计数器是否从规定值开始
  2. 使能为 1 时是否每个边沿只变化一次
  3. 使能为 0 时是否保持
  4. 终值之后是否回到起始值
  5. tick 是否恰好保持一个时钟周期
  6. 多级计数器是否只在低位回绕时更新高位

仿真大型分频数会很慢。验证结构时可以临时使用较小的终值,但不要忘记恢复实际参数。后续学习参数化设计后,可以让 Testbench 覆盖一个较小参数,而综合时使用真实参数。

9.10 本章易错点

  • 模 10 计数器使用 4 位自然溢出,实际计数到 15
  • count == 10 时才清零,产生多余的状态 10
  • 位宽不足,计数器永远到不了设定终值
  • 终值常量位宽不清晰,导致比较或截断问题
  • 忘记在普通周期清除单周期 tick,使它保持为 1
  • 复位时只清零计数值,却没有复位脉冲输出
  • 使用组合反馈实现计数,没有触发器保存状态
  • 把计数器输出位直接当作大量模块的新时钟,形成难以管理的派生时钟
  • Testbench 只运行几个周期,没有覆盖回绕边界

9.11 本章练习

  1. 编写 8 位带使能加法计数器,并验证 8'hFF -> 8'h00
  2. 编写 8 位可逆计数器,验证向上和向下的回绕。
  3. 编写模 6 计数器,只允许出现 0~5。
  4. 修改模 10 计数器,使其支持同步装载任意 0~9 初值。
  5. 使用两个模 10 计数器组成 00~99 计数器。
  6. 计算 100 MHz 时钟产生 1 ms 使能脉冲所需的计数终值和位宽。
  7. 为一个模 24 计数器编写自检式 Testbench,至少检查两个完整周期。

9.12 本章检查点

  • 固定宽度二进制计数器为什么会自然回绕?
  • 模 10 计数器为什么不能只依赖 4 位溢出?
  • 表示 N 个状态所需的最小位宽如何计算?
  • 非阻塞赋值条件判断读取的是旧值还是新值?
  • 单周期 tick 为什么每个普通周期都要清零?
  • 时钟使能与新生成的低速时钟有什么区别?

第 10 章:有限状态机

10.1 什么是有限状态机

有限状态机,英文为 Finite State Machine,简称 FSM。它在有限个状态之间,根据当前输入和当前状态进行跳转。

一个有限状态机通常包含:

  • 当前状态 current_state
  • 下一状态 next_state
  • 状态转移条件
  • 输出逻辑
  • 时钟和复位

例如,交通灯可以抽象成红灯、绿灯和黄灯三个状态;序列检测器可以用状态记录已经匹配了目标序列的多少位。

状态机适合描述“分阶段执行”的控制逻辑。它不是软件程序逐行执行,而是一组状态寄存器和组合逻辑在每个时钟边沿协同更新。

10.2 状态图和状态转移表

设计状态机时,先画状态图或写状态转移表,再写 Verilog。以一个简化控制器为例:

当前状态 输入条件 下一状态
IDLE start = 0 IDLE
IDLE start = 1 WORK
WORK done = 0 WORK
WORK done = 1 IDLE

状态图用于表达状态之间的路径,状态转移表则适合逐项检查输入条件是否完整。写 RTL 前应明确:

  1. 初始或复位状态是什么
  2. 每个状态在所有输入下转向哪里
  3. 非法状态怎样恢复
  4. 输出由状态决定还是也由输入决定

10.3 Moore 型与 Mealy 型状态机

Moore 型状态机的输出只取决于当前状态:

1
output = f(current_state)

Mealy 型状态机的输出取决于当前状态和当前输入:

1
output = f(current_state, input)

两者对比:

项目 Moore 型 Mealy 型
输出依赖 当前状态 当前状态和当前输入
输出变化 通常随状态寄存器边沿变化 输入变化时可能立即变化
检测响应 常比 Mealy 晚一个状态 可以在输入到来时立即组合响应
输出稳定性 通常较稳定 需要关注输入毛刺和组合路径

选择哪一种不是看“代码更短”,而是看接口时序要求。需要严格同步、整周期有效的控制输出时,Moore 型通常更直观;需要尽快响应当前输入时,可以使用 Mealy 型并明确处理组合输出时序。

10.4 三段式状态机结构

本课程推荐先掌握三段式写法:

  1. 状态寄存器:时序逻辑,保存当前状态
  2. 次态逻辑:组合逻辑,计算下一状态
  3. 输出逻辑:组合逻辑,根据状态产生输出

基本模板如下:

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
localparam STATE_A = 2'b00;
localparam STATE_B = 2'b01;
localparam STATE_C = 2'b10;

reg [1:0] current_state;
reg [1:0] next_state;

always @(posedge clk) begin
    if (reset)
        current_state <= STATE_A;
    else
        current_state <= next_state;
end

always @(*) begin
    next_state = current_state;

    case (current_state)
        STATE_A: begin
            if (condition_a)
                next_state = STATE_B;
        end

        STATE_B: begin
            if (condition_b)
                next_state = STATE_C;
        end

        STATE_C: begin
            next_state = STATE_A;
        end

        default: begin
            next_state = STATE_A;
        end
    endcase
end

always @(*) begin
    output_signal = 1'b0;

    case (current_state)
        STATE_B: output_signal = 1'b1;
        default: output_signal = 1'b0;
    endcase
end

第一段使用非阻塞赋值,第二、三段使用阻塞赋值。次态逻辑开始先写 next_state = current_state,表示默认保持当前状态,再覆盖需要跳转的情况,可以减少漏赋值。

10.5 状态编码

状态必须编码成二进制值才能保存在寄存器中。常见方式包括:

  • 二进制编码:用较少触发器表示多个状态
  • 独热编码:每个状态使用一个有效位
  • 格雷编码:相邻状态尽量只有一位变化

初学阶段可以使用 localparam 明确写出状态编码:

1
2
3
4
5
localparam S_IDLE = 3'd0;
localparam S_1    = 3'd1;
localparam S_10   = 3'd2;
localparam S_101  = 3'd3;
localparam S_MATCH = 3'd4;

localparam 与普通 parameter 的区别将在第 11 章学习。这里使用名称代替裸数字,可以让状态转移逻辑更容易阅读和检查。

10.6 序列检测与重叠匹配

目标是检测串行输入中的 1011。状态可以表示“目前已经匹配了多少前缀”:

状态 含义
S_IDLE 尚未匹配
S_1 已匹配 1
S_10 已匹配 10
S_101 已匹配 101
S_MATCH 已匹配完整 1011

输入不匹配时不能总是直接回到 S_IDLE,因为已经接收的数据末尾可能仍是目标序列的前缀。例如:

  • S_1 再收到 1,末尾仍然匹配一个 1,所以保持 S_1
  • S_101 收到 0,末尾是 10,所以转到 S_10
  • 检测到 1011 后再收到 0,整个输入末尾是 10,可以继续重叠匹配

这种处理可以检测连续数据流中相互重叠的序列。

10.7 实践:Moore 型 1011 序列检测器

新建 sequence_1011.v

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
module sequence_1011 (
    input  wire clk,
    input  wire reset,
    input  wire data_in,
    output reg  detected
);

localparam S_IDLE  = 3'd0;
localparam S_1     = 3'd1;
localparam S_10    = 3'd2;
localparam S_101   = 3'd3;
localparam S_MATCH = 3'd4;

reg [2:0] current_state;
reg [2:0] next_state;

always @(posedge clk) begin
    if (reset)
        current_state <= S_IDLE;
    else
        current_state <= next_state;
end

always @(*) begin
    next_state = current_state;

    case (current_state)
        S_IDLE: begin
            if (data_in)
                next_state = S_1;
            else
                next_state = S_IDLE;
        end

        S_1: begin
            if (data_in)
                next_state = S_1;
            else
                next_state = S_10;
        end

        S_10: begin
            if (data_in)
                next_state = S_101;
            else
                next_state = S_IDLE;
        end

        S_101: begin
            if (data_in)
                next_state = S_MATCH;
            else
                next_state = S_10;
        end

        S_MATCH: begin
            if (data_in)
                next_state = S_1;
            else
                next_state = S_10;
        end

        default: begin
            next_state = S_IDLE;
        end
    endcase
end

always @(*) begin
    detected = 1'b0;

    if (current_state == S_MATCH)
        detected = 1'b1;
end

endmodule

detected 只取决于 current_state,因此这是 Moore 型状态机。检测到最后一个输入位的时钟边沿后,状态进入 S_MATCHdetected 在该状态对应的整个周期内为 1。

10.8 序列检测器 Testbench

新建 sequence_1011_tb.v

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
`timescale 1ns/1ps

module sequence_1011_tb;

reg  clk;
reg  reset;
reg  data_in;
wire detected;

integer errors;

sequence_1011 dut (
    .clk(clk),
    .reset(reset),
    .data_in(data_in),
    .detected(detected)
);

initial clk = 1'b0;
always #5 clk = ~clk;

task send_bit;
    input bit_value;
    input expected_detected;
    begin
        @(negedge clk);
        data_in = bit_value;
        @(posedge clk);
        #1;

        if (detected !== expected_detected) begin
            $display(
                "ERROR: input=%b expected detected=%b got=%b",
                bit_value, expected_detected, detected
            );
            errors = errors + 1;
        end
    end
endtask

initial begin
    errors = 0;
    reset = 1'b1;
    data_in = 1'b0;

    @(posedge clk);
    #1;

    @(negedge clk);
    reset = 1'b0;

    send_bit(1'b1, 1'b0);
    send_bit(1'b0, 1'b0);
    send_bit(1'b1, 1'b0);
    send_bit(1'b1, 1'b1);

    send_bit(1'b0, 1'b0);
    send_bit(1'b1, 1'b0);
    send_bit(1'b1, 1'b1);

    send_bit(1'b1, 1'b0);
    send_bit(1'b0, 1'b0);

    if (errors == 0)
        $display("PASS: sequence_1011 tests passed");
    else
        $display("FAIL: %0d sequence detector error(s)", errors);

    $finish;
end

endmodule

输入序列包含:

1
1011011

其中包含两个重叠的 1011。第一个匹配结束后,状态机没有无条件清空全部匹配历史,因此能够识别第二个序列。

10.9 状态机波形分析

状态机波形至少观察以下信号:

  • clk
  • reset
  • 输入信号
  • current_state
  • next_state
  • 输出信号

分析顺序为:

  1. 在时钟边沿前确定 current_state 和输入
  2. 根据组合逻辑判断 next_state
  3. 在时钟边沿后确认 current_state 更新为之前的 next_state
  4. 根据新状态检查 Moore 输出

next_state 是组合逻辑,输入变化后可以在时钟边沿之前变化;current_state 是寄存器,只在有效边沿更新。不要把两者混为同一个信号。

10.10 两段式与一段式写法

状态机也可以写成两段式甚至一段式,但初学阶段推荐三段式,因为它能清楚分离:

  • 哪些信号是寄存器
  • 哪些逻辑计算下一状态
  • 哪些逻辑产生输出

一段式状态机把状态更新和输出更新放在同一个时序块中,虽然可以综合,但状态转移、输出延迟和默认行为更难检查。掌握三段式结构后,再根据项目规范选择其他风格。

10.11 状态机安全性

状态寄存器可能因为上电状态、软错误或不完整编码进入未定义状态。组合 case 应包含 default,把非法状态引导回复位状态:

1
2
3
default: begin
    next_state = S_IDLE;
end

这不代表所有物理故障都能自动恢复,但至少定义了 RTL 对非法编码的行为。状态输出逻辑也应有默认值,避免组合锁存器。

10.12 本章易错点

  • 写代码前没有明确状态含义和转移条件
  • 状态寄存器使用阻塞赋值
  • 次态组合逻辑没有默认赋值,产生锁存器
  • case 没有 default,非法状态行为不明确
  • Moore 输出误用了当前输入,实际变成 Mealy 输出
  • 输入不匹配时全部回到初始状态,漏掉重叠序列
  • current_statenext_state 混用
  • Testbench 在上升沿同时改变输入并检查输出,产生竞争
  • 只测试正常路径,没有测试复位、中途失败和重复匹配
  • 状态编码位宽不足或不同状态使用了相同编码

10.13 本章练习

  1. 画出两状态启动/停止控制器的状态图和状态转移表。
  2. 编写 Moore 型 101 序列检测器,支持重叠检测。
  3. 1011 检测器改为不允许重叠匹配,并比较状态转移差异。
  4. 编写 Mealy 型 1011 检测器,比较 detected 的出现时机。
  5. 设计一个红、绿、黄三状态交通灯控制器,先用外部 timer_done 控制跳转。
  6. 给交通灯状态机增加行人请求输入,明确请求在哪些状态生效。
  7. 为状态机编写自检式 Testbench,覆盖每个状态、每条转移路径和复位。

10.14 本章检查点

  • 有限状态机由哪几部分组成?
  • Moore 型和 Mealy 型输出分别依赖什么?
  • 三段式状态机的三个过程块分别负责什么?
  • next_state = current_state 的默认赋值有什么作用?
  • 为什么非法状态需要 default 恢复路径?
  • 序列检测器怎样支持重叠匹配?

第三阶段综合练习

练习 A:通用 8 位移位寄存器

设计一个 8 位通用移位寄存器,输入包括:

  • clk
  • 同步高电平复位 reset
  • mode[1:0]
  • 并行输入 parallel_in[7:0]
  • 左右两端串行输入

模式规定:

mode 功能
2'b00 保持
2'b01 左移一位
2'b10 右移一位
2'b11 并行装载

要求使用一个时序过程块实现,并编写 Testbench 覆盖全部模式、复位和连续移位。

练习 B:可逆模 10 计数器

设计一个 0~9 可逆计数器:

  • enable = 0 时保持
  • enable = 1direction = 1 时向上计数
  • enable = 1direction = 0 时向下计数
  • 向上时 9 -> 0
  • 向下时 0 -> 9
  • 回绕时输出一个周期的 tick

Testbench 至少覆盖:复位、保持、两个方向、两个回绕边界以及中途改变方向。

练习 C:带定时器的交通灯状态机

设计红、绿、黄三状态交通灯:

  • 复位后进入红灯状态
  • 红灯保持 5 个有效时钟周期后进入绿灯
  • 绿灯保持 8 个有效时钟周期后进入黄灯
  • 黄灯保持 2 个有效时钟周期后回到红灯
  • 任意时刻只能有一种灯有效
  • 输出使用 Moore 型逻辑

建议把状态机和状态计数器分开思考:状态机决定当前灯色,计数器记录当前状态已经持续多少个周期。状态改变时清零计数器。

Testbench 必须运行至少两个完整的红绿黄周期,并自动检查状态持续时间和灯光互斥性。


参考答案

第 7 章练习要点

带复位、装载和使能的寄存器优先级可以写成:

1
2
3
4
5
6
7
8
always @(posedge clk) begin
    if (reset)
        q <= 8'b0;
    else if (load)
        q <= load_data;
    else if (enable)
        q <= d;
end

两级流水线必须使用非阻塞赋值:

1
2
3
4
always @(posedge clk) begin
    stage1 <= data_in;
    stage2 <= stage1;
end

第 8 章练习要点

通用移位寄存器的核心结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
always @(posedge clk) begin
    if (reset) begin
        q <= 8'b0;
    end else begin
        case (mode)
            2'b00: q <= q;
            2'b01: q <= {q[6:0], serial_right};
            2'b10: q <= {serial_left, q[7:1]};
            2'b11: q <= parallel_in;
            default: q <= q;
        endcase
    end
end

q <= q 可以省略,因为时序寄存器在没有赋值时自然保持;这里保留它是为了让模式含义更直观。

第 9 章练习要点

可逆模 10 计数器的核心判断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
tick <= 1'b0;

if (enable) begin
    if (direction) begin
        if (count == 4'd9) begin
            count <= 4'd0;
            tick  <= 1'b1;
        end else begin
            count <= count + 1'b1;
        end
    end else begin
        if (count == 4'd0) begin
            count <= 4'd9;
            tick  <= 1'b1;
        end else begin
            count <= count - 1'b1;
        end
    end
end

100 MHz 时钟产生 1 ms 使能脉冲:

1
2
3
100,000,000 × 0.001 = 100,000 个周期
计数范围:0~99,999
2^16 = 65,536,2^17 = 131,072,因此需要 17 位

第 10 章练习要点

交通灯状态编码可以写成:

1
2
3
localparam RED    = 2'b00;
localparam GREEN  = 2'b01;
localparam YELLOW = 2'b10;

Moore 输出逻辑先设置默认值,再根据状态打开一种灯:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
always @(*) begin
    red    = 1'b0;
    green  = 1'b0;
    yellow = 1'b0;

    case (current_state)
        RED:     red    = 1'b1;
        GREEN:   green  = 1'b1;
        YELLOW:  yellow = 1'b1;
        default: red    = 1'b1;
    endcase
end

检查输出互斥性时,可以在 Testbench 中直接检查三个灯是否为合法的独热码:

1
2
3
4
5
6
if (({red, green, yellow} !== 3'b100) &&
    ({red, green, yellow} !== 3'b010) &&
    ({red, green, yellow} !== 3'b001)) begin
    $display("ERROR: traffic lights are not one-hot");
    errors = errors + 1;
end

第三阶段完成标准

满足以下条件后,再进入第四阶段:

  • 能解释时钟周期、上升沿和下降沿
  • 能区分阻塞赋值与非阻塞赋值的使用场景
  • 能独立编写同步复位和异步复位触发器
  • 能实现带复位、使能和装载功能的多位寄存器
  • 能解释移位寄存器每一位在时钟边沿前后的变化
  • 能实现普通移位、循环移位和串并转换
  • 能实现加法、减法、可逆和模 N 计数器
  • 能计算计数器终值和所需位宽
  • 能使用时钟使能控制低速动作,而不是随意创建新时钟
  • 能画出状态图并写出完整状态转移表
  • 能使用三段式结构实现 Moore 型有限状态机
  • 能解释 Moore 型与 Mealy 型状态机的区别
  • 能编写自检式时序 Testbench,覆盖复位、保持、边界和回绕
  • 能完成流水灯、模 10 计数器和序列检测器中的至少两个实践项目
comments powered by Disqus