返回 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 的时钟频率为:
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
|
在同一个时钟边沿:
- 先读取所有右侧表达式的旧值
- 再统一更新所有左侧寄存器
因此,上面的电路是两级寄存器:q1 保存 d,q2 保存边沿到来前的 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
|
这个过程块的优先级是:
reset = 1:复位优先,q 清零
reset = 0 且 enable = 1:保存 d
reset = 0 且 enable = 0:q 保持原值
时序过程块中没有给 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 波形时应确认:
q 只在上升沿发生变化
- 同步复位在上升沿才使
q 清零
enable = 1 时,上升沿后的 q 等于边沿前的 d
enable = 0 时,即使 d 变化,q 仍保持原值
- 复位与使能同时有效时,复位优先
真实触发器要求数据在时钟边沿前后保持稳定一小段时间,分别称为建立时间和保持时间。RTL 功能仿真通常不会自动表现真实器件的建立时间和保持时间违例,相关时序约束将在第 15 章学习。
7.11 本章易错点
- 在时钟过程块中使用阻塞赋值,导致级联寄存器仿真行为错误
- 把同步复位误认为会立即生效
- 异步复位写进逻辑判断,却忘记加入敏感列表
- 低有效复位的条件写反,例如把
if (!reset_n) 写成 if (reset_n)
- 在同一个寄存器上使用多个过程块驱动
- Testbench 恰好在上升沿修改输入,造成仿真竞争
- 在可综合模块中写
#10、always #5 等仿真延时
- 认为时序过程块中的“不赋值”一定是错误;对触发器而言,这通常表示保持状态
7.12 本章练习
- 编写一个带同步高电平复位的 1 位 D 触发器。
- 把同步复位改成异步低电平复位,并通过 Testbench 验证复位不需要等待时钟。
- 编写一个 16 位带使能寄存器,使能为 0 时保持原值。
- 给
register8 增加同步 load 输入,并规定优先级为复位、装载、使能。
- 使用两个寄存器实现两级数据流水线,并观察输入经过几个时钟到达最后一级。
- 故意把两级流水线中的
<= 改成 =,比较仿真结果并解释原因。
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};
|
边沿到来前:
边沿到来后:
其中 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
|
循环左移不会丢弃最高位,而是把它送回最低位:
循环右移则把最低位送回最高位:
循环移位适合产生重复灯光图案、轮转优先级和固定宽度数据旋转。
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 波形分析
观察移位寄存器波形时,重点确认:
- 数据只在有效时钟边沿移动
- 复位值是否符合预期
- 关闭使能后是否保持当前图案
- 改变方向后,下一个有效边沿是否按新方向移动
- 循环移出的位是否正确回到另一端
- 串行输入位经过多少个时钟到达指定位置
不要只观察最后的十六进制值。对移位寄存器,使用二进制显示更容易看清每一位的位置变化。
8.10 本章易错点
- 把左移和右移方向写反
- 拼接时少写或多写一位,导致宽度不匹配
- 没有约定串行传输是最高位先传还是最低位先传
- 并入串出寄存器在
load 时又执行了一次移位
- 使用阻塞赋值,使多级数据在一个边沿穿过多级寄存器
- 循环移位时用常数 0 填充,实际变成普通逻辑移位
- 流水灯直接使用高频系统时钟,导致肉眼看不出移动
- 为了减慢流水灯而用普通寄存器输出直接当作新时钟,增加不必要的时钟域
8.11 本章练习
- 编写 4 位串入串出移位寄存器,并验证一个输入位经过 4 个时钟后输出。
- 编写 8 位串入并出寄存器,依次输入
8'b1010_0110。
- 编写最低位先输出的并入串出寄存器。
- 给
running_led 增加并行装载输入,可以装入任意初始灯光图案。
- 实现逻辑左移、逻辑右移、循环左移和循环右移四种模式。
- 设计一个 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
|
tick 在 9 -> 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 满足:
常见例子:
| 状态数量 |
最小位宽 |
可表示范围 |
| 2 |
1 |
0~1 |
| 10 |
4 |
0~15 |
| 16 |
4 |
0~15 |
| 17 |
5 |
0~31 |
| 1000 |
10 |
0~1023 |
数学上可以写成:
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 时是否每个边沿只变化一次
- 使能为 0 时是否保持
- 终值之后是否回到起始值
tick 是否恰好保持一个时钟周期
- 多级计数器是否只在低位回绕时更新高位
仿真大型分频数会很慢。验证结构时可以临时使用较小的终值,但不要忘记恢复实际参数。后续学习参数化设计后,可以让 Testbench 覆盖一个较小参数,而综合时使用真实参数。
9.10 本章易错点
- 模 10 计数器使用 4 位自然溢出,实际计数到 15
- 在
count == 10 时才清零,产生多余的状态 10
- 位宽不足,计数器永远到不了设定终值
- 终值常量位宽不清晰,导致比较或截断问题
- 忘记在普通周期清除单周期
tick,使它保持为 1
- 复位时只清零计数值,却没有复位脉冲输出
- 使用组合反馈实现计数,没有触发器保存状态
- 把计数器输出位直接当作大量模块的新时钟,形成难以管理的派生时钟
- Testbench 只运行几个周期,没有覆盖回绕边界
9.11 本章练习
- 编写 8 位带使能加法计数器,并验证
8'hFF -> 8'h00。
- 编写 8 位可逆计数器,验证向上和向下的回绕。
- 编写模 6 计数器,只允许出现 0~5。
- 修改模 10 计数器,使其支持同步装载任意 0~9 初值。
- 使用两个模 10 计数器组成 00~99 计数器。
- 计算 100 MHz 时钟产生 1 ms 使能脉冲所需的计数终值和位宽。
- 为一个模 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 前应明确:
- 初始或复位状态是什么
- 每个状态在所有输入下转向哪里
- 非法状态怎样恢复
- 输出由状态决定还是也由输入决定
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
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_MATCH,detected 在该状态对应的整个周期内为 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
|
输入序列包含:
其中包含两个重叠的 1011。第一个匹配结束后,状态机没有无条件清空全部匹配历史,因此能够识别第二个序列。
10.9 状态机波形分析
状态机波形至少观察以下信号:
clk
reset
- 输入信号
current_state
next_state
- 输出信号
分析顺序为:
- 在时钟边沿前确定
current_state 和输入
- 根据组合逻辑判断
next_state
- 在时钟边沿后确认
current_state 更新为之前的 next_state
- 根据新状态检查 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_state 和 next_state 混用
- Testbench 在上升沿同时改变输入并检查输出,产生竞争
- 只测试正常路径,没有测试复位、中途失败和重复匹配
- 状态编码位宽不足或不同状态使用了相同编码
10.13 本章练习
- 画出两状态启动/停止控制器的状态图和状态转移表。
- 编写 Moore 型
101 序列检测器,支持重叠检测。
- 把
1011 检测器改为不允许重叠匹配,并比较状态转移差异。
- 编写 Mealy 型
1011 检测器,比较 detected 的出现时机。
- 设计一个红、绿、黄三状态交通灯控制器,先用外部
timer_done 控制跳转。
- 给交通灯状态机增加行人请求输入,明确请求在哪些状态生效。
- 为状态机编写自检式 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 = 1 且 direction = 1 时向上计数
enable = 1 且 direction = 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 计数器和序列检测器中的至少两个实践项目