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.
$ 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.
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.
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.
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.
This is the concept where the gap between measured and quoted is widest, so it gets the longest scope list.
0x00000073; that a7 is the destination of the instruction that loads the number; that a7 and a6 differ by exactly bit 7 of one word; that the pair differs in exactly one word at all four optimisation levels.stvec functions’ words, all identical; the five trap CSR addresses and their decompositions; the three enable masks and the compiler’s ori immediates; the read-modify-write instruction triples.a7 and the return value in a0; that ecall raises an environment-call exception and traps to supervisor mode; that the stvec low two bits are the MODE field; that a mode of 3 is Reserved; that cause number i is bit i in sip/sie; that the read-modify-write race exists and needs a critical section.a7 is read; that a kernel exists; that anything returns; that the hardware clears the low two bits of stvec’s BASE; that the race actually loses an update.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.
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.)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.)ori with that immediate, you have measured the rule without quoting it, which is the whole of this section’s method.)-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.)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.