The ABI names roles. t0 is x5, s0 is x8 and is also called fp, ra is x1, sp is x2, zero is x0. And the encoding holds a NUMBER in every case, not a name. The register field is five bits wide and there are thirty-two things it can mean, so the disassembly’s job is to pick a name — and sometimes there are two names for one number, and a reader who has only seen one of them will think the other is a different register.
This concept is where the section’s spine stops being a slogan and becomes a register count. Concept 2 showed that there is no register a comparison writes to. The consequence is countable: a RISC-V callee saves fewer registers than an x86-64 callee for the same C, because there is no flags register to save. That is the whole of what a register list is for, stated as a difference.
And the page opens with a retraction, because the measurement that exists to settle the role-versus-number question — a census of all thirty-two registers across the whole corpus — was wrong the first time it ran, and wrong in a way no table of plausible names could reveal.
The census counts register fields by reading the bits with this file’s own table, and the first version of that table asked the inherited decoder for each field by name. Two things went wrong, and both produced numbers that looked entirely reasonable.
First: a decoder reads the fields its rendering needs. For c.sdsp ra, 24(sp) the inherited decoder emits one field line — the funct3 — and explains the rest in prose, because the CSS format’s rs2 exists only to be printed. So the lookup returned nothing, and the first version of the store audit reported the eighth integer argument of m18 as having arrived in t0 when it arrived in a7.
Second, and this is the one rv-encoding exists for: a compressed register field is THREE bits and its value 0 means x8. Reading it with the five-bit table reports every register eight too small. The first version did exactly that, and the census printed 131 writes to x0 — on an architecture where the manual says x0 is hardwired to zero and can never be written.
So the table is not trusted. It is audited against a second rendering of the same bits — the operand list the inherited decoder printed, produced by different code in a different file — and the count is printed above the census, on every run:
$ python3 rvabi.py --run | sed -n '/BEFORE THE CENSUS/,+5p'
BEFORE THE CENSUS, THE READER THAT PRODUCES IT IS AUDITED against
the inherited decoder's own printed operands -- a SECOND rendering
of the same bits, by a different code:
5973 instructions checked, 5973 agree, 0 disagree
10640 instructions placed from the FORMAT alone
0 instructions this table could not place at all
And it took four more rounds to get to 0, each of which was a real bug rather than a tuning problem. The last two were the sharpest: the base register of a floating-point load is in the INTEGER file, and the B format’s rs2 is at bits[24:20] and not bits[20:15] as the table first said. Both were found by the audit and neither was findable by reading the table, because a wrong lookup produces a real register of the wrong kind and a plausible disassembly.
$ python3 rvabi.py --run | sed -n '/^ x ABI name/,/^ 31 /p'
x ABI name role printed text as a source as a dest
0 zero zero 185 110 75
1 ra ra 442 430 113
2 sp sp 187 623 126
3 gp gp 0 0 0
4 tp tp 0 0 0
5 t0 temp 177 98 106
8 s0 callee-saved 222 750 134
10 a0 argument 2009 1169 1341
11 a1 argument 1236 829 622
... (a2 through a7 fall off)
26 s10 callee-saved 0 0 0
27 s11 callee-saved 0 0 0
31 t6 temp 28 14 14
The interesting rows are not the largest ones. Read these four:
x0 is hardwired with all bits equal to 0” — QUOTED — and a hardwired register is not a register you can write. It is a constant with an encoding. Every arithmetic comparison on this architecture is written sltu rd, rs1, x0 or beq rs, x0, target, so x0 is read constantly. And zero is the most common register in the printed text — 185 appearances, ahead of every temporaries and every callee-saved register, because a comparison against zero is the commonest operation there is.gp and tp are zero everywhere. The ABI calls them UNALLOCATABLE, which is a stronger word than “preserved” and appears nowhere else in the table: they are not yours to use even when nothing else is available. A rule that no compiler breaks and a rule that does not exist are indistinguishable except to a count, and the count is 0 across the whole corpus at every level, agreed by both readers.s10 and s11 are zero too, and for an ordinary reason: the corpus’s functions are not deep enough to need twelve callee-saved registers. A register the ABI preserves that no compiler ever uses is still a real row of the table, and its zero is about the corpus rather than about the architecture.sp is the most-read register in the architecture at 623 — ahead of a0 at 1169? No: a0 leads, and that is right, because every store in abi.c is fsd fa0, off(a0). What makes sp interesting is the opposite column: it is read 623 times and written only 126, and at -O1 and above the compressed stack forms have no base-register field at all — it is not five bits of encoding spent on 11111, it is the format.The ABI calls it s0 and the frame-pointer convention calls it fp, and THE TWO NAMES FOR x8 ARE THE SAME REGISTER. The decoder prints s0; the second reader prints s0; fp is the assembler’s alias and appears in the text only when you ask for it. Concept 1 quoted the rule: “the register remains callee-saved”, which is the sentence that lets a compiler use s0 as an ordinary register.
That is much milder than the AArch64 trap where register number 31 is sp in one instruction slot and xzr in another, and the reason it is milder is worth stating precisely: on RISC-V the number determines the name, always; on AArch64 the SLOT determines it. RISC-V has one name per number and the difficulty is entirely in knowing which file a number is in — which is the next trap.
$ python3 rvabi.py --run | sed -n '/AND THE FRAME POINTER/,+6p'
level instructions s0 written s0 read
regs.c -O0 270 12 124
regs.c -O1 127 0 0
regs.c -O2 127 0 0
regs.c -Os 127 0 0
At -O0 clang writes s0 twelve times and reads it 124 times; at every level above that it touches it zero times. And that is not the compiler declining to use a frame pointer — it is the compiler discovering that the corpus has no function deep enough to need one. A reader who took the -O0 row as “RISC-V uses s0 as a frame pointer” has taken a compiler’s choice as a property of the contract, and x86-frame is the lesson about what that mistake looks like when the choice goes the other way.
The floating-point table is a SECOND FILE with the same roles, and it is counted separately: 32 rows, ft0–ft11 temporary, fs0–fs11 callee-saved, fa0–fa7 argument. So fa5 and a5 are different registers that share a spelling up to one character, and the only reason they are not confused by eye is that the printed name has the f in it.
The eye is not the problem. A decoder is the problem, and here is the sentence that has to be carried out of this page:
A REGISTER NUMBER IS NOT A REGISTER UNTIL YOU KNOW WHICH
FILE IT IS IN, and the encoding does not say.
MEASURED from the corpus: fsd fa0, -24(s0) has rs2 = bits[24:20] = 10 and rs1 = bits[19:15] = 8. Same five bits, same two values, and two different registers: 10 in the integer file is a0 and in the floating-point file is fa0; 8 in the integer file is s0 and in the floating-point file is fs0. The store moved a value out of fa0 and addressed memory through s0. A reader that picks the wrong file reports a store of a0 to an offset from s0 — two real registers, one of the wrong kind, and a function whose every register name is plausible.
And this file got it wrong first, which is the only reason that sentence is stated so firmly. The census was originally built by asking “is this instruction floating-point?” once per instruction and applying the answer to every field. For fsd that answers “floating-point” for the base as well as the data, and the audit printed 18 writes to gp and 8 to tp over a corpus in which the compiler writes neither. The arithmetic of a wrong lookup turns one register into another: a real a2, read as a base in the wrong file, comes out as fs2, and fs2 read as an integer is x18, which is s2.
Nothing about that table looked wrong. Only the audit turned 201 disagreements into 0, and only the audit could have: it compares this file’s reading against a different piece of code’s rendering of the same word, instruction by instruction. That is the argument for the audit existing at all, and it is the same argument as rv-verify’s cross-check and this course’s own two-reader comparison — a decoder is not checked by whether it decodes.
mv rd, x0 is not a c.mvThree spellings of a zero, asked of the assembler rather than of clang, and asked at two -march settings:
$ python3 rvabi.py --run | sed -n '/THREE SPELLINGS/,+9p'
source exactly rv64gc the second reader says rv64i
mv mv t0, zero 2 bytes li t0, 0x0 4 bytes
addi addi t0, zero, 0 2 bytes li t0, 0x0 4 bytes
li li t0, 0 2 bytes li t0, 0x0 4 bytes
add add zero, t0, t1 4 bytes add zero, t0, t1 4 bytes
li li zero, 42 4 bytes li zero, 0x2a 4 bytes
mv mv zero, t0 4 bytes mv zero, t0 4 bytes
nop nop 2 bytes nop 4 bytes
And here is the finding the concept is named for. Ask for mv t0, zero and the answer is that it does NOT become a c.mv: it becomes c.li t0, 0 — the compressed load-immediate with a zero immediate — which is the same encoding as addi t0, zero, 0 and as li t0, 0. All three are 2 bytes at -march=rv64gc and 4 bytes at -march=rv64i.
Which is the point. There is no c.mv with a zero source. In the compressed specification c.mv is defined only when rs2 is not x0, and the cell with rs2 = x0 is c.li. So a course that asked “does the compiler use mv rd, x0 or addi rd, x0, 0 for a zero” is asking about a distinction the assembler already removed — it canonicalises all three spellings into one instruction before the encoder ever sees them.
And what the compiler actually picks, counted by the immediate read from the bits rather than by the mnemonic:
$ python3 rvabi.py --run | sed -n '/AND WHAT THE COMPILER PICKS/,+6p'
c.li rd, 0 3 c.li a2, 0
addi rd, x0, 0 15 addi a2, zero, 0
lui rd, 0 0
mv rd, x0 0
(instructions with an immediate read) 260
The compiler emits the two-byte form 3 times and the four-byte form 15 times in the same corpus at the same optimisation level, and never mv rd, x0 at all — for the encoding reason above, not for taste. An instruction count cannot tell those two apart: both are one instruction. A byte count can: 2 against 4.
And read the denominator while you are there. 260 instructions carry an immediate this file knows how to read, and 18 of them have a zero immediate. The first version of that table printed all four counts as ZERO, because it asked the decoder for a field the decoder writes under a different name. A count of zero out of 260 is a measurement; a count of zero out of zero is a bug wearing the costume of a result, and the denominator sitting next to the numerator is the only thing on the page that tells them apart.
rvabi.py, find _name_of and delete the line that says the base of a floating-point load is an integer register. Re-run and look at section 7. Expect the register census to report writes to gp and tp over a corpus that contains none, and expect the audit printed immediately above the table to report a large disagreement count. Nothing crashes; every name is a real register. This is the exercise the whole concept is built for.)li zero, 42 is a real instruction with a real effect that is not an effect.(Assemble it, read the four bytes, and see that the immediate 42 is in the word. Then ask what the hardware does with them: nothing, and this course cannot measure that because there is no RISC-V silicon on this host. So the honest statement is two-part and both halves are on the limits list: the encoding is MEASURED, the discard is not.)s10 and s11 are zero because nothing in four files of ordinary C is deep enough to need twelve callee-saved registers. Write a function that is, count the callee-saved register it spills, and compare against the same function compiled for x86-64. The difference is the section’s spine made arithmetic: RISC-V saves fewer registers per callee because there is no flags register to save, and that is a number you can put in a table.)Backwards, rv-encoding is where the compressed rd' is measured — three bits at bits[9:7], value 0 meaning x8 — and the 131 phantom writes to x0 on this page are that finding used against itself. Concept 2 is where the absent register shows up: a register list with no entry for “the condition” is a register list that says something, and this page is the count.
Across architectures, a64-registers is the same trap with a worse mechanism, and the comparison is the point. AArch64 has register number 31 mean sp in one instruction slot and xzr in another, and it has the integer and floating-point files to get wrong as well. RISC-V has one name per number and two files; AArch64 has two names per number in some slots and two files. The RISC-V version is easier to read and the AArch64 version is easier to decode wrongly — which is not the same thing, and the reason to learn both.
And x86-saved is where the comparison in the “try it” above comes from: the x86-64 callee has a flags register to preserve and RISC-V’s does not, so the same C produces a different number of saved registers on the two, and the difference is a design consequence rather than an implementation detail.
Forwards, concept 4 takes the register file’s constraints and asks what compression does to them — because the compressed forms can only name x8 to x15, and that is a constraint the register allocator has to respect, which is why a “pure encoding” change can move an instruction count.