How to Write a Basic Test Bench Using SystemC

By John
February 24, 2022

In this post we look at how we can write a basic test bench using SystemC. We start by looking at the architecture of a SystemC test bench before considering some of the key concepts in the design of simple SystemC test benches. This includes modeling time in SystemC, the sc_main function and a full test bench example.

SystemC is actually a set of classes and libraries which are built on top of the C++ programming language. We can download and install these libraries for free from the accellera website.

As a result of this, the basic syntax of the SystemC language is taken directly from C++.

However, in these tutorials we will only look at the SystemC extensions which we can use in FPGA design and verification.

Therefore, if you are not already familiar with the C++ language then it is a good idea to take a beginners C++ course before reading through these tutorials.

Architecture of a Basic Test Bench

Test benches consist of non-synthesizable code which generates inputs to the design and checks that the outputs are correct.

The diagram below shows the typical architecture of a simple test bench.

Block diagram of a testbench, showing a stimulus block which generates inputs to the design and an output checker which checks for the correct outputs.

The stimulus block generates the inputs to our FPGA design and the output checker tests the outputs to ensure they have the correct values.

Typically, the stimulus and output checker will be in separate modules for larger designs. However, it is also possible to include all of these different elements in a single module. 

In more sophisticated test benches, we would write an output checker which automatically checks that our design is working correctly.

However, in this example we don't discuss automatically checking our design as it adds unnecessary complexity.

Instead of this, we can simply print the state of our inputs and outputs as needed. This gives us a textual output that we can use to check the state of our signals at given times in our simulation.

In addition to this, we can also generate waveform files as part of our simulation in order to manually check the performance of our design.

We can use a simulation tool that allows for waveforms to be viewed directly. The freely available software packages from Xilinx (Vivado) and Intel (Quartus) both offer this capability.

We can also make use of EDA playground which is a free online SystemC simulation tool. In this case, we would have to use the EDA playground in conjunction with GTKWave in order to view waveform files

Modelling Time in SystemC

One of the key differences between test bench code and design code is that we don't need to synthesize the test bench.

As a result of this, we can use special constructs which consume time. In fact, this is crucial for creating test stimulus.

In SystemC, we use the sc_time data type to create objects which can be used to model time in our test bench.

The general syntax we use to declare a sc_time data type is shown below.

sc_time <name> (<time>, <units>);

As we can see from this, we have to supply both a number and a time unit when we declare a sc_time type.

In this construct, we use the <name> field to give a name to our variable.

We use the <time> field to declare the numeric part of our variable. This number is stored as an unsigned 64-bit number in the SystemC libraries.

We use the <units> field to declare the time unit of the sc_time type. The smallest unit of time that we can model is 1fs.

The sc_time class includes a number of constants that we use to define the time units of our data.

The table below summarizes the constants which are defined in the sc_time class.

ConstantTime Units
SC_FSFemtoseconds
SC_PSPicoseconds
SC_NSNanoseconds
SC_USMicroseconds
SC_MSMiliseconds
SC_SECSeconds

The code snippet below shows how we can use the sc_time data type in practise.

// 100 femtoseconds
sc_time time_example(100, SC_FS)

// 1100 picoseconds
sc_time time_example(1100, SC_PS)

// 1100 miliseconds
sc_time time_example(1100, SC_MS)

We can also use basic arithmetic operators on the sc_time data type, as shown in the code snippet below. We can also simulate these examples on EDA playground.

sc_time t0(100, ns);

// Using the multiplication operator 
sc_time t1 = t0 * 2;

// Using the division operator
sc_time t2 = t0 / 10;

// Using the addition operator
sc_time t3 = t1 + t2;

// Using the subtraction operator
sc_time t4 = t0 - t2;

Generating Clocks in SystemC

When we write a test bench in any programming language, one of the most fundamental things we have to do is generate a clock signal.

In SystemC, we use the sc_clock method to create a clock in our test bench. We typically call this method inside of the sc_main function.

