2. SystemC Tutorial - Simulation Time & Clocks

Introduction

Every digital circuit runs on a clock. Before you can simulate a register file, a pipeline stage, or an interrupt controller, you need to understand how SystemC models time.

This is the gap that surprises engineers from both worlds. C++ has no built-in time model — std::chrono gets you wall-clock durations, but nothing that knows about clock edges, setup windows, or the difference between 5ns and 5ps. SystemVerilog has timescales and #delays, but they are simulation-specific constructs tied to the simulator — there is no way to hand a timescale to a software function and have it mean something. SystemC bridges this gap deliberately: it gives you a physical time model (sc_time), a hardware clock generator (SC_CLOCK), and edge sensitivity (clk.pos(), clk.neg()) baked directly into the language.

Why does this matter for real chips? In pre-silicon verification, simulation time is hardware time. A 1GHz CPU has a 1ns clock period. When the SiFive FU740 — which runs at 1.4GHz, a clock period of roughly 714ps — is validated in a SystemC model before tape-out, the timing relationships between its pipeline stages, its cache controllers, and its RISC-V cores are encoded directly in sc_time values. Qualcomm and Apple run cycle-accurate SystemC models for millions of clock cycles to validate power-management sequences, performance counters, and cache coherency before a single gate is fabricated. Getting simulation time wrong means every temporal relationship in your model is wrong.

The RISC-V CPU we are building in this series will use a 10ns clock (100MHz). That is slow enough to trace by hand, realistic enough to mirror production verification work, and the exact value we will carry through every sequential stage from Post 8 (Register File) to Posts 18–22 (the 5-stage pipeline).

Post 2 gives us two things: the clk_monitor module that proves our clock is behaving correctly, and the dff module — a D flip-flop — which is the atomic building block of every register in our CPU. Every register file entry, every pipeline register, every program counter flip-flop in this series descends from the eight lines of dff code we write here.


Prerequisites

  • Completed Post 1 — the pass_through module must be working. The port binding pattern, SC_METHOD, and sc_signal concepts from Post 1 are used directly here.
  • Post 1 — Modules, Ports & Signals
  • SystemC 2.3.x installed — see the install guides linked in Post 1
  • Code for this post: GitHub — section1/post02

Translation Table

Before the concept explanations, here is the mapping from what you already know:

Concept C++ Engineer SystemVerilog Engineer
sc_time std::chrono::duration with hardware units timescale unit (e.g., 1ns/1ps)
SC_CLOCK no equivalent reg clk; always #5 clk = ~clk;
sc_start(10, SC_NS) "run simulation for 10ns" #10; delay in an initial block
sc_time_stamp() no equivalent $time
sensitive << clk.pos() no equivalent always @(posedge clk)
wait(10, SC_NS) std::this_thread::sleep_for (but non-blocking in simulation) #10; inside a procedural block

The key insight for C++ engineers: wait() in SystemC does not block the OS thread — it suspends the current simulation process and yields to the kernel, which then schedules other processes. For SystemVerilog engineers: edge sensitivity in SystemC is explicit per-process, not a global timescale setting. You choose your resolution at model construction time.


Concept Explanation

SystemC Language Reference

Every time-related construct used in this post, side-by-side with its SV equivalent:

