SystemVerilog Tips

发布于 作者: Ethan

引言

本文档旨在提供一些有用的 SystemVerilog 技巧的高层次概述,内容从基础结构更复杂的编辑模式不等。你不必使用这里提到的所有内容。

基础

顺序逻辑 vs 组合逻辑

简而言之:顺序逻辑对应于触发器 / 寄存器,而组合逻辑对应于逻辑门。 顺序逻辑只在时钟的上升沿(或你定义的触发沿)更新,而组合逻辑则在其输入发生变化时立即更新输出

组合逻辑 顺序逻辑
更新时机 输入一变化就更新 在时钟上升沿更新
过程块 always_comb always_ff
赋值运算符 阻塞赋值 (=) 非阻塞赋值 (<=)
硬件成本 中等

表 1:组合逻辑与顺序逻辑的差异

顺序信号的类型是 reg,而组合信号的类型是 wire。然而,在 SystemVerilog 中,你应该将所有信号都声明为 logic,并让编译器来推断具体类型。即使 wireregister 被定义为相同的类型,理解每个信号的行为方式仍然非常重要

可综合性

你编写的所有硬件都必须是可综合的。这意味着,你编写的行为级 Verilog 必须能够被综合成由基本逻辑门组成的结构级 Verilog

综合设计的过程会为不同的运算符引入不同的硬件成本。这里的成本指的是面积、延迟和功耗。例如,乘法运算(*)比按位或(|)要昂贵得多。此外,对 2 的幂取模要比对非 2 的幂取模便宜得多。一个优秀的硬件设计者会理解他们输入的每一个字符所隐含的硬件成本

