An Introduction to Tasks in SystemVerilog

By John
July 30, 2021

In this post we look at tasks and how we can use them to write SystemVerilog code which is reusable.

As we talked about in the post on SystemVerilog functions, tasks and functions are collectively known as subprograms.

We use subprograms to write small pieces of SystemVerilog code which we can use easily insert into our designs.

This allows us to reduce development time as we can easily port these small pieces of code between different designs.

There are two main differences between functions and tasks.

When we write a SystemVerilog function, it performs a calculation and returns a single value.

In contrast, a SystemVerilog task executes a number of sequential statements but doesn't return a value. Instead, the task can have an unlimited number of outputs .

In addition to this, SystemVerilog functions execute immediately and can't contain time consuming constructs such as delays, posedge macros or wait statements.

In contrast, we can use time consuming constructs inside of a SystemVerilog task.

We will discuss the use of SystemVerilog tasks in more detail in the rest of his post.

If you are already familiar with verilog then you may wish to skip most of this post. The reason for this is that SystemVerilog tasks inherit most of their behavior directly from verilog.

However, you may still wish to read the sections on returning from tasks, automatic variables and passing by reference as these features were all introduced as part of SystemVerilog.

SystemVerilog Task

Just like functions, we use tasks to implement small sections of code which we can reuse throughout our design.

In SystemVerilog, a task can have any number of inputs and can also generate any number of outputs. This is in contrast to functions which can only return at most one value.

