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_throughmodule must be working. The port binding pattern,SC_METHOD, andsc_signalconcepts 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
1. sc_time and Units
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.
2. SC_CLOCK — The Hardware Clock Generator
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.
3. Edge Sensitivity
This is where 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.
4. sc_start() — Advancing Simulation Time
Two 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.
5. 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.
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: firstsc_start(5, SC_NS)completes. Clock is still low (first posedge hasn't arrived).sig_qis still 0. Print confirms.t=10ns: secondsc_start(10, SC_NS)runs from t=5ns to t=15ns. At t=10ns the first posedge fires —dff.capture()runs,sig_qlatches 0x42 (= decimal 66). Print at t=15ns confirms.t=15ns–20ns:sig_dupdated 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: finalsc_start(5, SC_NS)runs from t=20ns to t=25ns. At t=20ns, posedge fires —sig_qlatches 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:
- Hold behavior — Between clock edges,
qholds its last captured value even whendchanges. This is verified by the third print statement: after settingsig_d = 0xFF, running 5ns (no posedge),sig_qis still 0x42. - Capture on posedge — Exactly at the rising edge,
qupdates to the valuedheld. This is verified by the second and fourth print statements. - Clock count — The
clk_monitorconfirms 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.
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 andSC_METHOD(capture); sensitive << clk.pos();pattern is identical. - Post 10 — Program Counter: A single 32-bit
dffwith reset. Same pattern. - Posts 18–22 — Pipeline Stages: Each pipeline register (IF/ID, ID/EX, EX/MEM, MEM/WB) is a bundle of
dffinstances 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.
Comments (0)
Leave a Comment