Keyboard Shortcuts

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

Six Formats and Four Field Positions

30 minadvancedModule 2: The Encoding: What the Bytes AreContinues from rv-isa
30 min · RISC-V: The Encoding Spectrum

Why this matters

Concept 1 established that the ISA is a set of documents. This concept takes the document that matters most for a decoder — the base integer set — apart at the bit level, and it opens with a retraction, because the plan this course was written from got the central prediction backwards.

The plan said: “the same register number sits in different bit positions in different formats, and that is why an assembler is not a lookup table.” Measured, the first half is false and the second half is true for a different reason. Here is the measurement, and then what it costs.

$ python3 rvdec.py --fields | sed -n '/^  field  /,/rs2, in the R/p'
  field                         xor mask      vars   the bit positions it moved   what
  rd, in the R format           0x00000f80    20     7,8,9,10,11                 rd
  rs1, in the R format          0x000f8000    20     15,16,17,18,19              rs1
  rs2, in the R format          0x01f00000    20     20,21,22,23,24              rs2
                

And those same three positions, on the I format, the S format, the B format and the U format: bits[11:7], bits[19:15], bits[24:20], every time. The manual says so and says why, and it is QUOTED, section 2.2:

“The RISC-V ISA keeps the source (rs1 and rs2) and destination (rd)
 registers at the same position in all formats to simplify decoding.”

and in the note: “Decoding register specifiers is usually on the critical
paths in implementations, and so the instruction format was chosen to keep all
register specifiers at the same position in all formats at the expense of
having to move immediate bits across formats.”
                

The registers stay still and the immediates pay for it. That is the whole trade, in the specification’s own words, and the rest of this concept is the bill.

The six formats, and where the manual’s four are

The count is six and the manual’s own count is four, and both are right about different things. QUOTED, section 2.2: “In the base RV32I ISA, there are four core instruction formats (R/I/S/U)”; and then section 2.3: “There are a further two variants of the instruction formats (B/J) based on the handling of immediates.”

So: four core formats plus two immediate variants, and the six names are the union. A course that said “RISC-V has six formats” and left it there would be implying the manual counts six, and it does not. The distinction is not pedantic: the four core formats are four field layouts, and the two variants are rearrangements of layouts that already exist. B is S with the immediate moved. J is U with the immediate moved and the two register slots spent on the displacement instead.

  format   inst[31:0]                                name       what it is for
  R        funct7|rs2|rs1|funct3|rd|opcode           reg-reg     no immediate at all
  I        imm[11:0]|rs1|funct3|rd|opcode            reg-imm     CONTIGUOUS at inst[31:20]
  S        imm[11:5]|rs2|rs1|funct3|imm[4:0]|opcode  store      no rd, so the imm is split
  B        imm[12|10:5]|rs2|rs1|funct3|imm[4:1|11]|op  branch     target must be EVEN
  U        imm[31:12]|rd|opcode                       upper imm   20 bits already in place
  J        imm[20|10:1|11|19:12]|rd|opcode           jump        no rs1, no rs2, so 20 bits
                                                                     become 21
                

Read the R row and the S row together, because that pair is the whole of the register-position decision. R has no immediate, so its five register-ish fields use all 32 bits: seven of opcode, three of funct3, seven of funct7, five each of rd, rs1 and rs2. S has no destination — a store writes to memory, not to a register — and the five bits the destination would have occupied are the low five bits of the immediate instead. That is the whole of the S format and it is why the S immediate is in two places.

And read the B row against the S row again, because the relationship is stated in the manual and it is the sharpest single fact in the base encoding. QUOTED, section 2.3:

“The only difference between the S and B formats is that the 12-bit
 immediate field is used to encode branch offsets in multiples of 2 in the B
 format. Instead of shifting all bits in the instruction-encoded immediate
 left by one in hardware as is conventionally done, the middle bits (imm[10:1])
 and sign bit stay in fixed positions, while the lowest bit in S format
 (inst[7]) encodes a high-order bit in B format.”
                

Read it twice. In S, inst[7] is the immediate’s bit 0. In B, inst[7] is the immediate’s bit 11. The same bit position, the same instruction width, the same seven-bit funct7 slot above it — and it is the top of the displacement instead of the bottom. The masks see it exactly:

$ python3 rvdec.py --fields | sed -n '/the S immediate/,/the B funct3/p'
  the S immediate, via sw       0xfe000e00    11     9,10,11,25,26,27,28,29,30,31  imm
  the B immediate, 17 offsets   0xfe000f80    17     7,8,9,10,11,25,26,27,28,29,30,31  imm
                

Two masks, ten bits each, and they differ by exactly bit 7. One bit, one position, the two ends of a displacement.

The masks, and what a mask is not

