RISC-V Assembly for Beginners
By: Erik Engheim
Tags: Risc V, Tutorial, Cpu, Assembly, Learning To Code

RISC-V Assembly for Beginners

If you are new to assembly programming, then RISC-V is a good start.

HiFive Unmatched Mini ITX motherboard for building a RISC-V based PC. Image: CrowdSupply

If you don’t know any assembly programming or perhaps don’t know much coding at all then RISC-V may be one of the better assembly languages to start with. Sure, there are way more x86 assembly tutorials out there. More people who could help you. But x86 is a beast with over 1500 different instructions.

RISC-V in contrast was made specifically to be easy to teach while pragmatic enough to actually allow the implementation of high performance microprocessors.

If you need a smoother start or don’t know anything about microprocessors, you could read my: How Does a Modern Microprocessor Work?

If you need something easier and more “fun” then you could being with various games built around assembly programming: Learn Assembly Programming the Fun Way.

Another might be to learn a retro CPU such as the 6502 used in Commodore 64. The problem with this is that it has an entirely outdated design. It was not designed with the realities of modern hardware in mind.

The great thing about RISC-V is that it is a modern and simple instruction-set designed for modern hardware realities such as slow main memory, use of branch predictors, super scalar out of order execution units etc.

If you are curious about what those things mean, read: Why Is Apple’s M1 Chip So Fast?

Okay, before we get started you may want to consider printing out this: James Zhu RISC-V Reference.

Getting Setup and Started

To make this as easy as possible I will use this online RISC-V interpreter at Cornell University.

You can see a screenshot below. The leftmost orange circle shows a simple program with only a single instruction adding two numbers contained in the registers x1 and x2 before storing result in register x3 .

Registers are memory cells inside the microprocessor. The microprocessor cannot perform operations directly on data stored in memory (RAM). It needs to get data into a register before it can perform an operation.

On the right side you can see a list of registers. The second column named Register show the name of the register. In the first column we can set the initial value for a register. Encircled in orange, you got the initial values of register x1 an x2 set to 3 and 4 respectively.

ADD x3, x2, x1

Hit the green Run button to run program. It adds register x1 and x2 , storing result in register x3. I have already run the program, so you can see the Decimal column shows 7 for register x3 .

You can copy paste in a larger program and try to run that. In this case the input is placed in register x1 . It is a number to count down from. Put e.g. 14 in the register by editing the Init Value column.

ADDI x2, x0, 1



loop:

SUB x1, x1, x2

SW x1, 4(x0)

BLT x0, x1, loop

HLT

You can see the count down happening by looking at the Decimal column for the registers or the Memory. The countdown happens at the second row in the memory for address 0x04.

Before running you should set the CPU to 2 Hz. That means two instructions are executed each second. If the CPU runs faster you will not easily see what is going on. Afterwards you can hit the green Run button. While the program runs you can see it prints out in the box you see below, which line of code is being executed.

To Run the program again you need to hit the blue Reset button. Now you know the basics of how to insert a program, input data and run it. So let us start talking about the RISC-V microprocessor and how it is programmed.

RISC-V Register Usage

In my introduction to microprocessors, I mentioned that in modern RISC processors you can only perform operations on numbers inside registers, not on numbers in memory.

RISC-V is no exception. It has 32 general purpose registers named x0 to x31 . The first register x0 is special in that it is always zero. Doesn't matter what value you copy into it. It will always produce zero if you try to read from it.

It may seem strange, but it is very practical. There are a lot of operations where you need the number zero. By having a register always be zero, you don’t have to copy the value 0 into it. For those familiar with Unix, it serves a bit of the same purpose as /dev/null . It s a place to send values you want to discard.

All registers have an alias in parenthesis which help remind the code writer what the register is for. The alternative name for x0 is zero . That means the two lines below are equivalent:

ADDI x2, x0 , 1

ADDI x2, zero, 1

The latter part is more readable as it reminds you what the register is being used for.

Let us look at some of the other registers:

ra return address. Common used to store return address before calling a subroutine.

return address. Common used to store return address before calling a subroutine. sp stack pointer.

stack pointer. gp global pointer.

global pointer. tp thread pointer

thread pointer t0 to t6 temporary registers. Subroutines make no promise to save these.

to temporary registers. Subroutines make no promise to save these. s0 to s11 saved registers. Subroutines promise to keep these unchanged.

