BUG-009: Instruction after a taken branch/JAL/JALR executes once before being flushed
| Field | Value |
|---|---|
| Severity | CRITICAL |
| Component | RTL |
| File(s) | rtl/riscv_core.sv |
| Status | Fixed |
| Date | 2026-07-23 |
Symptom
Found while verifying the BUG-008 fix: tb/branch_regression.s still failed after that fix, with s3 = 12 instead of 15 — specifically, the contributions from the BLT and BGE branches (worth 1 and 2) were missing, even though a cycle-by-cycle trace showed both resolving as branch_taken=1 with the correct target address.
The test's control-flow shape is the ordinary if/else code-generation pattern: a conditional branch immediately followed by an unconditional jal (the "skip the true-branch code" jump):
blt s1, s2, blt_ok
jal x0, after_blt # <- this instruction is the one that gets a free pass
blt_ok:
addi s3, s3, 1
after_blt:
Root Cause
A cycle-accurate trace (if_pc/if_id/id_ex dumped every cycle) showed: when blt resolves as taken in EX, flush_if_id correctly turns IF/ID's next value into a NOP — but the ID/EX register's own update is unconditional on if_id's current (pre-flush) content:
end else if (load_use_stall) begin
...
end else begin
id_ex_pc <= if_id_pc;
id_ex_is_branch <= id_is_branch;
id_ex_valid <= if_id_valid;
...
end
So the instruction sitting in IF/ID at the moment the branch resolves (fetched one cycle earlier, down the not-taken path — here, the delay-slot jal x0, after_blt) still gets copied into ID/EX on that same edge and executes one cycle later, before the flush that was meant to discard it actually takes effect on IF/ID.
This was invisible in every pre-existing test because the delay-slot instruction always happened to be harmless: either it wrote x0 (a no-op — test_rv32m_control_flow_regression.asm's jal x0, 8 skip pattern), or its own redirect target coincided with where the pipeline would have landed anyway (this test's blt_ok/bge_ok targets are exactly 2 instructions past the branch, which is also where straight-line IF naturally is by the time EX resolves 2 stages later — see BUG-008's regression test). Here, the phantom re-execution of the delay-slot jal is itself a taken jump, so it fires another branch_taken/flush_if_id pulse one cycle later that flushes the next IF/ID slot too — which is exactly the correctly-redirected blt_ok/bge_ok instruction, discarding it instead.
Fix
Insert a bubble into ID/EX when flush_if_id is asserted, instead of letting the normal-advance path copy IF/ID's about-to-be-flushed content — mirrors the existing load_use_stall bubble exactly:
end else if (mul_busy | mem_stall) begin
; // Full freeze during math op or L2 miss — ID/EX holds same instruction
+ end else if (flush_if_id) begin
+ // A taken branch/JAL/JALR is resolving in EX this cycle. IF/ID currently
+ // holds the not-taken-path instruction; without this, it would advance
+ // into EX and execute anyway, one cycle before IF/ID's own flush lands.
+ id_ex_rd<=0; id_ex_reg_write<=0; id_ex_mem_read<=0;
+ id_ex_mem_write<=0; id_ex_is_branch<=0; id_ex_is_jal<=0;
+ id_ex_is_jalr<=0; id_ex_is_mul<=0; id_ex_is_div<=0; id_ex_valid<=0;
end else if (load_use_stall) begin
Notes
This affects every taken branch/JAL/JALR, not just BLT/BGE/BLTU/BGEU — it just took a test with two control-transfer instructions back to back (a branch immediately followed by a jump, or vice versa) to expose it, because that's the only shape where the phantom re-executed instruction is itself another redirect. Re-verified against tb/test_rv32m_control_flow_regression.s (JAL immediately followed by a poison addi, then BNE) after this fix — see BUG-010 — and the full 15-test regression suite, both pass.