SystemVerilog 基础

第 9 章 · 共 16 章

任务与函数:SystemVerilog 中的可复用行为

厘清 function 与 task 的本质区别、参数的传值与传引用(ref)、默认参数值、void 函数,以及一个非常容易踩的坑:automatic 与 static 生命周期。

第 8 章的 always_comb/always_ff/always_latch 描述的是硬件行为——每一个都在建模一块真实存在的电路,持续对输入做出反应。这一章的 functiontask 是另一种工具:不是在描述硬件,而是把一段代码——一次计算、一串步骤——打包起来,好让它能被多个地方调用,而不是复制粘贴。验证代码尤其离不开这个,因为 testbench 本来就没有硬件要描述,只有行为需要复用。

和大多数编程语言一样,SystemVerilog 也有"把一段行为包装成可复用单元"的机制——只不过它提供了两种:functiontask,二者的选择不是风格问题,而是由能不能消耗仿真时间这个硬性规则决定的。

functiontask 的核心区别

  • function:在零仿真时间内执行完毕,不能包含 #延时@(事件)wait 这类会让仿真时间前进的语句;必须返回一个值(或显式声明为 void);可以出现在表达式内部(比如 y = parity(data) + 1;)。
  • task:可以消耗仿真时间,内部可以自由使用 #延时@(事件) 等待信号变化;不要求返回值,可以有多个 output/inout 参数;只能作为独立语句调用,不能出现在表达式内部。
// function:瞬间算出一个值,可以用在表达式里
function bit parity(input logic [7:0] data);
  parity = ^data;  // 归约异或:所有位依次异或
endfunction
 
initial begin
  logic [7:0] d = 8'b1011_0110;
  $display("parity = %b", parity(d));
end
// task:随时间推进,逐位把数据"驱动"到引脚上
task automatic drive_byte(input logic [7:0] data, ref logic sclk, ref logic sdata);
  for (int i = 7; i >= 0; i--) begin
    sdata = data[i];
    #5 sclk = 1;
    #5 sclk = 0;
  end
endtask

一个简单的判断准则:需要立刻算出一个值、可以嵌在表达式里用,就用 function;需要随时间推进去做一件事(比如逐拍驱动引脚),就用 task

参数方向:inputoutputinout

参数默认方向是 input(只读,调用方传值进来);output 用于把结果写回调用方的变量;inout 则是"读取初始值,也能写回":

task automatic sum_and_diff(input int a, input int b, output int sum, output int diff);
  sum  = a + b;
  diff = a - b;
endtask
 
initial begin
  int s, d;
  sum_and_diff(10, 3, s, d);
  $display("sum=%0d diff=%0d", s, d);  // sum=13 diff=7
end

按值传递 vs. 按引用传递:ref

参数默认按值传递——调用时会把实参拷贝一份给形参,函数/任务内部对形参的修改不会影响调用方的原始变量(output/inout 是例外,它们的写回是语言规则专门定义的)。但如果参数是一个很大的数组、队列或 struct,每次调用都拷贝一份既浪费性能,有时候你也确实想让任务直接操作调用方的原始数据——这时候用 ref 声明按引用传递

task automatic clear_queue(ref int q[$]);
  q.delete();  // 直接清空调用方传进来的那个队列,不是清空一份拷贝
endtask
 
initial begin
  int my_q[$] = '{1, 2, 3};
  clear_queue(my_q);
  $display("size = %0d", my_q.size());  // 0
end

ref 参数没有拷贝开销,而且任务内部的修改会直接反映到调用方的变量上——这也是验证代码里传递大型数据结构时的标准做法。

默认参数值

参数可以像很多现代语言一样声明默认值,调用时省略对应实参即可使用默认值:

function automatic int add(int a, int b = 10);
  add = a + b;
endfunction
 
initial begin
  $display("%0d", add(5));      // 15,b 使用默认值 10
  $display("%0d", add(5, 20));  // 25,显式传入 b
end

void 函数

如果一个函数纯粹是为了产生副作用(比如打印一条日志),不需要返回值,可以声明成 void 函数——它就不能再出现在表达式里,只能作为独立语句调用,语义上更接近 task,但仍然遵守 function 不能消耗仿真时间的限制:

function void print_banner(string msg);
  $display("==== %s ====", msg);
endfunction
 
initial begin
  print_banner("hello");
end

反过来,如果一个函数返回值,但调用者这次不需要用它,SystemVerilog 要求显式声明"我知道有返回值,但故意不用"——这正是第 6 章数组那一节里 void'(inbox.pop_front()) 的含义:pop_front() 是一个有返回值的方法(返回被弹出的元素),void'(...) 把这次调用的返回值显式转换成 void,告诉工具这是有意为之,而不是漏掉了什么。

automaticstatic 生命周期:一个非常容易踩的坑

这是本章最重要,也最容易被忽视的一点:在模块里声明的 task/function,默认生命周期是 static——意味着它内部的局部变量(包括参数)只有一份存储空间,被所有调用共享。如果这个任务被递归调用,或者被多个并发的 initial/always 块同时调用,不同调用之间会互相踩踏彼此的局部变量:

// 默认 static:多个并发调用会共享同一份局部变量存储
task count_up(int n);
  for (int i = 0; i < n; i++) begin
    #1;
    $display("i = %0d", i);
  end
endtask
 
initial begin
  fork
    count_up(3);
    count_up(3);
  join
end

上面这段代码里,count_up 是默认的 static 生命周期,两个并发调用共享同一个 i(以及同一个参数 n)的存储空间——两次调用会互相覆盖对方的循环变量,打印出来的结果是错乱的,而不是两组独立的 0, 1, 2。修复方法很简单,加上 automatic 关键字:

task automatic count_up(int n);
  for (int i = 0; i < n; i++) begin
    #1;
    $display("i = %0d", i);
  end
endtask

加上 automatic 之后,每一次调用都会拿到自己独立的一份局部变量存储(这才是大多数软件语言里函数调用的默认行为),两个并发调用互不干扰。经验法则:几乎总是应该显式给 task/function 加上 automatic,除非你确实需要多次调用之间共享状态(这种情况很少见,而且通常有更清晰的写法,比如用一个模块级变量)。

小结

functiontask
能否消耗仿真时间(#/@/wait
是否必须返回值是(或显式声明 void
能否出现在表达式里
能否有多个 output/inout 参数可以但少见常见
  • 参数默认按值传递,需要避免拷贝开销、或需要直接修改调用方数据时用 ref
  • 参数可以声明默认值,调用时省略即可使用默认值。
  • void 函数用于纯副作用、不需要返回值的场景;对有返回值但故意不用的调用,用 void'(...) 显式声明。
  • 模块内声明的 task/function 默认是 static(局部变量被所有调用共享),递归调用或并发调用会互相踩踏;几乎总应该显式加上 automatic,让每次调用拥有独立的局部变量。

以下哪一项准确描述了 function 与 task 的区别?

给 task/function 的参数加上 ref 关键字,主要作用是什么?

在模块中声明的 task/function,默认生命周期会导致局部变量被所有调用共享;应该在声明前加上哪个关键字,让每次调用拥有独立的局部变量?(英文小写)