Summary
On main at b91c909, the machine-level mtval is written by an early trap-entry path that is not conditioned on the trap actually being taken into M-mode. When an exception is delegated and handled in S-mode, mtval is clobbered with that exception's value even though no trap into M-mode occurred.
The divergent write is visible in the RTL and is measured below by a testbench probe. It cannot be read by a software sequence after the fact, and the reason is architectural rather than a property of this core: the observability section states it and closes the interrupt route as well.
The rule
Privileged Architecture 1.10, src/machine.tex:
When a trap is taken into M-mode, mtval is written with exception-specific information to assist software in handling the trap. Otherwise, mtval is never written by the implementation, though it may be explicitly written by software.
The sentence entered the manual twenty-six days before 1.10 was tagged, under the subject "Clarify [s/m][epc/tval/cause] are only written on exceptions into that mode" — the commit message states the rule directly. It is verbatim in ratified 1.11 and 1.12.
riscv-isa-manual bf25f06a004196d445e50014f9ac9b68f37de701 // added 2017-04-11
The C910 manual §1.5 declares Privileged Architecture 1.10 and names exactly one post-1.10 adoption (mcountinhibit), so this rule is in the declared target. The manual carries one version statement and no later or narrower one.
We deliberately do not rest this on 1.12's delegated-trap list, which additionally names mtval where 1.10's corresponding paragraph enumerates only mcause, mepc, MPP and MPIE. The claim stands on the 1.10 sentence above.
What the RTL does
C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_regs.v. The mtval write has two arms, and the early one is not qualified by the trap's destination:
:2045 else if((rtu_cp0_expt_vld || iui_regs_inv_expt) && !mdeleg_vld) // mtval
:2376 else if((rtu_cp0_expt_vld || iui_regs_inv_expt) && mdeleg_vld) // stval
iui_regs_inv_expt asserts before rtu_cp0_expt_vld, and while it is asserted mdeleg_vld still reads 0 for the exception in question — so !mdeleg_vld is satisfied and the early term admits a write for an exception that is about to be delegated to S-mode. In the run below the two events are three cycles apart (3291 and 3294); we report the measured separation rather than a fixed number, because the flop between them lives in ct_cp0_iui.v and we have not characterised its latency across cases.
That mdeleg_vld reads 0 during the early window is the pivot of the whole report, so here is what it is made of, in ct_cp0_regs.v:
:1790 assign medeleg_vld = (pm[1] == 1'b0) && !rtu_yy_xx_expt_vec[5]
:1791 && |(vec_num[15:0] & edeleg[15:0]);
:1839 assign mideleg_vld = (pm[1] == 1'b0) && rtu_yy_xx_expt_vec[5]
:1840 && |(vec_num[18:0] & mideleg_value[18:0]);
:1842 assign mdeleg_vld = medeleg_vld || mideleg_vld;
It is a function of the privilege mode and of the exception vector RTU presents, and it does not reference rtu_cp0_expt_vld at all, so it is not a delayed copy of the trap event. The 0 in the probe line below is therefore a measured value of this expression during the early window, not an assumption about pipeline timing. What we did not establish is which of its terms reads 0 there, which is also why the separation above is reported as measured rather than derived.
"Early" here means early relative to the trap, not early relative to commit, and that bound matters. A signal that wrote an architectural register from the execution stage would raise a second and worse question — whether a flushed wrong-path instruction can write mtval with no trap taken at all — and the answer is no: iui_regs_inv_expt is itself qualified by RTU's commit of that exact instruction. The divergence here is about WHICH register a committed, trapping instruction writes, and not about speculative state escaping a flush.
The commit qualification, and where the six occurrences of the early term are
In ct_cp0_iui.v:
:1081 assign iui_ex2_commit = rtu_yy_xx_commit0
:1082 && (rtu_yy_xx_commit0_iid[6:0]
:1083 == iui_ex1_iid[6:0]);
:1140 assign cp0_ex2_select = (cur_state[1:0] == EX2) && iui_ex2_commit;
:1430 assign iui_regs_inv_expt = !iui_privilege && cp0_ex2_select;
A wrong-path instruction never appears on rtu_yy_xx_commit0 with its own iid, so it never asserts iui_regs_inv_expt and never reaches :2045. That is where we stop following the chain, and the reason is that the two terms answer the question between them: commit is by definition after the flush that would kill a wrong-path instruction, and the iid comparison makes the signal specific to this instruction rather than to whatever else is committing.
Back in ct_cp0_regs.v, mtval and stval are the only two registers in the file whose trap-entry arm carries iui_regs_inv_expt, and one grep shows it. Grepping the file for that signal returns six lines, quoted whole so the count and the classification are checkable rather than asserted:
:176 iui_regs_inv_expt,
:270 input iui_regs_inv_expt;
:802 wire iui_regs_inv_expt;
:1098 || iui_regs_inv_expt
:2045 else if((rtu_cp0_expt_vld || iui_regs_inv_expt) && !mdeleg_vld)
:2376 else if((rtu_cp0_expt_vld || iui_regs_inv_expt) && mdeleg_vld)
The last two are the mtval and stval guards quoted above, and they are the only two in a guard anywhere in the file. Three of the remaining four are declarations. The fourth is one term of a clock-gating enable:
:1092 assign regs_flush_clk_en = rtu_yy_xx_flush || iui_regs_sel
:1098 || iui_regs_inv_expt
That one is worth naming rather than waving past: it can wake the gated clock these flops run on, which is part of how the early write happens at all, but it is an enable and cannot write a register.
Every other trap-entry register in the file is written on rtu_cp0_expt_vld alone. Each guard is quoted with the assignment underneath it, so the attribution is checkable rather than a list to be trusted:
Every other trap-entry write in `ct_cp0_regs.v`, guard and assignment
:1620 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:1621 mpp[1:0] <= pm[1:0];
:1635 else if(rtu_cp0_expt_vld && mdeleg_vld)
:1636 spp <= pm[0];
:1655 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:1656 mpie <= mie_bit;
:1669 else if(rtu_cp0_expt_vld && mdeleg_vld)
:1670 spie <= sie_bit;
:1685 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:1686 mie_bit <= 1'b0;
:1699 else if(rtu_cp0_expt_vld && mdeleg_vld)
:1700 sie_bit <= 1'b0;
:1987 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:1988 mepc_reg[62:0] <= rtu_cp0_epc[63:1];
:2009 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:2010 m_intr <= rtu_yy_xx_expt_vec[5];
:2021 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:2022 m_vector[4:0] <= rtu_yy_xx_expt_vec[4:0];
:2318 else if(rtu_cp0_expt_vld && mdeleg_vld)
:2319 sepc_reg[62:0] <= rtu_cp0_epc[63:1];
:2340 else if(rtu_cp0_expt_vld && mdeleg_vld)
:2341 s_intr <= rtu_yy_xx_expt_vec[5];
:2352 else if(rtu_cp0_expt_vld && mdeleg_vld)
:2353 s_vector[4:0] <= rtu_yy_xx_expt_vec[4:0];
:2653 if(rtu_cp0_expt_vld && !mdeleg_vld)
:2654 pm_wdata[1:0] = 2'b11;
:2655 else if(rtu_cp0_expt_vld && mdeleg_vld)
:2656 pm_wdata[1:0] = 2'b01;
Fourteen guards, seven register pairs — the privilege mode's own next value, the privilege stack, the interrupt-enable stack, the exception PC, and the two halves of the cause register, each with its M-mode and S-mode arm — and not one of them admits the early term. Within this file the early term is the exception and not the pattern, and stval shares it because mtval and stval are the same write pair.
A fragment out of a priority chain cannot show that the guard quoted is the only condition under which the register is written, so here are both chains in full — the mtval block and, for the paragraph below on interrupts, the mcause one, both from ct_cp0_regs.v:
The complete always blocks
:2041 always @(posedge regs_flush_clk or negedge cpurst_b)
:2042 begin
:2043 if(!cpurst_b)
:2044 mtval_data[63:0] <= 64'b0;
:2045 else if((rtu_cp0_expt_vld || iui_regs_inv_expt) && !mdeleg_vld)
:2046 mtval_data[63:0] <= mtval_upd_data[63:0];
:2047 else if(mtval_local_en)
:2048 mtval_data[63:0] <= iui_regs_src0[63:0];
:2049 else
:2050 mtval_data[63:0] <= mtval_data[63:0];
:2051 end
:2052 assign mtval_value[63:0] = mtval_data[63:0];
:2005 always @(posedge regs_flush_clk or negedge cpurst_b)
:2006 begin
:2007 if(!cpurst_b)
:2008 m_intr <= 1'b0;
:2009 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:2010 m_intr <= rtu_yy_xx_expt_vec[5];
:2011 else if(mcause_local_en)
:2012 m_intr <= iui_regs_src0[63];
:2013 else
:2014 m_intr <= m_intr;
:2015 end
Both ranges are quoted line for line, :2041 to :2052 and :2005 to :2015, with nothing omitted — an excerpt could not establish what follows. Three arms each: reset, trap entry, and the CSR write, then the hold. No higher-priority arm can mask the trap-entry one, mtval_value is the readback and is the register itself, and there is no fourth write path — which is what makes the one-line change below sufficient rather than merely necessary.
Reproduction
A full reproduction — tool version strings, the exact commands, every test program and the whole of each run's output, each with a sha256 — is posted as the first comment on this issue.
Every transcript excerpt in this report is reformatted for reading — the probe's cycle column moved to the front, 0x added, short annotations appended, and lines the paragraph is not about left out. The reproduction comment carries each run's output verbatim and in full, so a line here can always be found there.
Whole SoC from reset, vendor smart_run Verilator flow, rv64imafdc, Verilator 5.048.
openc910 at b91c90914c19f114d35c8f6b73408eb241ed847c
Software writes a sentinel 0x0123456789abcdef into mtval from M-mode — which the specification explicitly permits — then delegates illegal-instruction (medeleg[2]), drops to S-mode and executes csrr a0, mstatus, which traps and is handled in S-mode.
Observed, in the reformatted shape stated above — the probe's own order is PROBE-EVENT cyc=<n> inv_expt=...:
cyc=2784 PROBE-MTVAL 0123456789abcdef // the sentinel software wrote
cyc=3291 PROBE-EVENT inv_expt=1 expt_vld=0 mdeleg=0 upd=0000000030002573
cyc=3292 PROBE-MTVAL 0000000030002573 // <-- mtval clobbered with the opcode
cyc=3294 PROBE-EVENT inv_expt=0 expt_vld=1 mdeleg=1 upd=0000000030002573
cyc=3295 PROBE-STVAL 0000000030002573 // stval written, correctly
STRAP 0000000000000002 0000000030002573 // the trap is handled in S-mode
The mtval write at 3292 follows the early event at 3291, before the trap event at 3294 has asserted mdeleg.
The STRAP line confirms the trap really went to S-mode. mtval was written anyway, and the value software had placed there is gone.
Controls, in companion runs of the same build — each case is its own simulation, so each transcript below is a separate run of the same model. A delegated ecall takes no early event, and the sentinel survives:
cyc=2784 PROBE-MTVAL 0123456789abcdef // the sentinel, still there at the end
cyc=3264 PROBE-EVENT inv_expt=0 expt_vld=1 mdeleg=1 upd=0000000000000000
STRAP 0000000000000009 0000000000000000 // ecall from S-mode, delegated
A non-delegated illegal instruction in S-mode, and one in M-mode, both write mtval correctly and read it back architecturally — these two are read by software, not by the probe:
MTRAP 0000000000000002 0000000030002573
MTRAP 0000000000000002 00000000f14090f3
About observability, stated plainly
The PROBE- lines above come from a testbench probe on the mtval write, not from software. That is not a weakening we chose — no software sequence can read mtval after a delegated trap, because entering M-mode to read it is itself a trap into M-mode, and every trap into M writes mtval. The specification sentence is precisely about a write that software cannot observe after the fact.
The obvious escape is an interrupt rather than an exception, and ct_cp0_regs.v closes that one too. mcause's interrupt bit is written under rtu_cp0_expt_vld && !mdeleg_vld, which IMPLIES :2045's guard rather than repeating it — :2045 admits the same condition and one more — and it takes its value from the interrupt indicator of the exception vector. Satisfying the guard is enough here because :2045 is the highest arm of its chain apart from reset, as the complete block above shows, so nothing higher can pre-empt it. A trap that reports an interrupt cause in M-mode has therefore necessarily written mtval as well:
:2009 else if(rtu_cp0_expt_vld && !mdeleg_vld)
:2010 m_intr <= rtu_yy_xx_expt_vec[5];
:2039 assign mtval_upd_data[63:0] = rtu_cp0_expt_vld ? rtu_cp0_expt_mtval[63:0]
:2040 : {32'b0, iui_regs_opcode[31:0]};
Neither guard discriminates between an interrupt and an exception, and the second line of :2039 is where the early window's upd=0x30002573 comes from: with rtu_cp0_expt_vld still 0, the update data is the IUI opcode. So an M-mode timer interrupt used to re-enter M-mode and read mtval overwrites it on the way in.
What it overwrites it with is decidable from the source, in C910_RTL_FACTORY/gen_rtl/rtu/rtl/ct_rtu_retire.v — quoted whole for the same reason as above, since the answer depends on where the interrupt arm sits in the chain:
The trap-value selection in full, then the two assignments that carry it out
:1212 // &CombBeg; @157
:1213 always @( rob_retire_inst0_mtval[39:0]
:1214 or retire_async_expt_vld
:1215 or rob_retire_inst0_immu_expt
:1216 or rob_retire_inst0_next_pc[38:11]
:1217 or ae_phy_addr[39:0]
:1218 or retire_ack_int
:1219 or rob_retire_inst0_high_hw_expt
:1220 or rob_retire_inst0_cur_pc[38:0])
:1221 begin
:1222 if(retire_async_expt_vld)
:1223 retire_expt_mtval_src[39:0] = ae_phy_addr[39:0];
:1224 else if(retire_ack_int)
:1225 retire_expt_mtval_src[39:0] = 40'b0;
:1226 else if(rob_retire_inst0_immu_expt && !rob_retire_inst0_high_hw_expt)
:1227 retire_expt_mtval_src[39:0] = {rob_retire_inst0_cur_pc[38:0],1'b0};
:1228 //32 bit inst cross 4k page fault, high half-word is 4k align of next pc
:1229 else if(rob_retire_inst0_immu_expt)
:1230 retire_expt_mtval_src[39:0] = {rob_retire_inst0_next_pc[38:11],12'b0};
:1231 else
:1232 retire_expt_mtval_src[39:0] = rob_retire_inst0_mtval[39:0];
:1233 // &CombEnd; @169
:1234 end
:1236 assign retire_expt_mtval[63:0] =
:1237 mmu_xx_mmu_en && !retire_async_expt_vld
:1238 ? {{24{retire_expt_mtval_src[39]}}, retire_expt_mtval_src[39:0]}
:1239 : {24'b0, retire_expt_mtval_src[39:0]};
:1244 assign retire_ack_int = retire_expt_int;
:1313 assign rtu_cp0_expt_mtval[63:0] = retire_expt_mtval[63:0];
:1212 to :1239 is quoted line for line apart from the blank line :1235; :1244 and :1313 are two further assignments in the same file, quoted because the chain needs them.
The qualification first, because it is what a single-line quotation would have hidden: the interrupt arm at :1224 is second in the chain, under retire_async_expt_vld, which is the access-error state of the retire unit's asynchronous-exception machine (:2163 assign retire_async_expt_vld = (ae_cur_state[1:0] == AE_EXPT);). We did not characterise whether that arm can be live in the same retire as an interrupt, so what follows holds for an interrupt taken with no asynchronous exception valid alongside it. With that said: zero. The widening at :1236 sign-extends a value that is all zeros, so it is zero at 64 bits either way.
The other two terms are one line each in the same file, for a reader who wants the chain closed rather than named:
:1198 assign retire_expt_int = rob_retire_inst0_int_vld
:1199 && !rob_retire_inst0_split
:1200 && !rob_retire_inst0_intmask;
:2163 assign retire_async_expt_vld = (ae_cur_state[1:0] == AE_EXPT);
That the 40-bit selection above is the same wire as the 64-bit rtu_cp0_expt_mtval the mtval block reads is not an inference from the names. :1313 is the module output, and ct_core.v declares the wire and connects it to both instances:
:2054 wire [63 :0] rtu_cp0_expt_mtval;
:4688 .rtu_cp0_expt_mtval (rtu_cp0_expt_mtval ),
:4919 .rtu_cp0_expt_mtval (rtu_cp0_expt_mtval ),
So the interrupt path does not carry a second wrong value — it carries the benign one, and still destroys anything software had placed there. We are deliberately not building a second finding on that write: 1.10's paragraph enumerates the exceptions for which mtval is written and says it "is not modified for other exceptions", and says nothing about interrupts, so whether a zeroing write on an interrupt is permitted is a reading of 1.10 we are not going to assert. This whole section is read from the source and was not separately simulated; the reproduction above measures the exception path.
The probe testbench is the vendor's tb_verilator.v plus inserted lines only; the tooling refuses to run if the diff against the vendor file contains anything but insertions. It is one block, and this is all of the code in it:
`define CP0_REGS `CPU_TOP.x_ct_top_0.x_ct_core.x_ct_cp0_top.x_ct_cp0_regs
always @(posedge clk)
if(rst_b)
begin
if(`CP0_REGS.iui_regs_inv_expt || `CP0_REGS.rtu_cp0_expt_vld)
$display("PROBE-EVENT cyc=%0d inv_expt=%b expt_vld=%b mdeleg=%b upd=%h",
cycle_count, `CP0_REGS.iui_regs_inv_expt,
`CP0_REGS.rtu_cp0_expt_vld, `CP0_REGS.mdeleg_vld,
`CP0_REGS.mtval_upd_data);
if(`CP0_REGS.mtval_data !== probe_mtval_last)
begin
$display("PROBE-MTVAL cyc=%0d %h", cycle_count, `CP0_REGS.mtval_data);
probe_mtval_last = `CP0_REGS.mtval_data;
end
if(`CP0_REGS.stval_data !== probe_stval_last)
begin
$display("PROBE-STVAL cyc=%0d %h", cycle_count, `CP0_REGS.stval_data);
probe_stval_last = `CP0_REGS.stval_data;
end
end
It reads flops, drives nothing, and takes no trap, so it cannot be the cause of what it reports. The two probe_*_last registers are declared and initialised beside it; the surrounding comment block is omitted here.
The practical consequence is the one the sentence protects: mtval cannot hold M-mode state across S-mode execution on this core, even though the specification explicitly permits software to write it.
Suggested change
Drop the early term from the two guards, leaving the arm every other trap-entry register in the file already uses:
diff --git a/C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_regs.v b/C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_regs.v
index 11e7518..391041d 100644
--- a/C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_regs.v
+++ b/C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_regs.v
@@ -2042,7 +2042,7 @@ always @(posedge regs_flush_clk or negedge cpurst_b)
begin
if(!cpurst_b)
mtval_data[63:0] <= 64'b0;
- else if((rtu_cp0_expt_vld || iui_regs_inv_expt) && !mdeleg_vld)
+ else if(rtu_cp0_expt_vld && !mdeleg_vld)
mtval_data[63:0] <= mtval_upd_data[63:0];
else if(mtval_local_en)
mtval_data[63:0] <= iui_regs_src0[63:0];
@@ -2373,7 +2373,7 @@ always @(posedge regs_flush_clk or negedge cpurst_b)
begin
if(!cpurst_b)
stval_data[63:0] <= 64'b0;
- else if((rtu_cp0_expt_vld || iui_regs_inv_expt) && mdeleg_vld)
+ else if(rtu_cp0_expt_vld && mdeleg_vld)
stval_data[63:0] <= stval_upd_data[63:0];
else if(stval_local_en)
stval_data[63:0] <= iui_regs_src0[63:0];
That block is a file git apply will take from the repository root, checked against b91c909 as it stands rather than being retyped here. sha256 08f408027946a456921c4a8bacd9d0093136820a875b607d4e5ae03a5456f620 is over exactly those bytes. Take it with the code block's own copy control rather than by selecting the text; the note below says why that matters here.
The two hunks touch only the mtval and stval trap-entry guards in ct_cp0_regs.v and do not reach the pending-bit assignments earlier in that file, so they do not overlap them; note that it does change ct_cp0_regs.v, so if you are applying more than one patch to that file, apply each to a clean tree or expect the second index pre-image not to match.
Why the copy method matters for this diff
Here all four changed lines end in one of the vendor file's trailing spaces, and selecting the text by hand out of a rendered page is free to drop them. sha256sum on what you pasted answers whether the copy survived. If it does not match, recopy rather than reaching for a flag: we checked, and a copy with those four spaces gone applies under neither git apply nor git apply --ignore-whitespace, because the removed lines no longer match the file they are removing.
We re-elaborated the entire SoC from this patched source and re-ran the four cases; the reproduction comment carries every line of both runs under The whole output, with the suggested change applied. The clobbering write disappears and the sentinel survives. In the two non-delegated cases the write does not disappear but relocates to the trap event three cycles later with the same value — case C moves from cycle 3292 to 3295 and case D from 2834 to 2837 — and the architectural MTRAP readbacks are unchanged. That is a measured result, not a prescription.
Only the first of those two lines has a measurement behind it, and the diff says both because both are what we built. In every case we ran, :2376's early term contributed nothing: mdeleg_vld reads 0 while iui_regs_inv_expt is asserted, which fails that guard, and the stval write at 3295 comes from the ordinary rtu_cp0_expt_vld arm. We are not claiming the term is dead — its guard does not depend on rtu_cp0_expt_vld, so the source does not rule out a case in which it fires, and we did not construct one. The second line is a symmetric change carrying no evidence of its own; it is in the diff because the model we elaborated and measured contains it, and separating the two would mean publishing a patch we never built. To drop it, delete the second hunk — the one headed @@ -2373 — and the remainder applies on its own; the two hunks touch different registers and neither depends on the other.
Practical scenario
The first consequence needs no hypothetical user, and the evidence for it is the guard table above. mepc at :1987, and both halves of mcause at :2009 and :2021, take their trap-entry value under rtu_cp0_expt_vld && !mdeleg_vld and nothing more; each also has a CSR write arm below it, which is the software write the architecture requires and not a second trap path. mtval at :2045 is the one whose trap-entry arm carries an extra term. Those three registers, across four trap-entry write sites, exist to describe one trap between them, and after any delegated exception they describe two: mcause and mepc still hold the last trap that genuinely entered M-mode, while mtval holds an instruction that was handled entirely in S-mode. The M-mode trap record is internally inconsistent, and it is the extra term on one register that makes it so.
State the same limit here as in the observability section: that inconsistency is not software-observable either, for the same reason — every path back to M-mode rewrites mtval before anything can read it. It is a better statement of the same fact, not a second finding.
The sentence also makes one specific pattern legal: mtval may be written by software, and is otherwise written only on traps into M-mode, so a value placed there survives S-mode execution. Machine-mode firmware that uses it as scratch across an sret — an SBI implementation, a monitor staging a value before resuming a supervisor, a nested-trap debug path — gets the value back on this core only if no delegated exception occurred in between, and reads the opcode of whatever S-mode instruction trapped if one did. We did not find such a user in shipped firmware and do not claim one exists. The claim is narrower: the specification permits the pattern, and this core does not support it.
Summary
On
mainatb91c909, the machine-levelmtvalis written by an early trap-entry path that is not conditioned on the trap actually being taken into M-mode. When an exception is delegated and handled in S-mode,mtvalis clobbered with that exception's value even though no trap into M-mode occurred.The divergent write is visible in the RTL and is measured below by a testbench probe. It cannot be read by a software sequence after the fact, and the reason is architectural rather than a property of this core: the observability section states it and closes the interrupt route as well.
The rule
Privileged Architecture 1.10,
src/machine.tex:The sentence entered the manual twenty-six days before 1.10 was tagged, under the subject "Clarify [s/m][epc/tval/cause] are only written on exceptions into that mode" — the commit message states the rule directly. It is verbatim in ratified 1.11 and 1.12.
The C910 manual §1.5 declares Privileged Architecture 1.10 and names exactly one post-1.10 adoption (
mcountinhibit), so this rule is in the declared target. The manual carries one version statement and no later or narrower one.We deliberately do not rest this on 1.12's delegated-trap list, which additionally names
mtvalwhere 1.10's corresponding paragraph enumerates onlymcause,mepc, MPP and MPIE. The claim stands on the 1.10 sentence above.What the RTL does
C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_regs.v. Themtvalwrite has two arms, and the early one is not qualified by the trap's destination:iui_regs_inv_exptasserts beforertu_cp0_expt_vld, and while it is assertedmdeleg_vldstill reads 0 for the exception in question — so!mdeleg_vldis satisfied and the early term admits a write for an exception that is about to be delegated to S-mode. In the run below the two events are three cycles apart (3291 and 3294); we report the measured separation rather than a fixed number, because the flop between them lives inct_cp0_iui.vand we have not characterised its latency across cases.That
mdeleg_vldreads 0 during the early window is the pivot of the whole report, so here is what it is made of, inct_cp0_regs.v:It is a function of the privilege mode and of the exception vector RTU presents, and it does not reference
rtu_cp0_expt_vldat all, so it is not a delayed copy of the trap event. The 0 in the probe line below is therefore a measured value of this expression during the early window, not an assumption about pipeline timing. What we did not establish is which of its terms reads 0 there, which is also why the separation above is reported as measured rather than derived."Early" here means early relative to the trap, not early relative to commit, and that bound matters. A signal that wrote an architectural register from the execution stage would raise a second and worse question — whether a flushed wrong-path instruction can write
mtvalwith no trap taken at all — and the answer is no:iui_regs_inv_exptis itself qualified by RTU's commit of that exact instruction. The divergence here is about WHICH register a committed, trapping instruction writes, and not about speculative state escaping a flush.The commit qualification, and where the six occurrences of the early term are
In
ct_cp0_iui.v:A wrong-path instruction never appears on
rtu_yy_xx_commit0with its owniid, so it never assertsiui_regs_inv_exptand never reaches:2045. That is where we stop following the chain, and the reason is that the two terms answer the question between them: commit is by definition after the flush that would kill a wrong-path instruction, and theiidcomparison makes the signal specific to this instruction rather than to whatever else is committing.Back in
ct_cp0_regs.v,mtvalandstvalare the only two registers in the file whose trap-entry arm carriesiui_regs_inv_expt, and one grep shows it. Grepping the file for that signal returns six lines, quoted whole so the count and the classification are checkable rather than asserted:The last two are the
mtvalandstvalguards quoted above, and they are the only two in a guard anywhere in the file. Three of the remaining four are declarations. The fourth is one term of a clock-gating enable:That one is worth naming rather than waving past: it can wake the gated clock these flops run on, which is part of how the early write happens at all, but it is an enable and cannot write a register.
Every other trap-entry register in the file is written on
rtu_cp0_expt_vldalone. Each guard is quoted with the assignment underneath it, so the attribution is checkable rather than a list to be trusted:Every other trap-entry write in `ct_cp0_regs.v`, guard and assignment
Fourteen guards, seven register pairs — the privilege mode's own next value, the privilege stack, the interrupt-enable stack, the exception PC, and the two halves of the cause register, each with its M-mode and S-mode arm — and not one of them admits the early term. Within this file the early term is the exception and not the pattern, and
stvalshares it becausemtvalandstvalare the same write pair.A fragment out of a priority chain cannot show that the guard quoted is the only condition under which the register is written, so here are both chains in full — the
mtvalblock and, for the paragraph below on interrupts, themcauseone, both fromct_cp0_regs.v:The complete always blocks
Both ranges are quoted line for line,
:2041to:2052and:2005to:2015, with nothing omitted — an excerpt could not establish what follows. Three arms each: reset, trap entry, and the CSR write, then the hold. No higher-priority arm can mask the trap-entry one,mtval_valueis the readback and is the register itself, and there is no fourth write path — which is what makes the one-line change below sufficient rather than merely necessary.Reproduction
A full reproduction — tool version strings, the exact commands, every test program and the whole of each run's output, each with a
sha256— is posted as the first comment on this issue.Every transcript excerpt in this report is reformatted for reading — the probe's cycle column moved to the front,
0xadded, short annotations appended, and lines the paragraph is not about left out. The reproduction comment carries each run's output verbatim and in full, so a line here can always be found there.Whole SoC from reset, vendor
smart_runVerilator flow,rv64imafdc, Verilator 5.048.Software writes a sentinel
0x0123456789abcdefintomtvalfrom M-mode — which the specification explicitly permits — then delegates illegal-instruction (medeleg[2]), drops to S-mode and executescsrr a0, mstatus, which traps and is handled in S-mode.Observed, in the reformatted shape stated above — the probe's own order is
PROBE-EVENT cyc=<n> inv_expt=...:The
mtvalwrite at 3292 follows the early event at 3291, before the trap event at 3294 has assertedmdeleg.The
STRAPline confirms the trap really went to S-mode.mtvalwas written anyway, and the value software had placed there is gone.Controls, in companion runs of the same build — each case is its own simulation, so each transcript below is a separate run of the same model. A delegated
ecalltakes no early event, and the sentinel survives:A non-delegated illegal instruction in S-mode, and one in M-mode, both write
mtvalcorrectly and read it back architecturally — these two are read by software, not by the probe:About observability, stated plainly
The
PROBE-lines above come from a testbench probe on themtvalwrite, not from software. That is not a weakening we chose — no software sequence can readmtvalafter a delegated trap, because entering M-mode to read it is itself a trap into M-mode, and every trap into M writesmtval. The specification sentence is precisely about a write that software cannot observe after the fact.The obvious escape is an interrupt rather than an exception, and
ct_cp0_regs.vcloses that one too.mcause's interrupt bit is written underrtu_cp0_expt_vld && !mdeleg_vld, which IMPLIES:2045's guard rather than repeating it —:2045admits the same condition and one more — and it takes its value from the interrupt indicator of the exception vector. Satisfying the guard is enough here because:2045is the highest arm of its chain apart from reset, as the complete block above shows, so nothing higher can pre-empt it. A trap that reports an interrupt cause in M-mode has therefore necessarily writtenmtvalas well:Neither guard discriminates between an interrupt and an exception, and the second line of
:2039is where the early window'supd=0x30002573comes from: withrtu_cp0_expt_vldstill 0, the update data is the IUI opcode. So an M-mode timer interrupt used to re-enter M-mode and readmtvaloverwrites it on the way in.What it overwrites it with is decidable from the source, in
C910_RTL_FACTORY/gen_rtl/rtu/rtl/ct_rtu_retire.v— quoted whole for the same reason as above, since the answer depends on where the interrupt arm sits in the chain:The trap-value selection in full, then the two assignments that carry it out
:1212to:1239is quoted line for line apart from the blank line:1235;:1244and:1313are two further assignments in the same file, quoted because the chain needs them.The qualification first, because it is what a single-line quotation would have hidden: the interrupt arm at
:1224is second in the chain, underretire_async_expt_vld, which is the access-error state of the retire unit's asynchronous-exception machine (:2163 assign retire_async_expt_vld = (ae_cur_state[1:0] == AE_EXPT);). We did not characterise whether that arm can be live in the same retire as an interrupt, so what follows holds for an interrupt taken with no asynchronous exception valid alongside it. With that said: zero. The widening at:1236sign-extends a value that is all zeros, so it is zero at 64 bits either way.The other two terms are one line each in the same file, for a reader who wants the chain closed rather than named:
That the 40-bit selection above is the same wire as the 64-bit
rtu_cp0_expt_mtvalthemtvalblock reads is not an inference from the names.:1313is the module output, andct_core.vdeclares the wire and connects it to both instances:So the interrupt path does not carry a second wrong value — it carries the benign one, and still destroys anything software had placed there. We are deliberately not building a second finding on that write: 1.10's paragraph enumerates the exceptions for which
mtvalis written and says it "is not modified for other exceptions", and says nothing about interrupts, so whether a zeroing write on an interrupt is permitted is a reading of 1.10 we are not going to assert. This whole section is read from the source and was not separately simulated; the reproduction above measures the exception path.The probe testbench is the vendor's
tb_verilator.vplus inserted lines only; the tooling refuses to run if the diff against the vendor file contains anything but insertions. It is one block, and this is all of the code in it:It reads flops, drives nothing, and takes no trap, so it cannot be the cause of what it reports. The two
probe_*_lastregisters are declared and initialised beside it; the surrounding comment block is omitted here.The practical consequence is the one the sentence protects:
mtvalcannot hold M-mode state across S-mode execution on this core, even though the specification explicitly permits software to write it.Suggested change
Drop the early term from the two guards, leaving the arm every other trap-entry register in the file already uses:
That block is a file
git applywill take from the repository root, checked againstb91c909as it stands rather than being retyped here.sha256 08f408027946a456921c4a8bacd9d0093136820a875b607d4e5ae03a5456f620is over exactly those bytes. Take it with the code block's own copy control rather than by selecting the text; the note below says why that matters here.The two hunks touch only the
mtvalandstvaltrap-entry guards inct_cp0_regs.vand do not reach the pending-bit assignments earlier in that file, so they do not overlap them; note that it does changect_cp0_regs.v, so if you are applying more than one patch to that file, apply each to a clean tree or expect the secondindexpre-image not to match.Why the copy method matters for this diff
Here all four changed lines end in one of the vendor file's trailing spaces, and selecting the text by hand out of a rendered page is free to drop them.
sha256sumon what you pasted answers whether the copy survived. If it does not match, recopy rather than reaching for a flag: we checked, and a copy with those four spaces gone applies under neithergit applynorgit apply --ignore-whitespace, because the removed lines no longer match the file they are removing.We re-elaborated the entire SoC from this patched source and re-ran the four cases; the reproduction comment carries every line of both runs under The whole output, with the suggested change applied. The clobbering write disappears and the sentinel survives. In the two non-delegated cases the write does not disappear but relocates to the trap event three cycles later with the same value — case C moves from cycle 3292 to 3295 and case D from 2834 to 2837 — and the architectural
MTRAPreadbacks are unchanged. That is a measured result, not a prescription.Only the first of those two lines has a measurement behind it, and the diff says both because both are what we built. In every case we ran,
:2376's early term contributed nothing:mdeleg_vldreads 0 whileiui_regs_inv_exptis asserted, which fails that guard, and thestvalwrite at 3295 comes from the ordinaryrtu_cp0_expt_vldarm. We are not claiming the term is dead — its guard does not depend onrtu_cp0_expt_vld, so the source does not rule out a case in which it fires, and we did not construct one. The second line is a symmetric change carrying no evidence of its own; it is in the diff because the model we elaborated and measured contains it, and separating the two would mean publishing a patch we never built. To drop it, delete the second hunk — the one headed@@ -2373— and the remainder applies on its own; the two hunks touch different registers and neither depends on the other.Practical scenario
The first consequence needs no hypothetical user, and the evidence for it is the guard table above.
mepcat:1987, and both halves ofmcauseat:2009and:2021, take their trap-entry value underrtu_cp0_expt_vld && !mdeleg_vldand nothing more; each also has a CSR write arm below it, which is the software write the architecture requires and not a second trap path.mtvalat:2045is the one whose trap-entry arm carries an extra term. Those three registers, across four trap-entry write sites, exist to describe one trap between them, and after any delegated exception they describe two:mcauseandmepcstill hold the last trap that genuinely entered M-mode, whilemtvalholds an instruction that was handled entirely in S-mode. The M-mode trap record is internally inconsistent, and it is the extra term on one register that makes it so.State the same limit here as in the observability section: that inconsistency is not software-observable either, for the same reason — every path back to M-mode rewrites
mtvalbefore anything can read it. It is a better statement of the same fact, not a second finding.The sentence also makes one specific pattern legal:
mtvalmay be written by software, and is otherwise written only on traps into M-mode, so a value placed there survives S-mode execution. Machine-mode firmware that uses it as scratch across ansret— an SBI implementation, a monitor staging a value before resuming a supervisor, a nested-trap debug path — gets the value back on this core only if no delegated exception occurred in between, and reads the opcode of whatever S-mode instruction trapped if one did. We did not find such a user in shipped firmware and do not claim one exists. The claim is narrower: the specification permits the pattern, and this core does not support it.