to saved registers. Subroutines promise to keep these unchanged. a0 to a7 function arguments. Before jumping to a subroutine, you pass arguments into these registers.

This naming relates to conventions used for RISC-V code generated by a compiler for a high level language. Say you write some pointless C code like this:

01 int calculate(int x, int y) {

02 int tmp = x * 10;

03 return tmp + y;

04 }

05

06 int main() {

07 int alpha = 4;

08 int beta = calculate(2, 3);

09 return alpha + beta;

10 }

What would this translate into in RISC-V assembly code? I will not talk about instructions used but the registers used. In line 07 we store the value 4 in variable alpha . We will need a register to store that value.

Because registers could be used by the calculate function called in the line 08 right below, we need to make sure alpha is preserved? How do we do that? In the past assembly coders would use memory. In particular to a location we call the stack. However back then, main memory was fast relative to the CPU. Today the CPU is insanely fast compared to memory. By the time you get something stored in memory, hundreds of instructions could have been executed. Hence we always do our best to avoid reading and writing to memory.

The RISC-V solution is to come up with a convention. If you place the value of alpha in one of the s registers s0 to s6 which corresponds to x8 , x9 and x18 to x27 then it will be saved. Called subroutines must promise not to change these or if they do, save them and restore them before returning.

But every register cannot be an s register otherwise we cannot use any register without saving them. The solution to that is the t registers which are temporary.

The tmp variable inside calculate can be store in e.g. t0 because it does not need to be preserved.

The next problem is how do we pass arguments to functions (subroutines with arguments returning values)? The old conventions frequently used on x86 Intel processors was to put arguments in memory on the stack. But again unnecessary memory access is bad. The RISC-V solution is to use the a registers a0 to a6 to store function arguments. x and y argument is passed in a0 and a1 respectively. This corresponds to x10 and x11 . The result of the calculation is returned in a0 .

When we call calculate in line 08 we want to return and be able to continue executing line 09 . The convention here is to store the return address in the ra register, which corresponds to x1 . Again if you have CISC assembly code background, e.g. from x86 you would have been used to placing this on the stack.

Of course eventually we run out of registers. There are conventions for how we store data in memory then, but I will not cover them here.

RISC-V Instructions

Before we look at concrete instructions it can be useful to look at the common pattern used with RISC-V instructions. If you look at the code below you will see that pretty much all of them take 3 arguments.

ADDI x2, x0, 1



loop:

SUB x1, x1, x2

SW x1, 4(x0)

BLT x0, x1, loop

These arguments are used in a very similar fashion across most instructions. Actually scratch that. We don’t call it arguments in assembly code. Arguments are for functions. Assembly instructions have operands, and the the type of instruction is the opcode. Thus the whole line below is an instruction:

ADDI x2, x0, 1

While the ADDI part is the opcode and x2 , x0 and 1 are the operands. Or more accurately ADDI is a mnemonic, a short word which works as an alias for the actual numeric opcode, used in the machine code. For practical reasons I will mix this terminology a bit.

Anyway a typical RISC-V instruction is on this format:

OPCODE rd, rs1, rs2

In programming terms we could think about this as:

rd = f(rs1, rs2)

The opcode specifies some function f to perform on the two source registers rs1 and rs2 and produce a result which is stored in the destination register rd . For the ADD and SUB instruction this is often written as:

rd ← rs1 + rs2

rd ← rs1 - rs2

Instead of covering every instruction separately, I will use this notation in comments next to instructions. Comments in RISC-V assembly begin with hash symbol # , just like in script languages. Sadly I was not aware of this until writing this very line. Thus a bunch of my previous RISC-V writing is wrong, because I used ; which was commonly used in other assembly languages. Our RISC-V interpreter will not be happy with semi-colons though for comments. It wants hash symbols.

Anyway I will use comments to explain specific instructions:

ADDI x2, x0, 1 # x2 ← x0 + 1

BLT x0, x1, loop # IF x0 < x1 GOTO loop

Why Three Operands?

If you are used to higher level languages, it is not common for functions to take a fixed number of arguments. However assembly code instructions are encoded in a fixed width format of 32-bits. To make it easier for the decoders inside the CPU to figure out what an instruction does, it helps to have different parts of the instruction being in fixed locations.

The diagram below shows what the different bits in the 32-bit word encoding an instruction is used for. If you look at the diagram you can see the RISC-V designers have tried to regularize things to to make the job for the decoder easy.