The code snippet below shows the general syntax we use with the sc_clock method.

sc_clock <variable_name>(<name>, <period>, <units>, <duty>);

We use the <name> field to assign a name to our clock signal.

We use the <period> and <units> fields to declare the period of our clock signal. The <period> field is the numeric portion of the time declaration whilst the <units> field defines the time units of the clock. As we discussed in the section on modeling time, we use a constant to define our time unit.

Finally, we use the <duty> field to declare the duty cycle of our clock.

The code snippet below shows how we would use the sc_clock method in practise.

// Generate a 10ns clock with a duty cycle of 50%
sc_clock clk("clock", 10, SC_NS, 0.5);

The sc_start and sc_stop Methods

Unlike Verilog or VHDL test benches, when we write a SystemC test bench we need a way to both start and stop our simulations.

We have two in-built methods which we can use for this in the SystemC - sc_start and sc_stop.

As the name suggests, we use the sc_start method to start our simulation. We typically call this method inside of the sc_main function to begin a simulation.

The sc_start method is an actually an overloaded method. As a result of this, there are three different ways we can call this method, depending on the number of arguments we pass to it.

The simplest way to call the sc_start method is by calling it without passing any arguments to it. When we do this our simulation will run until we invoke the sc_stop method to stop the simulation.

We can also pass arguments to the method when we call it in order to control the length of the simulation. There are two ways to do this.

Firstly, we can pass a single integer type argument to the method when we call it. When we do this, we are telling our simulator how many time steps to execute for.

However, this method is ambiguous as it is often not immediately clear how long our simulation will execute in this instance. As a result, it is not generally recommended that we use this method.

The second way that we can specify the time is by passing both an integer number and a time unit.

When we use this method, our simulation will run for the length of time specified by the arguments. We use the constants which we discussed in the section on modeling time to define our time unit.

The code snippet below shows how we use the sc_start method in practise.

// Run the simulation indefinitely
sc_start();

// Run the simulation for 5 time units
sc_start(5);

// Run the simulation for 10ns
sc_start(10, SC_NS);

The sc_stop method is easier to use than the sc_start method. We simply call this method whenever we want to end our simulation. We don't need to pass any arguments to this method when we call it.

The code snippet below shows how we invoke the sc_stop method.

// Stopping a simulation with sc_stop
sc_stop();

Capturing Waveforms

When we write a test bench in SystemC, we often want to capture waveforms from our design to help us with debugging.

Unfortunately, SystemC does not automatically capture or display waveforms from our simulations.

As a result of this, we have to include some extra code in our test bench to do this.

There are three steps that we have to take to capture waveforms in a SystemC test bench.

1. Create the Waveform File

The first step that we take is to create a file that we can use to save our waveform data. We use the in-built sc_create_vcd_trace_file method to do this.

When we call this method we have to supply a single argument - the name of the file which we want to create.

The sc_create_vcd_trace_file method returns an sc_trace_file data type.

The code snippet below shows the general syntax we use to create a waveform file in a SystemC test bench.

sc_trace_file * <variable_name> = sc_create_vcd_trace_file(<filename>);

We use the <filename> field in the above construct to give the name of the file which we want to create.

3. Add Signals to the Waveform File

After we have created a file to store our data, we then need to add signals to our files. We use the sc_trace method to do this.

The general syntax for the sc_trace method is shown below.

sc_trace(<file>, <signal>, <name>);

We use the <file> field to determine where our signal should be stored. We pass a sc_trace_file type to this field, normally after we have called the sc_create_vcd_trace_file method to create one. This argument should either be a pointer or it should be passed as a reference.

We use the <signal> field to determine which signal we are adding to the file. This should match the name of the signal in our SystemC test bench.

We use the <name> to give a textual name to the signal. This is the name that will be displayed when we view our waveform data after the simulation has finished.

3. Close the Waveform File

Finally, we have to close the waveform file after our simulation has finished running. We use the sc_close_vcd_trace_file method to do this.

