Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

The Register File as Roles, Not Numbers

24 minadvancedModule 2: The Absence: What Is Missing, and What It BuysContinues from rv-noflags
24 min · The RISC-V ABI, and the Register That Isn’t There

Why the register list is a claim and not a caption

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 audit, before the census

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.

Thirty-two registers, counted by role

$ 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:

THE TWO NAMES FOR x8

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.

The frame pointer, OPTIONAL and measured at four levels

$ 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.

Two files, one spelling, and the trap that caught this file

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.mv

Three 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.

Try it

  1. Reproduce the file bug, then fix it and watch the audit.(In 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.)
  2. Prove that 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.)
  3. Find the register the corpus never uses, and then make it use one.(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.)

Connect

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.