Learn Multi platform Risc-V Assembly Programming... For Open Source CPUs!

Risc-V is a relative newcomer - essentially competing with ARM.

When ARM is incredibly cheap, widely available, and impressively powerful, why would we need a competitor? Well the answer is simple... you need an ARM license to make the chip... and that license can be revoked at any time

If you want a truly open platform that you have total control over, ARM cannot currently provide that... which is where Risc-V comes in!

In this tutorial we'll be using RARS... a Risc-V simulator with macro and include support.


If you want to learn Risc-V get the Cheatsheet! it has all the RISC-V commands, it covers the commands and how those commands compile to bytecode

These tutorials assume you have a basic understanding of concepts like HEX and Registers...

As Risc-V is really an 'Upcoming' CPU If you've never programmed before, you're better off looking at a more mainstream CPU like the Z80, 6502 or 68000

ChibiAkumas Tutorials

Absolute Beginner Series

A warm up for those who aren't ready to start programming, covers concepts and terminology
   
Less0n 1Getting started with Retro Programming in Assembly - Basics 1

Lesson 2 - Basics� The Mysteries of the CPU!

Lesson 3 - Basics� More Detailed Mysteries of the CPU!

Lesson 4 - Data Representation

Lesson 5 - Assembler Terminology.

Lesson 6 - More Assembler Terminology!

Lesson 7 - Even More Assembler Terminology!!!!111

Lesson 8 - Programming Techniques

Lesson 9 - Graphics Terminology

Lesson 10 - Sound Terminology.

Lesson 11 - Other Hardware Terminology

Risc-V tutorials

Lesson1 : For absolute beginners!
RISC-V Lesson 2 - The stack and conditions
Risc-V Assembly Lesson 3 - Bit ops and more maths!

Hello World Series

Hello world with RARS! - RiscV - Lesson H1

Simple Samples

RiscV Lesson S1 - Reading and Showing a Register

Risc-V Registers

Bits Int Reg Name Detail
00000 x0 zero Hard-wired zero
00001 x1 ra Return address Caller May be changed by sub
Used for function return with jalr x0, x1, 0
00010 x2 sp Stack pointer Callee if changed by Sub must be backed up
00011 x3 gp Global pointer
00100 x4 tp Thread pointer
00101 x5 t0 Temporary/alternate link register Caller May be changed by sub
00110 x6 t1 Temporaries Caller May be changed by sub
00111 x7 t2 Temporaries Caller May be changed by sub
01000 x8 s0 / fp Saved register/frame pointer Callee if changed by Sub must be backed up
01001 x9 s1 Saved register Callee if changed by Sub must be backed up
01010 x10 a0 Function arguments/return values Caller Use to pass to functions - May be changed by sub
01011 x11 a1 Function arguments/return values Caller Use to pass to functions - May be changed by sub
01100 x12 a2 Function arguments Caller Use to pass to functions - May be changed by sub
01101 x13 a3 Function arguments Caller Use to pass to functions - May be changed by sub
01110 x14 a4 Function arguments Caller Use to pass to functions - May be changed by sub
01111 x15 a5 Function arguments Caller Use to pass to functions - May be changed by sub
10000 x16 a6 Function arguments Caller Use to pass to functions - May be changed by sub
10001 x17 a7 Function arguments Caller Use to pass to functions - May be changed by sub
10010 x18 s2 Saved registers Callee if changed by Sub must be backed up
10011 x19 s3 Saved registers Callee if changed by Sub must be backed up
10100 x20 s4 Saved registers Callee if changed by Sub must be backed up
10101 x21 s5 Saved registers Callee if changed by Sub must be backed up
10110 x22 s6 Saved registers Callee if changed by Sub must be backed up
10111 x23 s7 Saved registers Callee if changed by Sub must be backed up
11000 x24 s8 Saved registers Callee if changed by Sub must be backed up
11001 x25 s9 Saved registers Callee if changed by Sub must be backed up
11010 x26 s10 Saved registers Callee if changed by Sub must be backed up
11011 x27 s11 Saved registers Callee if changed by Sub must be backed up
11100 x28 t3 Temporaries Caller May be changed by sub
11101 x29 t4 Temporaries Caller May be changed by sub
11110 x30 t5 Temporaries Caller May be changed by sub
11111 x31 t6 Temporaries Caller May be changed by sub

All the registers function the same... but there are 'Official Rules!'
A calling function should use the Ax registers to send data to a subroutine... if the calling function needs the Tx registers to stay the same it should back them up - the Ax registers may also change...
The Sx registers can be changed by the subroutine - but it's the subroutines job to back them up if it changes them... not the calling function!