Construct Syntax SV Equivalent Key Difference
sc_time sc_time(10, SC_NS) timescale unit (implicit) A value type — you can pass it around, compare it, store it in variables. Not a global setting
sc_set_time_resolution sc_set_time_resolution(1, SC_NS) `timescale 1ns/1ps Must be called before any sc_time object is created; cannot be changed mid-simulation
SC_CLOCK sc_clock clk("clk", 10, SC_NS) always #5 clk = ~clk A full sc_module that generates a boolean signal; period, duty cycle, and phase all configurable
sc_clock::pos() sensitive << clk.pos() always @(posedge clk) Returns an sc_event for the 0→1 transition; using clk without .pos() fires on both edges
sc_clock::neg() sensitive << clk.neg() always @(negedge clk) Returns an sc_event for the 1→0 transition
sc_time_stamp() sc_time_stamp() $time Returns current simulation time as sc_time; reads during any process at any time
sc_start(T, unit) sc_start(10, SC_NS) #10; in initial block Runs the event loop for exactly T simulation time units then returns control
sc_start() sc_start() $finish driven from within Runs until sc_stop() is called from inside a process
SC_THREAD SC_THREAD(func_name) initial begin ... end (with blocking waits) A process that can call wait() and suspend; used for clocked behavior and testbench drivers
wait(T, unit) wait(5, SC_NS) #5; inside initial/task Suspends the SC_THREAD for T simulation time; does not block the OS thread
wait(event) wait(clk.pos()) @(posedge clk) Suspends until a specific event fires
SC_ZERO_TIME sc_start(SC_ZERO_TIME) #0; Runs all pending delta cycles without advancing simulation time

1. sc_time — The Simulation Time Model

sc_time is SystemC's physical time type. You construct it with a value and a unit:

sc_time t1(10, SC_NS);    // 10 nanoseconds
sc_time t2(500, SC_PS);   // 500 picoseconds
sc_time t3(1, SC_US);     // 1 microsecond
sc_time t4(1, SC_MS);     // 1 millisecond
sc_time t5(1, SC_SEC);    // 1 second

Under the hood, sc_time stores everything as an integer count of the simulation's time resolution. The default resolution is 1ps, so sc_time(10, SC_NS) stores 10,000 internally. This means you can add, subtract, and compare sc_time values with normal arithmetic operators, and the simulator keeps everything consistent.

A critical constraint: you cannot change the time resolution after the first sc_time object is created. Set it at the top of sc_main if you need something other than the default 1ps:

sc_set_time_resolution(1, SC_NS);  // must be before any sc_time construction

For our RISC-V CPU, the default 1ps resolution is fine — our 10ns clock is well above the resolution floor.

Comparison with SystemVerilog timescale:

Aspect SystemVerilog SystemC
Setting time precision `timescale 1ns/1ps — per-file pragma sc_set_time_resolution(1, SC_PS) — global, set once
Granularity Compile-time constant; all delays in that file use the declared unit Runtime value; sc_time objects carry their own unit
Portability `timescale must be consistent across files Resolution is global; sc_time values are unit-agnostic
Reading current time $time returns integer scaled to timescale unit sc_time_stamp() returns sc_time with full precision
Time arithmetic Implicit, based on timescale Explicit via sc_time operators; sc_time(5,SC_NS) + sc_time(3,SC_NS) = sc_time(8,SC_NS)

The key advantage of SystemC's model: time is a first-class value you can pass to functions, store in data structures, and compute with. In SV, #10 is a literal delay baked into source code — you cannot parameterize it without a macro or package parameter.


2. SC_CLOCK vs. Manual Clock Generation

SC_CLOCK is a built-in SystemC module that generates a periodic boolean signal. Its constructor takes four parameters:

sc_clock clk("clk", 10, SC_NS);         // name, period=10ns, default 50% duty cycle
sc_clock clk2("clk2", 10, SC_NS, 0.5);  // explicit 50% duty cycle
sc_clock clk3("clk3", 714, SC_PS);      // ~1.4GHz, like SiFive FU740
  • Period — the full cycle length. A 10ns period = 100MHz.
  • Duty cycle — fraction of the period the clock spends high. 0.5 = 50%. Most digital logic uses 50%.
  • Start phase — fourth parameter, default 0.0. Setting it to 0.5 starts the clock high instead of low.

The clock is connected to module ports exactly like any sc_signal:

sc_clock clk("clk", 10, SC_NS);
my_module mod("mod");
mod.clk(clk);  // bind sc_clock directly to sc_in<bool> port

sc_clock is compatible with sc_in<bool> ports — no adapter needed.

Manual clock generation with SC_THREAD:

In testbenches, engineers frequently drive clocks manually for more control over gating, phase, and startup behavior:

SC_MODULE(clk_gen) {
  sc_out<bool> clk;

  void generate_clock() {
    clk.write(false);
    while (true) {
      wait(5, SC_NS);   // low half-cycle
      clk.write(true);
      wait(5, SC_NS);   // high half-cycle
      clk.write(false);
    }
  }

  SC_CTOR(clk_gen) {
    SC_THREAD(generate_clock);
  }
};

This is useful for:
- Clock gating tests — add a gate signal and conditionally skip a cycle
- Non-50% duty cycles — use different wait times for the two halves
- Startup sequences — delay the first clock edge by N nanoseconds
- Multi-phase clocks — generate phase-shifted clocks using separate threads

Side-by-side translation:

Pattern Verilog SystemVerilog (modern) SystemC (SC_CLOCK) SystemC (manual SC_THREAD)
Basic free-running clock initial forever #5 clk = ~clk; initial forever #5 clk = ~clk; sc_clock clk("clk", 10, SC_NS) while(true) { wait(5,SC_NS); clk.write(!clk.read()); }
Edge-triggered process always @(posedge clk) always_ff @(posedge clk) sensitive << clk.pos() wait(clk.posedge_event())
Negedge-triggered always @(negedge clk) always_ff @(negedge clk) sensitive << clk.neg() wait(clk.negedge_event())
Clock period access Not directly accessible Not directly accessible clk.period() returns sc_time Stored in the generator's wait() argument
Gated clock assign gated_clk = clk & gate; assign gated_clk = clk & gate; Manual SC_THREAD with condition if (gate.read()) { ... } in thread

The critical behavioral difference: in Verilog, forever #5 clk = ~clk uses a blocking assignment to a reg — the clock transitions happen as discrete events in the Verilog scheduler. In SystemC, SC_CLOCK is an actual sc_module object that schedules posedge and negedge events into the kernel's event queue. The kernel processes them just like any other scheduled event. This is why you can bind sc_clock directly to an sc_in<bool> — it implements the same interface a signal would.


3. Edge Sensitivity — How Flip-Flops Are Born

SC_METHOD processes can be made sensitive to specific clock edges:

SC_METHOD(my_process);
sensitive << clk.pos();   // rising edge only  — use this for flip-flops
sensitive << clk.neg();   // falling edge only — DDR logic, negedge registers
sensitive << clk;         // BOTH edges        — almost never what you want

For a D flip-flop, you want clk.pos(). Using clk (level-sensitive) means your process fires twice per cycle: once when the clock goes high and once when it goes low. That doubles your update rate and produces incorrect behavior. This is one of the most common bugs in first-time SystemC code; we cover it in depth in the DV Insight section.

What .pos() and .neg() actually are:

clk.pos() returns an sc_event& reference to the internal posedge event that SC_CLOCK fires each time the clock transitions from 0 to 1. It is the same sc_event mechanism used for explicit notification in Post 3. Adding it to a sensitivity list registers this process as a "waiter" for that specific event:

// These two are equivalent:
sensitive << clk.pos();          // in SC_METHOD sensitivity list

wait(clk.posedge_event());       // in SC_THREAD (posedge_event() == pos())

clk.posedge_event() and clk.pos() refer to the same underlying event object.


4. SC_THREAD and wait() — Sequential Process Semantics

SC_THREAD is the second process type (alongside SC_METHOD). It can call wait() and suspend, making it suitable for sequential behavior like testbench drivers, clock generators, and state machines that span multiple cycles.

wait() variants and their meanings:

// In an SC_THREAD:

wait();                  // Wait for the NEXT event in this thread's sensitivity list
                         // (Must have called sensitive << something in SC_CTOR)

wait(10, SC_NS);         // Suspend for exactly 10ns of simulated time
                         // Kernel advances time, this thread resumes at T+10ns

wait(my_event);          // Suspend until my_event.notify() is called
                         // No timeout — thread waits indefinitely

wait(10, SC_NS, my_ev);  // Suspend until my_event fires OR 10ns elapses
                         // Returns whichever happens first

There is no wait(condition) in standard SystemC. Unlike SV's wait(signal == 1), SystemC has no built-in wait-until-condition primitive. The idiomatic pattern is:

// SV equivalent: wait(my_signal == 1);
while (my_signal.read() != 1) {
  wait(my_signal.value_changed_event());
}

Side-by-side: SC_THREAD vs. SV initial/always:

Pattern Verilog/SV SystemC SC_THREAD
Wait for N nanoseconds #10; wait(10, SC_NS);
Wait for posedge @(posedge clk); wait(clk.posedge_event());
Wait for signal change @(my_sig); wait(my_sig.value_changed_event());
Wait for named event @(my_event); wait(my_event);
Wait for condition wait(sig == 1); while(sig.read()!=1) wait(sig.value_changed_event());
Infinite loop forever begin ... end while(true) { ... }
Fork multiple threads fork ... join_none Multiple SC_THREAD registrations
Signal a named event ->my_event; my_event.notify();

The key difference under the hood: SV's #10 and @(posedge clk) are simulator constructs that the Verilog scheduler handles. SystemC's wait(10, SC_NS) and wait(clk.posedge_event()) are C++ function calls that suspend the current SC_THREAD coroutine and yield control to the kernel. The kernel records when this thread should resume, advances simulation time as needed, and resumes the thread at the right moment — all without blocking any OS thread.

SC_METHOD cannot call wait():

This is a hard rule, not a recommendation:

SC_METHOD(my_method);

void my_method() {
  wait(10, SC_NS);  // RUNTIME ERROR: "wait() is only allowed in SC_THREAD"
}

SC_METHOD is modeled as a function that runs to completion — it has no coroutine stack. Calling wait() inside an SC_METHOD throws sc_report_handler error SC_ID_WAIT_NOT_ALLOWED_. Use SC_THREAD for any process that needs to wait.


5. sc_start() — The Simulation Engine

Two primary forms:

sc_start(10, SC_NS);   // advance simulation by exactly 10ns, then return
sc_start();            // run until sc_stop() is called from inside a process

The first form is what we use for directed tests — "apply this input, run for one cycle, check the output." The second form is for testbenches that run indefinitely until a pass/fail condition is detected. For the RISC-V CPU tests in this series, we use the first form with explicit time steps, which makes tests easy to read and deterministic.

sc_start(SC_ZERO_TIME) — delta-cycle settling without advancing time:

sig_input.write(new_value);
sc_start(SC_ZERO_TIME);    // settle combinational logic — runs all delta cycles at T
// read combinational outputs here — they reflect new_value
sc_start(10, SC_NS);       // tick the clock — registers capture
// read registered outputs here

SC_ZERO_TIME is the tool for settling combinational logic. It runs all pending delta-cycle updates without advancing the simulation clock. Post 3 explains why this works and when you need it.


6. sc_time_stamp() — The Simulation Wall Clock

sc_time_stamp() returns the current simulation time as an sc_time value. Call it from anywhere in your process code to print timestamps:

std::cout << "At time: " << sc_time_stamp() << std::endl;
// prints: "At time: 15 ns"

This is the SystemC equivalent of $time in SystemVerilog. It is invaluable for debugging — any time a signal update happens at an unexpected time, sc_time_stamp() tells you exactly when.


Simulation Semantics — How sc_start() Interacts with SC_CLOCK

Understanding the exact sequence of events when sc_start() runs with a clock source is critical for writing correct testbenches. Here is the concrete execution timeline for our DFF test:

ELABORATION (before sc_start()):
  sc_clock clk("clk", 10, SC_NS)  constructed
    → kernel schedules: posedge at t=10ns, negedge at t=5ns (clock starts LOW)
  sig_d.write(0x42)               → sig_d.m_new_val = 0x42

sc_start(5, SC_NS):
  INITIALIZATION PHASE:
    dff::capture() runs once (init, no edge yet)
    clk_monitor::on_posedge() runs once (init)
    UPDATE: sig_d.m_cur_val ← 0x42 (from elaboration write)

  EVENT-DRIVEN LOOP, t=0 → t=5ns:
    t=5ns: negedge event fires (clk: 1→0... wait, clk starts low → negedge not yet)
    Actually at t=5ns: clk transitions 0→1? No — SC_CLOCK default start is low.
    Negedge at t=5ns, posedge at t=10ns.
    No processes sensitive to clk.neg() in our design.

  t=5ns reached → sc_start(5, SC_NS) returns

  State: sig_d=0x42, sig_q=0x00, clk=1 (halfway through first cycle)

sc_start(10, SC_NS):  [from t=5ns to t=15ns]
  t=10ns: posedge event fires
    EVALUATE: dff::capture() runs
              sig_q.write(sig_d.read() = 0x42) → sig_q.m_new_val = 0x42
              clk_monitor::on_posedge() runs → prints "Rising edge #1 at 10 ns"
    UPDATE:   sig_q.m_cur_val ← 0x42

  t=15ns reached → sc_start(10, SC_NS) returns

ASCII timing diagram:

clk:    0     0     1     1     0     0     1
        |     |     |     |     |     |     |
        0ns   5ns  10ns  15ns  20ns  25ns  30ns
                    ^                   ^
                    posedge #1          posedge #2

sig_d:  0x42 ─────────────────── 0xFF ─────────
                                 (changed at ~15ns)

sig_q:  0x00 ────────── 0x42 ──────────── 0xFF
                        ^                  ^
                   captured at         captured at
                   posedge #1          posedge #2

Sequence:
  sc_start(5,NS) runs t=0→5ns: no posedge, sig_q still 0x00
  print "Before posedge: d=66 q=0"

  sc_start(10,NS) runs t=5ns→15ns: posedge at t=10ns, sig_q captures 0x42
  print "After posedge: d=66 q=66"

  sig_d.write(0xFF)
  sc_start(5,NS) runs t=15ns→20ns: no posedge (next is at t=20ns)
  print "D changed, before next posedge: q=66"

  sc_start(5,NS) runs t=20ns→25ns: posedge at t=20ns, sig_q captures 0xFF
  print "After next posedge: q=255"

This timeline makes explicit what the code does at each simulation time. Notice that sig_q does NOT change when sig_d changes mid-cycle — the DFF only captures on the posedge event, and between posedges the output is held stable regardless of input transitions.


Clock Event Flow

Here is how the kernel, SC_CLOCK, and a clk_monitor module interact:

sequenceDiagram
    participant K as Kernel
    participant C as SC_CLOCK (10ns)
    participant M as clk_monitor
    Note over C: t=0: clk=0
    Note over C: t=5ns: clk=1 (posedge)
    C->>M: posedge event
    M->>K: print "edge #1 at 5ns"
    Note over C: t=10ns: clk=0 (negedge)
    Note over C: t=15ns: clk=1 (posedge)
    C->>M: posedge event
    M->>K: print "edge #2 at 15ns"

The kernel sees the SC_CLOCK schedule posedge events at t=5ns, 15ns, 25ns, ... (half-period after start, since the clock starts low). It delivers each event to every process that declared sensitive << clk.pos(). The clk_monitor fires, prints, and returns control to the kernel.


Implementation

The implementation demonstrates two modules: clk_monitor (observes the clock) and dff (captures data on rising edges). Together they prove that our clock generator works and that edge-sensitive capture behaves correctly.

#include <systemc.h>

// clk_monitor: counts rising clock edges and prints timestamps.
// This is how we verify our RISC-V pipeline runs at the right frequency.
SC_MODULE(clk_monitor) {
  sc_in<bool> clk;
  int edge_count;

  void on_posedge() {
    edge_count++;
    std::cout << "Rising edge #" << edge_count
              << " at " << sc_time_stamp() << std::endl;
  }

  SC_CTOR(clk_monitor) : edge_count(0) {
    SC_METHOD(on_posedge);
    sensitive << clk.pos();  // trigger ONLY on rising edge
  }
};

// dff: D flip-flop — the atomic building block of all CPU registers.
// 32 of these arranged as 32 x 32-bit registers = our Register File (Post 8).
SC_MODULE(dff) {
  sc_in<bool>        clk;
  sc_in<sc_uint<8>>  d;
  sc_out<sc_uint<8>> q;

  void capture() {
    q.write(d.read());  // capture D on rising clock edge
  }

  SC_CTOR(dff) {
    SC_METHOD(capture);
    sensitive << clk.pos();  // ONLY posedge — this is what makes it a register
  }
};

int sc_main(int argc, char* argv[]) {
  // 10ns period clock = 100MHz.
  // In our RISC-V CPU, all sequential elements will share this clock.
  sc_clock clk("clk", 10, SC_NS);

  sc_signal<sc_uint<8>> sig_d, sig_q;

  clk_monitor mon("mon");
  mon.clk(clk);

  dff reg("reg");
  reg.clk(clk); reg.d(sig_d); reg.q(sig_q);

  // Test: apply data and watch it register on clock edges.
  sig_d.write(0x42);
  sc_start(5, SC_NS);   // halfway through first cycle — DFF hasn't captured yet
  std::cout << "Before posedge: d=" << sig_d.read()
            << " q=" << sig_q.read() << std::endl;

  sc_start(10, SC_NS);  // advance past the posedge
  std::cout << "After posedge:  d=" << sig_d.read()
            << " q=" << sig_q.read() << std::endl;

  sig_d.write(0xFF);    // change D — but Q won't update until next posedge
  sc_start(5, SC_NS);
  std::cout << "D changed, before next posedge: q=" << sig_q.read()
            << " (should still be 0x42)" << std::endl;

  sc_start(5, SC_NS);
  std::cout << "After next posedge: q=" << sig_q.read()
            << " (should be 0xFF)" << std::endl;

  return 0;
}

Expected output:

Rising edge #1 at 10 ns
Before posedge: d=66 q=0
After posedge:  d=66 q=66
D changed, before next posedge: q=66 (should still be 0x42)
Rising edge #2 at 20 ns
After next posedge: q=255 (should be 0xFF)

Walk through the timeline manually:

  • t=0: sig_d = 0x42, clock low, sig_q = 0 (default)
  • t=5ns: first sc_start(5, SC_NS) completes. Clock is still low (first posedge hasn't arrived). sig_q is still 0. Print confirms.
  • t=10ns: second sc_start(10, SC_NS) runs from t=5ns to t=15ns. At t=10ns the first posedge fires — dff.capture() runs, sig_q latches 0x42 (= decimal 66). Print at t=15ns confirms.
  • t=15ns–20ns: sig_d updated to 0xFF. sc_start(5, SC_NS) runs to t=20ns — no posedge yet (next posedge is at t=20ns). Print confirms q still 0x42.
  • t=20ns: final sc_start(5, SC_NS) runs from t=20ns to t=25ns. At t=20ns, posedge fires — sig_q latches 0xFF (= decimal 255). Print confirms.

The output values 66 and 255 are the decimal representations of 0x42 and 0xFF that sc_uint<8> prints by default.


Build & Run

Create CMakeLists.txt in the post02 directory:

cmake_minimum_required(VERSION 3.16)
project(post02_sim_time)

set(CMAKE_CXX_STANDARD 17)

find_package(SystemCLanguage CONFIG REQUIRED)

add_executable(post02 main.cpp)
target_link_libraries(post02 SystemC::systemc)

Build and run:

mkdir build && cd build
cmake .. -DCMAKE_PREFIX_PATH=$SYSTEMC_HOME
cmake --build .
./post02

Note on SC_ZERO_TIME: There is a special constant sc_time(0, SC_NS) commonly aliased as SC_ZERO_TIME. Calling sc_start(SC_ZERO_TIME) advances delta cycles without moving the simulation clock. Use it to settle combinational logic without triggering any clock edges:

sig_d.write(0x42);
sc_start(SC_ZERO_TIME);  // combinational logic settles, clock does not advance
// safe to read combinational outputs here
sc_start(10, SC_NS);     // now advance one full clock cycle

Delta cycles are covered in depth in Post 3. For now, know that SC_ZERO_TIME exists and is distinct from advancing by 1ps.


Verification

The DFF test sequence is the verification. No separate testbench file is needed for this post — the sc_main test IS the test.

What we are checking:

  1. Hold behavior — Between clock edges, q holds its last captured value even when d changes. This is verified by the third print statement: after setting sig_d = 0xFF, running 5ns (no posedge), sig_q is still 0x42.
  2. Capture on posedge — Exactly at the rising edge, q updates to the value d held. This is verified by the second and fourth print statements.
  3. Clock count — The clk_monitor confirms exactly two rising edges occurred, at the expected times (10ns and 20ns).

This is the setup-hold time concept expressed in software. In real silicon, d must be stable for a setup time before the clock edge and a hold time after it. In our SystemC model, there is no setup/hold window — the model is ideal. But the behavioral pattern is identical: data is sampled at the rising edge, and changes at any other time have no effect.


DV Insight

Bug: Level Sensitivity Instead of Edge Sensitivity

The most common beginner mistake with flip-flops in SystemC:

// WRONG — fires on both rising AND falling edges
SC_METHOD(capture);
sensitive << clk;        // level-sensitive: fires when clk changes at all

// CORRECT — fires only on rising edge
SC_METHOD(capture);
sensitive << clk.pos();  // edge-sensitive: fires only on 0->1 transition

With the wrong version, capture() runs twice per cycle: once when the clock goes high (capturing the current value of d) and once when the clock goes low (capturing d again). For a stable d, the output appears correct. But change d mid-cycle and the flip-flop will capture the new value on the falling edge — exactly the kind of metastability bug that is hard to catch in directed testing and catastrophic in silicon.

In SystemVerilog, this is caught immediately because you write always @(posedge clk) explicitly. In SystemC, the port object has both .pos() and direct sensitivity — and the direct form compiles without error. Always spell out .pos() for rising-edge flip-flops.

sc_start(SC_ZERO_TIME) vs sc_start(1, SC_NS)

These two calls look similar but do fundamentally different things:

sc_start(SC_ZERO_TIME);   // run all pending delta cycles; clock does NOT advance
sc_start(1, SC_NS);       // advance clock by 1ns; all posedges in that window fire

SC_ZERO_TIME is the tool for settling combinational logic. When you write a value to a signal, it does not propagate instantly — it schedules a delta-cycle update. Calling sc_start(SC_ZERO_TIME) drains all pending delta-cycle updates without ticking the clock. This lets you observe the settled state of combinational logic before any flip-flops capture it.

The standard pattern in directed tests:

sig_input.write(new_value);
sc_start(SC_ZERO_TIME);    // settle combinational logic
// read combinational outputs here — they reflect new_value
sc_start(10, SC_NS);       // tick the clock — registers capture
// read registered outputs here

Post 3 explains why this delta-cycle behavior exists and why it is actually the correct model for hardware semantics. For now, know the pattern.


Common Pitfalls for SV Engineers

These five time-related gotchas are among the most frequent bugs in SystemC code written by engineers coming from an SV background.

Pitfall 1: SC_METHOD cannot call wait() — runtime crash, not compile error.

SC_METHOD(my_method);

void my_method() {
  wait(10, SC_NS);  // compiles fine. Crashes at runtime.
  // Error: wait() is only allowed in SC_THREAD processes
}

The compiler does not catch this because wait() is just a regular C++ function. The error only appears when the kernel calls this method at runtime and the method tries to use the coroutine stack that SC_METHOD does not have. Convert to SC_THREAD if you need waiting.

Pitfall 2: sc_start(0, SC_NS) vs SC_ZERO_TIME — subtle difference.

sc_start(0, SC_NS) and sc_start(SC_ZERO_TIME) both run delta cycles without advancing real time. They are functionally equivalent in most cases. However, sc_start(0, SC_NS) requires constructing an sc_time(0, SC_NS) object, which can interact oddly with the time resolution if called before sc_set_time_resolution(). Use sc_start(SC_ZERO_TIME) as the canonical form.

Pitfall 3: Forgetting the initial wait() in an SC_THREAD that should start after reset.

void run_test() {
  // BUG: no initial wait — this runs at t=0, before any reset or clock
  do_transactions();  // runs immediately at simulation start
}

At t=0, simulation has just initialized. Reset may not have been applied, the DUT may not be in its initial state, and the first clock edge has not occurred. For any SC_THREAD testbench that should start after reset, begin with:

void run_test() {
  wait(rst.negedge_event());  // wait for reset to deassert
  wait(clk.posedge_event());  // align to first clock edge post-reset
  do_transactions();           // now safe to proceed
}

Pitfall 4: SC_CLOCK period is simulation time, not related to real hardware frequency unless you choose matching values.

sc_clock clk("clk", 10, SC_NS) creates a clock with a 10ns simulation period. It does NOT run in real time — 10 million simulated nanoseconds can complete in milliseconds of wall-clock time on a fast simulator. The frequency only corresponds to hardware if you deliberately match sc_time values to your target hardware (e.g., 10ns → 100MHz).

Pitfall 5: Multiple sc_start() calls from sc_main do NOT reset the simulation state between calls.

sig_d.write(0x42);
sc_start(10, SC_NS);   // simulation runs from t=0 to t=10ns

sig_d.write(0xFF);
sc_start(10, SC_NS);   // simulation runs from t=10ns to t=20ns
                       // NOT from t=0 to t=10ns again

Each sc_start(T) call advances simulation time FROM WHERE IT CURRENTLY IS by T. Simulation time is monotonically increasing and does not reset between sc_start calls. This is correct and expected — but SV engineers used to $reset semantics sometimes expect a restart. To reset the DUT, drive a reset signal through the ports; do not try to restart the simulation.


Integration

The dff module we wrote here is the direct ancestor of every register in the CPU:

  • Post 8 — Register File: 32 instances of a 32-bit variant of dff, plus write-enable logic. The clock port and SC_METHOD(capture); sensitive << clk.pos(); pattern is identical.
  • Post 10 — Program Counter: A single 32-bit dff with reset. Same pattern.
  • Posts 18–22 — Pipeline Stages: Each pipeline register (IF/ID, ID/EX, EX/MEM, MEM/WB) is a bundle of dff instances capturing instruction fields on the rising edge.

The SC_CLOCK defined here — 10ns, 100MHz — is also the clock that drives all of those stages. In the final sc_main, there will be one sc_clock clk("clk", 10, SC_NS) and it will be bound to every sequential module in the design.

Progress so far:

[Post 1: Skeleton ✓] → [Post 2: Clocks ✓] → [Post 3: Delta Cycles] → ... → [Post 8: Register File uses 32 DFFs]

What's Next

Post 3: Delta Cycles & Event-Driven Semantics — the most counterintuitive part of SystemC for engineers coming from both C++ and SystemVerilog.

We saw in this post that sc_start(SC_ZERO_TIME) does something different from advancing the clock. Post 3 explains exactly why: the SystemC kernel runs multiple passes at the same simulation timestamp before it advances the clock, and the order of those passes has consequences for signal values. We will see why writing to a signal does not update it instantly, how the kernel schedules events, and why two modules reading the same signal in the same delta cycle might see different values.

That behavior is not a bug — it is the correct model for hardware. Post 3 shows you how to think about it.

Author
Mayur Kubavat
VLSI Design and Verification Engineer sharing knowledge about SystemVerilog, UVM, and hardware verification methodologies.

Comments (0)

Leave a Comment