Skip to content

mip.SSIP and mip.STIP OR in an external CLINT source, so Priv 1.10's acknowledge cannot clear them #62

Description

@dreamqin68

Summary

On main at b91c909, mip.SSIP is the logical OR of a software-writable register and an external CLINT line. Privileged Architecture 1.10 grants that pattern to SEIP (and UEIP) and to no other bit; for SSIP it says the opposite — that software both reads and writes it.

The two bits are not equally evidenced, and the title names both anyway. Everything measured below is SSIP. mip.STIP is the same expression with one letter changed, one line above it, and it was deliberately left untouched in our experiment so that it could serve as a control — so it is argued from the source rather than reproduced. The section at the end says why that is still worth naming in the title rather than hiding behind a narrower one.

The consequence is not cosmetic. An S-mode handler that acknowledges the interrupt exactly as the architecture prescribes cannot clear it, and the hart re-enters the handler until it gives up.

A store, the acknowledge the architecture prescribes, and a mip readback after each show it, with nothing enabled in mie and no handler involved. The third readback follows a store that clears the CLINT register itself. This is case C of the run reported below, reformatted for reading: 0x is added and short annotations are appended. The reproduction comment carries the verbatim lines.

MIP     0x0000000000000002    // after the store to the CLINT
MIP     0x0000000000000002    // after csrc sip, 2 -- the prescribed acknowledge, unchanged
MIP     0x0000000000000000    // only once the CLINT register itself is written back to 0

mip.STIP on the adjacent line is the same expression and the same finding; the last section says why it is reported here and why the title names both.

The rule

Privileged Architecture 1.10, src/machine.tex. The OR pattern is granted, by name, to one bit:

The SEIP field in mip contains a single read-write bit. … The logical-OR of the software-writeable bit and the signal from the external interrupt controller is used to generate external interrupts to the supervisor. … However, the value used in the read-modify-write sequence of a CSRRS or CSRRC instruction is only the software-writable SEIP bit, ignoring the interrupt value from the external interrupt controller.

and then to exactly one more:

The UEIP field in mip … is defined analogously to SEIP.

For the two bits at issue it says something different in kind:

The UTIP and STIP bits may be written by M-mode software to deliver timer interrupts to lower privilege levels. User and supervisor software may clear the UTIP and STIP bits with calls to the AEE and SEE respectively.

Each lower privilege level has a separate software interrupt-pending bit (SSIP, USIP), which can be both read and written by CSR accesses from code running on the local hart at the associated or any higher privilege level.

That the specification spends a paragraph of CSR-instruction machinery on SEIP — because the OR breaks the ordinary read-write contract — and then declines to extend it beyond UEIP is what makes the enumeration load-bearing rather than an omission.

The C910 manual §1.5 declares Privileged Architecture 1.10 and names exactly one post-1.10 adoption (mcountinhibit), so these sentences are in the declared target rather than in a later version this core never claimed. The manual carries one version statement and no later or narrower one:

riscv-priv-1.10 eb86a900f418a5436b8e31abc0563be3cb402a16   // tagged 2017-05-07

The passages above are not carried forward unchanged into the later releases. Where they go is the first place a reader will look for a defence, so this report states it rather than leaving it to be found: they survive ratified 1.11 with one spelling change, and ratified 1.12 replaces them, with a sentence that does not license a held-level OR. The claim here rests on 1.10, which is the declared target.

What ratified 1.11 and 1.12 do to these four passages, and why 1.12 is not a defence

Everything below is our finding, and not the manual's words, except for the one block that says otherwise on its own line.

Ratified 1.11 — this report's finding:

Present, with one spelling change: 1.10's software-writeable becomes software-writable in the SEIP paragraph. The read-modify-write exemption, the UTIP and STIP sentence and the SSIP sentence are unchanged.

Ratified 1.12 — this report's finding:

Absent. Privileged Architecture 1.12 removes the N extension and rewrites the mip section bit by bit; not one of these four sentences survives. Its rewritten STIP sentence does reuse one clause of the 1.10 UTIP-and-STIP sentence, so absence here is a statement about whole sentences and not about every fragment of them.

Quotation, Privileged Architecture 1.12 at 98964261, src/machine.tex — what it puts in place of the SSIP sentence:

SSIP is writable in mip and may also be set to 1 by a platform-specific interrupt controller.

