4. Verilog基础语法(下):组合逻辑设计、时序逻辑设计、状态机基础、仿真与Testbench编写
好,咱们接着往下聊。上一章我们把Verilog的基本语法框架搭起来了,这一章要动真格的了——组合逻辑、时序逻辑、状态机,还有仿真验证。这些都是FPGA设计的核心基本功,说白了,你以后写的每一行代码,都跑不出这几个范畴。
我个人习惯把这一章叫做“从代码到硬件”的桥梁。你写出来的Verilog,最终要变成真实的逻辑门和触发器。如果脑子里没有硬件概念,写出来的代码大概率是跑不起来的。
4.1 组合逻辑设计
组合逻辑,就是输出只取决于当前输入,跟历史状态没关系。没有记忆,没有时钟,纯纯的“即时反应”。
我在项目中遇到过最典型的组合逻辑就是地址译码器。比如你要从总线上选通某个外设,就得根据地址信号来产生片选信号。
4.1.1 用assign实现组合逻辑
最简单的写法,就是用assign连续赋值。比如一个2选1多路选择器:
module mux2to1(
input a,
input b,
input sel,
output y
);
assign y = sel ? b : a;
endmodule
嗯,这里要注意:assign左边的变量必须是wire类型。如果你用reg,编译器会报错。我刚开始学的时候就被这个坑过,折腾了半天才发现是类型写错了。
4.1.2 用always块实现组合逻辑
有时候组合逻辑比较复杂,用assign写起来很费劲。这时候可以用always @(*)块,里面的变量用reg类型。注意,这里的reg只是语法要求,并不代表综合成寄存器。
module decoder_3to8(
input [2:0] in,
output reg [7:0] out
);
always @(*) begin
case(in)
3'b000: out = 8'b0000_0001;
3'b001: out = 8'b0000_0010;
3'b010: out = 8'b0000_0100;
3'b011: out = 8'b0000_1000;
3'b100: out = 8'b0001_0000;
3'b101: out = 8'b0010_0000;
3'b110: out = 8'b0100_0000;
3'b111: out = 8'b1000_0000;
default: out = 8'b0000_0000;
endcase
end
endmodule
@(*)或者把所有输入信号都列全。漏掉一个,仿真结果可能对,但综合出来的硬件会多出意想不到的锁存器。我曾经因为这个原因,让一块板子在调试时怎么都不对,查了两天才发现是敏感列表少写了一个信号。
4.2 时序逻辑设计
时序逻辑就不一样了,它要“记住”东西。靠的是时钟沿和触发器。你想想看,如果FPGA里全是组合逻辑,那数据怎么保持?怎么实现状态跳转?
4.2.1 同步复位与异步复位
写时序逻辑,最常用的就是always @(posedge clk)。复位方式有两种:
// 同步复位
always @(posedge clk) begin
if(rst)
q <= 1'b0;
else
q <= d;
end
// 异步复位
always @(posedge clk or posedge rst) begin
if(rst)
q <= 1'b0;
else
q <= d;
end
我个人习惯用异步复位、同步释放的方式。为什么呢?因为异步复位能保证上电瞬间寄存器处于已知状态,而同步释放又能避免复位撤除时的亚稳态问题。这个技巧在高速设计中特别实用。
4.2.2 阻塞赋值与非阻塞赋值
这是新手最容易搞混的地方。我直接说结论:
- 组合逻辑用阻塞赋值(=)
- 时序逻辑用非阻塞赋值(<=)
为什么会这样?因为非阻塞赋值在时钟沿到来时,会先把右边的表达式都计算好,然后统一赋值。这模拟了真实触发器的行为——所有寄存器同时更新。而阻塞赋值是顺序执行的,用在时序逻辑里会综合出意想不到的硬件。
避坑指南:我曾经在一个项目中,把移位寄存器的代码写成了阻塞赋值。仿真看起来没问题,但下载到板子上,数据就是移不对。后来用示波器一看,发现多个寄存器在同一个时钟沿上互相覆盖了。从那以后,我写时序逻辑时都会默念一遍“非阻塞、非阻塞、非阻塞”。
4.3 状态机基础
状态机是FPGA设计的灵魂。你想想看,任何复杂的控制逻辑,本质上都可以抽象成状态机。激光运动控制更是如此——什么时候加速、什么时候匀速、什么时候减速,都是状态在驱动。
4.3.1 三段式状态机
我强烈推荐使用三段式写法。为什么?因为代码清晰,综合出来的硬件也规整。
// 第一段:状态跳转
always @(posedge clk or posedge rst) begin
if(rst)
state <= IDLE;
else
state <= next_state;
end
// 第二段:次态逻辑(组合逻辑)
always @(*) begin
case(state)
IDLE: if(start) next_state = RUN;
else next_state = IDLE;
RUN: if(done) next_state = IDLE;
else next_state = RUN;
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(组合逻辑或时序逻辑)
always @(posedge clk or posedge rst) begin
if(rst)
out <= 1'b0;
else begin
case(state)
IDLE: out <= 1'b0;
RUN: out <= 1'b1;
endcase
end
end
小技巧:状态编码尽量用独热码(one-hot),虽然浪费触发器,但译码逻辑简单,时序容易收敛。在激光控制这种对实时性要求高的场景,独热码是首选。
4.4 仿真与Testbench编写
写代码只是第一步,验证才是重头戏。我见过太多人,代码写完了直接上板子,结果一跑就崩。仿真不是为了走形式,是真的能帮你省下大把调试时间。
4.4.1 Testbench的基本结构
一个标准的Testbench长这样:
`timescale 1ns / 1ps
module tb_mux2to1();
reg a, b, sel;
wire y;
// 实例化待测模块
mux2to1 uut(
.a(a),
.b(b),
.sel(sel),
.y(y)
);
// 产生激励
initial begin
a = 0; b = 0; sel = 0;
#10;
a = 1; b = 0; sel = 0;
#10;
a = 0; b = 1; sel = 1;
#10;
a = 1; b = 1; sel = 1;
#10;
$finish;
end
// 监控输出
initial begin
$monitor("time=%0t, a=%b, b=%b, sel=%b, y=%b", $time, a, b, sel, y);
end
endmodule
4.4.2 时钟与复位生成
仿真时最常用的就是生成时钟和复位信号:
// 生成50MHz时钟
reg clk;
initial clk = 0;
always #10 clk = ~clk; // 周期20ns
// 生成复位信号
reg rst;
initial begin
rst = 1;
#100;
rst = 0;
#1000;
rst = 1;
end
经验之谈:写Testbench时,我习惯把激励和检查分开。激励用initial块,检查用always块或者$monitor。这样代码结构清晰,出了问题也容易定位。另外,别忘了加$stop或者$finish,不然仿真会一直跑下去。
4.5 本章知识体系
下面这张图,是我自己总结的本章知识脉络。你可以把它当作一个检查清单,看看自己是不是都掌握了。
这张图把本章的四个核心模块串起来了。组合逻辑和时序逻辑是基础,状态机是控制核心,仿真验证是质量保证。四者缺一不可。
我的建议:学完这一章,别急着往下走。找个小项目练练手,比如做一个简单的按键消抖模块,或者一个PWM发生器。把组合逻辑、时序逻辑、状态机、仿真全走一遍,你才能真正理解它们是怎么配合的。
好了,这一章的内容就到这儿。记住,写Verilog不是写C语言,脑子里时刻要有硬件画面。下一章我们会把这些知识用到激光运动控制的具体场景中,到时候你就知道今天学的东西有多重要了。