Skip to content

mtval is written on delegated traps handled in S-mode, which Priv 1.10 forbids ("otherwise, mtval is never written by the implementation") #66

Description

@dreamqin68

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions