System Verilog 入门使用
概述
写硬件和写软件代码是完全不同的——软件代码体现了指令顺序流执行的思想, 这是和冯诺依曼计算机的结构直接相关的; 硬件的特性是信号在各条信号线上并行传播, 硬件描述语言描述的是各个模块之间的连接关系.
模块结构
一个 2 输入与门的模块描述:
module and2x (
input wire a,
input wire b,
output wire r);
assign r = a & b;
endmodule
需要注意的几点是:
- 输入和双向端口不能被定义为
reg
类型. - 如果无法确认使用
reg
还是使用wire
, 都可以使用logic
来代替. wire
是默认的类型, 所有的wire
都可以省略.- 通过
assign
语句进行持续赋值, 通常被用来进行组合逻辑的设计.
语言元素
硬件描述里面的 “综合” 这个概念非常重要, 类比于将高级语言编译为机器语言, 最终在物理硬件上执行. “综合” 的含义就是将硬件描述语言的功能翻译为能够直接实现的电路, 可以放到 FPGA 或者直接转化为硬件电路, 用以执行所描述的功能.
SystemVerilog 的语言元素
参数
在 C 语言中, 一个编程习惯是使用符号来代替常数硬编码. 这一点在 SystemVerilog 中也是一样的. 可以使用 parameter
来定义一个符号常量, 便于修改也增加了程序的可读性. 一个最为典型的应用就是来指定信号 (向量) 的宽度, 使用 parameter 的语法形式为:
parameter PARAM1=EXP1, PARAM2=EXP2......;
编译指导语句
SystemVerilog 中的编译指导语句与 C 语言 (例如 #include
) 类似, 指示编译器的工作. 编译指导语句都是不可综合的, 会在编译的时候进行字符串等替换操作.
`define
宏定义语句
`define
语句相当于 C 语言中的 #define
, 在编译时, `define
的宏名称被替换为后面的字符串. 如:
`define WIDTH 16
reg [`WIDTH-1:0] r;
这就与 reg[15:0]
相当. 在每次使用宏名称的时候, 需要加上 `
, 在 `define
这一行的行末不需要分号. `define
的宏替换功能与 C 语言一样强大, 能够用来替换比较复杂的表达式. 例如:
`define sum a+b
定义之后, 可以使用:
assign res=sum;
来获得将两个 a 和 b 信号相加的效果.
`include
文件包含语句
`include
语句相当于 C 语言中的 #include
语句, 用来包含其它的文件. 这里同样也没有行末的分号, 需要设置对应的相对路径, 例如:
`include "../common/adder.v"
`ifdef
, `else
, `elsif
, `endif
条件编译语句
`ifdef
, `else
, `elsif
, `endif
条件编译语句相当于在 C 语言中的 #ifdef
, #else
, #elif
, #endif
语句, 用来设定哪一部分源代码会最终编译, 例如:
`define sum a+b
`ifdef sum
assign res=sum;
`else
assign res=a+b;
`endif
可以通过控制 sum
是否定义来选择需要编译的源代码, 不需要编译的源代码就被忽略. 需要嵌套更多判断, 可以使用 `elsif
进一步判断.
SystemVerilog 中的数据
整数常量
在硬件开发的过程中, 不能够改变的量被称为是常量 (constants). 在处理器设计中, 最为重要的常量形式为整数, 整数常量按照一定的格式写出:
+/- <位宽>'<进制><数字>
进制包括了二进制 (b
或者 B
), 十进制 (d
或者 D
), 八进制 (o
或者 O
) 以及十六进制 (h
或者 H
).
8'b01001010
16'H45EF
-8'D123
-16'o3333
数据取值
在进行硬件编码的时候, 除了 0
和 1
两个信号取值外, 还有其它的一些逻辑状态. 两个比较重要的是 x
/X
和 z
/Z
.
x
或者 X
的取值表明为不确定, 或者未知逻辑状态, 用于不关心对应信号值的情况.
z
或者 Z
代表高阻态, 典型应用是用于获得内存的输入, 先将处理器引脚的状态置于 z
, 经过一定的时间延迟, 可从对应的引脚处获得内存的输入值.
数据类型
基本数据类型包括了 wire
类型和 reg
类型. wire
类型代表了在硬件电路中的连线, 输出的值紧随着输入值的变化而变化. reg
数据类型会放到过程语句中进行赋值, 不一定必然会对应到硬件的寄存器, 综合时依据实际情况使用连线 (组合逻辑) 或者寄存器 (时序逻辑).
数据类型还有向量和标量的区别. 没有指定则默认为 1 位的位宽, 是一个标量. 向量使用中括号指定位宽, 形式为 [msb:lsb]
. 例如:
wire [7:0] data; // 这是一个 8 位的连线.
reg [31:0] res; // 32 位的数据变量
在 SystemVerilog 中有非常方便的向量访问方式:
l = data[7]; // 获取 data 的最高位
lob = res[7:0]; // 获取数据 res 中的最低 8 位, 即最低一个字节
SystemVerilog 中的运算
位运算符
位运算符是最基本的运算符, 表达了两个操作数对应的位进行位运算的结果.
~
按位取反.&
按位与.|
按位或.^
按位异或.^~
或~^
按位同或.>>
右移.<<
左移.
在位运算符中还有一类特殊的运算符, 即缩位运算符. 可以将一个向量按照一定的位运算 “缩” 成 1 位.
&
缩位与.~&
缩位与非.|
缩位或.~\
缩位或非.^
缩位异或.^~
或~^
缩位同或.
例如 reg [7:0] value;
如果 value = 7'b01010101
. 则 &value
结果为 0, |value
结果为 1, ~^value
结果为 1.
关系和逻辑运算符
这些运算符可用于条件判断, 取值结果为 true
或者 false
. 着重解释一下全等运算符和不全等运算符.
===
关系运算符全等.!==
关系运算符不全等.
相等运算符 ==
在进行比较时, 需要按每位进行比较, 只有所有的位都相等的时候, 最后的结果值才会是 true
. 如果其中的某一位是高阻态或者不定值, 最终的结果是不定值. 对于全等 ===
来说, 这些高阻态或者不定值也需要进行比较, 完全一致才会获得 true
结果.
A | B | A==B | A===B |
---|---|---|---|
4b1101 | 4b1101 | 1 | 1 |
4b1100 | 4b1101 | 0 | 0 |
4b110Z | 4b110Z | X | 1 |
4b11XX | 4b11XX | X | 1 |
算术运算符
算术运算符并不是最基本的运算符, 需要使用对应的门电路组织成的组合逻辑来完成. 这是语言内部提供的高层的逻辑单元功能, 方便在开发的时候直接集成使用, 而不需要采用模块调用的方式. 在处理器的设计中, 算术运算符的最重要的作用是用来构成 ALU.
位拼接运算符
位拼接运算符 { }
能够把多个信号拼接为向量的形式. {a[3:0], b[7:6],c}
代表了将 a
的第 3 至第 0 位, b
的第 7 位和第 6 位, 以及信号 c
拼接在一起, 构成一个新的信号向量.
SystemVerilog 的行为语句
关于硬件描述语言功能的讨论
行为语句是 SystemVerilog 中最为重要的功能语句, 用来定义具体模块的行为. 在术语上一直使用的是硬件描述语言这样的叙述方式, 而不是硬件设计语言. 因为硬件描述语言大部分没有说明底层的硬件构成, 更多阐述模块对外的功能表现.
在使用硬件描述语言的时候, 更多的是描述对应的电路模块应具有什么功能. 硬件语言的编译器会翻译为对应的底层硬件的实现, 有一定的中立性, 不依赖于具体的物理实现方式. 因此, 描述完成之后不一定是可以综合的 (可物理实现的). 在开发的时候, 时刻要注意程序最终需要被转化为硬件电路.
SystemVerilog 的行为语句综述
SystemVerilog 的行为语句包括赋值语句, 过程语句, 条件语句, 编译指导语句等. 不是所有的行为语句都是可以综合的, 但不可综合的行为语句作用也十分重要, 会被应用到仿真环境中. 在 SystemVerilog 中可综合的行为语句主要包括以下部分:
always
过程语句;- 使用
begin-end
组合的语句块; - 可以进行持续赋值的语句
assign
; - 阻塞的过程赋值语句
=
, 非阻塞的过程赋值语句<=
; for
循环语句.
always 过程语句
一个模块的多个 always
过程语句是并行执行的. 在实现的时候, 通常会使用两种 always
过程语句:
always_comb
: 用来实现组合逻辑;always_ff
: 用来实现时钟边沿触发的时序逻辑.
always_comb 过程语句
always_comb
过程语句的使用方法如下:
always_comb
语句 (可以是一条语句, 或者是语句块)
如果只有一条语句, 不需要加 begin end
构成语句块; 如果超过一条则需要构造成语句块. 更加经常使用的形式是如下形式:
always_comb
begin
//本过程的功能描述
end
一个四选一数据选择器的模块例子:
module mux4_1 (
input din1,
input din2,
input din3,
input din4,
input se1,
input se2,
output reg out);
always_comb
case ({se1,se2})
2'b00 : out = din1;
2'b01 : out = din2;
2'b10 : out = din3;
2'b11 : out = din4;
endcase
endmodule
这个 always_comb
过程语句的有四个输入信号以及两个选择信号, 任何一个发生变化, 输出都将发生变化.
always_ff 过程语句
在实现时序逻辑的时候, 需要在上升沿或者下降沿事件中触发寄存器的更新. 在 SystemVerilog 中, 使用 posedge
指定上升沿, 使用 negedge
指定下降沿. 可以将边沿敏感类型的信号放置到 always_ff
:
always_ff @(posedge clk)
这里响应的是一个时钟 clk
的上升沿信号, 时钟是驱动处理器执行的基础, 在进行综合的时候会综合出时序电路.
begin/end 块语句
begin/end
能够将多条语句组合成语句块.
module decoder2_4 (
input [1:0] in,
output reg [3:0] out);
always_comb begin
out = 4'b0000;
case(in)
2'b00 : out = 4'b0001;
2'b01 : out = 4'b0010;
2'b10 : out = 4'b0100;
2'b11 : out = 4'b1000;
endcase
end
endmodule
因为需要一个初值, 在 begin/end
中有两条语句, 必须构造出一个语句块, 也往往被称为是串行块, 其含义就是 “顺序执行” 的. 在硬件综合完成之后, 各个部分的电信号就开始驱动整个硬件电路信号扩散, 并逐步稳定下来, 输出会随着输入的变化而随时变化.
赋值语句
赋值语句可以将不同的信号组织起来, 包括了持续赋值语句和过程赋值语句. 持续赋值语句在过程外使用, 与过程语句并行执行. 过程赋值语句在过程内串行执行, 用于描述过程的功能.
持续赋值语句
在 SystemVerilog 中使用 assign
作为持续赋值语句使用, 用于对 wire
类型的变量进行赋值. 其对应的硬件即通过对输出进行赋值, 当输入变化时, 经过一定延迟, 输出就会按照 assign
所描述的那样发生变化. 例如:
assign res = input_a & input_b;
在一个模块中, 可以有多个 assign
的持续赋值语句并行执行. 一个模块的持续赋值语句和前面所说的 always
过程语句可以出现多次, 执行关系也是并行的.
过程赋值语句
在 always
过程里面的赋值语句被称为过程赋值语句, 一般用来对 reg
类型的变量进行赋值. 一个是非阻塞赋值语句 <=
, 一个是阻塞赋值语句 =
. 之间的区别是:
- 非阻塞赋值语句
<=
不立即更新被赋值的信号, 等到整个过程块结束时才更新. 由于不是立即发生的, 在过程内仿佛这条语句不存在一样. 在这个执行的过程中, 所有的左值会维持不变, 反映了时钟边沿触发的寄存器的行为特征, 在always_ff
中需要采用非阻塞赋值.
- 阻塞赋值语句
=
立即完成赋值操作, 左值立刻发生变化. 一个块语句中存在多条阻塞赋值语句, 会按照先后顺序关系执行. 这种行为模式和网络 IO 编程中的阻塞函数调用方式一样, 完成函数执行之后调用才会退出. 这种特性可以用来直观描述组合逻辑的行为特征, 在always_comb
中需要采用阻塞赋值.
非阻塞赋值要比阻塞赋值多加一个触发器, 因为信号的变化不是同步的, 需要进行一个周期的延迟.
条件语句
在 SystemVerilog 中, 条件语句包括了 if-else
语句以及 case
语句.
module decoder2_4 (
input [1:0] din,
output reg [3:0] dout);
always_comb begin
dout = 4'b0000;
if (din == 2'b00)
dout = 4'b0001;
else if (din == 2'b01)
dout = 4'b0010;
else if (din == 2'b10)
dout = 4'b0100;
else if (din == 2'b11)
dout = 4'b1000;
end
endmodule
在 SystemVerilog 中也提供了 case
这样的条件判断语句, 避免使用过多的 if-else 进行编写.
case (敏感表达式)
条件判断1: 语句1;
条件判断2: 语句2;
.........
条件判断n: 语句n;
default: 语句n+1;
endcase
这里语句不需要插入 break
, 在语句执行完成后, 直接跳出了 case 语句本身, 这样的行为模式对于程序员来说更加友好.
循环语句
SystemVerilog 中也存在循环语句. 可综合的循环语句为 for
语句. 循环语句不容易直观想象得出综合之后的效果, 描述的功能更加高层和抽象, 转化为硬件的难度会更大. 其它三个循环语句分别为 forever
语句, repeat
语句, while
语句. 其中 forever
语句会连续执行语句, 主要在仿真中使用, 生成周期性的波形 (时钟信号).
repeat
语句:
repeat(循环次数的表达式)
begin
语句或者语句块
end // 单个语句不需要 begin 和 end
while
语句:
while(循环执行的条件表达式)
begin
语句或者语句块
end // 单个语句不需要 begin 和 end
repeat
和 while
往往不可综合, 编写代码时尽量使用 for
语句来实现循环.
module for_adder (
input [7:0] a,
input [7:0] b,
input cin,
output reg [7:0] sum,
output reg cout);
reg c;
integer i;
always_comb begin
c = cin;
for (i = 0; i < 8; i++) begin
{c,sum[i]} = a[i] + b[i] + c;
end
cout = c;
end
endmodule
这是一个功能描述的代码, 描述层次比较抽象, 不是功能设计的代码.
SystemVerilog 的设计层次与风格
SystemVerilog 的语言有很大的灵活性, 对于相同的电路可以有不同的设计方法. 一个 1 位全加器的输入包括 1 位的低位进位 cin
, 两个 1 位的输入信号 a
和 b
, 输出则包括了一个当前位的和 sum
以及向高位的进位 cout
. 从 1 位全加器的真值表可以获得逻辑表达式 (这里只使用与或非门的表达):
CarryOut=(¬A*B*CarryIn)+(A*¬B*CarryIn)+(A*B*¬CarryIn)+(A*B*CarryIn)
=(B*CarryIn)+(A*CarryIn)+(A*B)
Sum=(¬A*¬B*CarryIn)+(¬A*B*¬CarryIn)+(A*¬B*¬CarryIn)+(A*B*CarryIn)
很容易获得 1 位全加器的电路表达形式:
在上述的电路中, 使用了三个非门 not
, 四个 3 输入的与门 and
, 三个 2 输入的与门 and
, 一个 4 输入的或门 or
, 一个 3 输入的或门 or
. 这里的非门, 与门和或门都是 SystemVerilog 中内置的门电路, 可以直接构造出 SystemVerilog 的结构描述.
module full_adder1 (
input a,
input b,
input cin,
output sum,
output cout);
wire a_n, b_n, cin_n, sum_p1,sum_p2,sum_p3,sum_p4, cout_p1, cout_p2, cout_p3;
not(a_n,a),(b_n,b),(cin_n,cin);
and(sum_p1,a_n,b_n,cin),(sum_p2,a_n,b,cin_n),(sum_p3,a,b_n,cin_n),(sum_p4,a,b,cin),(cout_p1,b,cin),(cout_p2,a,cin),(cout_p3,a,b);
or(sum,sum_p1,sum_p2,sum_p3,sum_p4),(cout,cout_p1,cout_p2,cout_p3);
endmodule
门级结构描述虽然不是最底层的描述 (晶体管搭建), 但是已经非常接近, 可以使用元件进行直接映射. 这种方法一般用于设计比较简单或者高效的工作电路, 方便综合器直接进行综合.
门级结构描述虽然方便了底层的综合器, 但是对于编程来说不方便, 希望能够进行更加高层的设计, 一个选择是将上述的逻辑表达式写到程序里, 这是数据流描述方法.
module full_adder1 (
input a,
input b,
input cin,
output sum,
output cout);
assign sum = (~a&~b&cin)|(~a&b&~cin)|(a&~b&~cin)|(a&b&cin);
assign cout = (b&cin)|(a&cin)|(a&b);
endmodule
数据流描述方法描述了组合逻辑中, 输出是如何随着输入数据的变化而变化, 使用持续赋值语句 assign
. 但是, 数据流描述的抽象层次还不是很高, 对于复杂的硬件逻辑设计来说, 使用行为级描述, 即直接描述出硬件所需要完成的功能更为妥当.
module full_adder1 (
input a,
input b,
input cin,
output reg sum,
output reg cout);
always_comb begin
{cout,sum}=a+b+cin;
end
endmodule
从行为级描述中看不到电路怎样使用元件以及怎样布线, 但是完整描述了一个全加器所需要完成的功能.
在设计更加大型的硬件电路的时候, 使用结构级描述是必不可少的. 可以设计一些小型电路模块, 通过结构描述设计出规模更大的电路. 通过设计 4 位的加法器来说明:
module full_adder4 (
input [3:0] a,
input [3:0] b,
input cin,
output [3:0] sum,
output cout);
full_adder1 a0(a[0],b[0],cin,sum[0],cin1);
full_adder1 a1(a[1],b[1],cin1sum[1],cin2);
full_adder1 a2(a[2],b[2],cin2sum[2],cin3);
full_adder1 a3(a[3],b[3],cin3sum[3],cout);
endmodule
在实际进行硬件设计的时候, 出发点还是自顶向下, 对硬件总体先分成多个互相独立的模块, 然后定义之间的连线关系, 连线关系即是它们之间的接口, 最终完成的硬件通过结构描述方式将模块连接在一起.
一些编程建议与经验
`default_nettype none
SystemVerilog 中没有被定义的标记 label 都被默认为是 wire
类型的, 建议的做法是 `default_nettype none
. 这可以防止在信号名字上出现的拼写错误.
锁相环电路
PLL 是 FPGA 上专用的时钟生成模块, 内部是模拟电路. PLL 在启动时需要一段时间才能进入稳定状态, locked
信号输出表示稳定. 在锁相环电路稳定输出之后, locked
信号会被置位, 此时可以进行电路寄存器初始化.
调时序
硬件编程是仿真驱动的, 比较难的部分是调时序, 使各个部分的时序相互匹配, 同时满足对于外设的时间要求. 不同模块之间由于寄存器的关系有相位差, 需要增加几个空的状态机节拍, 匹配不同路径的信号传播.
阻塞赋值语句和非阻塞赋值语句
一般来说, 组合逻辑用 =
, 时序逻辑用 <=
. wire
和 reg
是语法层面的内容, assign
的左值必须是 wire
, always
里的左值必须是 reg
, 否则综合会报错. 是否综合成触发器, 根据有没有时钟信号决定. 综合器通过 posedge
的描述方法知道对应的模块里面需要响应 posedge
或是 negedge
, 从而综合出触发器. 尽量使用 logic
类型并匹配 always_comb
和 always_ff
来分别描述组合逻辑和时序逻辑.
程序的可读性
增加程序的可维护性, 在选择信号名称的时候需要按照名称选择的惯例, 有一些命名方法是常用的.
_i
,_o
, 分别代表一个模块的输入信号和输出信号.n
或者_n
为后缀, 表明这个信号是 0 使能, 0 表示有效.clk
,clock
时钟信号, 后面或者前面接上频率, 可以显示时钟信号的频率.rst
,reset
复位信号, 使得信号可以重置, 一般在重置响应中写入状态机的初值.we
,write enable
信号, 对应于模块的写入使能.oe
,output enable
信号, 对应于模块的输出使能.ce
,chip enable
, 对应于模块的总体使能信号. 上述的信号几乎在所有的模块中都会有 (注意信号是正向的还是反向的, 即 1 使能还是 0 使能. 0 使能会在信号名称的上面带有横线).select
,sel
信号, 一般用于对芯片的选择.
代码检查工具
这个网址中有一些 SystemVerilog 的工具可供参考. 使用
verilator --lint-only -Wall [source_files.v]...
可以帮助做一些检查.
一些特殊的语法点
ram_address = pc[2+:21];
从第 2 位开始的 21 位, 把最后两位去掉. 也可以写成 pc[22-:21]
或 pc[22:2]
.
case 语句可能出现错误的情况:
使用 case
的时候把所有信号在所有情况下写全, 或者灵活使用阻塞赋值语句 =
, 在过程最前面的时候先进行赋值.
always_comb begin
ram_we_n = 1'b1;
ram_oe_n = 1'b1;
ram_address = 21'h0;
case (state)
STATE_FETCH: begin
ram_oe_n = 1'b0;
ram_address = pc[22:2];
end
STATE_MEM: begin
ram_oe_n = mem_op_write;
ram_we_n = ~mem_op_write;
ram_address = ex_val_o[22:2];
end
endcase
end
下面的 case 语句代码块也是正确的:
always_comb begin
case (state)
STATE_FETCH: begin
ram_oe_n = 1'b0;
ram_we_n = 1'b1;
ram_address = pc[22:2];
end
STATE_MEM: begin
ram_oe_n = mem_op_write;
ram_we_n = ~mem_op_write;
ram_address = ex_val_o[22:2];
end
default: begin
ram_oe_n = 1'b1;
ram_we_n = 1'b1;
ram_address = 21'h0;
end
endcase
end
但是下面的 case 语句代码块是错误的.
always_comb begin
case (state)
STATE_FETCH: begin
ram_oe_n = 1'b0;
ram_address = pc[22:2];
end
STATE_MEM: begin
ram_oe_n = mem_op_write;
ram_we_n = ~mem_op_write;
ram_address = ex_val_o[22:2];
end
default: begin
ram_oe_n = 1'b1;
ram_we_n = 1'b1;
ram_address = 21'h0;
end
endcase
end
Warning: empty statement in sequential block
两个分号放在一起 ;;
就会出现这个警告. 一个容易出现的错误是在信号常数定义 `define
的时候, 在信号后面跟了一个分号, 在模块代码里面直接使用的时候就会出现上面的情况.