The code snippet below shows the general syntax we use to call the sc_close_vcd_trace_file method.

sc_close_vcd_trace_file(<file>);

We use the <file> field to declare which file we are closing.

After running our simulation and capturing the waveforms we can then view them in a program such as GTKWave.

The code snippet below gives a general example of how we capture waveforms in SystemC.

// Set up the waveform dump
sc_trace_file* f = sc_create_vcd_trace_file("waves");
sc_trace(f, clk, "clock");

//Run the simulation
sc_start(200, SC_NS);

// Close the waveform file
sc_close_vcd_trace_file(f);

The sc_main Function

When we write a traditional program using C or C++, the entry point for the program is the main function.

This simply means that the code in the main function is executed whenever we run our program.

However, the SystemC libraries are written in such a way that they already contain a main function.

As a result of this, we can't include a main function in our code as this will simply be bypassed when we execute our program.

Therefore, when we write a SystemC based test bench we have to include a sc_main function instead.

We can simply think of this is as the entry point of any SystemC programs which we want to execute.

When we execute our compiled program, the SystemC library will then call this function from its own main function.

The code snippet below shows the general syntax for the sc_main function in SystemC.

int sc_main(int argc, char* argv[]) {
  // We write our main program here
}

SystemC Test Bench Example

Now that we have discussed the most important topics for test bench design using SystemC, let's consider a complete example.

For this example, we will use a very simple circuit and build a test bench that generates every possible input combination.

We will also write a basic output checker which simply prints the state of the inputs and outputs. A more sophisticated test bench would also include automatic checking but we will exclude this functionality to keep things simple.

The circuit shown below is the one we will use for this example. This consists of a simple four-input AND gate and a d type flip flip.

A circuit diagram showing a two input and gate with the output of the and gate being an input to a D type flip flop

The full code for this test bench example can also be simulated on EDA playground.

1. Instantiate the DUT

The first step which we take in creating our SystemC test bench is to instantiate our DUT. We carry this step out inside of the sc_main function, typically in a file called main.c or main.cpp.

As we discussed in the post on SystemC modules, there are two different methods for doing this - named instantiation or positional instantiation.

As named instantiation is generally easier to maintain than positional instantiation, as well as being easier to understand, this is the method we use.

Firstly, we have to create a new instance of the module. The code snippet below shows how we do this.

Example example("gated_and");

After we have done this, we can then connect each of the individual ports to a signal in our design. The code snippet below shows how we connect the ports to a signal.

example.a(a);
example.b(b);
example.q(q);

The signals a, b and q should all be declared as a sc_signal with the bool data type. The code snippet below shows how we would declare these signals.

sc_signal<bool> a;
sc_signal<bool> b;
sc_signal<bool> q;

We discuss the declaration of the clock signal in the next section.

2. Generate the Clock Signal

The next thing which we have to do in our test bench is generate the clock signal.

We declare the clock signal inside of the sc_main function using the sc_clock method which we previously discussed.

For this example, we will use a 100MHz clock with a duty cycle of 50%. The code snippet below shows how we would generate this clock in SystemC.

sc_clock clk("clock", 10, SC_NS, 0.5)

After we have declared our clock signal we also need to connect it to our DUT. We connect the clock to the relevant port in the same way as we would for all the other signals in our design.

The code snippet below shows how we connect the clock to our DUT.

example.clk(clk);

3. Write the Stimulus

After we have connected our DUT and generated a clock, the next thing we need to do is write some code that generates inputs to our DUT.

In order to test the example circuit, we need to generate each of the four possible input combinations in turn. We then need to wait for a short time while the signals propagate through our code block.

To do this, we will create a new module that contains a thread type process. We need to include the falling edge of the clock signal in the sensitivity list for this process.

Using this approach, we assign the inputs a value inside of the thread and then call the wait method to allow for propagation through the FPGA.

The code snippet below shows how we declare this thread inside of our module constructor.

// Declaring the stim process as a thread
SC_CTOR(ExampleStim) {
  SC_THREAD(stim);
  sensitive << clk.neg();
}   