That sentence does not rescue this design, and the distinction is the same one the ACLINT section below turns on. This report's finding:

1.12 permits a platform-specific controller to SET the software-writable bit, which is an event: after it, a software clear is the last word. That is the ACLINT SSWI shape. A continuous level OR-ed into the read value is a different thing, because the software clear never takes effect while the level is asserted.

One more answer this report should meet head-on. mxstatus.clintee is a custom C910 control, and the natural first reply is that the OR is a documented C910 extension rather than a divergence. It is not a defence: the vendor's own declaration is the RISC-V Privileged Architecture at 1.10, and a custom control that changes the reset behaviour of an architectural CSR does not move the CSR out of that declaration. The manual's one carve-out is named, and it is mcountinhibit.

What the RTL does

C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_regs.v, three adjacent lines:

:2115  assign seip = biu_cp0_se_int || seip_reg;              // the OR the architecture GRANTS
:2116  assign stip = biu_cp0_st_int && clintee || stip_reg;   // same shape, not granted
:2117  assign ssip = biu_cp0_ss_int && clintee || ssip_reg;   // same shape, not granted

:2115 is correct and is the reference for what the other two are doing. mxstatus.clintee gates the two extra ORs, but it resets to 1'b1, so the divergent configuration is what a core comes out of reset in. Clearing clintee is a software opt-out, not a fix — and it is a custom C910 control, not something portable software knows to touch.

ssip_reg is the software-writable bit, and assign ssip = ... || ssip_reg is the value the readback and the interrupt-enable logic use. Clearing ssip_reg therefore does not clear ssip while the CLINT line is asserted.

Two things are quoted below rather than asserted: that clintee really does reset asserted, and that the same ssip wire is what mip returns, what sip returns, and what raises the interrupt.

The reset arm, and all three consumers of the wire

mxstatus.clintee resets asserted, in the same file. The single line would not show that this is the reset arm rather than some configuration path, so here is the whole of it, :2722 to :2732:

:2722   if (!cpurst_b)
:2723   begin
:2724     cskyisaee <= 1'b1;
:2725     maee      <= 1'b1;
:2726     insde     <= 1'b0;
:2727     mhrd      <= 1'b0;
:2728     clintee   <= 1'b1;
:2729     ucme      <= 1'b1;
:2730     pmdm      <= 1'b0;
:2731   end
:2732   else if(mxstatus_local_en)

The only other arm is the CSR write at :2732, which is what makes the sentence above exact: clearing clintee is a software action taken after reset, not a configuration the core can come up in.

The three consumers of ssip are in the same file too, so the wire above is the one software reads through mip, the one it reads through sip, and the one that raises the interrupt:

:2119 assign mip_value[63:0] =  {45'b0, mhip, moip, mcip, 4'b0,
:2120                                   meip, 1'b0, seip, 1'b0,
:2121                                   mtip, 1'b0, stip, 1'b0,
:2122                                   msip, 1'b0, ssip, 1'b0};
:2133 assign ssip_en = ssie && ssip;
:2394 assign sip_value[63:0] =  {45'b0, mhip && mhip_acc_en,
:2395                             moip && moip_acc_en, mcip && mcip_acc_en,
:2396                             6'b0, seip && seip_acc_en, 1'b0,
:2397                             2'b0, stip && stip_acc_en, 1'b0,
:2398                             2'b0, ssip && ssip_acc_en, 1'b0};
:2078 assign ssip_acc_en = mideleg_value[1];

Both readback expressions are four concatenated groups, and SSIP is bit 1, so it is the second element from the right in the last group: ssip in mip_value, ssip && ssip_acc_en in sip_value. The same wire, once bare and once masked.

That settles a question a reader will otherwise ask about case A. sip is not an independent register that might disagree with mip: case A delegates SSIP and is entered in S-mode, so mideleg[1] is 1, so ssip_acc_en is 1, so sip exposes exactly the OR that mip does. That is why an S-mode handler writing sip has no way to reach the external term, and why the acknowledge fails rather than partially working.

:2133 is the third consumer and the one that explains the ENTRIES count in case A: the interrupt REQUEST follows the same OR, not the software bit, so clearing ssip_reg leaves the request asserted and the handler is re-entered on every return.

The SEIP read-modify-write exemption is implemented, which is why this reads as an oversight