The method: assemble an instruction, change one operand, assemble it again, and OR the XOR over a sweep of variants. Every bit that moves belongs to that operand. Three rules make it work, and all three are the results of getting it wrong first.

Here is the base table, and the immediate rows are the interesting half because the registers are the same everywhere:

$ python3 rvdec.py --fields | sed -n '/^  field  /,/the opcode/p'
  field                         xor mask      vars   the bit positions it moved
  rd, in the R format           0x00000f80    20     7,8,9,10,11
  rs1, in the R format          0x000f8000    20     15,16,17,18,19
  rs2, in the R format          0x01f00000    20     20,21,22,23,24
  funct7+funct3, 9 base R ops   0x42007000    9      12,13,14,25,30
  funct3, the 6 other load sizes  0x00007000   6     12,13,14
  imm[11:0], the I format       0xfff00000    15     20,21,22,23,24,25,26,27,28,29,30,31
  the S immediate, via sw       0xfe000e00    11     9,10,11,25,26,27,28,29,30,31
  the B immediate, 17 offsets   0xfe000f80    17     7,8,9,10,11,25,26,27,28,29,30,31
  the B funct3, 6 branches      0x00007000    5      12,13,14
  the U immediate, 20 bits      0xfffff000    10     12,13,...,31
  the J immediate, 20 offsets   0xfffff000    18     12,13,...,31
                

A WINDOW AND A FIELD ARE DIFFERENT OBJECTS. That is the sentence to carry to the next architecture. The U and J masks are identical — both 0xfffff000, twenty contiguous bits at inst[31:12] — and the two immediates are nothing alike. U is 20 bits at the top of a 32-bit value with twelve zeros below. J is 21 bits scaled by two with a zero at the bottom, spread across inst[19:12] and inst[30:20] in a permuted order. A mask says two fields occupy the same WINDOW. It does not say they are the same FIELD, and a decoder that reads the J immediate as a contiguous 20-bit field gets every displacement in [-2048, 2046] right — which is the small ones — and every larger one wrong.

funct7: the clearest example of a lower bound in the course

The funct7+funct3 row moved bits 25 and 30 out of a seven-bit field, and a reader is entitled to ask whether the measurement failed. It did not, and the reason is the field is not seven bits of one thing:

$ python3 rvdec.py --section 4 | sed -n '/^  source  /,/fclass/p'
  source                      word          inst[31:25]   inst[26:25]  funct3
  add a0, a1, a2              0x00c58533    0000000       00           0
  sub a0, a1, a2              0x40c58533    0100000       00           0
  sll a0, a1, a2              0x00c59533    0000000       00           1
  mul a0, a1, a2              0x02c58533    0000001       01           0
  fadd.s fa0, fa1, fa2        0x00c5f553    0000000       00           7
  fsub.s fa0, fa1, fa2        0x08c5f553    0000100       00           7
  fmin.s fa0, fa1, fa2        0x28c58553    0010100       00           0
  fsqrt.s fa0, fa1            0x5805f553    0101100       00           7
  fcvt.w.s a0, fa0            0xc0057553    1100000       00           7
                

add has funct7 = 0b0000000 and sub has 0b0100000. One bit apart. Those are the only two values the base R format can reach from an add/sub pair, so a sweep over nine base operations moves bit 30 and not the other six — except that mul has funct7 = 0b0000001, which moves bit 25. To move the REST you have to leave the base ISA, because in the base ISA there is nothing else in funct7. The F and D extensions fill it, and the table above reaches all seven positions.

Both numbers are true, they measure different things, and a course that printed only the first would have claimed a two-bit funct7:

    The base sweep moved bits 25,30 of funct7's seven positions.
    The table above reaches all seven.  Both numbers are true,
    they measure different things, and a course that printed
    only the first would have claimed a two-bit funct7.
                

And the reason the manual’s funct7 column heading is a simplification: under opcode 0x53 those seven bits are a five-bit operation selector at inst[31:27] and a two-bit width at inst[26:25], and there is no way to read them as one seven-bit funct7 at all. fadd.s and fadd.d differ in inst[25] — one bit. The first version of this artifact’s r_fp model read a seven-bit funct7 and therefore could not tell them apart, and separately could not tell fsqrt.s from fadd.s, because for the square root the same five bits are the high half of the selector.

One more thing about funct7 that is a dispatch bug rather than a width bug, and it is the defect that shaped the whole of concept 5: a sub-opcode selector is not self-contained.fmadd.s is inst[31:27] = 0b01101 with opcode 0x43. fmin.s is inst[31:27] = 0b00101 with opcode 0x53. Two different five-bit values, so that is not the sharpest case — but fadd.s is inst[31:27] = 0b00001 under opcode 0x53 and add is inst[31:25] = 0b0000000 under opcode 0x33, and the floating-point one has funct3 = 7 where the integer one has funct3 = 0. A dispatch that asks models in order without first reading the opcode resolves these by list position, which is not a decoding strategy, it is a coin flip with a plausible-looking output.

