The RISC-V Privileged Architecture

ecall, the Five Registers, and Why the Mode Is Not in the Instruction

24 minadvancedModule 1: The Address, the Bits, and the TrapContinues from rv-paging
24 min · Concept 3 of 4 · module: the address, the bits, and the trap · The RISC-V Privileged Architecture

The strongest thing measured on this page, and it is measured as emitted

Everything a system call is — who handles it, what it returns, what the mode transition costs — is unmeasurable on this host. There is no RISC-V machine, no emulator, and no way to observe a trap. So this concept opens by being precise about the weaker claim it can measure, and then measures that as hard as it can.

The distinction, stated once and used throughout:MEASURED-AS-EMITTED means the compiler produced the constant 0x00000073 with the ABI’s registers around it. It does not mean the trap is taken, that supervisor mode is entered, that a7 is ever read, that a kernel exists, or that anything comes back. All five are quoted rules with section numbers attached, and the artifact labels every one of them QUOTED.

Now the measurement. The corpus contains three wrappers: one that puts the syscall number in a7, one that puts it in a6 — the register a reader would guess from x86-64, where the number goes in rax — and one other.

$ python3 rvpriv.py --run | sed -n '/THE CONTROL\./,/^$/p'
  THE CONTROL.  `sys_write` puts the number in a7 and
  `sys_write_a6` puts it in a6 -- the register a reader would
  guess from x86-64, where the number goes in rax.  The two
  functions are the same C with one word changed.

    li     a7, 0x40           0x04000893
    li     a6, 0x40           0x04000813
    XOR 0x00000080 -- bits 7
                

Two instructions. li a7, 0x40 is 0x04000893 and li a6, 0x40 is 0x04000813, and they differ by 0x00000080 — bit 7, one bit, and nothing else in the word. And that bit has a name: a7 is x17 and a6 is x16, and x17 is 0b10001 while x16 is 0b10000, so the difference is the lowest bit of the destination field, rd at inst[11:7]. Not the opcode, not the immediate, not the CSR field.

Now put that next to what ecall actually is. ecall is 0x00000073 — thirty-one fixed bits and nothing that names a register. The number is in a7, which is rd[0] of a word three instructions earlier, and nothing in the ecall refers to it.

So the entire difference between a working system call and a broken one, on this architecture, is a bit that appears in a DIFFERENT INSTRUCTION than the one that performs the call. There is no linker check for it, no assembler check for it, no encoding relationship between the two words, and no diagnostic. A kernel whose ABI said a6 instead of a7 would be a kernel that works, and the only way to find out which one you have is to read a7 or a6 out of a register in a debugger on hardware this host does not have.

That is the strongest form of “the ecall convention is an ABI and not an architecture”, and it is measured rather than asserted. The weaker form — the instruction does not name a register — is true and was already stated in rv-immediate. The stronger form is that the register the ABI names is carried by a field the ABI also has to agree on separately, one bit wide, in an instruction the architecture does not associate with the call.

And it holds at four optimisation levels

$ python3 rvpriv.py --run | sed -n '/THE SAME FUNCTIONS AT FOUR LEVELS/,+6p'
  level    sys_write insns   bytes    sys_write_a6 insns  words that differ  same length?
  O0       30                100      30                  1                 yes
  O1       5                 16       5                   1                 yes
  O2       5                 16       5                   1                 yes
  Os       5                 16       5                   1                 yes
                

The instruction counts spread from 30 to 5, and that spread is the compiler’s and not the convention’s. A reader who quoted 30 would be quoting a property of clang’s -O0 scaffolding; a reader who quoted 5 would be quoting clang 21.1.8’s -O1. The number that is the ABI’s is the last two columns: the two functions have the same length at every level and differ in exactly one word. Which word moves with the level — at -O0 the registers are spilled and reloaded, so it moves — and that is why the harness asserts the shape and not the offsets.

That distinction was learned the hard way and is retraction R4: the first version of the corpus never used its arguments after the trap, clang deleted the argument loads at -O1 and above, and the function became three instructions — a number that is a compiler’s opinion of unused parameters rather than a count of the convention. A count over -O0 is a count of a compiler’s scaffolding, and the only way to tell a convention’s number from a compiler’s is to vary the level and see which one moves.

The five trap registers, and the one bit that writes them