The SEIP paragraph's hardest clause is not the OR: it is the read-modify-write exemption, that a CSRRS or CSRRC must operate on the software-writable bit alone. This core implements it. So the paragraph was read, and the OR half of the pattern was then extended to two bits the same section describes as software-written and software-cleared, without the machinery that makes the pattern workable.

The exemption in the RTL, and what it settles

The SEIP paragraph's most demanding sentence is not the OR. It is the read-modify-write exemption — that a CSRRS or CSRRC must operate on the software-writable bit alone and ignore the external controller's value. That clause is awkward to implement, and this core implements it:

In ct_cp0_regs.v, quoted to the end of the statement:

:2063 assign seip_upd_val = (iui_regs_csrw || iui_regs_ori_src0[9])
:2064                      ? iui_regs_src0[9] : seip_reg;

and the signal it turns on, in C910_RTL_FACTORY/gen_rtl/cp0/rtl/ct_cp0_iui.v, likewise to the end of the ternary and with the write-form select on the line below it:

:1432 assign iui_regs_ori_src0[63:0] = (iui_inst_csrrs || iui_inst_csrrc) ? iui_src0[63:0]
:1433                                                                     : iui_uimm[63:0];
:1434 assign iui_regs_csrw         = iui_inst_csrrw || iui_inst_csrrwi;

iui_regs_ori_src0 carries the raw rs1 mask, not the read-modify-write result. So a CSRRS or CSRRC that does not name bit 9 leaves seip_reg untouched — exactly what the exemption requires — and one that does name it sets or clears the storage rather than the OR. That machinery exists for no other purpose.

So this is not a case of the SEIP paragraph having been missed. The paragraph was read and its hardest clause was built. What appears to have happened is that the OR half of the pattern was then extended to the two bits the same section describes as software-written and software-cleared, without the machinery that makes the pattern workable — and the specification grants that pattern by name to SEIP and, analogously, UEIP, and to nothing else.

This also settles what :2115 being "correct" rests on: it is correct because the exemption at :2063 is there to support it.

How the store reaches mip, through the shipped CLINT

The interrupt is raised by an ordinary store to the SoC's own CLINT, at the vendor testbench's own base. The whole path can be confirmed by reading rather than by running, and it is the shipped CLINT rather than anything exotic.

The address, line by line, from the testbench base to the term at `:2117`

The test raises the interrupt the way software does: an ordinary store to the SoC's own CLINT. The address is not a magic number, and the whole path can be confirmed by reading rather than by running.

The base is the vendor testbench's own, in smart_run/logical/tb/tb_verilator.v — the same base the vendor's plic_int case drives:

