Verilog 硬件描述语言基础:模块化设计、always块与assign语句、阻塞与非阻塞赋值、仿真testbench编写
各位同学,欢迎来到第三章。说实话,Verilog 这门语言,你把它当成软件来写,那基本就离流片失败不远了。我见过太多新手把 Verilog 当 C 语言写,结果综合出来的电路完全不是那么回事。今天我们就从 RISC-V 流水线设计的角度,把 Verilog 的核心概念彻底理清楚。
3.1 模块化设计:把大问题拆成小积木
模块(module)是 Verilog 的基本单元。一个 RISC-V 处理器,说白了就是几十个模块拼在一起。我个人习惯,每个模块只干一件事,比如 ALU 只做运算,寄存器堆只管读写。
- 高内聚:一个模块的功能要单一、完整
- 低耦合:模块之间只通过端口传递信号
- 接口清晰:输入输出信号命名规范,一看就懂
来看一个最简单的模块例子——这是我们后面流水线中 ALU 的雏形:
module alu (
input [31:0] a, // 操作数A
input [31:0] b, // 操作数B
input [3:0] alu_op, // 运算控制码
output reg [31:0] result // 运算结果
);
always @(*) begin
case (alu_op)
4'b0000: result = a + b; // ADD
4'b0001: result = a - b; // SUB
4'b0010: result = a & b; // AND
4'b0011: result = a | b; // OR
default: result = 32'b0;
endcase
end
endmodule
嗯,这里要注意:端口声明里的 output reg 表示这个输出是寄存器类型,可以在 always 块里被赋值。如果你用 output wire,那就只能用 assign 语句驱动。
3.2 always 块与 assign 语句:两种描述方式
Verilog 里描述电路有两种基本方式:always 块和 assign 语句。它们对应不同的硬件结构。
| 描述方式 | 对应硬件 | 典型用途 | 赋值对象 |
|---|---|---|---|
assign |
组合逻辑(连线) | 多路选择器、译码器 | wire 类型 |
always @(*) |
组合逻辑(过程赋值) | 复杂组合逻辑、状态机 | reg 类型 |
always @(posedge clk) |
时序逻辑(触发器) | 寄存器、计数器、流水线级 | reg 类型 |
assign 语句——说白了就是一根导线。左边是 wire,右边是表达式。综合工具会把它变成纯组合逻辑。
// 用 assign 实现一个 2 选 1 多路器
wire [31:0] mux_out;
assign mux_out = sel ? data_a : data_b;
always 块——这个就灵活多了。它既可以描述组合逻辑,也可以描述时序逻辑。区别在于敏感列表:
always @(*)或always @(a or b or sel):组合逻辑always @(posedge clk)或always @(negedge clk or negedge rst_n):时序逻辑
3.3 阻塞赋值与非阻塞赋值:新手最容易踩的坑
这个知识点,我当年刚入行时栽过跟头。阻塞赋值用 =,非阻塞赋值用 <=。它们的行为完全不同。
- 描述组合逻辑用 阻塞赋值(=)
- 描述时序逻辑用 非阻塞赋值(<=)
- 绝对不要混用在同一个 always 块里
为什么会这样?我们来看一个经典的例子——两个寄存器级联:
// 错误写法:阻塞赋值描述时序逻辑
always @(posedge clk) begin
reg_a = data_in; // 先执行
reg_b = reg_a; // 后执行,reg_b 拿到的是 reg_a 的新值
end
// 结果:reg_a 和 reg_b 在同一个时钟沿同时更新为 data_in
// 这不是我们想要的流水线行为!
// 正确写法:非阻塞赋值描述时序逻辑
always @(posedge clk) begin
reg_a <= data_in; // 先计算右侧值,但不立即更新
reg_b <= reg_a; // 同样先计算右侧值(此时 reg_a 还是旧值)
end
// 结果:reg_a 更新为 data_in,reg_b 更新为 reg_a 的旧值
// 这才是真正的流水线行为!
我记得有一次做项目,一个同事在流水线寄存器里用了阻塞赋值,结果仿真波形看起来完全正确,但综合后功能就不对了。查了两天才发现是赋值方式的问题。从那以后,我写时序逻辑时都会下意识检查一遍赋值符号。
3.4 仿真 testbench 编写:验证你的设计
写 Verilog 不仿真,就像造飞机不试飞。testbench 就是你的试飞场。它没有端口,内部实例化被测试模块(DUT),然后产生激励信号。
一个标准的 testbench 结构:
`timescale 1ns / 1ps
module tb_alu();
// 信号声明
reg [31:0] tb_a, tb_b;
reg [3:0] tb_alu_op;
wire [31:0] tb_result;
// 实例化 DUT
alu u_alu (
.a (tb_a),
.b (tb_b),
.alu_op (tb_alu_op),
.result (tb_result)
);
// 产生时钟(如果需要)
reg clk;
always #5 clk = ~clk; // 周期 10ns
// 测试激励
initial begin
// 初始化
tb_a = 32'h0;
tb_b = 32'h0;
tb_alu_op = 4'b0;
// 等待复位结束
#20;
// 测试 ADD: 5 + 3 = 8
tb_a = 32'h5;
tb_b = 32'h3;
tb_alu_op = 4'b0000;
#10;
$display("ADD: %d + %d = %d", tb_a, tb_b, tb_result);
// 测试 SUB: 10 - 4 = 6
tb_a = 32'hA;
tb_b = 32'h4;
tb_alu_op = 4'b0001;
#10;
$display("SUB: %d - %d = %d", tb_a, tb_b, tb_result);
// 测试 AND
tb_a = 32'hFF00FF00;
tb_b = 32'h0F0F0F0F;
tb_alu_op = 4'b0010;
#10;
$display("AND: %h & %h = %h", tb_a, tb_b, tb_result);
#20;
$finish;
end
// 波形输出(可选)
initial begin
$dumpfile("tb_alu.vcd");
$dumpvars(0, tb_alu);
end
endmodule
- 用
$display打印关键信息,方便调试 - 用
$dumpfile和$dumpvars生成 VCD 波形文件 - 测试用例要覆盖边界条件(全0、全1、最大值等)
- 别忘了加
$finish,否则仿真不会自动结束
3.5 本章知识体系总览
下面这张图把本章的核心知识点串起来了。你想想看,从模块化设计开始,到两种描述方式,再到赋值方式的区别,最后用 testbench 验证——这就是一个完整的 Verilog 设计闭环。
这张图把今天的内容串在了一起。从模块化设计出发,到两种描述方式,再到赋值方式的区别,最后用 testbench 验证——这就是一个完整的 Verilog 设计闭环。后面的流水线设计,每一步都会用到这些基础概念。
- 模块化设计:每个模块只干一件事,接口清晰
- assign 用于组合逻辑,always 块可描述组合和时序
- 组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)
- testbench 没有端口,实例化 DUT 后产生激励并检查结果