Loads, stores and immediates
RISC-V has three ways of naming an operand and only one of them reaches memory. What offset(base) can and cannot do, why indexing an array costs a shift and an add, and how auipc puts an address in a register without knowing where the program was loaded.
The overview of this topic is in Assembly basics. The same topic in M68K, Z80.
The instruction set lecture wrote an instruction's operands as destination and source without
saying what can go in them. This is what can, and the way an operand is written is its addressing
mode. RISC-V has three, which is fewer than any other machine in this editor.
| mode | written | in C |
|---|---|---|
| register | t0 | x |
| immediate | 7 | 7 |
| base plus offset | 4(t0) | p[1], *(p + 1) |
The first two name a value the CPU already has or the assembler already knows. The third names an address, and only a load or a store may use it.
Arithmetic never reaches memory
RISC-V is a load/store architecture, which means exactly this: lw, lh, lb, lbu, lhu,
sw, sh and sb are the only instructions that touch memory, and everything else works on
registers. There is no add that reads a variable, no comparison against a word in memory, no
increment of a counter that lives at an address.
So the shape of every program that works on data in memory is the same three steps: load it into a
register, do the work there, store it back. On the M68K, where add.l total, d0 adds the long at a
label straight into a register, that is one instruction; here it is three, and the reason the design
went that way is that a load can take many cycles and an add takes one, so the two are kept apart.
offset(base)
lw t3, 4(t2) reads the word at the address t2 + 4. The register is the base, the number is
the offset in bytes, and the offset is the same signed 12 bit constant every I-type instruction
carries, so it runs from -2048 to 2047. That is the whole mode: one register, one constant, added
while the instruction runs.
The four words sit at 0x10010000, where .data puts the first label:
| address | value | which element |
|---|---|---|
0x10010000 | 0000000A | numbers[0] |
0x10010004 | 00000014 | numbers[1] |
0x10010008 | 0000001E | numbers[2] |
0x1001000C | 00000028 | numbers[3] |
t3 comes out at 10 and t4 at 20. t5 is 1001000C, and t6 is 30, because the offset may be
negative and -4(t5) is one word back.
Two things the mode cannot do. It cannot add two registers, so there is no lw t0, (t1 + t2). And
it cannot scale anything, so an index has to be turned into a byte offset by your own code.
Indexing an array
C hides the size of an element: numbers[i] means the address of numbers plus i times four,
because the elements are 4 byte words. RISC-V makes you write both halves of that.
t4 comes out at 30 and t5 at 40, and after the sw the word at 0x10010008 reads 00000063,
which is 99. slli t2, t1, 2 is the multiplication by 4: shifting left by 2 multiplies by 4, and
every size on this machine is a power of two, so a shift is always what you want here.
Once the address is in a register, the constant offset does the rest of the work: 0(t3) and
4(t3) are two neighbouring elements out of one computed address, and a loop over pairs pays for
the arithmetic once.
Try changing li t1, 2 to li t1, 0 and watching which word changes instead.
auipc, and how la works
la t0, numbers looks like it should be a lui and an addi holding the address as a constant.
It is not. It assembles into auipc, add upper immediate to pc, and an addi:
auipc t0, 0xfc10 # t0 = pc + 0xfc10000
addi t0, t0, 0 # and the low 12 bits of the difference
auipc adds a 20 bit constant, shifted up 12 places, to the address of the auipc itself. So the
pair computes the distance from here to the label and adds it to where the program actually is,
which means the same three instructions work wherever the program was loaded. That is why RISC-V has
auipc at all, and it is what makes position independent code the default on this machine instead
of something you ask for.
The number in the disassembly, 0xfc10, is the top 20 bits of 0x10010000 minus 0x00400000,
which is the distance from the instruction to the data section.
The assembler's label forms
lw t1, numbers is not one of the three modes. It is a pseudo-instruction, and the assembler turns
it into an auipc that puts the address in the destination register and a real lw through it.
t1 comes out at 10 and, after the sw, the word at 0x10010000 reads 00000063. The store is
the one with an extra operand: a load can build the address in the register it is about to write,
and a store has no such register, so you name one, and t3 ends at 10010014 holding what the
auipc computed, which the store's own offset of -20 then brings back down to 10010000.
Two things this assembler will not do that MIPS's will. lw t1, numbers+8 is a build error, because
a label in an operand is the label and nothing added to it. And there is no hidden scratch register
anywhere in the three lines above, so nothing you were keeping got destroyed.
That is the trade. lw t1, numbers reads like C and costs two instructions every time it runs, so
inside a loop you do the la once, before the loop, and use offset(base) inside it. la is the
one you will write most, because a pointer in a register is what the loop wants.
Walking with a pointer
An array in memory has no length, no bounds and no element names, so a loop over it is built out of a pointer and a count.
t1 comes out at 00000096, which is 150, and t0 at 10010014, twenty bytes on and one word
past the last element. The addi t0, t0, 4 is C's p++ written out, because C hides the size of
what a pointer points at and assembly does not.
The other way to write that loop keeps t0 at the base and computes slli and add on every pass,
which is two instructions more and gives you the index in a register. Use the pointer when you touch
every element in order, and the index when you need the index itself, or when the loop jumps around
the array the way a binary search does.
Branches and jumps are addressed differently
None of the three modes applies to the instruction stream. beq t0, t1, label holds a distance in
bytes from the branch to the label, which reaches about 4 kilobytes either way. jal label holds a
distance too, and reaches about a megabyte. jalr t0, t1, 0 takes an address out of a register and
adds a 12 bit offset to it, which is how a subroutine return and a jump table both work.
You write a label in all of them and the assembler works out the distance.
Your turn
The four words are at 0x10010000. Leave numbers[2] in t0, working the address out at run time
from the index in t1 instead of writing the offset 8 yourself.
Show solution
The second one wants numbers[i] = 99 with i already in t1. The test starts it at 3, so the last
of the four words is the one that changes and the array ends up as 10, 20, 30, 99.