Unlike functions, we can also use timing consuming constructs such as wait, posedge or delays (#) within a task.

As a result of this, we can use both blocking and non-blocking assignment in SystemVerilog tasks.

These features mean we typically use tasks to implement simple pieces of code which we need to repeat several times in our design. A good example of this would be driving the pins on a known interface, such as SPI or I2C.

We can write the code for a task inside a module, class or package in SystemVerilog.

The code snippet below shows the general syntax for a task in SystemVerilog.

As with functions, there are two ways in which we can declare a task but the performance of both approaches is the same.

// 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

We must give every task a name, as denoted by the <name> field above.

When we write tasks which declare the inputs and outputs within the task body, we must use the begin and end keywords as well.

However, when we use inline declaration for the inputs and outputs, we can omit the begin and end keywords.

When we write tasks in SystemVerilog, we can also declare and use local variables. This means that we can create variables in a task which we can't access in other parts of our design.

In addition to this, we can also access all global variables within a SystemVerilog task.

Unlike SystemVerilog functions, we can call another task from within a task. We can also make calls to functions from within a task.

SystemVerilog Task Example

Let's consider a simple example to better demonstrate how to write a SystemVerilog task.

For this example, we will write a basic task which generates a pulse when we call it. The length of the pulse can be specified when we call the task in our design.

To do this we require one input, which determines how long the pulse is, and an output for the generated pulse.

The SystemVerilog code below shows the implementation of this example using the two different styles of task.

// Task implementation using inline declaration of IO
task pulse_generate(input time pulse_length, output pulse);
  pulse = 1'b1;
  #pulse_length
  pulse = 1'b0;
endtask : pulse_generate

// Task implementation with IO declared in body
task pulse_generate;
  input time pulse_length;
  output pulse;
  begin
    pulse = 1'b1;
    #pulse_length
    pulse = 1'b0;
  end
endtask : pulse_generate

Although this example is trivial, we can see here how we can use the verilog delay operator (#) in a task. If we attempted to write this code in a function, this would cause an error when we tried to compile it.

We can also see from this example that we don't return a value in the same way as we do with a function.

Instead, we must declare any outputs which we use in the task declaration.

We can include and drive as many outputs as we want when we write a task in SystemVerilog.

Calling a Task in SystemVerilog

As with functions, we must call a task when we want to use it in another part of our SystemVerilog design.

The method we use to do this is similar to the method used to call a function.

However, there is one important difference between calling tasks and functions in SystemVerilog.

When we call a task in SystemVerilog, we can't use it as part of an expression in the same way as we can a function.

We should instead think of task calls as being a short hand way of including a block of code into our design.

As with functions, we can use one of two methods to pass parameters to the task when we call it. The difference between these two methods is the way that we pass data to our task.

The first method which we can use is known as positional association. When we use this approach, we pass parameters to our task in the same order as we declared them.

This is exactly the same as positional association in function calls.

The code snippet below shows how we would use positional association to call the pulse_generate task which we previously considered.

In this case, we map the pulse_length input to the pulse_time variable and the pulse output to the pulse_out variable.

// Calling a task using positional association
generate_pulse(pulse_time, pulse_out);

The second method which we can use to call a task in SystemVerilog is known as named association.

When we use this approach, we explicitly declare which input or output we are passing data to. As a result, the order in which we declare the inputs and outputs is not important,

Again, this method is exactly the same as named association in SystemVerilog functions.

The code snippet below shows how we would use named association to call the pulse_generate task which we previously considered.

In this example, we map the pulse_length input to the pulse_time variable and the pulse output to the pulse_out variable.

// Calling a task using positional association
generate_pulse(.pulse_length (pulse_time), .pulse_out (pulse_out));

Returning from a Task in SystemVerilog

When we write tasks in SystemVerilog, we may have some instances where we wish to terminate our task before it has fully executed.

For example, we may wish to terminate our task before it has fully executed if an error occurs.

In SystemVerilog, we can use the return keyword to stop execution of a task. When we use the return keyword, our task will exit immediately and our code will start executing from the point where the task was called.

To better demonstrate how this works, let's consider a basic example.

In this example, we write a simple task which displays the simulation time after waiting for a small delay. We will use an input to the task to determine the length of the delay.

However, we also use an if statement which prevents the task from executing when the delay input is 0.

The code snippet below shows how we would implement this example task in SystemVerilog.

task example (input time delay);
  // Halt execution if delay is 0
  if (delay == 0) begin
    return;
  end
  // Wait for some time and then print the time 
  #delay
  $display("Simulation time = %0t ns", $time);
endtask : example

We can also simulate this example in EDA playground to show how the task behaves.

To do this, we firstly call the task with an input which is greater than zero. We then call the task again but this time with the delay input set to zero.

The code snippet below shows the code we use to simulate this example task.

initial begin
  example(10ns);
  example(0ns);
end

The console output below shows the result of running this code in EDA playground.

Simulation time = 10 ns

As we can see from this example, our task displays a message when we call it the first time.

However, our task doesn't display a message when we call the second time. The reason for this is that the return keyword forces the task to stop executing before it reaches the $display macro.

Automatic Tasks in SystemVerilog

We can also use the automatic keyword with SystemVerilog tasks in order to make them reentrant.

As we talked about in the post on SystemVerilog functions, using the automatic keyword means that our simulation tool uses dynamic memory allocation.

As with functions, tasks use static memory allocation by default. This means that our simulator can only run one instance of the task at a time.

In contrast, tasks which use the automatic keyword allocate memory whenever the task is called. The memory is then freed once the task has finished with it.

Automatic Task Example

Let's consider a basic example to show how we use automatic tasks and how they differ from normal tasks.

For this example, we will write a simple task which increments the value of a local variable by a specified amount. We use an input to the task to determine how much the local variable is incremented by.

The SystemVerilog code below shows how we would implement this example using a static task.

// Task which performs the increment
task static_increment(input int incr);
  int i = 1;
  i += incr;
  $display("Result of static increment = %0d", i);
endtask : static_increment

The code snippet below shows how we implement this task using an automatic task.

// automatic task which increments a local variable
task automatic auto_increment(input int incr);
  int i = 1;
  i += incr;
  $display("Result of automatic increment = %0d", i);
endtask : auto_increment

As we can see from this, we use practically the same code for both implementations. We have simply included the automatic keyword in the second task to change the way that memory is allocated.

We can then use EDA playground to write a simulation which calls both of these tasks a number of times in order to show the difference between the behavior of automatic and static tasks.

The SystemVerilog code below shows how we implement this simple simulation.

initial begin
  // First call the static task three times
  static_increment(1);
  static_increment(2);
  static_increment(3);
  // Then call the automatic task three times
  auto_increment(1);
  auto_increment(2);
  auto_increment(3);
end

The console output below shows the result of running this simulation.

Result of static increment = 2
Result of static increment = 4
Result of static increment = 7
Result of automatic increment = 2
Result of automatic increment = 3
Result of automatic increment = 4

As we can see from this, the value of the local variable i is stored in a single memory location when we call the static task.

As a result of this, the value of i is persistent and it retains its value between calls to the task.

When we run the task, we increment the value which is already stored in the given memory location.

In contrast, the local variable i is allocated new memory whenever we call the automatic task. After the memory has been allocated, it is then assigned the value of 1.

When the task has finished running, the dynamically allocated memory is freed and the local variable no longer exists.

Automatic Variables in a Task

We can also use the automatic keyword to create variables inside of a SystemVerilog task, as is shown in the code snippet below.

// General syntax to declare an automatic variable in systemverilog
automatic <type_name> <size> <variable_name>;

By default, any variables which we declare inside of a task use static memory allocation.

This means that our simulator allocates memory once at the start of our simulation. Our simulator will also never deallocate this memory while a simulation is running.

As a result of this, when we write data to this variable it will either be stored until our simulation finishes executing or until we write new data to it.

In addition to this, any values stored in this memory will maintain their value between calls to the task.

In contrast, our simulator uses dynamic memory allocation when we create automatic variables in our SystemVerilog tasks.

This means that our simulator allocates memory to store the variable whenever we call the task.

Once the task has finished executing, this memory is then deallocated again.

As we can see from this, the main difference between static and automatic variables is their lifetime.

When we declare a static variable, we are telling our tools that we want the variable exist for the entire simulation.

In contrast, when we declare a dynamic variable we are telling our tools that we want to limit the lifetime so that it only exists for as long as our task is executing.

In SystemVerilog, we can declare and use static variables in both static and automatic functions or tasks.

Automatic Variable Example

To better demonstrate the difference between static and automatic variables let's consider a basic example.

For this example, we will write a simple task which declares two internal variables that we invert every time we call the task.

In addition to this, we will also include a single input which we use to delay the inversion of these two variables.

One of the variables in this task will be static while the other will be an automatic variable.

The code snippet below shows how we implement this task in SystemVerilog.

task auto_example (input time delay);
  // Initialize the variables
  logic static_var = 1'b0;
  automatic logic auto_var = 1'b0;
  // Display the values at start of task
  $display("Entering task at %0tns", $time);
  $display("Automatic variable = %0d", auto_var);
  $display("Static variable = %0d", static_var);
  // Wait the given time and invert the signals
  #delay
  static_var = ~static_var;
  auto_var = ~auto_var;
  // Display the values at end of task
  $display("Task completed at %0tns", $time);
  $display("Automatic variable = %0d", auto_var);
  $display("Static variable = %0d", static_var);
endtask : auto_example

We can then simulate this in EDA playground by writing a loop which calls our task three times. The console output below shows the result of this simulation.

Entering task at 0ns
Automatic variable = 0
Static variable = 0
Task completed at 10ns
Automatic variable = 1
Static variable = 1
Entering task at 10ns
Automatic variable = 0
Static variable = 1
Task completed at 20ns
Automatic variable = 1
Static variable = 0
Entering task at 20ns
Automatic variable = 0
Static variable = 0
Task completed at 30ns
Automatic variable = 1
Static variable = 1

We can see from this example that our simulator retains the value of the static variable between calls to the task.

As a result of this, the static_var variable always retains the last value it was assigned to when we call the task.

In contrast, our simulator discards the value of the automatic variable once the task has finished executing.

As a result of this, our simulator sets the value of the automatic variable to 0 every time we call the task.

Passing Parameters by Reference

In the section on calling a task in SystemVerilog, we saw how we pass inputs to a task when we call it.

By default, all of our parameters are passed by value when we call a task in SystemVerilog. This means that our task receives it's own individual copy of the data.

As a result, we can modify these values and the changes will not be visible outside of the task.

As we talked about in the post on SystemVerilog functions, we can also pass parameters by reference in SystemVerilog.

Although we are unlikely to use this approach very often, we can also pass parameters to tasks by reference.

When we do this, we no longer create a local copy of the data inside of the task.

Instead, we pass a memory address which tells the task where it can find the data. In effect, we are passing a  pointer  to our data rather than the actual data itself.

As a result of this, any changes which we make to this data inside of our task will also be visible to the rest of our program.

The SystemVerilog code below shows the general syntax we use to declare task which pass data by reference.

task <return_type> <name> (ref <argument>);
  // task code
endtask : <name>

As we can see from this, when we want to pass a parameter by reference rather than value then we simply replace the input keyword with the ref keyword.

We use the ref keyword for each argument which we want to pass by reference. This means that we can use a mixture of passing by value and passing by reference in SystemVerilog.

Passing by Reference Example

To better demonstrate the difference between passing by value and passing by reference, let's consider a simple example.

In this example, we will write a task which takes two time type inputs and increments their value by 10ns.

We will pass one of the parameters by reference and one by value. After incrementing the values, our task will exit without returning any data.

The code snippet below shows how we would implement this task in SystemVerilog.

  task inc_time(ref time x, input time y);
    x = x + 10ns;
    y = y + 10ns; 
  endtask : inc_time

We can then use the code below to run a simple simulation which demonstrates how our task affects the two arguments differently.

  initial begin
    $display("Before task call a = %0tns, b = %0tns", a, b);
    inc_time(a, b);
    $display("After task call a = %0tns, b = %0tns", a, b);
  end

Running this example on EDA playground results in the console output shown below.

Before task call a = 0ns, b = 10ns
After task call a = 10ns, b = 10ns

As we can see from this example, our task modifies the value of the parameter when we pass the data by reference.

As we discussed, this is because we are passing a memory location to the task and any changes to the memory are visible to the rest of our program.

In contrast, our task does not affect the value of the parameter when we pass data to the task by value.

Again, we expect this behavior as the data is copied into the task and changes we make to it are not visible to the rest of our program.

Exercises

There are two main differences between tasks and functions, what are they?

A task can have ore than one output but a function can only have one. A function can not consume time but a task can.

What is the difference between an automatic task and a normal task in SystemVerilog?

Normal SystemVerilog tasks use static memory allocation whereas automatic tasks use dynamic memory allocation

What is the difference between passing a parameter by value and by reference?

When we pass a parameter by value, our task receives it's own copy of the data and any modifications we make are not visible in the rest of our design. When we pass a parameter by reference, our function gets a pointer to the data and changes to the data are visible in the rest of our design.

Write the code for a task which can generate a fixed number of pulses. The task should take three inputs - one which sets the high time of the pulse, one which sets the low time of the pulse and another which determines how many pulses should be generated. (TIP: Use a for loop to generate the pulses).

task pulse_generate (;
  input time pulse_high;
  input time pulse_low;
  input int num_pulses;
  output pulse;
);

  for (int i = 0; i < num_pulses; i = i + 1) begin
    #pulse_low 
    pulse = 1'b1;
    #pulse_high 
    pulse = 1'b0;
  end

endtask : pulse_generate

Enjoyed this post? Why not share it with others.

2 comments on “An Introduction to Tasks in SystemVerilog”

  1. Hello,

    First I'd like to thank you for that really interesting article. I just had a small question about the pulse_generate task. It takes pulse_length in input but uses pulse_time for the waiting statement, is that normal ?

    Kind regards,

    Camille

    1. Hi Camille, I am glad you found the article interesting. That is actually a typo in the pulse_generate task, I have now fixed it.

Leave a Reply

Your email address will not be published. Required fields are marked *

Subscribe

Join our mailing list and be the first to hear about our latest FPGA tutorials
Sign Up to our Mailing List
© 2024 FPGA Tutorial

Sign up free for exclusive content.

Don't Miss Out

We are about to launch exclusive video content. Sign up to hear about it first.

Close
The fpgatutorial.com site logo

Don't Miss Out

We are about to launch exclusive video content. Sign up to hear about it first.

Close