Global training solutions for engineers creating the world's electronics

Primitive Channels and the Kernel

This section describes a little of the operation of the SystemC simulation kernel, and then relates this to the behaviour of primitive channels.

Simulation Kernels

Most modelling languages, VHDL for example, use a simulation kernel. The purpose of the kernel is to ensure that parallel activities (concurrency) are modelled correctly. In the case of VHDL, a fundamental decision was made that the behaviour of the simulation should not depend on the order in which the processes are executed at each step in simulation time. This section starts by describing what happens in VHDL, because SystemC mimics some key aspects of the VHDL simulation kernel, but also allows other models of computation to be defined. Once VHDL is understood, it is possible to extend the discussion to incorporate the more general simulation kernel of SystemC.

For instance, suppose in SystemC there are two SC_THREADs, both sensitive to a trigger.

SC_THREAD(proc_1);
sensitive << Trig.pos();
SC_THREAD(proc_2);
sensitive << Trig.pos();

 

When the trigger changes from low to high, which process will run first? More importantly, does it matter? In the analogous situation using VHDL, you really do not care. This is because in VHDL, communication between processes is done via signals, and process execution and signal update are split into two separate phases.

The VHDL simulation kernel executes each process in turn, but any resulting changes on signals do not happen instantaneously. The same is true of SystemC and sc_signal. To be precise, the assignments are scheduled to happen in the future, meaning when all currently active processes have been evaluated and have reached a point where they need to suspend and wait for some event to occur.

It is possible that no simulated time has passed. If that is the case, and there are pending updates for signals, the processes that react to those signals will run again, without time having passed. This is known as a "delta cycle" and has the effect of determining unambiguously the order in which communicating processes are to execute in cases where no simulation time is passing.

SystemC can do this too, but can also model concurrency, communication and time and in other ways.

Non-determinism

When signals in VHDL or sc_signals in SystemC are used to communicate between processes, the simulation is deterministic; it will behave the same on any simulation tool.

In SystemC however, the language allows non-determinism. For instance, suppose a variable declared in a class is accessed from two different SC_THREADs, as described above. Here is an example

SC_MODULE(nondet)
{
  sc_in Trig;

  int SharedVariable;
  void proc_1()
  {
    SharedVariable = 1;
    cout << SharedVariable << endl;
  }

  void proc_2()
  {
    SharedVariable = 2;
    cout << SharedVariable << endl;
  }

  SC_CTOR(nondet)
  {
    SC_THREAD(proc_1);
    sensitive << Trig.pos();
    SC_THREAD(proc_2);
    sensitive << Trig.pos();
  }
};

 

In this example, which SC_THREAD will run first is undefined - there is no way of telling which will run first.

For hardware modelling, this is unacceptable. But for software modelling, this might represent a system where a shared variable is used, and where non-determinism is not important - all that is necessary is to guarantee that two processes cannot simultaneously access the variable.

Software engineers use concepts such as mutual exclusion (mutex) or semaphores to cope with such situations.

Events and Notifications

After looking at the background, it is now possible to summarise the operation of the SystemC simulation kernel.

The SystemC simulation kernel supports the concept of delta cycles. A delta cycle consists of an evaluation phase and an update phase. This is typically used for modelling primitive channels that cannot change instantaneously, such as sc_signal. By separating the two phases of evaluation and update, it is possible to guarantee deterministic behaviour (because a primitive channel will not change value until the update phase occurs - it cannot change immediately during the evaluation phase).

However, SystemC can model software, and in that case it is useful to be able to cause a process to run without a delta cycle (i.e. without executing the update phase). This requires events to be notified immediately (immediate notification). Immediate notification may cause non-deterministic behaviour.

Notification of events is achieved by calling the notify() method of class sc_event.

There are three cases to consider for the notify method.

 

  • notify() with no arguments: immediate notification. Processes sensitive to the event will run during the current evaluation phase
  • notify() with a zero time argument: delta notification. Processes sensitive to the event will run during the evaluation phase of the next delta cycle
  • notify() with a non-zero time argument: timed notification. Processes sensitive to the event will run during the evaluation phase at some future simulation time

The notify() method cancels any pending notifications, and carries out various checks on the status of existing notifications.

The behaviour of the simulation kernel can now be described

  1. Initialisation: Execute all processes (except SC_CTHREADs) in an unspecified order.
  2. Evaluation: Select a process that is ready to run and resume its execution. This may cause immediate event notifications to occur, which may result in additional processes being made ready to run in this same phase.
  3. Repeat step 2 until there are no processes ready to run.
  4. Update: Execute all pending calls to update() resulting from calls to request_update() made in step 2.
  5. If there were any delta event notifications made during steps 2 or 4, determine which processes are ready to run due to all those events and go back to step 2.
  6. If there are no timed events, simulation is finished.
  7. Advance the simulation time to the time of the earliest pending timed event notification.
  8. Determine which processes are ready to run due to all the timed events at what is now the current time, and go back to step 2.

 

Note the functions update() and request_update(). The kernel provides these functions specifically for modelling primitive channels such as sc_signal. update() actually runs during the update phase if it was requested by calling request_update() during the evaluation phase.

A primitive channel

So how do you write a primitive channel? It is actually surprisingly straightforward! Firstly, all primitive channels are based on the class sc_prim_channel - you can think of this as the primitive channel equivalent of sc_module for hierarchical channels.