:53 `define APB_BASE_ADDR       40'hb0000000

The CLINT select and its offset are in C910_RTL_FACTORY/gen_rtl/ciu/rtl/ct_ciu_apbif.v:

:367 `define CLINT_BASE_START 11'h400
:375 assign sel_clint = (apbif_addr[26:16] == `CLINT_BASE_START);

and the register, its write enable and its path out to the core are in C910_RTL_FACTORY/gen_rtl/clint/rtl/ct_clint_func.v:

:162 parameter SSIP0      = 16'hC000;
:285 assign ssip0_wen      = sreg_wen && paddr[15:0] == SSIP0;
:496 assign clint_core0_ss_int = ssip0_reg;

That base, plus the select shifted into bits 26:16, plus the offset, is the address the test program stores to:

#define CLINT_SSIP0  0xb400c000

:496 is where this closes on the finding: clint_core0_ss_int is the biu_cp0_ss_int term of :2117. Nothing in this path is exotic — it is the shipped CLINT, reached at the vendor testbench's own base.

One more line matters for reading case A, and it is in the same file:

:217 assign sreg_wen  = (mach_mode || supv_mode) && clint_wen;

ssip0 is writable from S-mode as well as M-mode. That is what gives the handler an escape from a trap it cannot otherwise leave: after the prescribed acknowledge has failed a fixed number of times in a row, the handler stores zero to the CLINT register itself, clears sip once more, prints, and the run ends. Without that line there would be no way out, and the entry count below would be a timeout rather than a measurement.

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 — 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, logical/tb/tb_verilator.v unmodified, rv64imafdc, Verilator 5.048. Everything below is printed by the test program itself through the vendor character port — no probe, no internal signal.

openc910 at b91c90914c19f114d35c8f6b73408eb241ed847c

Case A — external CLINT source. A store to the CLINT ssip0 register at 0xb400c000 raises the interrupt; the S-mode handler acknowledges with csrc sip, 2, the architecture's prescribed clear.

MIP     0x0000000000000002    // the interrupt is pending
ENTRIES 0x0000000000000008    // re-entered 8 times, hitting the test's limit
SIP     0x0000000000000000    // read AFTER the handler gave up and wrote the CLINT register

The S marker — printed by the S-mode program after its first instruction — is never reached. The hart cannot make forward progress in S-mode.

Be precise about that third line, because it is easy to over-read: the handler prints sip only after it has abandoned the prescribed acknowledge and written zero to the CLINT register itself. It shows the bit finally going away once the external term is withdrawn, which is the escape and not the acknowledge. The evidence that the acknowledge itself does nothing is the entry count above it, and case C below.

Case B — same interrupt, same instruction, software source. csrs mip, 2 instead of the CLINT store:

MIP     0x0000000000000002
S                             // the S-mode program runs
ENTRIES 0x0000000000000001    // entered once, acknowledged, done

B is the control that isolates the cause: identical interrupt, identical acknowledge, and the only difference is where the pending bit came from.

Case C — the same thing without any interrupt plumbing at all. M-mode, nothing enabled in mie, no handler: store to the CLINT, read mip, execute the prescribed acknowledge, read mip again, then withdraw the external source and read a third time. This is the shortest form of the report and it needs no delegation, no S-mode and no trap:

MIP     0x0000000000000002    // after the store to the CLINT
MIP     0x0000000000000002    // after csrc sip, 2 -- unchanged
MIP     0x0000000000000000    // only once the CLINT register itself is written back to 0

Case D confirms the gate: the CLINT store raises the bit, then clearing mxstatus.clintee takes it back out of mip with no CSR write in between.

MIP     0x0000000000000002    // after the CLINT store
MIP     0x0000000000000000    // after clearing mxstatus.clintee

Why routine testing does not find this

The readback alone looks right — mip reads 0x2 in both cases, which is what a pending-bit check expects. The divergence only appears when software performs the acknowledge and the interrupt does not go away. A test that checks mip values, or that delivers the interrupt from software, sees nothing.

A conforming shape for this feature is already written down

The natural objection to any fix here is that it removes a feature: an external, memory-mapped way to raise a supervisor-level software interrupt is useful, and Priv 1.10 says nothing about how to provide one. But a RISC-V specification does describe exactly this device, and it describes it in a shape that needs no OR.

riscv-aclint 4e570bfd3201f2c09e5afd290b5091526b0f099a   // 1.0-rc4, 2022-01-14

The ACLINT SSWI device's SETSSIP register:

writing 1 to the least significant bit sends an edge-sensitive interrupt signal to the corresponding HART causing the HART to set SSIP in the mip CSR

An edge that causes the hart to set a bit it owns — after which software's write to that bit is the last word. One section earlier, the same document specifies the machine-level register the other way:

The least significant bit is reflected in MSIP of the mip CSR.

That is the held-level shape, and :496 assign clint_core0_ss_int = ssip0_reg is a held level: the CLINT register drives mip for as long as it holds 1. OpenC910 applies the MSIP shape to the SSIP bit. The document says in as many words why the supervisor-level one is different:

The RISC-V Privileged Architecture defines SSIP in mip and sip CSRs as a writeable bit so the M-mode or S-mode software can directly clear SSIP.

That is the property the OR removes, and it is the property case A measures the loss of.

With its own caveat, because we read the document rather than a summary of it: at the commit pinned above it carries revision 1.0-rc4, states that it will change before it is accepted as a standard, and its head has not moved since 2022-01-14. It is therefore not ratified, and it is also later than this repository's first public commit of 2021-10-19 — so it is not a rule that was available at design time and it is not a rule this core is measured against. The conformance claim in this report rests on Priv 1.10 alone. This section is here for one purpose: to show that a conforming shape for the feature exists, so that conforming costs the OR rather than the feature.

Suggested change

Two shapes are available and they are not equivalent:

  1. Delete the external term, assign ssip = ssip_reg;. This conforms, and it removes the external path: the shipped CLINT's S-level software interrupt line stops reaching mip at all. We measured that cost rather than mentioning it. It is the smaller change and the larger loss.
  2. Route the CLINT write into ssip_reg instead — the SSWI shape above, an external write that sets a bit software then owns. Conforming and feature-preserving, and the write-conflict question it raises is one the ACLINT text explicitly leaves room for: writes to SETSSIP are guaranteed to be reflected in SSIP but not necessarily immediately, so an implementation may arbitrate between the external write and a CSR write rather than having to merge them in the same cycle.

We verified option 1 and not option 2, and the gap is worth naming before a reader finds it: we measured the shape we do not recommend. Option 1 is the smallest edit that isolates the OR, which is what makes it a usable intervention — one line, one signal, and every other behaviour in the run unchanged, so the cost it exposes is attributable. Option 2 is the shape we expect a fix to take, and it is a design change rather than an isolation, so measuring it would have said more about our version of it than about this core. We ran option 1 on the whole SoC, re-elaborated from this exact patch:

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..0e77b15 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
@@ -2114,7 +2114,7 @@ assign msip = biu_cp0_ms_int;
 
 assign seip = biu_cp0_se_int || seip_reg;
 assign stip = biu_cp0_st_int && clintee || stip_reg;
-assign ssip = biu_cp0_ss_int && clintee || ssip_reg;
+assign ssip = ssip_reg;
 
 assign mip_value[63:0] =  {45'b0, mhip, moip, mcip, 4'b0,
                                   meip, 1'b0, seip, 1'b0, 

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 f1f950d273ee9d0f484098c4a6c0a0908e8399affd29825b952f79444fef34fe 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 one hunk touches only the pending-bit assignments around :2114 in ct_cp0_regs.v and does not reach the trap-entry write blocks further down that file, so it does 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

Three lines of this diff end in a space, and two of them are a space and nothing else — those two are how a diff spells a blank context line, so a copy that trims line ends does not make the patch untidy, it makes it malformed. 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 trimmed copy applies under neither git apply nor git apply --ignore-whitespace.

We re-elaborated the entire SoC from this patched source and re-ran every case; the reproduction comment carries every line of both runs under The whole output, with the suggested change applied. Case A becomes:

MIP     0x0000000000000000    // the CLINT source no longer reaches mip
S                             // the S-mode program runs
ENTRIES 0x0000000000000000    // the handler is never entered

Note what that says: the interrupt is no longer raised at all, because the external source no longer reaches mip. It is not that the acknowledge started working; it is that the illegitimate source is gone, which is exactly the feature cost above.

The software-sourced control B is byte-identical to its unpatched run, still entering once and clearing:

MIP     0x0000000000000002
S
ENTRIES 0x0000000000000001

Byte-identical is checkable and not a manner of speaking. The reproduction comment publishes both runs of B with a sha256 beside each, and it is the same digest twice:

mip_witness_B, unmodified sources  63e78b28fcaff5fece2db3f5cbf727b61df644efa021509e803246fd357a9181
mip_witness_B, change applied      63e78b28fcaff5fece2db3f5cbf727b61df644efa021509e803246fd357a9181

The two elaborations are different binaries — their digests differ and the reproduction comment prints both — so the identical output is a result of the change and not of one model being served twice. That is the strongest form the sentence above can take: the software path is untouched, measured at the level of bytes.

Case E is the adjacent control for STIP, the bit this change deliberately does not touch. It asserts the CLINT timer line and reads mip once, with no handler and nothing enabled in mie:

MIP     0x0000000000000020    // stip, from the external timer line, before the change
MIP     0x0000000000000020    // and after it: the untouched stip line still ORs in

Those two are byte-identical as well, which is what makes E a control rather than an illustration: it is read from both builds and it does not move.

This is a report of a divergence and a measured cost, not a recommendation.

Why STIP is in the title

:2116 and :2117 are the same expression with one letter changed, so mip.STIP is the same finding. It is reported here rather than separately because splitting one expression across two issues helps nobody, and it was deliberately left untouched in our experiment so that it could serve as a control on the change.

The evidence for the two is therefore not equally strong: everything measured above is SSIP. The title names both anyway, because a change applied to :2117 alone would leave :2116 in exactly the state this report is about — and stating the asymmetry is better than hiding it behind a narrower title.

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