C⏚ v2.0.0Updated 2026-05-14·Guides

Common pitfalls

Three patterns that look reasonable in .cg but break the bytecode simulator in subtle ways - silent hangs (no AssertionError, no diagnostic) or quietly wrong state. Each has a known workaround and has bitten real designs.

If you hit one of these, the fix is almost always to restructure the .cg, not to dig into the simulator. The bytecode runtime correctly enforces the language semantics; the patterns below collide with those semantics in ways that don't show up at parse time.

1. Conditional push outputs hang the simulation

Symptom

A task declares an output:

out push bool update_flags;

…and the loop() body only writes to it on some iterations (e.g. only on a CMP opcode arm of an if-else). The bytecode sim hangs and eventually fails with:

Simulation timed out after 10000000 cycles
  • even on a stimulus that should finish in a handful of cycles. The same source generates Verilog that simulates correctly under iverilog.

Minimal pattern that breaks

task ExecuteWithBug {
  in push u8 opcode;
  out push bool update_flags;
  // ... other ports
 
  void loop() {
    u8 op = opcode.read();
    if (op == 0x39) {                 // CMP
      update_flags.write(true);       // only path that writes!
    } else {
      // any other opcode - update_flags never gets pushed
    }
  }
}

Workaround

Write the port every loop iteration with a sensible default, and push the gating semantics into the consumer:

void loop() {
  u8 op = opcode.read();
  if (op == 0x39) {
    update_flags.write(true);
  } else {
    update_flags.write(false);        // unconditional default
  }
}

The consumer then reads the bool value rather than just checking .available(). The signal is always asserted; only the value varies.

Why

A push port's valid pulse lasts exactly one cycle. The bytecode scheduler holds the producer waiting for its outputs to be consumed before advancing - if the loop body sometimes never writes the port, the consumer's .available() is permanently false on those iterations and the FSM stalls. Verilog's stall mechanism handles this differently and doesn't expose the bug.

See bytecode_sim_bugs §9 for the project-internal tracking; the emitter-side fix would risk the green Tier 0–4 ladder, so the canonical fix is the unconditional-write pattern above.

2. for loops in monitor setup() hang silently

Symptom

A monitor task drains N events from the DUT with a for loop in setup():

void setup() {
  for (u4 i = 0; i < 10; i++) {
    while (!bus.obs_addr.available()) { fence; }
    u16 addr = bus.obs_addr.read();
    // assert / print on addr
  }
  finished = true;
}

The DUT does execute correctly - the trace shows all 10 expected bus events. But the simulation times out at 10M cycles with no AssertionError, no Simulation finished line, and monitor.finished is never set.

Workaround

Use sequential while-blocks instead of a for loop, one per event:

void setup() {
  while (!bus.obs_addr.available()) { fence; }
  u16 a0 = bus.obs_addr.read();
  // ...
 
  while (!bus.obs_addr.available()) { fence; }
  u16 a1 = bus.obs_addr.read();
  // ...
 
  // ... repeat for each expected event ...
 
  finished = true;
}

Verbose, but reliable. Generate the repetition in your editor or with a small .cg helper task.

Why

The for loop compiles to a bytecode FSM state (or several) that doesn't re-enter cleanly after the inner while's fence. The inner block runs once, hands control back to a state that should loop the counter, and the scheduler never picks it up again. The loop body's bytecode and the while's fence interact in a way the compiler doesn't currently reject; the runtime then hangs.

The same caveat may apply to loop() bodies that use for loops crossing fence / wait boundaries; that case has not been investigated.

3. Outer-scope variables die across inner if/else with multi-cycle reads

Symptom

A loop() arm declares a local at its top, then nests an if/else where one branch does multiple .read() calls (or any multi-cycle operations). After the inner block returns, the local appears to have reverted to its declarator value:

void loop() {
  u4 instr_b0 = instr.read();
  u4 saved = 0;                       // outer-scope local
 
  if (instr_b0 == 0xC7) {             // MOV reg, imm16 - multi-cycle
    u4 modrm = instr.read();          // cycle break
    saved = modrm;                    // assignment happens
    u8 lo = instr.read();             // another cycle break
    u8 hi = instr.read();             // another
    // 'saved' looks correct here, inside the inner block
  }
 
  // 'saved' is BACK TO 0 here, not whatever the inner block set
  use(saved);                         // surprise
}

Workaround

Don't nest a multi-cycle decode under another arm. Split the opcode into its own top-level else if branch and keep all of its bookkeeping inside that single block:

void loop() {
  u4 instr_b0 = instr.read();
 
  if (instr_b0 == 0xB8) {             // MOV-imm path (template)
    u8 lo = instr.read();
    u8 hi = instr.read();
    do_mov_imm(lo, hi);               // all reads in this arm
  } else if (instr_b0 == 0xC7) {      // MOV r/m, imm - new arm
    u4 modrm = instr.read();
    u8 lo = instr.read();
    u8 hi = instr.read();
    do_mov_rm_imm(modrm, lo, hi);     // bookkeeping local to this arm
  }
  // no outer-scope locals that need to survive
}

Duplication of the read sequence is fine - the bytecode compiler won't merge them.

Why

The inner if/else compiles to its own scheduler state(s). Locals declared in the outer scope live in a different state's locals frame. When control reaches the post-if/else code in the next FSM state, the outer local is re-initialised to its declarator value because the new state has its own fresh frame.

The MOV-imm path (opcodes 0xB80xBF) in the Intel-8088 reference design escapes this trap because every multi-cycle read happens at the top of its own arm. Use it as a template when adding a new opcode.

When in doubt

  • Run the bytecode sim first. A correct trace + a correct Verilog gate (cycle-by-cycle agreement on DUT outputs) is the ground truth. If both backends disagree with intent, suspect the source first.
  • Don't speculate from source reading. The bytecode FSM lowering is opaque enough that 20 minutes of pattern-match is often less productive than 5 minutes of print() instrumentation in the .cg itself.
  • Add a Tier 1 atom test before extending the design. A small asserting fixture for the new construct catches the silent-hang category before it metastasizes into the integration sim.

Where next

  • Bytecode simulator - full reference for the test pattern, monitors, VCD output.
  • Network boundaries - a fourth pitfall with its own canonical fix, factored out into its own guide because the bare-port + bare-bool-flag workaround is itself a useful idiom.
  • Troubleshooting - symptom-first lookup if the issue you're hitting doesn't match any of the three above.