Advanced RISC-V AssemblyTable of ContentsNone

Contents

  1. Introduction
  2. Design the logic in a comfortable language
  3. Take small bites
  4. Know your role
  5. Know how to call a function
  6. Document!

Introduction

Writing assembly is itself an art. When C, C++, or any other language is compiled, the compiler determines the art of writing assembly. However, this time, we will some of the techniques and decisions we can make to write these ourselves.

We will use RISC-V to see how to design logic, write up the logic, and translate the logic into assembly.


Design the Logic in a Comfortable Language

This is the step that we must get right to start off on the right foot. Many students want to sit down and write the complete package. However, if you’re not comfortable with assembly, this is a doomed approach. Instead, to divide the logic from the language, we have to write in a language we understand.

If you don’t know C or some lower-level language that well, then I suggest they write in pseudocode. Too high of a language will make the translation harder and too low of a language will make the logic design harder. So, I recommend C or C++, or some language at that level.

When translating, it is helpful to have some sort of editor that can place them side-by-side. Keeping a list of instructions in your brain is difficult, especially if you’re translating a complex program.

Atom’s “split” windows.

Take Small Bites

Many students try to write the entire program from start to finish without testing anything in between. I recomment incremental programming, especially for new learners. The point is to test when you get a portion of logic completed. This could be as simple as getting a for loop done, or shifting to scale a pointer.

One way you can test is to link your C or C++ program with your assembly program. You can do this by prototyping the name of the assembly function in C++ and switching between the two. You need to make sure you keep the two different, otherwise your linker won’t be happy. I usually put a “c” in front of my C functions to differentiate. I don’t change the assembly names since in the end, we want all assembly functions to replace the C functions.

Combining assembly-written and C-written functions.

With what I’ve done above, we can call show to run the assembly function or cshow to run the C function. In C++, the names of the functions are mangled to allow for overloading. However, you can turn this off by doing the following:

extern "C" { // Turn off name mangling
    void show(int *x);
}

The extern “C” will tell C++ that the functions follow the C “calling convention”. We really care that name mangling is turned off so that we can just create a label named “show” and have our function.


Know Your Role

As Dwayne “The Rock” Johnson always said, “know your role”. It is important to know what C/C++ was doing for us versus what assembly doesn’t do for us. This includes order of operations. For example, 4 + 3 * 4 will automatically order the operations to perform multiplication first, followed by addition. However, in assembly, we must choose the multiply instruction first followed by the addition instruction. There is no “reordering” performed for us.


Know How to Call and Write a Function (including main!)

Most ISAs include a calling convention manual, such as ARM and RISC-V. These just lay down some ground rules to make calling a function work across all languages. Luckily, the “ABI” names of the RISC-V registers lend to what they mean. Here are some of the rules we need to abide by.

  1. Integer arguments go in order a0, a1, …, a7, floating point arguments go in order fa0, fa1, …, fa7.
  2. Allocate by subtracting from the stack pointer, deallocate by adding it back. Whatever is allocated must be deallocated!
  3. The stack pointer must always be a multiple of 16.
  4. All a (argument) and t (temporary) registers must be considered destroyed after a function call. Any time I see the “call” pseudo-instruction, I start thinking about saving registers using the stack.
  5. All s (saved) registers can be considered saved after a function call.
  6. For functions: If you use a saved register, the old value must be saved and restored before the function returns.
  7. If you use any of the saved registers, you must restore their original value before returning.
  8. Return data back to the caller through the a0 register (for integers) or fa0 register (for floating-point).
  9. Don’t forget to save the one and only RA (return address) register any time you see a call instruction (jal or jalr) in your function.
.global main
main:
    addi    sp, sp, -16     # allocate stack space
    sd      ra, 0(sp)       # save RA register since we're calling
    la      a0, test_solve  # load registers for function call
    call    solve           # call the function "solve"
    mv      a0, zero        # get ready to return from main (return 0)
    ld      ra, 0(sp)       # load back the return address
    addi    sp, sp, 16      # deallocate the stack space
    ret                     # return!

You can see from the code above, we allocate our stack frame first, save all registers that need to be saved, execute, and then undo everything before returning.


Document!

Writing assembly from C or another language will have you writing multiple lines of assembly code for every single line of C code. This can get confusing and utterly frustrating if you’re trying to debug your program. So, I always write the C code as a comment for the assembly and then pull it apart and show each step of me doing it.

# used |= 1 << ( x[i * 9 + col] - 1)
    mul     t1, s3, t0          # t1 = i * 9
    add     t1, t1, s2          # t1 = i * 9 + col
    slli    t2, t1, 2           # Scale by 4
    add     t2, t2, s6          # x + i * 9 + col
    lw      t3, 0(t2)           # x[i * 9 + col]
    addi    t3, t3, -1          # x[i * 9 + col] - 1
    li      t4, 1
    sll     t4, t4, t3          # 1 << x[i * 9 + col] - 1
    or      s5, s5, t4          # used |= ...

You can see from the code above, I have the original C code (first comment), and then inline comments for each piece. This keeps me honest when it comes to order of operations and that I’m doing each step correctly.

Advanced RISC-V AssemblyTable of ContentsNone