Here is the code of a FIFO channel, which is a drastically simplified version of the "built-in" sc_fifo channel. It is simplified in that it only provides blocking methods, and it is not a template class (it only works on type char).

Here is the interface, fifo_if.h

#include "systemc.h"
class fifo_out_if :  virtual public sc_interface
{
public:
  virtual void write(char) = 0;          // blocking write
  virtual int num_free() const = 0;      // free entries
protected:
  fifo_out_if()
  {
  };
private:
  fifo_out_if (const fifo_out_if&);      // disable copy
  fifo_out_if& operator= (const fifo_out_if&); // disable
};

class fifo_in_if :  virtual public sc_interface
{
public:
  virtual void read(char&) = 0;          // blocking read
  virtual char read() = 0;
  virtual int num_available() const = 0; // available
                                         // entries
protected:
  fifo_in_if()
  {
  };
private:
  fifo_in_if(const fifo_in_if&);            // disable copy
  fifo_in_if& operator= (const fifo_in_if&); // disable =
};

 

Basically, there is a read and write method, both of which are blocking, i.e. they suspend if the FIFO is empty (read) or full (write).

Here is the code for the first part of the channel.

#include "systemc.h"
#include "fifo_if.h"

class fifo
: public sc_prim_channel, public fifo_out_if,
  public fifo_in_if
{
protected:
  int size;                 // size
  char* buf;                // fifo buffer
  int free;                 // free space
  int ri;                   // read index
  int wi;                   // write index
  int num_readable;
  int num_read;
  int num_written;

  sc_event data_read_event;
  sc_event data_written_event;

public:
  // constructor
  explicit fifo(int size_ = 16)
  : sc_prim_channel(sc_gen_unique_name("myfifo"))
  {
    size = size_;
    buf = new char[size];
    reset();
  }

  ~fifo()                   //destructor
  {
    delete [] buf;
  }

 

Notes

  • The channel is derived from sc_prim_channel, not sc_module
  • The constructor automatically generates an internal name so the user does not have to specify one
  • The constructor uses dynamic memory allocation, so there must also be a destructor to delete the claimed memory
  • There are two sc_event objects created. These are used to signal to the blocked read and write processes when either space becomes available (if a write is blocked) or data becomes available (when a read is blocked)

 

The next few functions are used to calculate if space is available and how much is free. The algorithm uses a circular buffer, accessed by the write index (wi) and the read index (ri).

  int num_available() const
  {
    return num_readable - num_read;
  }

  int num_free() const
  {
    return size - num_readable - num_written;
  }

 

Here is the blocking write function. Note that if num_free() returns zero, the function calls wait(data_read_event). This is an example of dynamic sensitivity. The thread from which write is called will be suspended, until the data_read_event is notified.

  void write(char c)        // blocking write
  {
    if (num_free() == 0)
      wait(data_read_event);
    num_written++;
    buf[wi] = c;
    wi = (wi + 1) % size;
    free--;
    request_update();
  }

 

Once the process resumes after data_read_event, it stores the character in the circular buffer, and then calls request_update(). request_update() makes sure that the simulation kernel calls update() during the update phase of the kernel.

Here is the reset function that clears the FIFO.

  void reset()
  {
    free = size;
    ri = 0;
    wi = 0;
  }

 

And here is the read function. Behaviour is similar to the write function, except this time the process blocks if there is no space available (the FIFO is full).

  void read(char& c)        // blocking read
  {
    if (num_available() == 0)
      wait(data_written_event);
    num_read++;
    c = buf[ri];
    ri = (ri + 1) % size;
    free++;
    request_update();
  }

 

For convenience, here is a "shortcut" version of read, so we can use

  char c = portname->read();

 

syntax.

  char read()                // shortcut read function
  {
    char c;
    read(c);
    return c;
  }

 

And finally the update() method itself. This is called during the update phase of the simulation kernel. It checks to see if data was read or written during the evaluation phase, and then calls notify(SC_ZERO_TIME) as appropriate to tell the blocked read() or write() functions that they can proceed.

  void update()
  {
    if (num_read > 0)
      data_read_event.notify(SC_ZERO_TIME);
    if (num_written > 0)
      data_written_event.notify(SC_ZERO_TIME);
    num_readable = size - free;
    num_read = 0;
    num_written = 0;
  }
};

 

The top level of the design looks very similar to the hierarchical channel. Here it is (from main.cpp)

#include "systemc.h"
#include "producer.h"
#include "consumer.h"
#include "fifo.h"

int sc_main(int argc, char* argv[])
{
  sc_clock ClkFast("ClkFast", 1, SC_NS);
  sc_clock ClkSlow("ClkSlow", 500, SC_NS);

  fifo fifo1;

  producer P1("P1");
  P1.out(fifo1);
  P1.Clock(ClkFast);

  consumer C1("C1");
  C1.in(fifo1);
  C1.Clock(ClkSlow);

  sc_start(5000, SC_NS);

  return 0;
}

 

Note how there is no need to give the FIFO primitive channel a name, due to the use of sc_gen_unique_name().

Conclusions

This chapter has shown a glimpse of writing a primitive channel. There are many more details, and you might want to look at the source code for sc_signal or sc_fifo to see more information.

A particular thing to note is the use of dynamic sensitivity - note how the blocked read and write functions are actually overriding the static sensitivity to the clock signal.

Prev