2. SystemC Tutorial - Simulation Time & Clocks
Why this matters
Rewritten 2026-05-23 with deeper first-principles material.
There is a peculiar trap that catches almost every engineer who comes to SystemC from a software background. They write a short program, call sc_start(10, SC_NS), see no output, and conclude the framework is broken. Then they discover that simulation time is not real time, that the kernel may return long before 10 ns has elapsed if nothing is scheduled, and that printing sc_time_stamp() shows times in units they did not configure. The trap is not the framework. The trap is the mental model — SystemC does not run in real time, does not "tick along at a constant rate," and has no built-in notion of "advance the clock by 1 ns and see what changes." It runs an event-driven kernel that maintains a 64-bit integer counter of simulation time, advances that counter only when there is nothing left to do at the current time, and otherwise sits idle waiting for sc_start to release it. Every SystemC model — every virtual platform validated at ARM, every cycle-accurate simulator at Intel, every memory-controller verification environment at SiFive — runs on top of that one mechanism. If you do not internalize how simulation time advances, the difference between a clocked process and a delta-cycle-only process will feel arbitrary, and the time-stamps in your output will read like noise.
This post fixes that. You will leave able to read any sc_time_stamp() printout and explain why a value is what it is; able to choose between the three forms of sc_start; able to instantiate and bind an sc_clock; and — in the Advanced section — able to reason about time resolution at the integer level, which is the foundation for the cycle-accurate work that begins in Part 3.
Prerequisites
- Part 1: Modules, Ports & Signals
- SystemC 2.3.x installed (P1, P2 installer posts)
- C++17 compiler
Mental model (first principles)
Simulation time inside the SystemC kernel is not a double. It is a 64-bit unsigned integer counter, denominated in units of the time resolution. The default time resolution is 1 picosecond, which means the internal counter has units of 1 ps. If you write sc_time(1, SC_NS), the constructor multiplies 1 by the conversion factor between nanoseconds and the time resolution (1000) and stores 1000 internally. If you write sc_time(0.5, SC_NS), it stores 500. If you write sc_time(0.0001, SC_NS) — that is, 0.1 ps — it stores 0 after rounding, and you have silently lost the value.
sc_time as the kernel sees it
─────────────────────────────
sc_time(1, SC_NS) → internal counter = 1000 (1 ns / 1 ps)
sc_time(0.5, SC_NS) → internal counter = 500
sc_time(2, SC_PS) → internal counter = 2
sc_time(1, SC_FS) → internal counter = 0 (rounded)
SC_ZERO_TIME → internal counter = 0
Default time resolution: 1 ps.
Settable once via sc_set_time_resolution(value, unit),
before any sc_time is constructed.
This is the single most important fact about SystemC time: once converted to the internal counter, all arithmetic on time is exact integer arithmetic. No comparison ever fails due to floating-point round-off. No event ever fires "almost" at the right time. The trade-off is a fixed resolution that you choose once at program start.
The kernel maintains one global current-time variable. It starts at SC_ZERO_TIME when sc_main enters. It changes only inside sc_start(...), and only at the time-advance step of the scheduler (§4.2.1.4): when the runnable set of processes is empty AND no timed notifications are scheduled for the current time, the kernel finds the time of the earliest pending timed notification, jumps the current-time variable to that time, processes the events scheduled at that time, and goes back to evaluating processes. The kernel never advances time "smoothly" in increments of 1 ps; it jumps directly to the next interesting time.
Simulation lifecycle (kernel's view)
────────────────────────────────────
sc_main entered (time = 0)
│
▼
┌─────────────────────────┐
│ Elaboration │ Modules constructed, ports bound, processes
│ (between sc_main entry │ registered. Time stays at 0.
│ and first sc_start call) │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Initialization phase │ Every method process runs once unless it
│ (start of first sc_start)│ called dont_initialize(). Threads start
│ │ at their entry point and run until first
│ │ wait().
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Scheduling loop │ Per-delta: evaluate runnable, commit
│ │ pending writes, notify on changes.
│ │ When runnable set is empty: advance time
│ │ to next pending timed event, repeat.
└─────────────────────────┘
│
│ When (a) sc_start's time limit reached, or
│ (b) sc_stop() called, or
│ (c) runnable set empty AND no future events
▼
Control returns to sc_main, time is fixed at current value.
sc_main can inspect, write more stimulus, and call sc_start again.
That last point is what makes incremental testbench stimulus possible: sc_main can call sc_start repeatedly, with the simulation state persisting between calls. Each sc_start resumes from exactly where the previous one stopped.
Beginner: First Principles
Here is the smallest SystemC program that exercises time and a clock:
// file: clocked_hello.cpp
#include <systemc.h>
SC_MODULE(ticker) {
sc_in<bool> clk;
int count = 0;
void on_edge() {
std::cout << "[" << sc_time_stamp() << "] tick " << count++ << "\n";
}
SC_CTOR(ticker) {
SC_METHOD(on_edge);
sensitive << clk.pos();
dont_initialize();
}
};
int sc_main(int, char**) {
sc_clock clk("clk", 10, SC_NS); // 10 ns period, default phase
ticker t("t");
t.clk(clk);
sc_start(45, SC_NS);
std::cout << "Simulation finished at " << sc_time_stamp() << "\n";
return 0;
}
Build and run:
g++ -std=c++17 clocked_hello.cpp -I$SYSTEMC_HOME/include \
-L$SYSTEMC_HOME/lib-linux64 -lsystemc -o clocked_hello
./clocked_hello
Expected output:
[10 ns] tick 0
[20 ns] tick 1
[30 ns] tick 2
[40 ns] tick 3
Simulation finished at 45 ns
Walk through what just happened in the order the kernel cares about.
sc_clock clk("clk", 10, SC_NS); constructs a clock channel. The default constructor has duty_cycle = 0.5, start_time = 0, and — critically — posedge_first = true (this is the LRM default that catches almost every reader). With posedge_first = true, the clock's initial value is true: at simulation time 0, before any kernel scheduling has happened, the clock is already high. The first scheduled toggle is the falling edge at start_time + (1 - duty_cycle) * period = 0 + 5 ns = 5 ns. The next toggle is the rising edge at 10 ns; then falling at 15 ns, rising at 20 ns, and so on.
So the rising edges happen at 10, 20, 30, 40 ns — matching the output. The first rising-edge transition of the simulation is at 10 ns, not at time 0: at time 0 the clock is already high, but a "transition" requires a value change, and the value does not change from high until the 5 ns falling edge happens.
This is the most common surprise when reading sc_clock output for the first time. The simple mental model: a default clock's positive-edge transitions happen at period, 2 * period, 3 * period, …. If you actually need a rising-edge event at time 0 — most code does not — you would have to drive an sc_signal<bool> manually from your own process, since sc_clock cannot schedule a value-change at start_time itself.
Now the ticker module. It declares a sc_in<bool> port for the clock. Its constructor registers on_edge as a method process sensitive to clk.pos() — the positive-edge event of the bound clock. The dont_initialize() call says "do not run me at simulation start; only run me when my sensitivity actually triggers." Without dont_initialize(), the method would fire once at time 0 with count = 0 before any clock edge, polluting the output with a [0 s] tick 0 line that has nothing to do with the clock.
sc_start(45, SC_NS) runs the simulation. The kernel processes the initialization phase (no methods to run because of dont_initialize), then enters the scheduling loop. The earliest pending event is the clock toggle at 5 ns — but that is a low-going edge, and the ticker is sensitive only to clk.pos(), so nothing happens. The next event is the rising edge at 10 ns; on_edge runs, prints tick 0. Then the same pattern at 20, 30, 40 ns. The next would be at 50 ns but we asked for only 45 ns of simulation, so sc_start returns at 45 ns. The Simulation finished at 45 ns line confirms it.
A common confusion at this point: "Did the simulation 'actually' stop at 45 ns, or at 40 ns when the last event fired?" The kernel stopped processing new events at 40 ns (the last one before the 45 ns limit), but the current-time variable was advanced to 45 ns when sc_start exited. The kernel honours the time limit even if nothing is scheduled exactly there.
Intermediate: How It Really Works
Three forms of sc_start
The Beginner section used sc_start(45, SC_NS). There are three meaningfully different forms.
sc_start(); // (1) "until done"
sc_start(sc_time(100, SC_NS)); // (2) "up to 100 ns from now"
sc_start(sc_time(100, SC_NS),
SC_RUN_TO_TIME); // (3) "exactly to 100 ns"
Form (1) runs until either sc_stop() is called by some process, or until the runnable set is empty and there are no more timed events scheduled. If your simulation has an sc_clock running, form (1) will run forever — sc_clock always has another tick scheduled. Use form (1) only when you have a deliberate termination mechanism (a testbench sc_stop() call at the end of a finite sequence, for example).
Form (2) is the most common. It runs the kernel for at most 100 ns of additional simulation time. If the runnable set drains and no events are pending earlier than current_time + 100 ns, the kernel returns early, with the current time set to wherever the simulation actually stopped — not 100 ns. This is usually what you want for incremental stimulus: "advance the simulation for at most this long, then return so I can do something else."
Form (3) — the SC_RUN_TO_TIME starvation policy — overrides the early-return behaviour. The kernel guarantees that on return, sc_time_stamp() is current_time + 100 ns, even if nothing was scheduled exactly there. It does this by advancing the time variable to the requested point at the end. Use this when your test framework wants every sc_start call to advance time by an exact amount regardless of what was scheduled.
Side-by-side example:
// file: sc_start_forms.cpp
#include <systemc.h>
int sc_main(int, char**) {
std::cout << "Start: " << sc_time_stamp() << "\n";
sc_start(sc_time(10, SC_NS)); // form (2)
std::cout << "After sc_start(10 ns): " << sc_time_stamp() << "\n";
sc_start(sc_time(10, SC_NS), SC_RUN_TO_TIME); // form (3)
std::cout << "After sc_start(10 ns, SC_RUN_TO_TIME): "
<< sc_time_stamp() << "\n";
return 0;
}
Expected output:
Start: 0 s
After sc_start(10 ns): 0 s
After sc_start(10 ns, SC_RUN_TO_TIME): 10 ns
With no clock and no processes, form (2) returns immediately because there is nothing scheduled. Form (3) advances time to the requested 10 ns regardless. Same code, different answer — depending on the starvation policy.
Clocked counter — the canonical pattern
A synchronous counter with synchronous reset:
// file: counter.cpp
#include <systemc.h>
SC_MODULE(counter) {
sc_in<bool> clk;
sc_in<bool> rst_n;
sc_out<sc_uint<8>> count;
sc_uint<8> value;
void tick() {
if (!rst_n.read()) {
value = 0;
} else {
value = value + 1;
}
count.write(value);
}
SC_CTOR(counter) : value(0) {
SC_METHOD(tick);
sensitive << clk.pos();
dont_initialize();
}
};
int sc_main(int, char**) {
sc_clock clk("clk", 10, SC_NS);
sc_signal<bool> rst_n;
sc_signal<sc_uint<8>> q;
counter dut("dut");
dut.clk(clk);
dut.rst_n(rst_n);
dut.count(q);
rst_n.write(false);
sc_start(25, SC_NS);
rst_n.write(true);
sc_start(60, SC_NS);
std::cout << "Final count = " << q.read()
<< " at " << sc_time_stamp() << "\n";
return 0;
}
Expected output:
Final count = 6 at 85 ns
The trace: clock rises at 10, 20 (both with rst_n = false, so the counter is held at 0 — but the assignment still runs and writes 0). At 30 ns, sc_main resumes between sc_start calls and sets rst_n to true. The next positive edge is at 30 ns... wait, actually the next positive edge is at 30 ns (already in the simulation), but sc_main between sc_start calls runs at 25 ns (where the first sc_start stopped). Let me re-trace carefully.
After sc_start(25, SC_NS): simulation is at 25 ns. Positive edges fired at 10, 20 ns. Counter wrote 0 twice (because rst_n = false). q = 0.
rst_n.write(true) queues a pending write. The pending write commits in the initialization update phase of the next sc_start call — which means at the start of the next sc_start, before any process runs at the current time.
After sc_start(60, SC_NS): simulation runs from 25 to 85 ns. Positive edges fire at 30, 40, 50, 60, 70, 80 ns — that's 6 ticks, with rst_n = true throughout. Counter increments to 1, 2, 3, 4, 5, 6. Final q = 6 at time 85 ns. ✓
sc_time arithmetic
sc_time supports all the arithmetic you would expect: +, -, * (by a scalar), /, %, all the comparisons, and explicit conversion. Because everything is integer underneath, these are exact:
sc_time a(10, SC_NS);
sc_time b(3, SC_NS);
sc_time c = a + b; // 13 ns
sc_time d = a - b; // 7 ns
sc_time e = a * 2; // 20 ns
sc_time f = a / 3; // 3333 ps (= 3.333 ns, integer divide on 10000 ps)
sc_time g = a % b; // 1 ns (10 mod 3)
bool eq = (a == b); // false
bool gt = (a > b); // true
The to_double(SC_NS) and to_seconds() accessors return the time as a double in the specified unit, with the inevitable precision loss of floating-point. Use these only for output formatting; never store them.
Time resolution
The default 1 ps resolution is right for most digital simulations. If you are modelling RF or analog mixed-signal, you may want femtosecond resolution; if you are modelling system-level transactions in microseconds and want a slightly faster simulation, nanosecond resolution may be enough. Set it exactly once, before any sc_time is constructed:
// file: resolution.cpp
#include <systemc.h>
int sc_main(int, char**) {
sc_set_time_resolution(1, SC_FS); // 1 femtosecond resolution
sc_time a(1.2345, SC_PS); // = 1234.5 fs → stored as 1235 fs
std::cout << "a = " << a << "\n"; // prints "1235 fs"
sc_time b(1, SC_NS);
sc_time c(999, SC_PS);
std::cout << "b - c = " << (b - c) << "\n"; // = 1000 ps - 999 ps = 1 ps
std::cout << "(b > c) = " << (b > c) << "\n"; // 1
return 0;
}
Three rules apply: (1) you can only call sc_set_time_resolution before any sc_time is constructed; calling it later raises a kernel error; (2) you can set a coarser resolution to speed up simulation but you lose precision permanently; (3) all sc_time values you construct after setting the resolution are stored at that resolution.
Multiple clocks
A SystemC model can have any number of clocks at any rates. Each is just another sc_clock instance with its own period; the kernel does no special handling. This is what makes multi-clock-domain modelling natural in SystemC:
// file: two_clocks.cpp
#include <systemc.h>
SC_MODULE(printer) {
sc_in<bool> clk;
const char* tag;
void on_edge() {
std::cout << "[" << sc_time_stamp() << "] " << tag << " tick\n";
}
printer(sc_module_name n, const char* t) : sc_module(n), tag(t) {
SC_HAS_PROCESS(printer);
SC_METHOD(on_edge);
sensitive << clk.pos();
dont_initialize();
}
};
int sc_main(int, char**) {
sc_clock clk_a("clk_a", 10, SC_NS); // 100 MHz
sc_clock clk_b("clk_b", 14, SC_NS); // ~71.4 MHz, deliberately non-integer ratio
printer a("a", "A");
printer b("b", "B");
a.clk(clk_a);
b.clk(clk_b);
sc_start(50, SC_NS);
return 0;
}
Expected output:
[10 ns] A tick
[14 ns] B tick
[20 ns] A tick
[28 ns] B tick
[30 ns] A tick
[40 ns] A tick
[42 ns] B tick
[50 ns] A tick
(Note: simulation stops at 50 ns; the A edge at 50 ns fires, but B's next edge would be at 56 ns and is not reached.) Two observations. First, the two clocks have a non-integer period ratio (10:14) and the kernel handles it cleanly — no rounding, no skew accumulation, because everything is integer-counted in time-resolution units. Second, the events at distinct times serialize naturally; events at the same time would be processed in the same delta cycle in implementation-defined order.
Decision table: when to use which form of sc_start
| You want… | Use |
|---|---|
Run until your stimulus calls sc_stop() |
sc_start() (no args) |
| Run for at most N units, return early if idle | sc_start(sc_time(N, unit)) |
| Run to exactly N units regardless of idleness | sc_start(sc_time(N, unit), SC_RUN_TO_TIME) |
| Run just the initial delta and stop | sc_start(SC_ZERO_TIME) |
| Run continuously in an interactive REPL | A loop calling sc_start(small_time) per user command |
Performance note
The kernel's time-advance is O(log N) in the number of pending timed notifications (using a heap / priority queue). For models with thousands of pending events at any moment — a memory model with thousands of in-flight transactions, for example — this is the right asymptotic behaviour but the constant factor matters. Two practices help. First, prefer one well-chosen sc_event over many short-lived ones. Second, avoid registering huge numbers of overlapping timed notifications when one combined event would do.
Time-driven vs event-driven processes — choosing the right pattern
Two ways to write a process that does something every N time units:
// Pattern A: event-driven (clock-sensitive)
SC_MODULE(ticker_a) {
sc_in<bool> clk;
void on_edge() { do_work(); }
SC_CTOR(ticker_a) {
SC_METHOD(on_edge);
sensitive << clk.pos();
dont_initialize();
}
void do_work() {
std::cout << "[" << sc_time_stamp() << "] worked\n";
}
};
// Pattern B: time-driven (self-scheduling thread)
SC_MODULE(ticker_b) {
void loop() {
while (true) {
do_work();
wait(10, SC_NS);
}
}
SC_CTOR(ticker_b) { SC_THREAD(loop); }
void do_work() {
std::cout << "[" << sc_time_stamp() << "] worked\n";
}
};
Both produce the same observable timing: do_work() runs every 10 ns. But the two patterns differ in three important ways.
First, kernel overhead. Pattern A schedules one event per clock edge — the kernel does sensitivity lookup, runnable-set update, and process dispatch. Pattern B schedules one timed notification per loop iteration. The two have similar overhead in practice; both are well-optimized in the Accellera kernel.
Second, flexibility. Pattern A is rigid: the process is sensitive to a clock; if the clock stops, the process stops. Pattern B is more flexible: the thread controls its own scheduling, can vary the wait time, can react to events between waits, can call multiple distinct wait types in sequence.
Third, idiom match. Pattern A maps directly to RTL — "every rising clock edge, do this." It is the right choice when you are modelling synchronous hardware. Pattern B maps to algorithmic/sequential descriptions — "do A, wait, do B, wait, …" — and is the right choice for stimulus generators, sequencers, and any process whose logic is intrinsically temporal rather than reactive.
A useful rule of thumb: if the process represents a piece of hardware that exists in the circuit (a register, an FSM, a counter), use Pattern A with a clock. If the process represents behaviour that drives or observes the circuit (a testbench scenario, a stimulus generator, a checker), use Pattern B.
Clock-domain crossing — a practical multi-clock example
Two clocks, two registers, and a careful look at what happens when data crosses between them. This is not a treatment of metastability or CDC verification — those belong in a verification-specific post later in the series. This is just the timing illustration.
// file: cdc_illustration.cpp
#include <systemc.h>
SC_MODULE(reg) {
sc_in<bool> clk;
sc_in<sc_uint<8>> d;
sc_out<sc_uint<8>> q;
sc_uint<8> value;
void tick() {
value = d.read();
q.write(value);
}
SC_CTOR(reg) : value(0) {
SC_METHOD(tick);
sensitive << clk.pos();
dont_initialize();
}
};
int sc_main(int, char**) {
sc_clock clk_fast("clk_fast", 10, SC_NS); // 100 MHz
sc_clock clk_slow("clk_slow", 25, SC_NS); // 40 MHz, deliberately not a divisor
sc_signal<sc_uint<8>> source;
sc_signal<sc_uint<8>> stage1;
sc_signal<sc_uint<8>> stage2;
reg r1("r1"); // captures `source` on fast clock
reg r2("r2"); // captures `stage1` on slow clock
r1.clk(clk_fast); r1.d(source); r1.q(stage1);
r2.clk(clk_slow); r2.d(stage1); r2.q(stage2);
// Drive a sequence of values onto source from sc_main.
source.write(1);
sc_start(20, SC_NS); // fast captures 1 at 10 ns, then again at 20 ns
source.write(2);
sc_start(20, SC_NS); // 30, 40 ns fast edges; 25 ns slow edge
source.write(3);
sc_start(30, SC_NS);
std::cout << "stage2 final = " << stage2.read()
<< " at " << sc_time_stamp() << "\n";
return 0;
}
Trace the kernel's scheduling for the first 50 ns:
- 10 ns: fast clk posedge →
r1::tickreadssource = 1, writesstage1 = 1. - 20 ns: fast clk posedge →
r1::tickreadssource = 1again (nosc_mainchange yet), writesstage1 = 1. - 20 ns end-of-sc_start:
sc_mainresumes, writessource = 2. Pending. - 25 ns: slow clk posedge →
r2::tickreadsstage1 = 1, writesstage2 = 1. The slow capture sees the value that was clocked intostage1by the fast clock at 20 ns. - 30 ns: fast clk posedge →
r1::tickreadssource = 2, writesstage1 = 2. - 40 ns: fast clk posedge →
r1::tickreadssource = 2, writesstage1 = 2. - 40 ns end-of-sc_start:
sc_mainresumes, writessource = 3. - 50 ns: slow clk posedge →
r2::tickreadsstage1 = 2, writesstage2 = 2.
So stage2 lags source by varying amounts depending on the phase relationship — exactly what you would expect from a real CDC scenario in hardware. The kernel does no metastability modeling; it gives you the deterministic worst-case result of each capture. CDC verification at the model level is a separate concern entirely.
Driving stimulus with an SC_THREAD
Manual driving from sc_main is the easiest way to start, but it does not scale. A dedicated stimulus thread is closer to how a real testbench is organized:
// file: stimulus_thread.cpp
#include <systemc.h>
SC_MODULE(driver) {
sc_out<sc_uint<8>> data;
sc_in<bool> clk;
void run() {
std::cout << "[" << sc_time_stamp() << "] driver starts\n";
for (sc_uint<8> v = 1; v <= 5; v++) {
wait(clk.posedge_event());
data.write(v);
std::cout << "[" << sc_time_stamp() << "] drove " << v << "\n";
}
wait(clk.posedge_event());
std::cout << "[" << sc_time_stamp() << "] driver done\n";
sc_stop();
}
SC_CTOR(driver) { SC_THREAD(run); }
};
SC_MODULE(monitor) {
sc_in<sc_uint<8>> data;
sc_in<bool> clk;
void on_edge() {
std::cout << "[" << sc_time_stamp() << "] monitor sees "
<< data.read() << "\n";
}
SC_CTOR(monitor) {
SC_METHOD(on_edge);
sensitive << clk.pos();
dont_initialize();
}
};
int sc_main(int, char**) {
sc_clock clk("clk", 10, SC_NS);
sc_signal<sc_uint<8>> bus;
driver d("d"); d.data(bus); d.clk(clk);
monitor m("m"); m.data(bus); m.clk(clk);
sc_start(); // run until driver's sc_stop()
return 0;
}
Two things to notice. The driver uses SC_THREAD and wait(clk.posedge_event()) — dynamic sensitivity. The monitor uses SC_METHOD with sensitive << clk.pos() — static sensitivity. Both observe the same clock; both fire at the same times. The choice is about the internal logic of the process, not about the clock: the driver's logic is sequential (drive value, wait, drive next, wait), so SC_THREAD fits naturally; the monitor's logic is reactive ("each cycle, report what you see"), so SC_METHOD fits.
Advanced: Edge Cases & LRM Corners
Corner 1: Time resolution locks at first sc_time construction
sc_set_time_resolution must be called before any sc_time object is constructed. The kernel enforces this:
sc_time a(1, SC_NS); // first sc_time -> resolution locks
sc_set_time_resolution(1, SC_FS); // ERROR: resolution already locked
This is implemented by tracking a time_resolution_fixed flag that flips to true the first time a non-default time-resolution-dependent operation happens. Constructing sc_time(1, SC_NS), calling sc_time_stamp(), or starting the simulation — all of these flip the flag. Some kernels are stricter than others about exactly which operations lock it; treat the rule as "set it on the first line of sc_main, before anything else."
A subtler version of this corner: sc_time static initializers (like const sc_time MY_PERIOD(10, SC_NS); at file scope) construct before sc_main even starts, locking the resolution before you have a chance to set it. The fix is to declare such constants as inline const sc_time& MY_PERIOD() { static const sc_time t(10, SC_NS); return t; } — function-local statics that construct on first call.
Corner 2: sc_time print formatting
The stream insertion operator operator<< for sc_time chooses a "natural" unit for printing — it scans down from seconds and picks the largest unit that gives an integer result. So sc_time(1500, SC_PS) prints as "1500 ps", sc_time(1, SC_NS) prints as "1 ns", but sc_time(2500, SC_PS) prints as "2500 ps" even though 2.5 ns is more readable, because 2500 ps is exactly representable as picoseconds but 2.5 ns is not exactly representable as nanoseconds (it is, in picoseconds, but the printer chooses based on integer-ness in the printed unit). For controlled output, format manually with to_double(SC_NS).
Corner 3: Negative times
sc_time is unsigned. Constructing sc_time(-1, SC_NS) underflows the unsigned 64-bit counter and gives a huge positive time value — typically interpreted by the kernel as "very far in the future." This is almost always a bug. Subtracting two sc_times where the second is larger underflows similarly. Either guard with explicit comparisons before subtracting, or use std::max(a, b) - std::min(a, b) style if you need an absolute difference.
Corner 4: The posedge_first default is true — a recurring source of confusion
The sc_clock constructor signature, with LRM defaults:
sc_clock(const char* name,
double period_v = 1.0,
sc_time_unit period_tu = SC_NS,
double duty_cycle = 0.5,
double start_time_v = 0.0,
sc_time_unit start_time_tu = SC_NS,
bool posedge_first = true); // NOTE: true is the LRM default
The six knobs:
duty_cycle = 0.5(default) means equal high and low durations.start_time = 0(default) means the clock's initial value is fixed at time 0; the first transition happens atstart_time + (1 - duty) * periodforposedge_first = true, orstart_time + duty * periodforposedge_first = false.posedge_first = true(default) means the clock starts high. Counterintuitively, this means the first rising-edge transition happens not at time 0 but atstart_time + period(because the value is already high at time 0, falls atstart_time + (1-duty)*period, then rises atstart_time + period).
Per LRM §5.12, the alternative sc_clock("clk", period_t, duty, start_time_t, posedge_first) form takes sc_time objects directly. Use it when you want type-safety on the periods.
A clock with a phase offset for multi-clock-domain models: sc_clock("clk_b", sc_time(10, SC_NS), 0.5, sc_time(3, SC_NS), true) — 10 ns period, 50% duty, phase-offset by 3 ns relative to a clk_a with no offset. The two clocks' rising-edge transitions are always 3 ns apart.
A more comprehensive table of the four common cases:
| Constructor call | Initial value at t=0 | First transition | First rising-edge time |
|---|---|---|---|
sc_clock("clk", 10, SC_NS) (defaults) |
true |
falling at 5 ns | 10 ns |
sc_clock("clk", 10, SC_NS, 0.5, 0, SC_NS, false) |
false |
rising at 5 ns | 5 ns |
sc_clock("clk", 10, SC_NS, 0.5, 2, SC_NS, true) |
true |
falling at 7 ns | 12 ns |
sc_clock("clk", 10, SC_NS, 0.5, 0, SC_NS, true) (explicit) |
true |
falling at 5 ns | 10 ns |
Corner 5: posedge_event() vs value_changed_event() vs default_event()
Both sc_in<bool> and sc_clock (which inherits from sc_signal<bool>) expose three event accessors:
default_event()— the event used when you writesensitive << port(i.e., the same asvalue_changed_event()).value_changed_event()— fires on any value change, both rising and falling edges.posedge_event()/negedge_event()— fires only on rising / falling transitions.
The shorthand clk.pos() is a method on the sc_clock that returns a reference to its posedge_event(). sensitive << clk.pos() is identical to sensitive << clk.posedge_event(). Use whichever reads more naturally.
The decision is mechanical:
| Sensitivity | Triggers on |
|---|---|
sensitive << sig |
Any change in sig (rising OR falling) |
sensitive << sig.pos() |
Only rising edges of sig |
sensitive << sig.neg() |
Only falling edges of sig |
Inside SC_THREAD: wait() |
The static sensitivity list above |
Inside SC_THREAD: wait(sig.posedge_event()) |
One-shot rising edge |
Inside SC_THREAD: wait(sc_time(10, SC_NS)) |
Timed delay |
Mixing the static and dynamic forms is allowed and common. Static sensitivity is for "this process always cares about these events." Dynamic wait(...) is for "this thread is going to suspend for a specific reason right now."
Corner 6: sc_stop() semantics
sc_stop() is the "polite" way to terminate a simulation from inside a process. It does not immediately stop the kernel — it tells the kernel to stop after the current delta cycle completes. So if you call sc_stop() and then call wait(10, SC_NS) in the same SC_THREAD, the wait is skipped because sc_stop has already set the termination flag and the kernel will return from sc_start at the end of the current delta.
There is also sc_set_stop_mode(SC_STOP_FINISH_DELTA) (the default) vs sc_set_stop_mode(SC_STOP_IMMEDIATE). The former lets the current delta finish (commits pending writes, fires post-update notifications) before exiting; the latter cuts off in the middle of the evaluate phase. Use the default unless you have a specific reason to want immediate termination — SC_STOP_IMMEDIATE can leave channels in inconsistent states.
Corner 7: sc_clock derived class vs. building your own clocked signal
sc_clock is implemented as a derived class of sc_signal<bool> with an internal SC_METHOD that toggles the held value at the right times. Per IEEE 1666-2011 §5.12, sc_clock IS-A sc_signal<bool>, which is why an sc_in<bool> can bind to it directly: a clock is just a bool signal that happens to toggle on its own. You can verify this with static_assert(std::is_base_of_v<sc_signal<bool>, sc_clock>).
This has a useful implication. If you need a "clock" with non-standard behavior — gated, glitching, frequency-modulated — you do not have to subclass sc_clock. You can build an sc_signal<bool> and drive it from your own SC_THREAD or SC_METHOD:
// file: gated_clock.cpp (sketch)
#include <systemc.h>
SC_MODULE(gated_clock_gen) {
sc_out<bool> clk;
sc_in<bool> enable;
bool state = false;
void toggle() {
if (enable.read()) {
state = !state;
clk.write(state);
next_trigger(5, SC_NS); // schedule self for half-period later
} else {
// Hold clk low when disabled; resume when enable rises.
clk.write(false);
state = false;
next_trigger(enable.value_changed_event());
}
}
SC_CTOR(gated_clock_gen) {
SC_METHOD(toggle);
// No static sensitivity — uses dynamic next_trigger only.
}
};
The next_trigger(...) API is the dynamic-sensitivity equivalent for SC_METHOD (the way wait(...) is for SC_THREAD). It changes the trigger condition for the next invocation of this specific method only. Subsequent invocations revert to the static sensitivity list unless another next_trigger is called. Using it, you can build clocks with arbitrary behaviour while still presenting the canonical sc_in<bool> port interface to consumers.
Corner 8: Time as a 64-bit integer — overflow horizons
The internal time counter is a 64-bit unsigned integer in time-resolution units. At the default 1 ps resolution, the maximum representable simulation time is 2^64 - 1 picoseconds, which is roughly 213 days. That is far beyond any practical simulation. At 1 fs resolution it is roughly 5 hours, still beyond typical simulation lengths. At 1 ns resolution it is roughly 585 years.
So overflow is not a real concern in practice — but it is a real concern if you naively scale to extreme resolutions and run simulations representing real-world-time multi-day workloads. If you ever need to model a system over weeks of real time, do the math: at default 1 ps resolution, 1 week ≈ 6.05 × 10^17 ps, well within 64-bit range. Femtoseconds × 1 week would be 6.05 × 10^20, which overflows. The fix is to use a coarser time resolution; this is the legitimate use case for sc_set_time_resolution(1, SC_NS).
Corner 9: sc_time_stamp() between sc_start calls — what guarantees the kernel makes
When sc_start(t) returns control to sc_main, the kernel guarantees three things about sc_time_stamp():
- It is monotonic. Each call returns a time ≥ the previous call.
- It reflects all completed deltas. Any pending write that was queued before
sc_startwas called has been committed; any timed notification scheduled beforesc_startfor a time within the simulated window has fired. - It is bounded. With
sc_start(t)(form 2), the returnedsc_time_stamp()is ≤start_time + t. Withsc_start(t, SC_RUN_TO_TIME)(form 3), it is exactlystart_time + t. Withsc_start()(form 1), it is the time of the last processed event beforesc_stop()was called (or before the runnable set went empty with no future events).
These guarantees mean it is safe to write incremental test code like:
sc_time t0 = sc_time_stamp();
input_signal.write(some_value);
sc_start(sc_time(100, SC_NS));
sc_time t1 = sc_time_stamp();
assert(t1 >= t0);
assert(t1 - t0 <= sc_time(100, SC_NS));
// Observe output_signal here, then write another stimulus.
In simple cases the assertions are obvious. In complex tests where many sc_start calls interleave with stimulus writes and observations, the kernel's guarantees are what makes the test logic tractable.
Corner 10: Why simulation time and wall-clock time are different — and when it matters
A simulation that prints [100 ns] tick did not take 100 ns of real time to run; it took whatever number of CPU cycles the kernel spent in elaboration, scheduling, and process execution. The ratio of wall-clock time to simulation time is called the simulation speed and is a primary performance metric for virtual platforms.
Approximate wall-clock-to-simulation ratios for SystemC models (rough order of magnitude, modern x86 host):
- A trivial single-clock counter: simulation runs faster than real time (10–100× simulation-time-per-wall-time, easy).
- A small TLM model with a few peripherals: roughly real-time to 10× faster.
- A cycle-accurate model of a multi-core CPU running a full Linux kernel: typically 10× to 1000× slower than real time. Booting Linux on a cycle-accurate model takes hours of wall time.
- A pure functional / loosely-timed virtual platform of the same system: can run within 5–10× of real time, which is why virtual platforms are the standard pre-silicon vehicle for firmware bring-up.
The takeaway: when you read about ARM Fast Models being "fast enough to run Android," it is because they use loosely-timed TLM rather than cycle-accurate models. Your choice of timing fidelity is the single biggest knob on simulation speed. The Advanced section of Part 18 (TLM virtual platforms) returns to this in depth.
Corner 11: Resolving the "first edge" question for clocks definitively
The semantics of sc_clock's first edge are surprisingly nuanced and trip up most readers. Walking through the exact rule from §5.12 with concrete examples settles it.
Constructor (per LRM §5.12.5): sc_clock(name, period, period_unit, duty_cycle, start_time_v, start_time_unit, posedge_first). Defaults: period = 1 ns, duty_cycle = 0.5, start_time = 0, posedge_first = true. The posedge_first = true default is the one that catches readers.
Per LRM §5.12.7, the initialization rule: the clock's value at time start_time is posedge_first itself (true if posedge_first = true, false otherwise). The kernel schedules the first toggle — to the opposite polarity — at:
start_time + (1 - duty_cycle) * periodifposedge_first = true(the initial high lasts the "high" duration; then falling)start_time + duty_cycle * periodifposedge_first = false(the initial low lasts the "low" duration; then rising)
Subsequent toggles alternate at intervals of duty_cycle * period (high → low) and (1 - duty_cycle) * period (low → high).
Worked example 1 — default constructor: sc_clock("clk", 10, SC_NS). Defaults duty = 0.5, start_time = 0, posedge_first = true. Initial value true at time 0. First toggle (falling) at 0 + (1 - 0.5) * 10 = 5 ns. Subsequent: rising at 10 ns, falling at 15 ns, rising at 20 ns, … First rising-edge transition at 10 ns.
Worked example 2 — posedge_first = false: sc_clock("clk", 10, SC_NS, 0.5, 0, SC_NS, false). Initial value false. First toggle (rising) at 0 + 0.5 * 10 = 5 ns. Then falling at 10 ns, rising at 15 ns, …
Worked example 3 — non-default duty and start: sc_clock("clk", 10, SC_NS, 0.3, 2, SC_NS, false) — 30% duty, start at 2 ns, posedge_first = false. Initial value false (at time 0 it's already low, even before start_time). First toggle (rising) at 2 + 0.3 * 10 = 5 ns. Then falling at 5 + 0.3 * 10 = 8 ns, rising at 8 + 0.7 * 10 = 15 ns, …
In practice, most projects use only two patterns: the default sc_clock(name, period, unit) for free-running clocks (clock is high at time 0, first rising-edge transition at the period mark), and sc_clock(name, period, unit, duty, start, unit, posedge_first) with explicit parameters when the timing diagram requires a phase offset for multi-clock-domain modelling.
Hands-on exercise
Build a divide-by-N clock divider module. The interface:
sc_in<bool> clk_in;sc_out<bool> clk_out;- Constructor takes the divider ratio
N(e.g., 4 = divide by 4).
On every rising edge of clk_in, the module counts up to N - 1 and toggles clk_out. So with clk_in at 100 MHz and N = 4, clk_out should be at 25 MHz — but careful: a divide-by-4 means the output period is 4x the input period, so 100 MHz / 4 = 25 MHz, which has period 40 ns — meaning the output toggles every 2 input cycles (which is N / 2 = 2), not every 4. Or — if you implement it as "toggle every N input cycles" — you get a divide-by-2N. Pick a convention, document it in a comment, and stick with it. Wire it from sc_main to a 100 MHz sc_clock, run for 100 ns, print the output every input edge.
Hint: you only need one SC_METHOD with sensitivity on clk_in.pos(). The body is straight C++ counter logic. Watch out for which transition you define as the "edge" of clk_out.
Common mistakes
- Calling
sc_set_time_resolutionafter constructing ansc_time. Even a temporarysc_timein a function-local variable triggers the lock. Set the resolution as literally the first statement ofsc_main, before any other SystemC API call. - Expecting
sc_start(t)to advance time by exactlyt. It does not — it advances by up totand may return early. UseSC_RUN_TO_TIMEif you need exact advancement. - Reading
sc_time_stamp()in elaboration code. Before the firstsc_start,sc_time_stamp()returnsSC_ZERO_TIMEregardless of any pending writes. It is not a bug; the simulation has not started. - Assuming
sc_clockhas its first rising-edge transition at time 0. With defaults (posedge_first = true), the clock starts high and its first rising-edge transition is at the full period mark (the clock is already high at t=0 — the first visible rising edge is the one after it falls and rises back). If you need a rising-edge event at time 0, drive ansc_signal<bool>manually. Be explicit if your CDC or phase relationships depend on the first-edge semantics. - Comparing
sc_time_stamp().to_double(SC_NS) == 1.5. Usesc_time_stamp() == sc_time(1.5, SC_NS)instead. The integer underneath compares exactly; thedoubleround-trip can mislead. - Calling
sc_stop()and expecting the simulation to terminate immediately on that line. It terminates at the end of the current delta cycle. Anything in the current process aftersc_stop()still runs, but subsequent processes in the runnable set still finish their current evaluation. Code aftersc_stop()should be considered "best effort." - Negative
sc_timearithmetic.sc_time(0, SC_NS) - sc_time(1, SC_NS)underflows to a huge positive time. Guard subtractions where the operands can be in either order. - Assuming
posedge_first = falseis thesc_clockdefault. It is not — the LRM default isposedge_first = true, meaning the clock starts at valuetrueand the first transition is a falling edge at half-period. Most tutorials get this wrong because the visible "ticks" (rising-edge transitions) happen at the period mark either way, and the difference only matters if you read the clock value at time 0 or write code that cares about which edge is first. When in doubt, instantiate the clock, runsc_start(SC_ZERO_TIME), read the clock's value, and check. - Mixing
sc_timeconstants and literal doubles in arithmetic. Constructs likeif (sc_time_stamp() > 100)will not compile —100is adouble(orint), not ansc_time. The fix isif (sc_time_stamp() > sc_time(100, SC_NS))— or, if you find yourself doing this comparison frequently, define a named constant:inline const sc_time& KICKOFF() { static const sc_time t(100, SC_NS); return t; }and writeif (sc_time_stamp() > KICKOFF()).
Recap
After this post, you can:
- Read any
sc_time_stamp()output and explain why a value is what it is. - Construct
sc_timevalues in any unit and predict their internal representation. - Choose between
sc_start(),sc_start(t), andsc_start(t, SC_RUN_TO_TIME)based on test-framework needs. - Instantiate an
sc_clockwith non-default period, duty cycle, start time, andposedge_first. - Bind a clock to a process via
sensitive << clk.pos(), and predict the time at which the first positive edge fires. - Set a non-default time resolution and reason about precision trade-offs.
- Build a multi-clock-domain model with arbitrary period ratios.
- Use
sc_stop()correctly, including theSC_STOP_FINISH_DELTAvsSC_STOP_IMMEDIATEdistinction.
Further reading
- IEEE 1666-2011, §4.2.1.1 (initialization), §4.2.1.4 (time advance), §4.4.2 (sc_start), §5.11 (sc_time), §5.12 (sc_clock), §5.13 (sc_start API). Read §5.11 carefully — most of the misconceptions in this post can be inoculated against by reading §5.11 end-to-end.
- Accellera SystemC User's Guide — the chapter on time and clocks. Plain-language treatment of the same material with worked examples.
- Accellera SystemC PoC source:
src/sysc/kernel/sc_time.handsrc/sysc/communication/sc_clock.h. Thesc_timeheader in particular is short and readable; spending five minutes reading it removes most of the mystery. - Doulos SystemC Golden Reference Guide — the time and clock chapters.
- Black, Donovan & Tahar, SystemC: From the Ground Up (2nd ed.) — the clocks chapter is the most accessible book-length treatment.
Next in this section
→ Part 3: Delta Cycles & Event-Driven Semantics — the kernel's per-delta-cycle scheduling loop, why sc_signal::write is not immediate, and the three distinct kinds of event notification. This is where the time machinery from this post starts to interact with the process semantics from Part 1, and where you stop thinking of SystemC as "C++ with some hardware-flavoured types" and start thinking of it as a coroutine-scheduling kernel with a carefully specified scheduling contract. If you internalized two things from this post — that simulation time is an integer counter, and that sc_start is the only way time advances — you have the foundation you need.
Comments (0)
Leave a Comment