In this post we talk about the SystemC scheduler which forms the core part of the simulation kernel. As part of this post, we will consider the four main stages of the scheduler - evaluate, update, delta notification and time notification - as well as the notify and wait methods.
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.
When we call the sc_start method in our SystemC simulation, we are actually telling our simulation tool to start running its underlying scheduler.
The SystemC scheduler is responsible for running the processes in our design and updating signals accordingly.
Although we can run SystemC simulations without a deep understanding of the scheduler, we can write more effective code if we at least understand the basics of how it functions.
The block diagram below shows the different phases of the SystemC scheduler and how they relate to each other.
The evaluate, update and delta notification phases are collectively known as a delta cycle.
Every time step can consist of several delta cycles, depending on the complexity of our design. However, these delta cycles are not typically visible to us in the simulation output.
Let's look at each of the different phases of the scheduler in more detail.
The initialisation phase is the first stage that the scheduler carries out.
As the name suggests, the simulator will initialize our design into its starting state during this phase.
During this phase, the scheduler creates all of our processes and gets them ready to be run for the first time.
In addition to this, our simulation tool will also assign initial values to any variables which we have created.
This includes any signals which we have declared inside of our modules.
Broadly speaking, we can think of the initialization phase as being roughly equivalent to resetting an IC.
The initialisation phase brings our design into a known state before we start running our simulation.
During the evaluate phase, the simulation kernel executes each of the processes in our design.
Our processes are not all run in parallel, instead the simulator runs our processes one at a time.
The simulator runs each process either until it reaches either the keyword return, executes the last statement in a process or the wait() is called. The simulator either suspends processes when it reaches the wait() statement or kills them when it reaches either the end of the process or a return statement.
After the process has been suspended or killed, the scheduler will begin executing the next process in our design.
The scheduler repeats this until all of the processes in our design have been executed in this manner.
At this point, the scheduler moves onto the update phase.
As we talked about in the post on processes in SystemC, we can use the wait statement to pause execution of a thread.
During the evaluate phase, the simulator executes every process in our design until they are either suspended or killed
As we can see from this, the wait method is actually an important part of the evaluate phase as it determines when a process is suspended.
This means that the wait statement is also often important in determining when the scheduler can move onto the update phase.
Let's take a closer look at the wait method to see the different way we can use it in our SystemC test benches.
As we have already seen in the post on SystemC processes, the most basic way to call the wait method is to directly call it without passing any arguments to it.
In this case, our process will be suspend until there is an event on one of the signals in our sensitivity list.
However, we can also pass arguments to the wait method when we call it. When we do this we can dynamically control which events our process will wait on.
We often refer to this method as dynamic sensitivity. In contrast, when we call the wait method without passing arguments, we call this static sensitivity.
The table below summarizes the different methods we can use to call the wait method with either static or dynamic sensitivity.
Syntax | Function |
---|---|
wait() | Suspend the thread until an event occurs on one of the signals in the sensitivity list (static sensitivity). |
wait(n) | Suspend the thread until <n> events occur on one of the signals in the sensitivity list |
wait(e1) | Suspend the thread until the event e1 is active. |
wait(n, SC_NS) | Suspend the thread for <n> ns. We can also pass a time object to the method to make it wait for a given period. |
wait(n, SC_NS, e1) | Suspend the thread either until event e1 is active or for <n> ns, whichever is the shortest. |
The code snippet below shows how we would use each of these techniques in practise. We can also simulate these examples on EDA playground.
// Wait for a single event
wait()
// Wait for 2 events
wait(2);
// Wait for event e1 (this is generated in event_schedule)
wait(e1);
// Wait for 10ns
wait(10, SC_NS);
// Wait for 20ns (using time object)
sc_time t_20ns(20, SC_NS);
wait(t_20ns);
// Wait for event e2 or 50ns
wait(50, SC_NS, e2);
In addition to this, we can also combine several events in a single wait statement. In order to this, we use the C++ bitwise "or" and "and" operators to combine events.
The code snippet below shows the general syntax we use to do this in SystemC.
// Wait for event e1 or e2
wait(e1 | e2);
// Wait for event e1 and e2
wait(e1 & e2);
Once the evaluate phase has finished executing, the scheduler then moves onto the update phase.
In order to properly model the behavior of digital circuits, the SystemC simulator creates update-requests during the evaluate phase.
The simulator creates these update-requests whenever it encounters a signal assignment whilst it is running the evaluate phase.
At this point, the simulator doesn't assign a new value to our signal. Instead, our simulator executes the rest of the evaluate phase using the original value of the signal.
After completing the evaluate phase, the scheduler will then move on to the update phase where it will implement all of the update-requests.
As a result of this, all of the signals which are assigned values are updated to this new value during the update phase rather than during the evaluate phase.
After all of the update-requests have been implemented, the update phase is complete and the simulator then moves onto the delta notification state.
In order to understand how the update-requests work, let's look at a simple example.
For this example, let's look at the simple twisted ring counter circuit as shown below.
The code snippet below shows how we would model this circuit in SystemC.
dff1.write( ~dff2.read() );
dff2.write( dff1.read() );
First, let's look at the behavior if our signals were updated immediately after assignment. This is the behavior that we would expect in a traditional programming language.
Let's assume that the output of both flip flops is 0 when a clock edge occurs. As a result of line 1 in the code above, the output of DFF1 changes to 1. We can then see that the next line of code would set the output of DFF2 to 1.
This is clearly not the intended behavior of the circuit we are modelling. Instead we expect DFF2 to remain at 0 and DFF1 to change to 1.
Now let's consider how the model behaves as a result of using update-requests.
The simulator firstly executes the statement to update DFF1 and creates an update-request for this signal. The scheduler creates this update-request in such a way that the output of DFF1 will be updated to 1b.
The simulator then runs the second line of code, using the original value of the DFF1 flip flop and creates an update-request for the output of DFF2. As DFF1 is still set to 0b at this point, the scheduler creates an update-request that will update the output of DFF2 to 0b.
As there are only two statements in this design, the evaluate phase is now complete and the scheduler moves onto the update phase.
During the update phase, the simulator acts implements the 2 update-requests that it created during the evaluate phase. After the update-request are implemented, DFF1 has the value of 1b and DFF2 has the value of 0b.
As we will discuss in more depth in the next post, we use the notify method in order to trigger events in SystemC.
During the update phase, we use the notify method to tell the simulator that a signal has changed value.
This means that the notify method is important in controlling how the signals in our design are updated.
In most instances, we will not actually need to directly call the notify method in our designs.
The reason for this is that many of the data types and signals we use in SystemC are implemented as classes. In most cases, these classes automatically call the notify method for us when it is needed..
For example, when we use sc_fifo, sc_mutex or sc_signal, all of these classes implement the notify method for us. As a result of this, we don't need to manually call the notify method when we want to update any variables which use these types.
However, if we want to write our own hierarchical channels in SystemC then we may need to manually call the notify method in our code.
We can actually use the notify method to create three different types of notification. Our scheduler will process each of these different notifications during different phases.
The table below shows summarizes the three different types of notification we can create using the notify method.
Syntax | Function |
---|---|
notify() | We use the notify method without any arguments to create an immediate notification. These are implemented in immediately. |
notify(SC_ZERO_TIME) | When we use the notify method together with the SC_ZERO_TIME constant we create a delta notification. These are implemented during the delta notification phase. |
notify(n, SC_NS) | We create a time notification by passing a time argument to the notify method. We can also pass a time object to create a time notification. These are implemented during the time notification phase. |
The code snippet below shows how we would use each of these techniques in practise. We can also simulate these examples on EDA playground.
// Create an immediate notification
e1.notfy();
// Create a delta notification
e1.notify(SC_ZERO_TIME);
// Create a time notification (in this case 10ns)
e1.notify(10, SC_NS);
Once the update phase has finished assigning new values to the signals in our design, the scheduler then moves onto the delta notification phase.
As we discussed in the section on the update phase, we can use the notify() method to create delta notifications.
The delta notification phase is then responsible for processing these notifications.
To do this, our simulator works out which processes are affected by the delta notifications.
For example, let's suppose that we were modelling a simple circuit such as the one shown below.
Now let's say that during the evaluate phase, the output of the and gate changes value. As a result of this change, the simulator raises a delta notification during the update phase.
During the delta notification phase, the simulator determines which processes are affected by this change of value. In this case, the delta notification affects the process that models the flip flop
Once the simulator has determined which processes are affected by the delta notification, it then marks these processes as runnable.
The scheduler returns to the evaluate phase after it has finished processing all of the delta notifications in this way.
The simulator will then execute all of the process which it has marked as runnable again.
The SystemC scheduler will move straight to the time notification phases is there are no delta notifications that need to be processed.
The scheduler moves on to the time notification phase after executing all of the delta cycles for a given time step.
As we discussed in the section on the update phase, we can use the notify() method in order to create time notifications.
It is the responsibility of the time notification phase to process these time notifications.
In order to do this, the scheduler analyzes all of the time notifications that were raised during the time step.
The scheduler then organizes these notifications based on the requested duration of the time out.
For example, if we had 3 time notificaitons of 1ms , 5ns and 10 ns then the scheduler would organize them in the order of the shortest to the longest. In this case, our scheduler would organize them in the order of 5ns, 10ns and 1ms.
Once the scheduler has finished organizing the time notifications in this way, it will then advanced the simulation time to the next earliest time step.
For example, if we had 3 time notifications of 5ns, 10ns and 1ms then the scheduler would advance the simulation time by 5ns.
After the scheduler has finished processing all of our time notifications in this way, the scheduler returns to the evaluate phase. The scheduler will then run through the full cycle again for the next time step.