FPGA的设计艺术(14)使用函数和任务提升逻辑的可重用性

前言

提到函数与任务,很多已从业的逻辑设计人员甚至都会对此陌生,听过是听过,但是很少用过。

与大多数编程语言一样,我们应该尝试使尽可能多的Verilog代码可重用。这使我们能够减少将来项目的开发时间,因为我们可以更轻松地将代码从一种设计移植到另一种设计。

人是懒惰的,觉得麻烦且使用场合局限,就不去重视,甚至不予接触,我宁愿使用其他语法代替。这样的话,就失去了这些语法宝贵的用途,Verilog中的函数与任务有时候使用起来会给工作带来很多便利,且有的时候这是一种竞争力,作为工作的一份子,难道你接触的都是自己的代码吗?不可能吧,甚至可以说,你接触的大多数都是别人的代码,你能保证别人不使用函数和任务吗?所以,这一关始终是难以绕过的。

使用Verilog中的函数和任务,可以编写出很多精炼的代码,让代码可读性提高。例如仿真中,某个功能模块我们需要重复利用,那么就可以使用函数或者任务的一种,让其成为我们仿真平台的一部分,逼格提升了不说,效率也提高了不少。

本文首发:https://www.ebaina.com/articles/140000010072

举个例子,此时你可以不用懂函数以及任务的语法,就看它们带来的方便即可:
定义一个求和函数:

function [7:0] sum;
	input [7:0] a, b;
	begin
		sum = a + b;
	end
endfunction

仿真时调用这个函数:

reg [7:0] result;
reg [7:0] a, b;

initial begin
	a = 4;
	b = 5;
	#10 result = sum (a, b);
end

有人会觉得好傻,我直接加不就可以了。
当然,不会为此抬杠,这只是一个例子,如果是其他复杂的复用结构,函数会是一个利器。

任务也是如此:

	task sum;
		input  [7:0] a, b;
		output [7:0] c;
		begin
			c = a + b;
		end
	endtask
	
	initial begin
		reg [7:0] x, y , z;
		sum (x, y, z);          
	end

这里只是说明函数和任务可以干什么?下面探讨如何在Verilog中使用任务和函数。这些被统称为子程序,它们使我们能够编写可重用的Verilog代码。在后续的进阶中,你可能会阅读到很多进阶的设计,用到了函数以及任务,你会越来越熟悉它们。

函数与任务

功能和任务之间有两个主要区别。

  • 当我们编写一个verilog函数时,它将执行计算并返回一个值。

  • 相反,verilog任务执行许多时序语句,但不返回值。相反,任务可以有无限数量的输出。

  • 除此之外,verilog函数可立即执行,并且不能包含耗时的构造,例如延迟,posege宏或wait语句

  • 一个Verilog的任务,在另一方面,可以包含耗时结构。

接下来,我们将深入讨论这两种构造。这包括提供一些示例,说明我们如何在Verilog中编写和调用函数和任务。

Verilog 函数

在Verilog中,函数是一个子程序,它接受一个或多个输入值,执行一些计算并返回输出值。

我们使用函数来实现一小部分代码,这些代码要在设计中的多个地方使用。

通过使用函数而不是在多个位置重复相同的代码,我们使我们的代码更具可维护性。

我们在verilog模块中编写函数代码,以调用该函数。

下面的代码段显示了Verilog中函数的一般语法。

// First function declaration style - inline arguments

function <return_type> <name> (input <arguments>);

  // Declaration of local variables

  begin

    // function code

  end

endfunction : <name>
// Second function declaration style - arguments in body

function <return_type> <name>;

  (input <arguments>);

  // Declaration of local variables

  begin

    // function code

  end

endfunction

我们必须给每个函数起一个名字,如上例中的字段所示。

我们可以在函数声明中内联声明输入,也可以将其声明为函数主体的一部分。我们用来声明输入参数的方法对函数的性能没有影响。

但是,当我们使用内联声明时,如果需要,我们也可以省略begin和end关键字。

在上面的示例中,我们使用字段声明函数的输入。

我们使用<return_type>字段来声明函数返回哪种Verilog数据类型。如果我们排除了函数声明的这一部分,则该函数将默认返回一个1位值。

当我们返回一个值时,我们通过为函数名称分配一个值来实现。下面的代码片段显示了我们如何简单地将输入返回给函数。

function integer easy_example (input integer a);

  easy_example = a;

endfunction : easy_example

在Verilog中使用函数的规则

尽管函数通常很简单,但是编写Verilog函数时必须遵循一些基本规则。

  • 函数最重要的规则之一是它们不能包含任何耗时的构造,例如延迟,posege宏或wait语句。

当我们想编写一个消耗时间的子程序时,我们应该改用verilog任务。

结果,我们也无法从函数中调用任务。相反,我们可以从函数体内调用另一个函数。

当函数立即执行时,我们只能在Verilog函数中使用阻塞分配。