Float Reg Name Detail
f0 ft0 FP temporaries Caller
f1 ft1 FP temporaries Caller
f2 ft2 FP temporaries Caller
f3 ft3 FP temporaries Caller
f4 ft4 FP temporaries Caller
f5 ft5 FP temporaries Caller
f6 ft6 FP temporaries Caller
f7 ft7 FP temporaries Caller
f8 fs0 FP saved registers Callee
f9 fs1 FP saved registers Callee
f10 fa0 FP arguments/return values Caller
f11 fa1 FP arguments/return values Caller
f12 fa2 FP arguments Caller
f13 fa3 FP arguments Caller
f14 fa4 FP arguments Caller
f15 fa5 FP arguments Caller
f16 fa6 FP arguments Caller
f17 fa7 FP arguments Caller
f18 fs2 FP saved registers Callee
f19 fs3 FP saved registers Callee
f20 fs4 FP saved registers Callee
f21 fs5 FP saved registers Callee
f22 fs6 FP saved registers Callee
f23 fs7 FP saved registers Callee
f24 fs8 FP saved registers Callee
f25 fs9 FP saved registers Callee
f26 fs10 FP saved registers Callee
f27 fs11 FP saved registers Callee
f28 ft8 FP temporaries Caller
f29 ft9 FP temporaries Caller
f30 ft10 FP temporaries Caller
f31 ft11 FP temporaries Caller

Risc-V Addressing Modes

The RISC-V is a 'Load and Store' architecture processor, meaning that many of the commands only work between registers.

Mode Notes Format   Example
Immediate Addressing
A fixed number is the parameter for an operation. n LI a7,12
ADDI a0,a0,-7
LI a2,255
Register Addressing
A register itself will be used as a source or destination of an operation. Rn OR a1,a1,A0
MV a3,a1
Register Indirect with Offset Addressing
This Addressing mode uses the value from the address in a register, offset by a fixed numeric value. n(Rm) LW a1,4(a0)
LW a1,0(a0)
Program Counter Relative with Offset Addressing
used by the AUIPC command (Add Upper Immediate, Program Counter), and for relative jump and branch operations.
BEQ a0,a1,TestLabel
AUIPC a0,0xFF


Functions we'd like to have but don't

Push .macro push(%reg)
    addi sp,sp,-4
    sw %reg,0(sp)   
.end_macro
Pop .macro pop(%reg)
    lw %reg,0(sp)
    addi sp,sp,4
.end_macro
Push multiple       addi sp,sp,-8
      sw x1,4(sp)
      sw x2,8(sp)
Pop multiple       lw x1,4(sp)
      lw x2,8(sp)
      addi sp,sp,8
PrintChar (on RARS simulator)     li a7, '!' #; Char to print
    li a7, 11
    ecall
Exit (on RARS simulator)     li a7, 10
    ecall



Lesson 1 - Getting Started with the Risc-V
Lets start learning about the Risc-V... Lets learn how to do simple maths operations, and how to transfer data to and from memory.

There's a video of this lesson,  just click the icon to the right to watch it ->

Our simulator

We're going to be using RARS as a simulator, it's a free open source Risc-V simulator.... RARS uses java.

My Devtools provide a batch file which will build the programs for you, but if you don't want to use them, the format of the build script is shown below:



%BuildFile%... this would be the sourcefile you want to compile... Eg: Lesson1.asm

A template program
To allow us to get started programming quickly and see the results, we'll be using a 'template program'...
This consists of 3 parts:

A Generic Header - this includes some parameters in the data segment for our program

The Program - this is the body of our program where we do our work.

A Generic Footer - this will return control to the system

The code needs to be in the .text section... the data needs to be in the .data section
Warning! ECALL is not a real Risc-V command - it's a special command used by our simulator to perform tasks like printing characters to the screen, making sounds, and returning to the OS!

We'll need it a lot (Via PrintChar) to output things to the screen during our tests!

Commands, Labels and jumps
Lets take a look at a simple program!...

There will be times we need to jump around the code... the simplest way to do this is the  command 'J'... this will jump to another position in the code ... notice, commands like this are indented by a tab.

Notice the line which is not indented and ends with a colon : - that makes it a label called 'shutdown' ... labels tell the assembler to 'name' this position in the program - the assembler will convert the label to a byte number in the executable... thanks to the assembler we don't need to worry what number that ends up being...

you'll also notice text in green starting with a  hash # - this is a comment (REMark) - they have no effect on the code (Semicolon has no effect!)