Five CSRs carry a trap’s state, and their addresses are the twelve-bit numbers from concept 1 decomposed. The artifact prints all of them with the decomposition beside the address, which is what makes the convention real rather than a list of hex numbers:

$ python3 rvpriv.py --run | sed -n '/  name      address/,/^  mtvec/p' | grep -E 'name|stvec|sepc|scause|stval|satp '
  name          address    word          csr[11:10]  access  csr[9:8]  privilege  csr[7:0]
  stvec         0x105      0x105022f3    0           rw      1          S          0x05
  sepc          0x141      0x141022f3    0           rw      1          S          0x41
  scause        0x142      0x142022f3    0           rw      1          S          0x42
  stval         0x143      0x143022f3    0           rw      1          S          0x43
  satp          0x180      0x180022f3    0           rw      1          S          0x80
  mtvec         0x305      0x305022f3    0           rw      3          M          0x05
                

Every supervisor row has csr[9:8] = 1 and every machine row has csr[9:8] = 3. That is not decoration: it is the difference between a trap handler and a user program, encoded in the number. And read the last two columns together — mtvec and stvec are both “number 5”, and they are different registers only because of the privilege pair.

And every one of them is written by the same instruction

Here is the finding this concept is built around, and it comes from writing the same CSR three ways and looking at the words:

$ python3 rvpriv.py --run | sed -n '/THE THREE stvec WRITES/,+7p'
  function                  insns    the register    the value in it        the whole body
  set_stvec_direct          4        a1              0x000000001000        lui | csrr | csrw | ret
  set_stvec_vectored        5        a1              0x000000001001        lui | addi | csrr | csrw | ret
  set_stvec_misaligned      4        a1              0x000000000123        li | csrr | csrw | ret

  ALL THREE FUNCTIONS EMIT THE SAME 32-BIT WORD FOR THE stvec WRITE, 0x10559073
                

Direct, Vectored and a misaligned value all emit 0x10559073. The mode is not in the instruction — it is in the value, four instructions earlier, and the instruction does not know which of the three things it is writing. So a disassembler reading the object can recover the instruction and cannot recover the mode, and the value that would tell it is four instructions earlier in the same function.

The third function is the interesting one. The specification says the CSR “contains only bits XLEN−1 through 2 of the address BASE” and that “the lower two bits are filled with zeroes”, so:

$ python3 rvpriv.py --run | sed -n '/AND THE MISALIGNED ONE/,+3p'
  AND THE MISALIGNED ONE IS THE INTERESTING ONE. ... So
  writing 0x123 does NOT set BASE to 0x123: it sets BASE to 0x120 and MODE
  to 3, and a mode of 3 is Reserved, which `priv-spec` says in one word and
  does not define.
                

And the assembler did not diagnose it. There is no diagnostic, and there could not be: the instruction does not know the value. A kernel that computes a trap vector address and forgets to clear the low two bits gets a Reserved mode, and the behaviour of a Reserved mode is not specified. This is the third place in the course where a constraint is enforced by hardware this host does not have, and it is the one a backend author is most likely to get wrong — because the code is a perfectly ordinary csrw stvec, a1 and reads correctly.

Decode that word and notice what is not in it.0x10559073 is: opcode 0x73, funct3 = 1 so CSRRW, inst[31:20] = 0x105 so stvec, rs1 = 11 = a1, rs2 = 5 = t0, funct7 = 8, and rd[4:0] = 0. The zero in rd is what makes the write discardable, and that is why csrw needs no encoding of its own: it is the encoding with rd = x0, which is concept 1’s alias table showing up here as a consequence rather than as a curiosity. And funct7 = 8 and rs2 = 5 are both read off the word and neither is in the source.

The interrupt bits: a quoted rule, measured without quoting it

Three functions set one interrupt-enable bit each, and the whole of the interrupt — the cause number, the bit, the mask — is a constant the compiler passed through:

$ python3 rvpriv.py --run | sed -n '/THE THREE INTERRUPT ENABLE BITS/,+4p'
  function        cause number    the bit       the mask   what the compiler emitted  body
  enable_ssie     1               1 << 1        0x2        0x2     csrr | ori | csrw | ret
  enable_stie     5               1 << 5        0x20       0x20    csrr | ori | csrw | ret
  enable_seie     9               1 << 9        0x200      0x200   csrr | ori | csrw | ret
                