Let me explain a bit how. The rd register e.g. spans the same bit positions, bit-7 to bit-11 regardless of form used. The second argument rs1 or begins in the same location in every case.

Pseudo Instructions

You can however write a bunch of RISC-V instruction which don’t take three arguments, but a lot of these are what we call pseudo-instructions. That means they don’t correspond directly to a machine code instruction. Instead they are simply a shorthand for another instruction.

For instance take the NEG instruction:

NEG x2, x4

This takes the content of x4 and makes it negative and stores that in x2 . Register cannot store a - symbol so what actually happens is that we get the two-complement. Before I explain that let me just show what NEG is shorthand for:

SUB x2, zero, x4 # x2 ← zero - x4

Now you can also see some of the advantage of the zero register. It makes it easier to create a lot of these pseudo instructions.

Unfortunately our simulator doesn’t support very many of these pseudo instructions, so you will have to write stuff out on the long form.

Two’s Complement and Ten’s Complement

To explain how we represent negative numbers it may be useful to work in decimal numbers first and consider ten’s complement first.

Computers always work with numbers with some fixed number of digits. Let us assume we work with a decimal computer where registers only work on 2-digit decimal numbers. Look at the calculation below:

10 - 12 = 98

How did we get that? Consider the how we do subtraction more in detail:

carry 11

---------

10

-12

---------

98

We carry from the hundreds position, but there isn’t anything there. Hence we get this wrap around effect.

But we can exploit this to have a representation for negative numbers. That means 99 would mean -1. 98 would mean -2 and so on. We can show that this works consistently:

10 + (-2) = 8

10 + 98 = 108 # Remove first digit and we have 8

50 + (-50) = 0

50 + 50 = 100 # Remove first and we got 0

Two’s complement is exactly the same idea but for binary numbers. For four bit numbers 1111 would mean -1. While 1001 would mean -2 and so on.

Loading and Storing Data

To load data from memory into registers or store data in registers to memory we use the L and S instructions. You need to use different suffixes to indicate what you are loading:

LB - L oad B yte.

- oad yte. LW - L oad W ord.

- oad ord. SB - S tore B yte.

- tore yte. SW - Store Word.

On RISC-V a word means 32-bits, while a byte obviously means 8-bits. These instructions take three operands but the third one is an immediate value. In assembly programming an immediate value is the same as a constant. It is encoded with the instruction rather than being stored in memory or inside a register.

Some examples of usage:

LW x2, 2(x0) # x2 ← [2], load contents at address 2

LW x3, 4(x2) # x3 ← [4 + x2], load content of addr 4 + x2

SW x1, 8(x0) # x1 → [8], store x1 at addr 8

Addresses, Jumps and Labels

For a RISC-V program we know every instruction takes 32-bits. That corresponds to 4 bytes. Thus in a program the first instruction will typically be at address 0, the second at 4, the third at 8 and so on.

Let us write out an earlier program with addresses in the first column to make it easier to see how jumps work. First let us clarify a jump or branch is. If you want to repeat part of your code several times over you need to make a jump to a previous instruction.

The microprocessor keeps track of the next instruction to execute with a special register called the Program Counter (PC). It gives the address in bytes of the next instruction to execute. Since each instruction is 4 bytes long, the PC gets incremented by 4 each time.

Let us look at an example:

BEQ x2, x4, 12

The BEQ instruction means branch or jump if registers are EQual. If x2 = x4 then the program counter (PC) will be updated to:

PC ← PC + 12

That means jumping 3 instructions forward. That means we skip the next two instructions. Let us look at the count down program from earlier. The first column contains the instruction address:

00: ADDI x2, zero, 1 # x2 ← 0 + 1

04: SUB x1, x1, x2 # x1 ← x1 - 1

08: SW x1, 4(zero) # x1 → [4 + 0]

12: BLT zero, x1, -8 # 0 < x1 => PC ← PC - 8 = 4

16: HLT # Halt, stop execution

You can copy this to the simulator without the address column. What you see here is that branching is relative to where you are. On line 12 , we want to check if x1 is still larger than zero to see if we should continue our countdown. If it is we want to jump to line 04 where we use SUB to subtract 1 from x1 . However we don't write BLT zero, x1, 4 . Instead we specify -8 . That is because jumps are relative. We jump two instructions backwards.

This is in fact quite practical because it means we could move our program to a different location in memory and it would still work. However more importantly it saves a lot of space. You only got 32-bits to encode an instruction:

Each register requires 5 bits to encode and the branch specifies two registers which eats up 10-bits.