Loading Immediate values
We have 31 registers in total - but we'll be using a0-a7 for testing... the t0-5 and s0-11 registers all work the same.

To load a register we use the function LI (Load Immediate)... This sets a register to a fixed value in the code..

the destination register is on the left of the comma... the source value is on the right.
We can use decimal, Hexadecimal (by starting the value with 0x) or Ascii (by putting a character in quotes '')

'Immediate values are values on the same source code line - rather than being taken from a register or memory address.
The Registers will be loaded as specified.
If we want to give a number a label we can use a Symbol... these are defined with .eqv - a name and value are specified

The symbol can then be used in the source code, the assembler will convert the symbols back to their numeric values in the bytecode.


Here's the result!
Some of these commands are 'Pseudo-ops'... this is where the assembler compiles one command into multiple in the final binary...

It makes things easier for us to let the assembler to do as much of the work as possible, so we won't differentiate between Psuedo and 'Real' commands.

Moving between registers
We can transfer values between registers with MV (move)

The destination register is on the left, the source is on the right.
The value is copied from A0 to A1 and A2

Add and Subtract
We can add or subtract immediate values... the AddI command will add a value to a register, and store the result in the leftmost register.

In this example we add 1 to A0, and save the result to A1 - A0 is unchanged by this function.

Strangely, we don't have a SubI command!... but we can 'add' a negative immediate command.
here is the result
As well as adding an immediate value, we can add or subtract a register.... the commands are ADD and SUB

The leftmost register is the destination, the middle one is the first value, the right hand register is the second value to be added or subtracted
Here is the result...


Reading and Writing to and from Ram

The Risc-V is a 32 bit CPU - so WORDS are 32 bits (not 16 like on 8/16 bit systems)...

A Half-Word is 16 bits!... but relax, a byte is still 8 bits! so at least everything hasn't changed!

To be able to read data, it needs to be in the .data section.

It cannot be in the code section
To read from an address we need to load it into a register with LA (LoadAddress) - any register can be used for this - not just A0-7... we need to specify the source address in brackets () - the destination register is on the left of the comma as always

We then need to load in from the address... We have 3 size options:
LW for Load Word (32 bit)
LH for Load Half (16 bit)
LB for Load Byte (8 bit)

These commands are 'sign extended' - meaning when we load a Byte or Half, the top unfilled bits take the same value of the top loaded bit (to maintain the sign)... if we don't want this we can use the Unsigned versions...  There is no LWU - as there is no uloaded bits to sign extend.

LHU for Load Half (16 bit)
LBU for Load Byte (8 bit)
Because the top bit of each memory byte was 1, The empty bytes of the LH,LB values are FFF...

This is because of the way negative numbers work in Hexadecimal. if you don't understand Hexadecimal, please watch this video
We can store back to ram with SW (Store Word), SH (Store Half) and SB (Store Byte)...

There is no need for signed and unsigned versions

As well as using (A2) as an address... we can use an Offset... just put a number before the (a2) part... this will be a byte offset from the address - this works with Load statements too, and can be positive or negative!
The results can be seen here

Jumps: J JR JAL JALR... and RET!

J - Jump

J is a simple jump to label command... we specify the label to jump to, and the code execution will continue there... there's not RETurn command like a call - if we want the code to continue after the jump, we probably want another jump back.
JR - Jump to Register

Rather than specifying a label as an 'immediate' value, we can use a register value
we would just load the address into the register, then specify that address
JAL - Jump and Link ... This is the equivalent of a Call / Gosub

Unlike other CPU's this function does not use the stack... the return address is loaded into RA (register x1)... to return we use the command RET (a psudeo op for jr ra)

If we want to nest calls with JAL - we should push RA onto the stack at the start, and pop it at the end.



JAL can also use an alternative return register, rather than RA we can specify a different register to store the return address



JALR - Jump and Link to Register

This function also uses a return address... however it jumps to the address in a register...

This function also allows an alternative return register (normally RA) - it also allows an 'offset' to the label in the register to be specified for the jump that occurs
Things tend to work a little different on RISC compared to the 6502 or z80 that you're used to! but don't worry... it's not so bad!
You can create macro's to do things you're used to, and things will soon be much easier...

In fact, the PUSH and POP commands above are macros - we'll learn how to use the stack soon!



Lesson 2 - The stack and conditions
We used the stack a bit in the last lesson with JAL - but we didn't really cover it... it's time to fix that now - it's also time to look at conditions.

The Stack

The Stack pointer uses register SP (Register x2) to point to the top of the stack...

We actually have no 'proper' Stack commands!... to 'push' an item onto the stack, we subtract 4 from SP - then load the register we want to push to the SP address