The code snippet below shows the test stimulus inside of our thread.

// Generate all possible input states
void stim () {   
  while (true) {
    a.write(false);
    b.write(false);
    wait();
    a.write(true);
    wait();
    a.write(false);
    b.write(true);
    wait();
    a.write(true);
    wait();
  }
}

4. Write the Output Checker

In order to make sure that the DUT is working correctly, we need to write a basic checker which monitors the outputs of the DUT.

As we want to keep the test bench simple, we will simply print the state of all the inputs and the output when there is a falling edge on the clock signal.

We will use a clocked thread type process to monitor the state of the DUT. This process can also be included in the module which contains the stimulus method.

The code snippet below shows how we declare this clocked thread.

// Declaring the monitor process as clocked thread
SC_CTOR(ExampleStim) {
  SC_CTHREAD(monitor, clk.neg());
}    

The code snippet below shows how we use the clocked thread to monitor the state of the DUT.

void monitor() {
  while (true) {
    std::cout << "a = " << a.read();
    std::cout << " b = " << b.read();
    std::cout << " q = " << q.read() << " ";
    std::cout << sc_time_stamp() << std::endl;
    wait();
  }
};

5. Connect the Stimulus and Output Checker to the DUT

The next step which we need to take is connecting the stimulus and output checker to our DUT.

In order to do this, we firstly need to define the inputs and outputs to the module which contains our stimulus and output checker processes.

The code snippet below shows how we do this.

sc_in<bool> clk;
sc_out<bool> a;
sc_out<bool> b;
sc_in<bool> q; 

As we can see from this example, the ports in this module are the same as the ports for the DUT. However, we have to reverse the direction of the ports in comparison to the DUT (i.e. outputs are now inputs).

As we have already declared the relevant signals in our sc_main function we simply need to connect this to our module.

To do this, we firstly have to create a new instance of our module. The code snippet below shows how we do this.

ExampleStim tb("ExampleTest");

After we have done this, we can then connect each of the individual ports to the relevant signal in our sc_main function. The code snippet below shows how we connect the ports to the signals.

tb.clk(clk);
tb.a(a);
tb.b(b);
tb.q(q);

As we have already connected these signals to our DUT, we don't need to do anything else to connect the DUT and the stimulus blocks.

6. Run the Simulation

The final step which we need to take in our test bench is to actually run the simulation.

To start our simulation, we simply call the sc_start method.

As we only have 4 test cases, we know that our simulation will take 40ns (i.e. 4 clock cycles) to run.

Therefore, we pass the value of 40ns to the sc_start method when we call it. By doing this, we ensure that our simulation will stop running after 40ns.

The code snippet below shows how we call the sc_start method to run our simulation.

sc_start(40, SC_NS);

Exercises

When using a basic test bench architecture which block generates inputs to the DUT?

The stimulus block is used to generate inputs to the DUT.

Write the code for a variable which models 100us of time.

sc_time example(100, SC_US);

Which methods do we use to stop and start a simulation in SystemC?

We use the sc_start method to start a simulation and the sc_stop method to stop a simulation.

Why do we use the sc_main function as an entry point to our program instead of just main?

The SystemC library already contains a main function which means it would be bypassed if we included one in our code.

Write a thread type process that generates the stimulus for a 3 input AND gate. We should update

// Declaration of the thread
SC_CTOR(Example) {
  SC_THREAD(and3_stim);
  sensitive << clk.neg();
}

// Stimulus code
void and3_stim() {
  while (true) {
    a.write(false);
    b.write(false);
    c.write(false);
    wait();
    a.write(true);
    wait();
    a.write(false);
    b.write(true);
    wait();
    a.write(true);
    wait();
    a.write(false);
    b.write(false);
    c.write(true);
    wait();
    a.write(true);
    wait();
    a.write(false);
    b.write(true);
    wait();
    a.write(true);
    wait();
  }
}
Enjoyed this post? Why not share it with others.

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