The opcode and function eat up 10-bits.

That leaves 12-bits to specify an address to jump to. The maximum number you get with 12-bits is 4096 (²¹²). Thus if your program was larger than 4 KB you couldn’t perform jumps.

With relative addressing this is not a problem. You can jump 2048 bytes backwards or forwards in the program. Most for-loops, while-loop and if-statement will not be larger than that.

However there is one problem with relative addressing. It is awkward for the programmer to write. This is where address labels save us:

ADDI x2, x0, 1



loop:

SUB x1, x1, x2

SW x1, 4(x0)

BLT x0, x1, loop

You can simply label the location you want to jump. Here we use the label loop . Use a colon to indicate this is a label. The assembler will use the label to calculate what offset needs to be used to jump to the given label. Thus depending on what instruction is using the loop label, a different offset will be calculated.

Ok let us look at different types of jumps. An instruction which makes an unconditional jump start with a J . Jumps which are conditional start with a B for Branch.

Branching — Conditional Jumps

For conditional branching we compose the mnemonic from a B and a two or three letter combination describing the condition such as:

EQ = EQ ual.

= ual. NE ≠ N ot E qual.

≠ ot qual. LT < L ess T han.

< ess han. GE ≥ Greater or Equal.

Let us look at a few examples of what that would translate to:

BEQ x2, x4, offset # x2 = x4 => PC ← PC + offset

BNE x2, x4, offset # x2 ≠ x4 => PC ← PC + offset

BLT x2, x4, offset # x2 < x4 => PC ← PC + offset

BGE x2, x4, offset # x2 ≥ x4 => PC ← PC + offset

Unconditional Jumps

Often we need to jump around in code without checking if a condition is true or false. Examples of this are:

Calling a function. That means setting a registers to function inputs and doing a unconditional jump to location in memory where instructions for function reside.

registers to function inputs and doing a unconditional jump to location in memory where instructions for function reside. Returning from a function. When we are done executing code in a function we need to return to instruction after call-site.

But often you simply need to make unconditional jumps to deal with different conditions.

Jump and Link — JAL

The JAL instruction can be used for both calling functions or just make a simple unconditional jump.

JAL makes a relative jump (relative to PC) just like the conditional branch instructions. However the provided register argument is not used for comparisons but to store return address. If you don't want to store the return address you can simply provide the zero register x0 .

JAL rd, offset # rd ← PC + 4, PC ← PC + offset

The convention used with RISC-V is that the return address should be stored with the return address register ra which is x1 . Say you got some C code with call like this:

foobar(2, 3)

In assembly this would be:

ADDI a0, zero, 2

ADDI a1, zero, 3

JAL ra, foobar

Why the emphasis on relative jumps in RISC-V? In earlier CPUs jumps where typically to absolute addresses. The reason is that in modern operating systems, one needs to be able to move code around in memory. Thus a developer cannot know the position of his or her code in memory. A relative jump will always work, regardless of where in memory a program is placed.

Jump and Link Register — JALR

This is really the same instruction but with the difference that we use an offset from register? What is the point of that?

In JAL there is simply not enough space to encode a full 32-bit address. That means you cannot jump anywhere in the code if you are in a larger program. But if you use an address contained in a register, you can jump to any address.

Other than that JALR works almost the same as JAL . It will also store the return address in rd .

JALR rd, offset(rs1) # rd ← PC + 4, PC ← rs1 + offset

A big difference is that JALR jumps are not relative to PC . Instead it is relative to rs1 .

While we could use a regular ADDI function to set the rs1 register that is impractical. We want an address relative to the program counter (PC). Fortunately we have a special instruction AUIPC which stands for: Add Upper Immediate to PC.

AUIPC rd, immediate # rd ← PC + immediate[31:12] << 12

The explanation may be hard to read, but it basically just means that we store the upper 20-bits of the offset to some label in the upper 20-bits of the rd register.

The JALR offset can be 12-bits wide, so together (20 + 12 = 32) they make up a 32-bit address. JAL uses a 20-bit address, so if you need to call a function foobar further away than that we use a combination of AUIPC and JALR like this:

ADDI a0, zero, 2 # Set first argument to function

ADDI a1, zero, 3 # Set second argument.

AUIPC t0, foobar # Store upper 20-bits address of foobar

JALR ra, foobar(t0)

Code Examples for Next Time

In my next story we will do some coding excercises: RISC-V Assembly Code Examples.