To 'pop' an item off the stack we do the reverse - loading the register from the address in SP - and then add 4 to the SP register.

We can define these as macros to make our lives easier.
Here we've loaded a value into A0... pushed it onto the stack, loaded a different value onto the stack and then performed a call...

Finally we pop the old value off the stack.

We dump the state of the system at each stage
The changes to the stack can be seen here...

Each push to the stack can be seen in memory.

Comparisons

Unlike other CPU's the Risc-V does not have a flag register as such... when we want to do a conditional branch, we use a branch command with two registers to compare, and a label to jump to if the condition is true...

Lets look at all the options!...

The examples shown here are all available for download!... there are various possible values and conditions remmed out with # -

You should try enabling different conditions, and providing different input values and see how things change!

Equals - Not Equals -  EQ / NE

if we want to perform actions if the two registers are the same - or different - we can do this with BEQ and BNE

BEQ will Branch if Equal
BNE will Branch if Not Equal
The results can be seen here

Less - Greater - Unsigned -  LTU / LEU / GEU / GTU

Because Hexadecimal signed numbers have their top bit as 1 we have to use different compare commands for signed and unsigned numbers... there is a U at the end of unsigned comparisons.
We have 4 options:

BLTU - Branch if Less Than Unsigned
BGTU - Branch if Greater Than Unsigned
BLEU - Branch if Less Than or Equals Unsigned
BGEU - Branch if Greater Than or Equals Unsigned
The results are shown here

Less - Greater - Signed-  LT / LE / GE / GT

If we're working with Signed numbers, we have alternate versions - these do not have U at the end
We have 4 options:

BLT - Branch if Less Than signed
BGT - Branch if Greater Than signed
BLE - Branch if Less Than or Equals signed
BGE - Branch if Greater Than or Equals signed
The results are shown here

Comparing with Zero

The Risc-V's x0 register always equals zero.. we can take advantage of this for quick comparisons to zero

x0's alias is ZERO - and we'll use this in the compare functions we've already seen

there are also Psuedo operations for these:BEQZ, BNEZ, BLEZ, BGEZ, BLTZ, BGTZ
Here is the result

Lesson 3 - Bit ops and more maths!
We've looked at basic maths, addressing modes and branches... but we've not covered all the maths functions of the Risc-V yet...

Lets take a look at the other options

Logical ops - AND, OR, XOR
We have OR, AND and XOR functions...

As always they have a Desitnation on the left, and two parameters for the logical operation...

All three commands have an Immediate version, where the second parameter is a fixed number - these are ORI ANDI and XORI
The results are shown here.

LUI - Load Upper Immediate

LUI will load an immediate value into the top half of a register.
Here is the result!
strictly speaking, a LI command is a pseudo op which uses the command LUI - which loads the top half of the register - the rest is ORed in.

Fortunately, this is all handled by our assembler... phew!

NOT (bitflip) and NEG (flip sign)

As well as the ones mentioned above we have NOT and NEG!

NOT flips all the bits in a register
NEG flips all the bits in a register and adds one, effectively converting a positive number into a negative one.
Here are the results of these operations

Bit Shifts... SLL(I), SRL(I), SRA(I)

The Risc-V offers three kinds of bitshifts, each of which supports a register shift amount, or an immediate one

SLL is Shift Left Logical Left - all bits move to the left - any bits pushed out the register are lost
SRL is Shift Right Logical - all bits move to the right - any bits pushed out the register are lost
SRA is Shift Right Ari thematic - all bits move to the right - any bits pushed out the register are lost - any new bits on the left contain the previous leftmost bit - maintaining the sign.
We've performed 4 shifts of A0 with each of the 3 options... here
There is no SLA command (shift arithmetic left) because there is no need for one.. SLL will do the job you need!

More problematic is the lack of rotation commands... ROR/ROL don't exist!... you'll have to use AND/OR and the shift commands that do exist to simulate them.

True/False returning conditions SLT(I), SGT(I), SLTU(I), SGTU(I)

The Risc-V has Four special commands for returning True (1) or False(0)... if the condition is true, the leftmost register will be set to 1 - else it will be set to zero

SLTU (Set Less Than Unsigned)
SGTU (Set Greater Than Unsigned)

SLT (Set Less Than Unsigned)
SGT (Set Greater Than Unsigned)

All these also have versions that take an immediate value.
The results are shown here.
Phew! We've covered the basics!

Risc-V has extensions for 64 bit and floating point, but they are beyond what this tutorial was intended to cover... you should at least have enough now to get started!