以下是一些不可综合的结构

  • 某些过程块(initialrepeatforever

  • 对同一个信号进行多次赋值(这会在门输出之间产生短路!)

  • 某些系统调用(以 $ 开头)

    • 有些只能用于编译期常量(例如:$clog2
    • 有些是完全不可综合的(例如:$time
    • 还有一些虽然可综合,但硬件成本可能未知(例如:$countones

以下是一些会导致综合问题的情况

  • 锁存器(Latch)!也就是在某些情况下没有显式赋值的线网

    • 请检查综合输出中是否出现 “Latch”,例如可以使用 grep 命令在输出日志中查找
  • 组合逻辑环路

    • 这些问题可能很难追踪,因为它们可能跨越多个模块
    • 综合脚本会在输出中提示存在 “timing arc”

数据类型

枚举(Enums)

枚举用于定义一个信号可能取值的集合。这最常见的用途是有限状态机(FSM)中的状态。可以为特定状态分配比特编码,但并非必须

下面是一个未定义比特编码的两状态枚举示例:

typedef enum logic { LOCKED, UNLOCKED } lock_state;

下面是一个显式定义编码的 2 位枚举示例:

typedef enum logic [1:0] {
    BYTE        = 2'h0,
    HALF_WORD   = 2'h1,
    WORD        = 2'h2,
    DOUBLE      = 2'h3
} MEM_SIZE_T;

结构体(Structs)

结构体用于将经常一起使用的信号分组。这可以是模块的输入和输出、存储结构中行的描述,或对数据解释方式的描述(例如一条程序指令)。

结构体中可以包含单比特信号、多维数组,甚至其他结构体。但需要注意,不要让结构体的尺寸增长得过大。

下面是一个包含其他自定义类型的结构体示例:

typedef struct packed {
    REG_IDX_T       Tnew;
    REG_IDX_T       Told;
    ARCH_REG_IDX_T  arch;
} ROB_ENTRY_T;

该结构体的比特数可以通过系统函数 $bits(ROB_ENTRY_T) 获得。

联合体(Unions)

联合体提供了一种方式,用来以多种方式解释同一组比特。这使得同一组比特可以根据上下文被解释为不同的含义。

下面是一个示例,其中一条 64 位的内存行可以被解释为 8 个字节、4 个半字、2 个字,或 1 个双字

typedef union packed {
    logic [7:0][7:0]   byte_level;
    logic [3:0][15:0]  half_level;
    logic [1:0][31:0]  word_level;
    logic [63:0]       dbbl_level;
} MEM_BLOCK_T;

打包数组与非打包数组

打包数组(Packed Array)的所有维度都定义在信号名的左侧,而非打包数组(Unpacked Array)则将除一个维度外的其余维度定义在信号名的右侧

打包数组 非打包数组
logic [DIM1-1:0][DIM2-1:0] signal_name; logic [DIM2-1:0] signal_name [DIM1-1:0];

明确来说,两者的区别在于:打包数组在硬件中是连续布局的,而非打包数组是分散布局的。这意味着,打包数组可以通过一次操作被设置为全 0(或全 1),而非打包数组则需要逐个维度分别赋值

在本项目中,建议仅使用打包数组,因为非打包数组往往会引发一些非预期的问题

GUI 调试器

创建一个 GUI 调试器 对于可视化设计状态以及快速定位问题非常有帮助。这是一项工作量很大的任务,仅建议在小组中有具备较强软件经验的成员时尝试。此外,如果你能在项目早期完成该调试器,它将对整个项目最有价值,因为你可以在开始集成处理器时就立即使用它。

从逻辑上讲,开发过程可以分为两个部分:后端(backend)前端(frontend)

  • 后端负责获取处理器的状态,并使其可供前端访问以进行显示。
  • 前端负责将数据清晰地展示给用户。

后端(Backend)

以下是一些此前使用过的 GUI 调试器后端方案:

  • 将状态导出到 .vcd.vpd 文件中。这些格式是开源的,且易于解析
  • 将状态导出到 .fsdb 文件中。该格式比 vcd 文件压缩程度更高,但更难解析
  • 通过 VCS 提供的 ucli 接口实时运行仿真
  • 通过将所需变量导出为自定义格式(例如 .json 文件)来创建你自己的输出。

若要将结构体拆分为各个独立字段,你需要使用 .vpd 文件而不是 .vcd 文件。VCS 默认生成 .vcd 文件,你可以按照以下步骤改为生成 .vpd 文件:

  1. Makefile 中,将 +memcbk 添加到 $VCS 变量中
  2. 在测试平台(testbench)中添加以下两行来设置 vpd 文件。第一行指定转储文件名,第二行指定转储所有信号。这替代了使用 $dumpvars 命令
$vcdplusfile("<filename>.vpd");
$vcdpluson();
  1. 像平常一样运行仿真(例如:make <program>.out

  2. 使用 Synopsys 转换器将 vpd 文件转换为 vcd 文件

    • 可执行文件位于 /opt/caen/synopsys/vcs-2023.12-SP2-1/bin/vpd2vcd 并且必须带有 +splitpacked 参数运行
    • 为了使该可执行文件正常工作,需要使用以下命令导出 VCS_HOME 环境变量。可以手动运行,或将其加入你的 ~/.bashrc 文件中:
export VCS_HOME=/opt/caen/synopsys/vcs-2023.12-SP2-1
  • 转换命令中,第一个参数是 vpd 文件名,第二个参数是输出的 vcd 文件名
  • 或者,添加如下别名(手动添加或写入 ~/.bashrc 文件):
alias vpd2vcd="/opt/caen/synopsys/vcs-2023.12-SP2-1/bin/vpd2vcd +splitpacked"
  • 这样即可使用以下命令完成转换:
vpd2vcd <filename>.vpd <filename>.vcd

前端(Frontend)

以下是一些此前使用过的 GUI 调试器前端方案:

  • Python(使用如 PyQT 等库)
  • React
  • 基于终端的程序

断言(Assertions)

SystemVerilog 断言(SVA)语言是一种强大的工具,能够让你精确描述模块的正确功能。尽管其语法学习起来可能较为繁琐,但它可以让你的测试过程更加顺畅

断言有两种形式:

  • 立即断言(Immediate assertions):在测试平台中调用一次,用于验证某一时刻的正确性
  • 并发断言(Concurrent assertions):在时钟驱动下运行,在每个时钟周期进行评估,以持续验证功能正确性

立即断言相对比较直观,因此这里我们将重点介绍并发断言

我们将先介绍基本语法,然后在本节末尾提供一些示例

语法(Syntax)

断言既可以直接写在 assert 语句中,也可以先定义为一个 property,再作为参数传递给 assert 语句。先定义属性通常可以让代码更加清晰。

一个良好的实践是在 时钟块(clocking block) 中描述属性,用来指定断言使用的默认时钟。通常形式如下:

default clocking cb @(posedge clk)
property property_name;
// 在此描述属性
endproperty
endclocking

在定义好这些属性之后,可以按如下方式对其进行断言:

assert property(cb.property_name);

cb.property_name 表示位于名为 cb 的块中、名称为 property_name 的属性。

你也可以为断言显式指定一个名字,当断言被触发时该名字会显示出来,便于在失败时进行定位:

AssertionName: assert property(cb.property_name);

最后,如果你希望在断言失败时触发其他操作(例如退出仿真),可以添加一个 else 语句,在断言被触发后执行:

AssertionName: assert property(cb.property_name) else some_other_task;

蕴含(Implication)

断言的核心是蕴含运算符。断言的工作方式是: 首先等待 前件序列(antecedent) 成立(即蕴含运算符之前的逻辑表达式或语句序列),然后验证 后件序列(consequent) 是否满足要求。

蕴含运算符两侧都可以是:

  • 单个周期内的信号组合描述
  • 跨越多个周期的语句序列

蕴含运算符有两种形式:

  • |->:前件和后件必须在同一个周期为真
  • |=>:后件必须在前件成立后的下一个周期为真

时序(Timing)

由于我们处理的是顺序逻辑,断言也可以描述时序行为。这通过 延迟(delay) 来实现,可以是固定周期数或一个周期范围。 延迟使用 ## 表示,范围用方括号 [ ] 表示。例如:

  • ##2 表示延迟 2 个周期
  • ##[3:5] 表示延迟 3–5 个周期

还有一些系统函数可用于描述特定的时序行为:

  • $rose():信号在前一个周期为低,在当前周期为高
  • $fell():信号在前一个周期为高,在当前周期为低
  • $past():获取信号在前一个周期的值
  • s_eventually:在前一个序列发生之后,其后的序列最终必须成立

此外,还有用于表示连续重复序列非连续重复序列以及序列的 组合(交集与并集) 的符号,但这里不再展开介绍。

假设(Assumptions)

有时,一个模块对其输入有某些前提假设,只有在这些假设成立的情况下,断言才可能为真。这些假设可以像断言一样被编码为属性,但在实例化时使用的是 assume 关键字,而不是 assert

覆盖(Covers)

覆盖率(Coverage)是一种有用的验证指标,用于描述一次仿真测试了多少相关的边界情况。如果一个模块存在某些希望测试团队重点验证的边缘场景(例如环形缓冲区回绕),就可以通过 cover 属性进行编码。

覆盖同样通过属性来描述,但在实例化时使用 cover 关键字。

示例(Example)

// 定义属性
default clocking cb @(posedge clk);

//----------- 断言 ----------- //
// 对于给定的保留站项,在被派发进入后,
// 如果不在错误预测路径上,最终一定会发射
property dispatch_will_issue (logic [$clog2(RS_SZ)-1:0] i);
    dispatch_en[i] && dispatch_req[i] |-> s_eventually
        (iss_req[i] && iss_gnt[i]) || mispredict;
endproperty

//----------- 覆盖 ----------- //
// 多条指令同时被唤醒
// 在某个周期没有发射任何项之后的一个周期,
// 保留站中至少有两个项请求发射,
// 且它们在前一个周期没有请求发射
property multiple_wakeup;
    !iss_gnt ##1 ($countones(iss_req ^ $past(iss_req))) >= 2;
endproperty

//----------- 假设 ----------- //
property sq_tail_in_range;
    sq_tail >= 0 && sq_tail < `SQ_SZ;
endproperty

endclocking

// 将属性实例化为断言、假设和覆盖
// 命名规范如下:
//
// P_* 表示断言
// A_* 表示假设
// C_* 表示覆盖
generate
    for (genvar i = 0; i < RS_SZ; i++) begin
        P_EventuallyIssue: assert property(cb.dispatch_will_issue(i));
    end
endgenerate

A_SQTailRange:
    assume property(cb.sq_tail_in_range);

C_MultipleWakeup:
    cover property(cb.multiple_wakeup);

Verilog-Mode

模块连线是编写 Verilog 时最繁琐的工作之一。如果有一个工具可以自动完成这些工作岂不是很好?事实上,确实有这样的工具!Verilog Mode 是一个 emacs 扩展,可以自动化编写 Verilog 中那些枯燥的部分。它能够匹配名称并使用正则表达式,轻松地将模块连接在一起。

运行 Verilog-Mode

可以通过以下命令从命令行调用 Verilog-Mode:

emacs <file_name> -f verilog-auto

如果没有错误,你可以按下 Ctrl-x 然后 Ctrl-c 退出 emacs。如果文件被修改过,你需要输入 y 来保存更改。

如果你是 vim 用户,可以添加一个键盘快捷键,直接在 vim 中调用该命令。为此,请在你的 ~/.vimrc 文件中添加以下一行:

map <C-\> :!emacs % -f verilog-auto<CR>

该命令将 Ctrl-\ 映射为对当前打开文件调用 verilog-auto。Emacs 会在 vim 中打开,按照上述方式退出 emacs 后,你将返回到 vim 编辑器。此时 vim 可能会提示你重新加载缓冲区,你可以在 emacs 关闭后输入 l 来完成重载。

实例化模块(Instantiating Modules)

在最简单的情况下,你可以使用 AUTOINST 命令来自动为模块实例进行连线。例如,你可以写下如下代码:

psel_gen
psel_gen (/*AUTOINST*/);

AUTOINST 命令会将模块实例展开为:

psel_gen
psel_gen (/*AUTOINST*/
    // Outputs
    .gnt      (gnt[WIDTH-1:0]),
    .gnt_bus  (gnt_bus[WIDTH*REQS-1:0]),
    .empty    (empty),
    // Inputs
    .req      (req[WIDTH-1:0])
);

这里,每个端口都会自动连接到同名(且位宽匹配)的线网。每根线的位宽都会被显式标注,以确保与端口宽度一致。

使用模板(Using Templates)

然而,并不是所有情况下你都希望线网名称与端口名称完全一致。这时可以使用模板(Templates),将特定端口名连接到你期望的线网名。

模板通过以下方式描述: 模块名 → AUTO_TEMPLATE 关键字 →(可选)用于匹配模块实例名的正则表达式 → 模块端口的连线描述。

模式通过正则表达式进行捕获。使用 \( 打开一个模式,使用 \) 关闭。

  • @ 表示匹配到的模块实例名中的模式
  • \1\9 表示在端口名中捕获的第 1 到第 9 个模式

在模板中指定连接关系有几种方式:

  1. 精确的端口名连接到精确的线网名
  2. 使用正则表达式批量重命名一组线网
  3. 使用正则表达式在单个线网名中引用模块名

下面是一个简单的模板示例:

/* psel_gen AUTO_TEMPLATE "psel_\(.*\)" (
    .req     (@_req),
    .gnt     (@_gnt),
    .gnt_bus (),
    .empty   (),
);
*/

该模板完成了以下几件事情:

  1. "psel_\(.*\)" 匹配模块实例名,并捕获括号内描述的模式。这里的 .* 表示匹配任意字符 0 次或多次。之后可以通过 @ 来访问该匹配结果。例如,如果模块名为 psel_alu,那么捕获到的模式就是 alu
  2. .req (@_req) 将端口 req 连接到线网 @_req,即由模块名捕获模式加上 _req 后缀组成
  3. .empty () 表示将名为 empty 的端口悬空,不连接任何信号

在 Verilog 文件中定义好上述模板后,你可以实例化多个不同名称的优先级选择器,得到如下结果:

psel_gen #(.REQS(1), .WIDTH(RS_SZ))
psel_alu (/*AUTOINST*/
    // Outputs
    .gnt
    .gnt_bus
    .empty
    // Inputs
    .req
    (alu_gnt),
    (),
    (),
    (alu_req)
);
// Templated

psel_gen #(.REQS(1), .WIDTH(RS_SZ))
psel_mul (/*AUTOINST*/
    // Outputs
    .gnt
    .gnt_bus
    .empty
    // Inputs
    .req
    (mul_gnt),
    (),
    (),
    (mul_req)
);
// Templated

下面是一个更复杂的示例,使用了名为 vldRdySlice 的模块。该模块在两段组合逻辑之间加入一个流水级,同时保持两者之间的 反压(backpressure) 机制:

/* vldRdySlice AUTO_TEMPLATE "\(.*\)_vldRdySlice" (
    .src\(.*\) (@\1_pre),
    .dst\(.*\) (@\1),
    .clr       (mispredict),
);
*/
vldRdySlice (.WIDTH($bits(ALU_DATA)))
alu_vldRdySlice (/*AUTOINST*/
    // Outputs
    .src_rdy  (alu_rdy_pre),
    .dst_vld  (alu_vld),
    .dst_data (alu_data),
    // Inputs
    .clk      (clk),
    .rst      (rst),
    .clr      (mispredict),
    .src_vld  (alu_vld_pre),
    .src_data (alu_data_pre),
    .dst_rdy  (alu_rdy)
);
// Templated

vldRdySlice (.WIDTH($bits(MUL_DATA)))
mul_vldRdySlice (/*AUTOINST*/
    // Outputs
    .src_rdy  (mul_rdy_pre),
    .dst_vld  (mul_vld),
    .dst_data (mul_data),
    // Inputs
    .clk      (clk),
    .rst      (rst),
    .clr      (mispredict),
    .src_vld  (mul_vld_pre),
    .src_data (mul_data_pre),
    .dst_rdy  (mul_rdy)
);
// Templated

自动连线(Auto Wiring)

Verilog-Mode 的最后一个特性是:自动创建模块内部的线网、输入和输出端口。在使用 Verilog-Mode 构建模块层次结构时,这一功能非常有用。

相关关键字包括:

  • AUTOWIRE:为该模块中所有自动连线的信号定义内部线网
  • AUTOINPUT:定义所有自动创建的输入端口,包括那些未连接到其他模块输出端口的自动实例输入
  • AUTOOUTPUT:与 AUTOINPUT 类似,但用于输出端口
  • AUTOARG:读取所有已声明的输入和输出(无论是手动还是自动创建的),并将它们加入模块端口列表

使用 AUTOINPUTAUTOOUTPUT 命令对于检查内部信号是否全部正确连线非常有帮助。如果某些信号意外地出现在输入或输出列表中,那通常意味着你忘记将它们连接到正确的位置。

为了让自动生成的线网默认类型为 logic,请在 Verilog 文件末尾的 endmodule 之后添加以下三行:

// Local Variables:
// verilog-auto-wire-type: "logic"
// End:

你还可以让 Verilog-Mode 将所有以 _T 结尾的类型名识别为自定义类型

// verilog-typedef-regexp: "_T$"