如果你第一次接触 Verilog,最容易产生的误会是:它看起来像 C,但运行方式完全不同。
C 程序是一条语句接着一条语句执行。Verilog 描述的是电路。多个模块可以同时工作,assign 描述连线,always 描述一段组合逻辑或时序逻辑。写代码之前,先问一句:这段代码最后会变成什么硬件?
本文不从语法表开始,而是从一个能运行的模块开始。后面每一章都围绕同一个小电路展开。
输入 a、b ──> 组合逻辑 ──> 输出 y │时钟 clk ─────> 寄存器 ─────> q1. 第一个模块
Verilog 的基本单位是 module。它有输入端口、输出端口和内部实现。
module and_gate ( input wire a, input wire b, output wire y); assign y = a & b;endmodulemodule and_gate 定义了一个名为 and_gate 的模块。a 和 b 是输入,y 是输出。assign y = a & b; 表示 y 始终连接到 a & b 的结果。
这里的 assign 不会只执行一次。只要 a 或 b 变化,y 就会跟着变化。
2. wire、reg 和 logic
传统 Verilog 常见两种信号类型:
wire表示连线,通常由assign或其他模块的输出驱动;reg表示可以在always块中赋值的变量。
module add_one ( input wire [7:0] a, output reg [7:0] y); always @(*) begin y = a + 8'd1; endendmodule[7:0] 表示 8 位信号,最高位是 7,最低位是 0。8'd1 表示一个 8 位十进制数 1。
如果工具支持 SystemVerilog,通常可以用 logic 代替 wire 和 reg,并使用 always_comb、always_ff。不过理解 wire 和 reg 有助于读老项目和很多 RISC-V RTL 代码。
3. 组合逻辑:输出只由当前输入决定
组合逻辑没有记忆。输入相同,输出就相同。
module alu_add_sub ( input wire [31:0] a, input wire [31:0] b, input wire sub, output reg [31:0] y); always @(*) begin if (sub) y = a - b; else y = a + b; endendmodulealways @(*) 的意思是:当这个块读取的输入变化时,重新计算一次。组合逻辑中,输出在每个分支都必须被赋值,否则综合工具可能推导出锁存器。
不推荐这样写:
always @(*) begin if (enable) y = a;end当 enable 为 0 时,y 没有新值。硬件为了保留旧值,可能生成 latch。更安全的写法是先给默认值:
always @(*) begin y = 32'b0; if (enable) y = a;end位宽和拼接
Verilog 会按位宽进行运算,位宽不清楚时很容易得到意外结果。
wire [7:0] low;wire [7:0] high;wire [15:0] value;
assign value = {high, low};{high, low} 是拼接,结果是 16 位。value[15:8] 是高 8 位,value[7:0] 是低 8 位。
常用写法还有:
assign next_pc = pc + 32'd4;assign is_zero = (result == 32'b0);assign sign = result[31];4. 时序逻辑:在时钟边沿保存状态
寄存器需要时钟。最常见的写法是在上升沿更新:
module register32 ( input wire clk, input wire reset, input wire [31:0] d, output reg [31:0] q); always @(posedge clk) begin if (reset) q <= 32'b0; else q <= d; endendmodule这里有两个重点:
posedge clk表示只在时钟上升沿执行;- 时序逻辑通常使用非阻塞赋值
<=。
阻塞赋值 = 更适合组合逻辑。非阻塞赋值会让同一个时钟沿上的寄存器同时更新,这正是硬件寄存器组的行为。
时钟上升沿:
d ──> [寄存器 q] ──> q 在此刻采样同步复位和异步复位写法不同。上面的 reset 只有在时钟沿到来时才生效,是同步复位。异步复位通常写成:
always @(posedge clk or posedge reset) begin if (reset) q <= 32'b0; else q <= d;end项目中应统一复位风格,不要在同一个设计里随意混用。
5. 用 always_comb 和 always_ff 写 SystemVerilog
现在的新项目通常使用 SystemVerilog:
module register32 ( input logic clk, input logic reset, input logic [31:0] d, output logic [31:0] q); always_ff @(posedge clk) begin if (reset) q <= 32'b0; else q <= d; endendmodule组合逻辑可以写成:
always_comb begin y = a + b;endalways_comb 和 always_ff 会让工具更容易检查你的意图。学习老代码时仍会遇到 always @(*) 和 always @(posedge clk),它们对应的是同一类硬件。
6. 模块例化:把电路组合起来
一个模块可以使用另一个模块。下面把两个半加器组合成一个全加器的结构形式:
module top ( input wire a, input wire b, output wire y); and_gate u_and ( .a(a), .b(b), .y(y) );endmodule.a(a) 左边是子模块端口,右边是当前模块的信号。建议使用这种按名字连接的写法。按位置连接虽然短,但端口一多就容易接错。
7. 数组:寄存器堆的基础
RISC-V CPU 需要寄存器堆。用 Verilog 可以先写一个非常小的版本:
module regfile ( input wire clk, input wire we, input wire [4:0] waddr, input wire [31:0] wdata, input wire [4:0] raddr1, input wire [4:0] raddr2, output wire [31:0] rdata1, output wire [31:0] rdata2); reg [31:0] regs [0:31];
assign rdata1 = (raddr1 == 5'd0) ? 32'b0 : regs[raddr1]; assign rdata2 = (raddr2 == 5'd0) ? 32'b0 : regs[raddr2];
always @(posedge clk) begin if (we && waddr != 5'd0) regs[waddr] <= wdata; endendmodulereg [31:0] regs [0:31] 是 32 个 32 位元素。这里假设 x0 永远为 0,所以写入 x0 会被忽略,读取 x0 直接返回 0。
这个例子也展示了组合读、时序写的常见寄存器堆结构。
8. 有限状态机
有限状态机通常由三部分组成:状态寄存器、下一状态逻辑、输出逻辑。
+----------------+输入 ->| 下一状态逻辑 |-> next_state +----------------+ | v +--------------+时钟 ------------------->| 状态寄存器 |-> state +--------------+下面是一个简单的两状态 FSM:
module simple_fsm ( input logic clk, input logic reset, input logic start, output logic busy); typedef enum logic {IDLE, RUN} state_t; state_t state, next_state;
always_comb begin next_state = state; case (state) IDLE: if (start) next_state = RUN; RUN: next_state = IDLE; default: next_state = IDLE; endcase end
always_ff @(posedge clk) begin if (reset) state <= IDLE; else state <= next_state; end
assign busy = (state == RUN);endmodulecase 的每个状态都应该有明确处理。default 可以让未知状态回到安全状态,也能帮助综合和仿真检查问题。
9. 测试平台不是硬件
testbench 用来驱动输入、检查输出,它不会被综合成芯片电路。
module tb; reg clk = 0; reg reset = 1; reg [31:0] d = 0; wire [31:0] q;
register32 dut ( .clk(clk), .reset(reset), .d(d), .q(q) );
always #5 clk = ~clk;
initial begin #12 reset = 0; #10 d = 32'd42; #10; $display("q = %0d", q); $finish; endendmodule#5 是仿真延时,$display 和 $finish 也是仿真语句。它们不能直接当作 RTL 写进可综合模块。
10. 写 Verilog 时先画电路
初学时,我建议每写一个 always 块就画一张小图:
- 这个块读取哪些信号?
- 它是否有时钟?
- 输出是连线、组合结果,还是寄存器?
- 每条路径是否都给输出赋值?
- 位宽是否一致?
如果这几个问题能回答清楚,语法通常不是最难的部分。真正需要练习的是把代码和电路一一对应起来。
接下来可以用 Verilog 写一个 ALU、寄存器堆,再把它们连接成单周期 RISC-V 数据通路。Chisel 和 BSV 文章会用尽量相同的模块继续比较三种语言的写法。
11. Verilog 的词法和基本写法
Verilog 的注释有两种:
// 单行注释/* 多行注释 */标识符通常由字母、数字、下划线和 $ 组成,但不能以数字开头。Verilog 区分大小写,data、Data 和 DATA 是三个名字。
数字常量
数字常量的格式是 位宽'进制数值:
8'b1010_0101 // 二进制8'hA5 // 十六进制12'd100 // 十进制8'o377 // 八进制32'b0 // 32 位零下划线只用于提高可读性。除了 0 和 1,Verilog 还允许 x 表示未知值、z 表示高阻态:
4'b10xz8'bz仿真时不要随意忽略 x。一个地址或控制信号出现未知值,通常说明复位、连接或赋值路径有问题。
参数和 localparam
参数让同一个模块可以适配不同位宽:
module adder #(parameter WIDTH = 32) ( input wire [WIDTH-1:0] a, input wire [WIDTH-1:0] b, output wire [WIDTH-1:0] y); assign y = a + b;endmoduleparameter 可以在例化时覆盖,localparam 只能在模块内部使用,适合表示不会被外部修改的常量。
localparam OPCODE_WIDTH = 7;12. 运算符
Verilog 的运算符大致分为以下几类:
assign sum = a + b;assign diff = a - b;assign mask = a & b;assign flag = (a == b);assign pick = enable ? a : b;- 算术:
+、-、*、/、%; - 位运算:
&、|、^、~; - 逻辑运算:
&&、||、!; - 比较:
==、!=、<、<=、>、>=; - 移位:
<<、>>、<<<、>>>; - 选择:
condition ? left : right。
& 是逐位与,&& 是逻辑与。== 在存在 x/z 时可能得到未知结果;验证代码中经常用 === 和 !== 进行包含未知值的比较。
assign y = a & b; // 每一位分别进行 ANDassign ok = valid && ready; // 把两个信号当作条件13. 阻塞赋值和非阻塞赋值
这是 Verilog RTL 最重要的语法区别之一。
always @(*) begin tmp = a + b; y = tmp;end组合逻辑通常使用阻塞赋值 =,语句按顺序计算。时序逻辑通常使用非阻塞赋值 <=:
always @(posedge clk) begin q1 <= d; q2 <= q1;end这个电路中,q2 每个周期取得的是旧的 q1,因此形成两级寄存器。不要为了“让代码看起来顺序执行”把时序寄存器改成阻塞赋值。
14. if、case 和优先级
组合逻辑的条件语句:
always @(*) begin y = 32'b0; if (sel == 2'd0) y = a; else if (sel == 2'd1) y = b; else y = c;endif 从上到下判断,多个条件同时成立时,靠前的分支优先。
always @(*) begin y = 32'b0; case (opcode) 7'b0110011: y = r_type_result; 7'b0010011: y = i_type_result; default: y = 32'b0; endcaseendcasez 可以把 z 和 ? 当作不关心位,casex 会把 x 也当作不关心位。RTL 中通常应谨慎使用 casex,因为它可能掩盖仿真中的未知值。
SystemVerilog 还提供 unique case 和 priority case,用来表达互斥或有优先级的意图。
15. 连接、切片和数组
信号可以选择单个位、连续切片和不连续拼接:
wire [31:0] instruction;wire [6:0] opcode = instruction[6:0];wire [4:0] rd = instruction[11:7];wire [31:0] upper = {20'b0, instruction[31:20]};多维数组适合描述存储器:
reg [7:0] memory [0:255];
always @(posedge clk) begin if (we) memory[waddr] <= wdata;end
assign rdata = memory[raddr];memory 有 256 个元素,每个元素 8 位。左边的 [7:0] 是元素宽度,右边的 [0:255] 是索引范围。
16. function 和 task
function 用于组合计算,并且只能返回一个值:
function [31:0] sign_extend_12; input [11:0] imm; begin sign_extend_12 = {{20{imm[11]}}, imm}; endendfunction
assign immediate = sign_extend_12(instruction[31:20]);task 可以有多个输出,也可以包含仿真时序,常用于 testbench。不要把带 # 延时的 task 当作可综合 RTL。
17. generate 和条件生成
generate 在 elaboration 阶段复制或选择硬件结构:
genvar i;generate for (i = 0; i < 8; i = i + 1) begin : gen_xor assign y[i] = a[i] ^ b[i]; endendgenerategenerate if 可以根据参数选择结构:
generate if (WIDTH == 32) begin // 32 位实现 end else begin // 其他位宽实现 endendgenerate循环展开和运行时循环不是一回事。generate for 生成固定数量的硬件,不能由运行时信号决定循环次数。
18. module、include 和 package
大型项目通常把模块拆成多个文件。`include 会把另一个文件的文本插入当前位置:
`include "defines.vh"宏用反引号开头:
`define XLEN 32SystemVerilog 的 package 更适合管理共享类型和常量:
package riscv_types; typedef enum logic [2:0] { ALU_ADD, ALU_SUB, ALU_AND } alu_op_t;endpackage
module alu (...); import riscv_types::*; alu_op_t op;endmodule19. 常用 SystemVerilog 类型
SystemVerilog 可以用 typedef 给复杂类型起名字:
typedef struct packed { logic [31:0] pc; logic [31:0] instruction; logic valid;} fetch_packet_t;packed struct 会按位连续排列,适合流水线寄存器和接口。枚举适合状态机:
typedef enum logic [1:0] {IDLE, BUSY, DONE} state_t;state_t state;interface 可以把一组握手信号放在一起,但初学阶段先把普通 module 端口读熟,再学习 modport 和 interface,会更容易定位连接方向。
20. 仿真系统任务和断言
testbench 中常见的系统任务:
$display("pc = %h", pc);$monitor("time=%0t valid=%b", $time, valid);$finish;SystemVerilog 断言可以把协议写成可检查的条件:
assert property (@(posedge clk) disable iff (reset) valid && ready |=> data_accepted);这类断言不是硬件数据通路,但对检查流水线、FIFO 和总线协议很有帮助。
21. 一份 RTL 代码检查表
写完 Verilog 后,至少检查这些问题:
- 组合逻辑的每条路径是否都给输出赋值?
- 时序逻辑是否使用
posedge和非阻塞赋值? - 一个寄存器是否被多个 always 块驱动?
- 所有算术运算的位宽和有符号属性是否明确?
case是否有default?- 复位后所有控制寄存器是否进入合法状态?
- testbench 中的延时和系统任务是否误写进可综合模块?
- 生成的 RTL 是否和预期电路、波形一致?
这套规则覆盖了日常 RTL 中最常用的 Verilog 和 SystemVerilog 语法。下一步就是把每种语法放进 ALU、寄存器堆、FIFO 和五级流水线中练习,而不是继续背关键字。