The compressed extension’s second register encoding

Everything above is the base ISA, and everything above is the same in every format. Here is the one place where RISC-V has a second way to name a register, and it is the trap this course calls the sharpest:

$ python3 rvdec.py --fields | sed -n '/C CR: rd/,/C CB: rd/p'
  C CR: rd, five bits           0x00000f80    20     7,8,9,10,11      rd (5 bits)
  C CR: rs2, five bits          0x0000007c    20     2,3,4,5,6        rs2 (5 bits)
  C CI: rd, five bits           0x00000f80    20     7,8,9,10,11      rd (5 bits)
  C CA: rd-prime                0x00000380    8      7,8,9            rd' (3 bits)
  C CA: rs2-prime               0x0000001c    8      2,3,4            rs2' (3 bits)
  C CL: rd-prime                0x0000001c    8      2,3,4            rd' (3 bits)
  C CL: rs1-prime               0x00000380    8      7,8,9            rs1' (3 bits)
                

Four fields, two positions, two widths. c.mv keeps rd at bits[11:7] and c.mv’s rs2 at bits[6:2] — the base positions, unchanged, which is what the manual means by “when the full 5-bit destination register specifier is present, it is in the same place as in the 32-bit RISC-V encoding”. And c.add rd', rs2' puts rd' at bits[9:7] and rs2' at bits[4:2]: three bits each, two positions lower.

And here is the part the bit positions do not tell you: the value 0 in rd' does not mean x0. It means x8. The three-bit field indexes the eight registers x8 to x15. The manual prints the table:

  000 x8  001 x9  010 x10  011 x11  100 x12  101 x13  110 x14  111 x15
  s0    s1    a0    a1    a2    a3    a4    a5
                

A decoder that reads a compressed register with a five-bit lookup reports x0 to x7 where the encoding means x8 to x15. Every register name in the disassembly is seven too small, every operand is a real register, and nothing about the output looks wrong. That is a decoder bug with no symptom, which is why concept 5 is worth its twenty minutes.

And the QUOTED reason those eight are those eight — because the coupling is the point, and it is between three things no single specification reveals:

“The RISC-V ABI was changed to make the frequently used registers map to
 registers x8-x15. This simplifies the decompression decoder by having a
 contiguous naturally aligned set of register numbers.”
                

So the compression is not only an encoding decision. It is a decision that REQUIRES an ABI that puts the eight most-used registers in a contiguous run, and the ABI was changed to make it possible. A coupling between an instruction encoding, a register file and a calling convention. Concept 3 measures what that coupling costs.

Try it

  1. Measure the S and B immediates yourself and check the one-bit difference.(Sweep sw a0, N(a1) over N = 0, 4, 8, …, 2040 and OR the XOR. Expect mask 0xfe000e00, bits[31:25] and bits[11:7], ten positions. Then sweep beq a0, a1, N over even N. Expect 0xfe000f80 and expect the difference to be bit 7 and nothing else. Then ask the assembler for sw a0, 2048(a1) and read why.)
  2. Verify the three-bit register trap on purpose.(In rvdec.py, change creg3 from return xreg(8 + (n & 7)) to return xreg(n & 7) and re-run. Expect the section 10 cross-check to report a large disagreement count that is entirely c.* words, and expect every one of them to be a register name that is seven too small. Nothing crashes. Nothing looks wrong. This is the exercise the whole cross-check concept is built for, and doing it by hand is worth more than reading about it.)
  3. Find the one bit that separates c.jr from c.jalr and then find the model that gets it wrong.(It is inst[12], and the artifact’s first version dispatched on rs1 == 0 instead — which is not a bit of that cell at all. Expect c.mv to have been unreachable and 33 corpus words to have reported as c.add, and expect c.jalr to have reported as c.jr, which is a decoder that turns every indirect call into an indirect jump. The cross-check found it only because the canonical form for c.jr and c.jalr was written so that conflating them produces a disagreement rather than a silent error.)

Connect

Backwards, concept 1 is where the funct7 value that the M extension fills came from, and this page is where the cost of it becomes visible: a64-encoding measured 58 field positions for AArch64 and found the class field four bits wide, because AArch64 has no prefixes and no length arithmetic and so can spend bits on a class. RISC-V has the same goal — keep the register specifiers still — and a completely different mechanism, because the C extension’s second register encoding means the register position is not uniform after all, only uniform within the base.

Forwards, concept 3 finishes the compressed half: the nine formats, the reserved code points, and the eleven families of displacement that share five bit positions and are assigned seven different orders. Concept 4 takes the two masks that differ by one bit and finds out what a 13-bit permuted displacement actually reaches.