当我们在verilog中编写函数时,我们可以声明和使用局部变量。这意味着我们可以在函数中声明无法在声明其的函数之外访问的变量。

除此之外,我们还可以访问Verilog函数中的所有全局变量。

例如,如果我们在模块块中声明一个函数,则该函数中可以访问和修改该模块中声明的所有变量。

下面总结在Verilog中使用函数的规则。

在Verilog中使用函数的规则

  • Verilog函数可以具有一个或多个输入参数
  • 函数只能返回一个值
  • 函数不能使用耗时的构造,例如posege,wait或delays(#)
  • 我们不能从函数中调用任务
  • 我们可以从一个函数中调用其他函数
  • 非阻塞分配不能在函数中使用
  • 局部变量可以在函数内部声明和使用
  • 我们可以从verilog函数内部访问和修改全局变量
  • 如果不指定返回类型,则该函数将返回单个位

Verilog函数示例

为了更好地演示如何使用Verilog函数,让我们考虑一个基本示例。

对于此示例,我们将编写一个函数,该函数接受2个输入参数并返回它们的总和。

我们使用verilog整数类型作为输入参数和返回类型。

我们还必须使用verilog加法运算符来计算输入的总和。

下面的代码段显示了此示例函数在verilog中的实现。

正如我们之前讨论的,可以使用两种方法来声明verilog函数,这两种方法都显示在下面的代码中。

// Using inline declaration of the inputs

function integer addition (input integer in_a, in_b);

  // Return the sum of the two inputs

  addition = in_a + in_b;

endfunction : addition
// Declaring the inputs in the function body

function integer addition;

  input integer in_a;

  input integer in_b;

  begin

    // Return the sum of the two inputs

    addition = in_a + in_b;

  end

endfunction : addition

在Verilog中调用函数

当我们想在Verilog设计的另一部分中使用函数时,必须调用它。我们用于执行此操作的方法类似于其他编程语言。

当我们调用一个函数时,我们以与声明它们相同的顺序将参数传递给该函数。这称为位置关联,这意味着我们声明参数的顺序非常重要。

下面的代码段显示了如何使用位置关联来调用附加示例函数。

在下面的示例中,in_a将映射到a参数,而in_b将映射到b。

// Calling a verilog function using positional association

func_out = addition(a, b);

Verilog中的自动功能

我们还可以使用verilog automatic关键字将函数声明为reentrant。

但是,自动关键字是在verilog 2001标准中引入的,这意味着在使用verilog 1995标准时我们无法编写可重入函数。

当我们将一个函数声明为可重入函数时,该函数内的变量和参数将动态分配。相反,普通函数对内部变量和参数使用静态分配。

当我们编写一个普通函数时,用于执行该函数处理的所有内存仅分配一次。这个过程在计算机科学中被称为静态内存分配。

因此,我们的仿真软件必须完全执行该功能,然后才能再次使用该功能。

这也意味着该函数使用的内存永远不会被释放。结果,存储在该存储器中的所有值将在调用该函数之间保持其值。

相反,每当调用函数时,使用自动关键字的函数都会分配内存。函数完成后,便会释放内存。

在计算机科学中,此过程称为自动或动态内存分配。

结果,我们的仿真软件可以执行自动功能的多个实例。

我们可以使用自动关键字在verilog中编写递归函数。这意味着我们可以创建调用自身以执行计算的函数。

例如,递归函数的一个常见用例是计算给定数字的阶乘。

下面的代码段显示了如何使用自动关键字在verilog中编写递归函数。

function automatic integer factorial (input integer a);

  begin

    if (a > 1) begin

      factorial = a * factorial(a - 1);

    end

    else begin

      factorial = 1;

    end

  end

endfunction : factorial

Verilog任务

就像函数一样,我们使用任务来实现一小段代码,这些代码可以在整个设计中重复使用。

在Verilog中,任务可以具有任意数量的输入,也可以生成任意数量的输出。这与只能返回单个值的函数相反。

与函数不同,我们还可以在任务中使用耗时的构造,例如等待,姿势或延迟(#)。

结果,我们可以在verilog任务中同时使用阻塞分配和非阻塞分配。

这些功能意味着任务最适合用来实现简单的代码,这些代码在我们的设计中重复了几次。一个很好的例子是在已知接口(例如SPI或I2C)上驱动引脚。

我们在verilog模块中编写任务代码,该代码将用于调用任务。

我们还可以创建全局任务,这些任务由给定文件中的所有模块共享。为此,我们只需在文件中的模块声明之外编写任务的代码即可。

下面的代码段显示了Verilog中任务的一般语法。

与函数一样,有两种方法可以声明任务,但是两种方法的性能相同。

// Task syntax using inline IO

task <name> (<io_list>);

  // Code which implements the task

endtask
// Task syntax with IO declared in the task body

task <name>;

  <io_list>

  begin

    // Code which implements the task

  end

endtask

我们必须给每个任务起一个名字,如上面的字段所示。

当我们编写在任务主体中声明输入和输出的任务时,还必须使用begin和end关键字。

但是,当我们将内联声明用于输入和输出时,可以省略begin和end关键字。

当我们在verilog中编写任务时,我们可以声明和使用局部变量。这意味着我们可以在任务中创建不能在声明它的任务之外访问的变量。

除此之外,我们还可以访问Verilog任务中的所有全局变量。

与verilog函数不同,我们可以从一个任务中调用另一个任务。我们还可以从任务中调用函数。

Verilog任务示例

让我们考虑一个简单的示例,以更好地演示如何编写Verilog任务。

对于此示例,我们将编写一个可用于生成脉冲的基本任务。当我们在设计中调用任务时,可以指定脉冲的长度。

为此,我们需要一个输入(用于确定脉冲多长时间)和一个用于生成脉冲的输出。

下面的verilog代码使用两种不同的任务样式显示了此示例的实现。

// Task implementation using inline declaration of IO

task pulse_generate(input time pulse_length, output pulse);

  pulse = 1'b1

  #pulse_time

  pulse = 1'b0;

endtask
// Task implementation with IO declared in body

task pulse_generate;

  input time pulse_length;

  output pulse;

  begin

    pulse = 1'b1;

    #pulse_time

    pulse = 1'b0;

  end

endtask

尽管此示例非常简单,但是我们可以在此处看到如何在任务中使用Verilog延迟运算符(#)。如果尝试在函数中编写此代码,则在尝试对其进行编译时将导致错误。

从这个例子中我们还可以看出,返回值的方式与使用函数的方式不同。

相反,我们必须声明在任务声明中使用的所有输出。

在verilog中编写任务时,我们可以包含并驱动任意数量的输出。

在Verilog中调用任务

与函数一样,当我们要在Verilog设计的另一部分中使用任务时,必须调用它。

我们用于执行此操作的方法类似于用于调用函数的方法。

但是,在verilog中调用任务和函数之间存在一个重要区别。

当我们在Verilog中调用任务时,不能像使用函数一样将其用作表达式的一部分。

相反,我们应该将任务调用视为在设计中包含代码块的简便方法。

与函数一样,当我们调用任务时,我们使用位置关联将参数传递给任务。

这只是意味着我们以编写任务代码时所声明的顺序将参数传递给任务。

下面的代码片段显示了我们将如何使用位置关联来调用先前考虑过的pulse_generate任务。

在这种情况下,pulse_length输入被映射到pulse_time变量,而脉冲输出被映射到pulse_out变量。

// Calling a task using positional association

generate_pulse(pulse_time, pulse_out);

Verilog中的自动任务

我们还可以将自动关键字与verilog任务一起使用,以使其可重入。同样,此关键字是在verilog 2001标准中引入的,这意味着它不能与verilog 1995兼容代码一起使用。

如前所述,使用关键字auto意味着我们的仿真工具使用了动态内存分配。

与函数一样,任务默认情况下使用静态内存分配,这意味着仿真软件只能运行任务的一个实例。

相反,无论何时调用任务,使用自动关键字的任务都会分配内存。任务完成后,然后释放内存。

让我们考虑一个基本示例,以显示自动任务的使用以及它们与正常任务的区别。

对于此示例,我们将使用一个简单的任务,该任务将局部变量的值增加给定的数量。

然后,我们可以在仿真工具中多次运行此命令,以查看局部变量在使用自动任务和正常任务时的行为。

下面的代码显示了我们如何编写静态任务来实现此示例。

// Task which performs the increment

task increment(input integer incr);

  integer i = 1;

  i = i + incr;

  $display("Result of increment = %0d", i);

endtask
// Run the task three times

initial begin

  increment(1);

  increment(2);

  increment(3);

end

在icarus verilog仿真工具中运行此代码将得到以下输出:

Result of increment = 2

Result of increment = 4

Result of increment = 7

从中可以看出,局部变量i的值是静态的,并存储在单个存储位置中。

结果,i的值是持久的,并且在任务调用之间保持其值。

当我们调用任务时,我们将增加已经存储在给定存储位置中的值。

下面的代码段显示了相同的任务,除了这次我们使用了自动关键字。

// Automatic task which performs the increment

task automatic increment(input integer incr);

  integer i = 1;

  i = i + incr;

  $display("Result of increment = %0d", i);

endtask
// Run the task three times

initial begin

  increment(1);

  increment(2);

  increment(3);

end

在icarus verilog仿真工具中运行此代码将得到以下输出:

Result of increment = 2

Result of increment = 3

Result of increment = 4

由此,我们现在可以看到局部变量i是动态的,并且在调用任务时会如何创建它。创建它之后,然后为其分配值1。

任务完成运行后,将释放动态分配的内存,并且本地变量不再存在。

上一篇:FPGA学习之verilog语言入门指导


下一篇:verilog数字跑表设计实现与仿真