Verilog 基础语法及其结构
参考资料:Verilog Tutorial
本节可能导致学习的混乱,若看不明白或有疑问,请忽略本节部分内容并移至下一节直接开始学习 SystemVerilog。
上节写了一个简单的计数器模块 counter.sv。
module counter (
input logic clk,
input logic rst,
output logic [3:0] count
);
always_ff @(posedge clk) begin
if (rst) count <= 4'd0;
else count <= count + 4'd1;
end
endmodule
这个代码使用 System Verilog 语法定义了一个计数器模块。模块有三个端口:时钟输入 clk、复位输入 rst 和 4 位输出 count。在时钟上升沿触发的 always_ff 块中,如果复位信号为高电平,计数器清零,否则计数器加一。
现在看一个 Verilog 风格的代码:
module ctr (
input up_down,
clk,
rstn,
output reg [2:0] out
);
always @ (posedge clk)
if (!rstn)
out <= 0;
else begin
if (up_down)
out <= out + 1;
else
out <= out - 1;
end
endmodule
同样的,使用 module 定义了一个计数器模块 ctr,包含时钟输入 clk、复位输入 rstn、方向控制输入 up_down 和 3 位输出 out。在时钟上升沿触发的 always 块中,如果复位信号为低电平,计数器清零,否则根据 up_down 信号决定计数器是加一还是减一。
通过简单对比,不难发现语法中 begin 和 end 是相似的,都是用来包裹多条语句的块。always_ff 是 System Verilog 的语法,用于描述时序逻辑,而 always 是 Verilog 的语法,也可以描述时序逻辑,但在 System Verilog 中推荐使用 always_ff 来明确表示这是一个时序逻辑块。声明输出信号时,System Verilog 使用 logic 类型,而 Verilog 使用 reg 类型。声明输入信号时,System Verilog 使用 logic 类型,而 Verilog 使用默认的 wire 类型。
不要以为…
“Verilog 是顺序执行的”
借用参考资料所说,Verilog 与 C 语言等的执行方式截然不同。Verilog 中 assign 语句与 always 是并行的。只要有信号发生变化,assign 语句就会立即执行,而 always 块则会在满足触发条件时执行。Verilog 的执行是基于事件驱动的,而不是像 C 语言那样顺序执行。
例如,下面的代码:
always @(posedge clk) a = 1;
always @(posedge clk) b = a;
只要 clk 上升沿触发,a 会被赋值为 1,而 b 会被赋值为 a 的旧值(在这个时钟周期之前的值),不能保证 b 也变成 1。
Verilog 涉及非阻塞赋值(<=)和阻塞赋值(=)的区别。非阻塞赋值用于时序逻辑,允许在同一个时钟周期内同时更新多个寄存器,而阻塞赋值用于组合逻辑,按顺序执行语句。
“wire 会存储状态”
wire 在 Verilog 里表示连线,不是变量,也不是寄存器。它必须被某个东西持续驱动。如果 wire w 没有被任何东西驱动,它的值就是高阻态(z)。同样,导线在现实中也不是记忆器件,它们只是连接不同模块的通路。
“认为 for 会依次循环”
for 循环在 Verilog 中是一个综合工具,它会被综合为硬件电路,而不是像软件语言那样顺序执行。for 循环的迭代次数必须是已知的常量,不能依赖于运行时的变量。
比如:
always @(*) begin
for (i = 0; i < 8; i = i + 1)
y[i] = a[i] & b[i];
end
它不是“用一个与门算 8 个周期”,而是描述 8 个与门同时工作。如果循环 100 次,就会倾向于生成 100 份逻辑,面积会变大。其对应的是硬件的复制而不是硬件的顺序执行。
Hello World
Verilog 显示一个 Hello World 几乎没用,不过可以认识一下基础的结构。
module tb;
initial
$display("Hello World!");
endmodule
这个 Hello World 模块 tb 使用 initial 块在仿真开始时执行 $display 语句,输出 Hello World! 到仿真控制台。
写好了之后,用 verilator 直接编译为一个可执行的仿真程序:
cd src
verilator --binarv ./hello.v
生成一个 obj_dir 文件夹,里面有一个 Vhello 可执行文件,运行它:
cd obj_dir
./Vhello
执行得到结果:
Hello World!
- S i m u l a t i o n R e p o r t: Verilator 5.032 2025-01-01
- Verilator: end at 0s; walltime 0.001 s; speed 0.000 s/s
- Verilator: cpu 0.001 s on 1 threads; alloced 57 MB
现在使用这个 verilator 比较麻烦,并且侵占了 src 目录,为了隔离并方便地编译 Verilog 文件,可以添加一个 just 项:
run-v FILE:
verilator --binary --top-module tb -Mdir build/vobj src/{{FILE}}
./build/vobj/Vtb
这样可以使用:
just run-v hello.v
来编译并运行 hello.v 文件。
注释、缩进与空格
与大多数语言一样,Verilog 也支持单行注释 // 和多行注释 /* ... */。在编写 Verilog 代码时,建议遵循良好的缩进和注释习惯,以提高代码的可读性和可维护性。
Verilog 中空格除字符串中大多可忽略。
reg [8*9:1] str = "114514ikz";
[8*9:1] 表示 9 个字节的寄存器,8*9 是位宽,1 是最低位索引,等同于 [8*9-1:0]。在 Verilog 中,位宽的表示方式可以使用表达式计算。
运算符
x = ~y; // 按位取反
x = y & z; // 按位与
x = y ? z : w; // 三元运算符
一元运算符
!逻辑非运算符,用于取反布尔值。~按位取反运算符,用于对每一位进行取反操作。&、~&归约与、归约与非运算符,将操作数的所有位归约为一位。|、~|归约或、归约或非运算符,将操作数的所有位归约为一位。^、~^、^~归约异或、归约同或运算符,将操作数的所有位归约为一位。+一元加运算符,通常用于表示正数。-一元减运算符,通常用于表示负数。
二元运算符
+加法运算符,用于两个数值的加法操作。-减法运算符,用于两个数值的减法操作。*乘法运算符,用于两个数值的乘法操作。/除法运算符,用于两个数值的除法操作。%取模运算符,用于两个数值的取模操作。<<、>>、<<<、>>>左移、右移、算术左移、算术右移运算符。&、|、^、~^、^~按位与、或、异或、同或运算符。&&、||逻辑与、逻辑或运算符。==、!=相等、不等运算符;如果操作数包含x或z,结果可能为x。===、!==全等、不全等运算符,会把x和z作为可比较的值。<小于运算符,用于比较两个数值的大小关系。>大于运算符,用于比较两个数值的大小关系。<=小于等于运算符,用于比较两个数值的大小关系。>=大于等于运算符,用于比较两个数值的大小关系。
其中 <= 在时序逻辑中还表示非阻塞赋值,具体含义取决于它所在的语法位置。
三元运算符
? :条件运算符,用于根据条件选择两个值中的一个。
条件复用器(Conditional Multiplexer Logic)是数字电路中常用的组件,它根据选择信号的值来选择输出。三元运算符在 Verilog 中可以用来实现条件复用器的功能。例如:
hit = (sel) ? data1 : data0;
数
Verilog 模拟器数字表示方法为 <位宽>'<进制><数值>,其中 <位宽> 是数字的位数,<进制> 可以是 b(二进制)、o(八进制)、d(十进制)、h(十六进制),而 <数值> 是具体的数字。有时位宽可以省略。
x = 4'b1010; // 二进制表示的 4 位数
y = 8'd255; // 十进制表示的 8 位数
z = 16'h1A3F; // 十六进制表示的 16 位数
w = 8'o77; // 八进制表示的 8 位数
注意不要使用 0b、0o、0x 这样的前缀表示二进制、八进制和十六进制。如需要表示负数,可以使用补码表示法,例如 -8'sd5 表示一个 8 位的有符号数,其值为 -5。如写作 -8'd5,则表示一个 8 位的无符号数,其值为 251(即 256 – 5)。
字符串
Verilog 中的字符串是由双引号包围的字符序列。字符串可以包含 ASCII 字符,也可以包含转义字符。Verilog 中的字符串通常用于显示信息或作为参数传递给模块。
reg [8*9:1] str = "114514ikz"; // 定义一个 9 字节的寄存器,并初始化为字符串 "114514ikz"
不要用 - 作标识符
不允许使用 - 作为标识符的一部分,因为它会被解释为减号运算符。例如,my-variable 会被解析为 my 减去 variable,而不是一个单独的标识符。正确的做法是使用下划线 _ 或驼峰命名法来命名标识符,例如 my_variable 或 myVariable。
wire my_variable; // 正确的标识符命名
wire my-variable; // 错误的标识符命名,编译器会报错
此外,Verilog 中的标识符必须以字母或下划线开头,后续可以包含字母、数字和下划线。标识符区分大小写,因此 myVariable 和 myvariable 是两个不同的标识符。
其余诸多关键词也不能作为标识符,在此不多赘述。
module
module 与 endmodule 用于定义一个模块,模块是 Verilog 的基本构建块。模块可以包含端口、参数、信号、实例化其他模块以及描述逻辑行为的语句。
// Basic module syntax with port list
module <name> ([port_list]);
// Variable declarations (wires, regs, integers, etc.)
// Dataflow statements (assign, always, initial blocks)
// Function and task definitions
// Sub-module instantiations
endmodule
// A module can have an empty portlist (typically for testbenches)
module testbench_top;
// Testbench logic without external ports
endmodule
一个基础的模块定义包括模块名、端口列表、变量声明、数据流语句、函数和任务定义以及子模块实例化。模块可以有一个空的端口列表,通常用于测试平台(testbench)。
示例:同步低有效复位 D 触发器
此模块有三个输入:
d:数据输入clk:时钟输入rst_n:低有效复位输入
一个输出:
q:数据输出
module dff (
input d,
input clk,
input rst_n,
output reg q
);
always @(posedge clk) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
endmodule
input 省略类型时默认是 wire,output reg 表示输出信号 q 是一个寄存器类型。always @(posedge clk) 块在时钟上升沿触发,如果复位信号为低电平(!rst_n),则输出 q 清零,否则将输入 d 的值传递给输出 q。
“低有效复位”意味着当复位信号为低电平时,模块处于复位状态。
flowchart LR subgraph COMB["组合逻辑(复位选择)"] direction TB RSTN[/"rst_n"/] -->|"S"| MUX["RTL_MUX<br/>q_i"] ZERO(("常数<br/>1'b0")) -->|"I0:S=0 时选中"| MUX end subgraph SEQ["时序逻辑(同步寄存器)"] direction TB CLK[/"clk"/] -->|"C"| REG["RTL_REG_SYNC<br/>q_reg"] RSTN -->|"RST"| REG end MUX -->|"O"| REG REG -->|"Q"| Q[/"q"/] REG -.->|"I1:反馈 Q"| MUX上面这张图展示了
dff模块对应的 RTL 结构,把它拆成组合逻辑和时序逻辑两部分来理解:
- 组合逻辑(复位选择):核心是一个多路选择器
RTL_MUX,其选择端S由复位信号rst_n驱动,用于决定寄存器下一个时钟沿要写入的值。- 时序逻辑(同步寄存器):
RTL_REG_SYNC是实际存储状态的寄存器,C接时钟clk,RST接复位信号rst_n,在clk上升沿把输入O采样到输出Q。MUX的输出O接到寄存器的数据输入,寄存器的Q又反向反馈回 >MUX的I1,构成反馈路径。- 输出
q直接来自寄存器的Q,因此q只能是0或保持值,属于寄存器输出。对应代码
if (!rst_n) q <= 1'b0; else q <= d;:当rst_n = 0(低有效复位)时,S = 0选中I0,即常数1'b0,于是时钟沿把0写入寄存器;当rst_n = 1时,复位路径被关闭,寄存器不再被强制清零。注意:图中
I1画的是“反馈Q”(即保持当前值),而代码里非复位分支写入的其实是输入d。二者表达的是同一个选择结构的两种画法:把I1换成d即与代码完全一致,画成反馈Q则表示“复位外的其他情况保持原值”。
模块的目的
模块类似于函数,封装了特定的功能,可以被实例化和复用。模块的设计目标是实现特定的逻辑功能,同时提供清晰的接口,使得不同模块之间可以通过端口进行通信。模块化设计有助于提高代码的可读性、可维护性和可扩展性。
比如说,上述 dff 可以被集成为一个移位寄存器。
module shift_reg (
input clk,
input rst_n,
input d,
output reg q
);
wire [2:0] q_net;
dff u0 (
.d(d),
.clk(clk),
.rst_n(rst_n),
.q(q_net[0])
);
dff u1 (
.d(q_net[0]),
.clk(clk),
.rst_n(rst_n),
.q(q_net[1])
);
dff u2 (
.d(q_net[1]),
.clk(clk),
.rst_n(rst_n),
.q(q_net[2])
);
dff u3 (
.d(q_net[2]),
.clk(clk),
.rst_n(rst_n),
.q(q)
);
模块一般按照 “底层” 到 “顶层” 的顺序进行设计。底层模块放上面实现基本功能,其次写中间模块,然后再写主模块。
注意:模块中不能再定义模块,Verilog 不支持嵌套模块。
端口
Verilog 支持三种端口类型:
| 端口类型 | 描述 | 使用场景 |
|---|---|---|
input |
输入端口,用于接收外部信号 | 时钟、数据输入、控制、复位 |
output |
输出端口,用于向外部输出信号 | 计算结果、数据输出 |
inout |
双向端口,可以作为输入或输出使用 | 数据总线、I2C / SPI 数据线、三态逻辑总线 |
语法
input [net_type] [range] list_of_names; // 输入端口
inout [net_type] [range] list_of_names; // 双向输入输出端口
output [net_type] [range] list_of_names; // 由 wire 驱动的输出端口
output [var_type] [range] list_of_names; // 由变量驱动的输出端口(reg)
示例
module example (
input wire clk, // 时钟输入(显式 wire 标注)
input en, // 使能输入(隐式 wire 标注)
inout rw, // 读写控制(隐式 wire 标注)
input [15:0] data, // 16 位双向数据总线
output int // 中断输出(隐式 wire 标注)
);
// 模块实现
endmodule
声明的标识符默认为无符号的(unsigned),若要声明为有符号的(signed),需要显式地使用 signed 关键字。
module example_signed (
input signed [7:0] a, // 有符号 8 位输入
output signed [7:0] b // 有符号 8 位输出
);
// ...
endmodule
