《SystemVerilog验证-测试平台编写指南》学习 - 第3章 过程语句和子程序
---- # 《SystemVerilog验证-测试平台编写指南》学习 - 第3章 过程语句和子程序 ----
做设计验证的大部分代码在任务和函数里。SystemVerilog增加了许多改进使它更接近C语言,从而使代码编写更容易。
3.1 过程语句
SystemVerilog从C和C++引入了很多操作符和语句。你可以在 for 循环中定义循环变量,它的作用范围仅限于循环内部,从而有助于避免一些代码漏洞。自动递增符”++“和自动递减符”--“既可以作为前缀也可以作为后缀。如果在begin和fork语句中使用标识符,那么在相对应的end和join语句中可以放置相同的符号,这使得程序块的收尾匹配更加容易。你也可以把标识符放在SystemVerilog的其他结束语句里,例如 endmodule、endtask、endfunction 以及其他语句。如下例所示:
// 例3.1 新的过程语句和操作符
initial
begin :example
integer array [10], sum, j ;
// 在 for 语句中声明 i
for (int i = 0; i < 10; i++) // 定义i,i 递增
array [i] = i ;
// 把数组里的元素相加
sum = array [9] ;
j = 8 ;
do // do ... while 循环
sum += array[j] ; // 累加
while (j--) ; // 判断 j=0 是否成立
$display ("Sum = %4d", sum) ; // %4d 指定宽度
end : example
SystemVerilog为循环功能增加了两个新语句。
循环功能新增语句 | 功能 |
---|---|
continue | 用于在循环中跳过本轮循环剩下的语句而直接进入下一轮循环 |
break | 用于终止并跳出循环 |
//例3.2 在读取文件时使用break和continue
initial begin
bit [127:0] cmd ;
int file , c ;
file = $fopen ("commands.txt", "r") ;
while (!$feof(file)) begin // $feof() 当读到文件末尾时(eof)为非0,否则为0
c = $fscanf (file, "%s", cmd) ; // $fscanf() 一行一行读取,成功返回 1
case (cmd)
"" : continue ; // 空行 - 跳过本轮循环
"done" : break ; // Done - 终止并跳出循环
...
endcase // case (cmd)
end
$fclose (file) ;
end
3.2 任务、函数以及void函数
在Verilog中,任务task和函数function之间有很明显的区别,其中最重要的是:
- 任务可以消耗时间而函数不能。函数里面不能带有诸如 #100 的时延语句或诸如 @(posedge clock) 、wait(ready) 的阻塞语句。
- 函数不能调用任务。
- 另外,Verilog中的函数必须有返回值,并且返回值必须被使用,例如用到赋值语句中。
SystemVerilog对这条限制稍有放宽,允许函数调用任务,但只能在由 fork...join_none 语句生成的线程中调用。
注:如果你有一个不消耗时间的SystemVerilog任务,你应该把它定义成void函数,这种函数没有返回值。这样它就能被任何任务或函数所调用了。从最大灵活性的角度考虑,所有用于调试的子程序都应该定义成void函数而非任务,以便以被任何其他任务或函数所调用。如下例所示:
// 例3.3 用于调试的void函数
function void print_state (...) ;
$display ("@%0t: state = %s", $time, cur_state.name()) ;
endfunction
在SystemVerilog中,如果你想调用函数并且忽略它的返回值,可以使用 void 进行结果转换,如下例所示。有些仿真器,如VCS,允许你在不使用上述void语法的情况下忽略返回值。
// 例3.4 忽略函数的返回值
void '($fscanf (file, "%d", i)) ;
3.3 任务和函数概述
一般情况下,不带参数的子程序在定义或调用时并不需要带括号()。
在SystemVerilog中,begin...end块变成可选的了,而在Verilog-1995中则对单行以外的子程序都是必须的。如下例所示,task/endtask和function/endfunction的关键词已经足以定义这些子程序的边界了。
// 例3.5 不带 begin...end 的简单任务
task multiple_lines ;
$display ("First line") ;
$display ("Second line") ;
endtask : multiple_lines
3.4 子程序参数
SystemVerilog对子程序的很多改进使参数声明变得更加方便,同时也扩展了参数的传递方式。
3.4.1 C语言风格的子程序参数
// 例3.6 Verilog-1995的子程序参数
task mytask2 ;
output [31:0] x ;
reg [31:0] x ;
input y ;
...
endtask
// 例3.7 C语言风格的子程序参数
task mytask1 (output logic [31:0] x,
input logic y) ;
...
endtask
3.4.2 参数的方向
在子程序参数方面还可以有更多的便捷。因为缺省的类型和方向是“logic 输入”,所以在声明类似参数时可不必重复。下例采用SystemVerilog的数据类型但以Verilog-1995的风格编写的一个子程序头。
// 例3.8 带Verilog风格的繁冗的子程序参数
task T3
input a, b ;
logic a, b ;
output [15:0] u, v ;
bit [15:0] u, v ;
...
endtask
// 例3.9 带缺省类型的子程序参数
task T3 (a, b, output bit [15:0] u, v) ;
尽管这种间接的编程方式,但是不建议使用这种方式,这种方式可能会使代码滋生一些细小而难以发现的漏洞。所以建议对所有子程序的声明都带上类型和方向。
3.4.3 高级的参数类型
Verilog对参数的处理方式很简单:
在子程序的开头把input和inout的值复制给本地变量,在子程序退出时则复制output和inout的值。除了标量以外,没有任何把存储器传递给Verilog子程序的办法。
在SystemVerilog中,参数的传递方式可以指定为引用而不是复制。这种 ref 参数类型比input、output或inout更好用。首先,你现在可以吧数组传递给子程序。
// 例3.10 使用ref和const传递数组
function void print_checksum (const ref bit [31:0] a []) ;
bit [31:0] checksum = 0 ;
for (int i = 0; i < a.size(); i++)
checksum ^= a[i] ;
$display ("The array checksum is %0d", checksum) ;
endfunction
SystemVerilog允许不带ref进行数组参数的传递,这时数组会被复制到堆栈区里。这种操作的代价很高,除非是对特别小的数组。
SystemVerilog的语言参考手册(LRM)规定了ref参数只能被用于带自动存储的子程序中。如果你对子程序或模块指明了automatic属性,则整个子程序内部都是自动存储的。
上例中使用了const修饰符,虽然数组变量 a 指向了调用程序中的数组,但子程序不能修改数组的值。如果你试图改变数组的值,编译器将报错。向子程序传递参数时应尽量使用ref以获取最佳性能。如果你不希望子程序改变数组的值,可以使用 const ref 类型。这种情况下,编译器会进行检查以确保数组不被子程序修改。
ref参数的第二个好处是在任务里可以修改变量而且修改结果对调用它的函数随时可见。
// 例3.11 在多线程间使用 ref
task bus_read (input logic [31:0] addr,
ref logic [31:0] data) ;
// 请求总线并驱动地址
bus.request = 1'b1 ;
@(posedge bus.grant) bus.addr = addr ;
// 等待来自存储器的数据
@(posedge bus.enable) data = bus.data ;
// 释放总线并等待许可
bus.request = 1'b0 ;
@(posedge bus.grant) ;
endtask
logic [31:0] addr, data ;
initial
fork
bus_read (addr, data) ;
thread2 : begin
@data ; // 只要数据变化时即可触发
$display ("Read %h from bus", data) ;
end
join
3.4.4 参数的缺省值
当测试程序越来越复杂时,你可能希望在不破坏原有代码的情况下增加额外的控制。在SystemVerilog中,可以为参数指定一个缺省值,如果在调用时不指名参数,则使用缺省值。
// 例3.12 带缺省值的函数
function void print_checksum (ref bit [31:0] a [] ,
input bit [31:0] low = 0,
input int high = -1) ;
bit [31:0] checksum = 0 ;
if (high == -1 || high >= a.size())
high = a.size() - 1 ;
for (int i = low; i <= high; i++)
checksum += a[i] ;
$display ("The array checksum is %0d", checksum) ;
endfunction
// 例3.13 使用参数的缺省值
print_checksum (a) ; // a[0:size()-1] 中所有元素的校验和 -- 缺省情况
print_checksum (a, 2, 4) ; // a[2:4]中所有元素的校验和
print_checksum (a, 1) ; // 从1开始
print_checksum (a,,2) ; // a[0:2]中所有元素的校验和
print_checksum () ; // 编译错误,a没有缺省值
使用-1(或其它任何越界值)作为缺省值,对于获知调用时是否有指定值,不失为一种好方法。
3.4.5 采用名字进行参数传递
在SystemVerilog的语言参数手册(LRM)中,任务或函数的参数有时也被称为端口“port”,就跟模块的接口一样。所以可以采用端口名关联法进行参数传递。
// 例3.14 采用名字进行参数传递
task many (input int a = 1, b = 2, c = 3, d = 4) ;
$display ("%0d %0d %0d %0d", a, b, c, d) ;
endtask
initial begin
many (6, 7, 8, 9) ; // 6 7 8 9 指定所有值
many () ; // 1 2 3 4 使用缺省值
many (.c(5)) ; // 1 2 5 4 只指定c,其它使用缺省值
many (, 6, .d(8)) ; // 1 6 3 8 混合方式
end
3.4.6 常见代码错误
在编写代码时最容易犯的错误就是,你往往会忘记,在缺省的情况下参数的类型是与其前一个参数相同的,而第一个参数的缺省类型是单比特输入。
// 例3.15 原始的任务头
task sticky (int a, b) ;
// 例3.16 加入额外数组参数的任务头
task sticky (ref int array[50] ,
int a, b) ; // 这些变量的方向是什么?
在例3.16中,a和b的参数类型是什么呢?它们在方向上实际采用的是与前一个参数一致的 ref 类型。对简单的int变量使用ref通常并无必要,但编译器不会对此作出任何反应,连警告都没有,所以你不会意识到正在使用一个错误的方向类型。
如果在子程序中使用了非缺省输入类型的参数,应该明确指明所有参数的方向,如下所示:
// 例3.17 加入额外数组参数的任务头
task sticky (ref int array[50] ,
input int a, b) ; // 明确指定方向
3.5 子程序的返回
Verilog中子程序的结束方式比较简单:当你执行完子程序的最后一条语句,程序就会返回到调用子程序的代码上。此外函数还会返回一个值,该值赋给与函数同名的变量。
3.5.1 返回(return)语句
SystemVerilog增加了return语句,使子程序中的流程控制变得更方便。下例的任务由于发现错误而需要提前返回。如果不这样做,那么任务中剩下的部分就必须放到一个else条件语句中,从而使得代码变得不规整,可读性也降低了。
// 例3.18 在任务中用return返回
task load_array (int len, ref int array []) ;
if (len <= 0) begin
$display ("Bad len") ;
return ;
end
// 任务中其余的代码
...
endtask
// 例3.19 在函数中使用return返回
function bit transmit (...) ;
// 发送处理
...
return ~ifc.cb.error ; // 返回状态:0=error
endfunction
3.5.2 从函数中返回一个数组
Verilog的子程序只能返回一个简单值,例如比特、整数或是向量。如果你想计算并返回一个数组,那就不是一件容易的事情了。在SystemVerilog中,函数可以采用多种方式返回一个数组。
第一种方式是定义一个数组类型,然后在函数的声明中使用该类型。
// 例3.20 使用typedef从函数中返回一个数组
typedef int fixed_array5 [5] ;
fixed_array5 f5 ;
function fixed_array5 init (int start) ;
foreach (init[i])
init[i] = i + start ;
endfunction
initial begin
f5 = init (5)
foreach (f5[i])
$display ("f5[%0d] = %0d", i, f5[i]) ;
end
上述代码的一个问题是,函数init创建一个数组,该数组的值被拷贝到数组f5中。如果数组很大,可能会引起性能上的问题。
另一种方式是通过引用来进行数组参数的传递。
// 例3.21 把数组作为ref参数传递给函数
function void init (ref int f [5], input int start) ;
foreach (f[i])
f[i] = i + start ;
endfunction
int fa [5] ;
initial begin
init (fa, 5) ;
foreach (fa[i])
$display ("fa[%0d] = %0d", i, fa[i]) ;
end
从函数返回数组的最后一种方式是将数组包装到一个类中,然后返回对象的句柄。
3.6 局部数据存储
3.6.1 自动存储
在Verilog-1995里,如果你试图在测试程序里的多个地方调用同一个任务,由于任务里的局部变量会使用共享的静态存储区,所以不同的线程之间会窜用这些局部变量。在Verilog-2001里,可以指定任务、函数和模块使用自动存储,从而迫使仿真器使用堆栈区存储局部变量。
在SystemVerilog中,模块(module)和program块中的子程序缺省情况下仍然使用静态存储。如果要使用自动存储,则必须在程序语句中加入automatic关键词。
// 例3.22 在program块中指定自动存储方式
program automatic test ; // program不可包含always/UDP/module/interface/program
task wait_for_mem (input [31:0] addr, expect_data,
output success) ;
while (bus.addr !== addr)
@(bus.addr) ;
success = (bus.data == expect_data) ;
endtask
...
endprogram
因为参数addr和expect_data在每次调用时都使用不同的存储空间,所以对这个任务同时进行多次调用是没有问题的。但如果没有修饰符automatic,由于第一次调用的任务处于等待状态,所以对wait_for_mem的第二次调用会覆盖它的两个参数。
3.6.2 变量的初始化
当你试图在声明中初始化局部变量时,类似的问题也会出现,因为局部变量实际上在仿真开始前就被赋了初值。常规的解决办法是避免在变量声明中赋予除常数以外的任何值。
// 例3.23 静态初始化的漏洞
program initialization ;
task check_bus ;
repeat (5) @(posedge clock) ;
if (bus_cmd == 'READ) begin
//何时对 local_addr 赋初值?
logic [7:0] local_addr = addr << 2 ; //有漏洞
$display ("Local Addr = %h", local_addr) ;
end
endtask
endprogram
存在的漏洞是,变量local_addr是静态分配的,所以实际上在仿真的一开始它就有初始值,而不是等到进入begin...end块才进行初始化。同样地,解决办法是把程序块声明为automatic:
// 例3.24 修复静态初始化的漏洞:使用automatic
program automatic initialization ; // 漏洞修复
...
endprogram
此外,你如果不在声明中初始化变量,那这个漏洞可以避免,只是这种方式不太好记住,尤其习惯了C语言的程序员。下例给双一种较为可取的编码风格,用于分离声明和初始化。
// 例3.25 修复静态初始化的漏洞:把声明和初始化拆开
logic [7:0] local_addr ;
local_addr = addr << 2 ; // 漏洞
3.7 时间值
SystemVerilog有几种新结构使你可以非常明确地在你的系统中指明时间值。
3.7.1 时间单位和精度
当你依赖语句 `timescale 时,在编译文件时就必须按照适当的顺序以确保所有的时延都采用适宜的量和精度。timeunit 和 timeprecision 声明语句可以明确地位每个模块指明时间值,从而避免模糊不清。注意,如果你使用这些语句代替 `timescale ,则必须把它们放到每个带有时延的模块里。
3.7.2 时间参数
SystemVerilog允许使用数值和单位来明确指定一个时间值。 $timeformat的四个参数分别是时间标度、小数点后的数据精度、时间值之后的后缀字符串、显示数值的最小宽度。
// 例3.26 时间参数和$timeformat
module timing ;
timeunit 1ns ;
timeprecision 1ps ;
initial begin
$timeformat(-9, 3, "ns", 8) ;
#1 $display ("%t", $realtime) ; // 1.000ns
#2ns $display ("%t", $realtime) ; // 3.000ns
#0.1ns $display ("%t", $realtime) ; // 3.100ns
#41ps $display ("%t", $realtime) ; // 3.141ns
end
endmodule
3.7.3 时间和变量
你可以把时间值存放到变量里,并在计算和延时中使用它们。根据当前的时间量程和精度,时间值会被缩放或舍入。time 类型的变量不能保存小数时延,因为它们是64bit的整数,所以时延的小数部分会被舍入。如果你不希望这样,你应该采用 real 变量。
// 例3.27 时间变量及舍入
`timescale 1ps/1ps
module ps;
initial begin
real rdelay = 800fs ; // 以0.800存储
time tdelay = 800fs ; // 舍入后得到 1
$timeformat (-15, 0, "fs", 5) ;
#rdelay ; // 时延后得到 1ps
$display ("%t", rdelay) ; // "800fs"
#tdelay ; // 再次延时 1ps
$display ("%t", tdelay) ; // "1000fs"
end
endmodule
系统任务 $time的返回值时一个根据所在模块的时间精度要求进行舍入的整数,不带小数部分,而 $realtime 的返回值则是一个带小数部分的完整实数。