Read the masks as a shape rather than as three numbers: 0x2, 0x20 and 0x200 are 1, 5 and 9 shifted left once, and that shift is the whole of the mapping from cause number to bit position. A reader who has internalised the rule can derive the mask for cause 13 without looking it up — and that is a property a quoted table does not give you.

The rule itself is quoted: “Interrupt cause number i … corresponds with bit i in both sip and sie.” The cause numbers 1, 5 and 9 are the specification’s Table 2. But notice what was measured: the compiler emitted ori a0, a0, <mask> and nothing else. It has no idea these are interrupts — it sees v | (1UL << 5) in C.

And the read-then-write is not a convention either. It is a race. Each of the three functions is csrr sie ; ori ; csrw sie — a read-modify-write of a register that hardware also writes, and two of them running at once lose an update. The specification’s answer is that sie and sip are subsets of mie and mip and the writes go to the same homonymous fields; it does not provide an atomic read-modify-write, and a kernel that enables interrupts does it in a critical section. None of that is measurable here. The race is a quoted consequence and the three instruction triples are measured on bytes, and the two must not be run together in a reader’s head.

What was measured here, and what was not

This is the concept where the gap between measured and quoted is widest, so it gets the longest scope list.

Retraction R11 is this page’s, and it is the one that took two attempts: the first draft reported the three functions’ bodies as three different instruction counts, and a reader could have concluded the mode was visible in the code; the second draft had the same finding for a different reason — the functions returned their constant, clang put the return value in a1 and the argument in a0, and the three csrw words came out with two differentrs1 values. The fix made the functions return the value instead, so all three write the same register. A disassembler reading the object can recover the instruction and cannot recover the mode, and the value that would tell it is four instructions earlier in the same function.

And one methodological retraction that belongs on this page even though it is about a different section: R4, the -O0 scaffolding, is recorded here because this is the concept whose numbers it nearly corrupted.

Try it

  1. Count the bits between two working system calls.(Take the two lines from the control block above and work out, from the ISA alone, which field the differing bit lives in and what its width is. The answer is one bit of rd, which is five bits wide — so the ABI chose the lowest of thirty-two register numbers and the difference is 0x80 in the word. Then ask: if the ABI had said a5, would the toolchain have noticed? It would not, and neither would yours.)
  2. Write a set_stvec that can be got wrong, and prove the assembler will not help.(Return base | 1 without clearing the low two bits — or base + 4, whose low two bits are already clear, versus base + 2, whose are not. Assemble all three and confirm the csrw word is identical in all three cases. Then write down what actually distinguishes them: a value four instructions earlier that a human being chose.)
  3. Derive a mask you have not looked up.(The page derives the masks for causes 1, 5 and 9 by shifting. Work out the mask for cause 13 — supervisor external interrupt is not 13, so pick a cause that is: use the specification’s table — and then write the C that would produce it and compile it. If your C emits ori with that immediate, you have measured the rule without quoting it, which is the whole of this section’s method.)
  4. Change the optimisation level and watch which number moves.(Rebuild the corpus at -O0. The instruction counts will move from 5 to 30 and the byte counts from 16 to 100. The two numbers that must not move are the last two columns: same length, one word differing. Those are the ABI’s numbers. Everything else in the table is clang 21.1.8’s, and quoting it as a property of the convention is retraction R4.)

Connect

Back to concept 1, where the twelve-bit CSR address was decomposed — the csr[9:8] = 1 column on this page is that decomposition applied to five specific numbers, and the funct3 bit that makes csrw discardable is the same bit concept 1 found between the register and immediate forms. Forward to concept 4, where every measured and quoted claim on the first three pages is counted in one table.

Out to rv-calling, which is where a7 and a6 were introduced as ordinary argument registers. On this page they acquire a job, and the interesting thing is that the job is not visible in the instruction that does it — which is a fact about the boundary between an ABI and an architecture, and that course is where the ABI half lives.

And out to a64-syscall and x86-syscall, which ask the same question on machines this collection can run. Read them beside this page and the difference is the point of the course: those two could watch a trap happen and describe its cost. This one cannot, so it counted the bits that make it work — and the bit it found is one that neither a linker nor an assembler will ever check for you.