Hallo Wurld: A RISC-V Assembly Program Walkthrough
I thought it might be useful exercise, both for readers and for myself, if I walked through a simple RISC-V Assembly program and broke down what was going on and how it all worked. The objective of this program is to print out 10 nice compliments to the user, keeping track of how many compliments we've said. Let's go!
The Code
Here's the full code upfront:
Don't worry if it seems like gibberish right now! We'll break it down as we go. By the end, it should all make a lot more sense. While simple, this program will cover many major aspects of RISC-V ASM like:
- sections
- read/write to memory data
- system calls
- jumps
Global/Globl
The first line of the program tells where the program will start exectution at. In this case, it's the funciton called `_start`. You might see this line called `.globl` or `.global`. Both spelling variations are valid with GNU assembler (can't comment on others), with the `.global` variation being added more recently.
Sections of an ASM program
The "sections" of an assembly program are used to organize different types of data and code. They help the assembler and linker allocate memory efficiently, placing and loading the sections in various spots.
Sections also serve to assert how data should be used (ie. read-only, writable, or executable). They are declared with `.section <name>` (ex. `.section .text`).
Each section serves a distinct purpose, following standard conventions of system memory layout, so let's go over each one quickly:
RODATA
If you squint at the name, I'd guess you can take a shot at this section handles. This is where **R**ead-**O**nly **Data** is declared in the program. These are the constants, the unchangable variables, etc. It is loaded near the Code Segment in virtual memory in an area called the Read-Only Data Segment. It is read-only (obvs!).
On lines 2-3 & 4-5, we're declaring two varibles, a nice compliment that we'll later print out(`compliment`) and an upper limit for the number of compliments to give (`max_compliments`).
DATA
This section handles declaration of all the writable data in the program. It is also loaded near the Code segment of memory in an area called the Data Section which is both readable and writable. On lines 2-3 above, we are allocating a placeholder of 4 bytes, or 1 word, in memory(`.skip 4`) for our `counter` variable.
TEXT
The Text Section is the bulk of our file contents, and is responsible for the logic of the ASM program. It is loaded in the Code Section of virtual memory (yes...ASM folx are pretty unimmaginative when it comes to naming stuff /j). On line 2 above the `_start:` is the label for the function, the one called in the global entry. It's here that we'll start to dive into the code logic!
Registers in 1 minute
Wait! I lied! Before we can jump into the code logic, we have to talk about the elephent in the ~~code~~ room: Registers. When it comes to registers in RISC-V ASM, just know that:
1. There are a bunch of them (ackshually there's 31!)
a. They have names consisting of a letter and a number (ex. `t0` or `a1`).
b. There's a special `x0` (or `zero`) register that _always_ holds and returns 0 and cannot be modified. This is highly useful for when you need to zero out a register's value or to check that that the output of something against zero.
2. There are only two actions we can take with registers:
a. **Load**: Updates the data in a register from another register, an immediate value (like integers) or a memory address
b. **Store**: Saves data held by a register into memory
3. There are agreed upon conventions(ABI) as to things like what registers should be used for holding function arguments or for returning values from a function. You can read about that in detail here: [Calling Convention - RISC-V](https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf).
Core program logic
_start Function
This function is most concerned with getting some inital variable setup, so it's short.
1. `la t0, counter`: `la` **L**oads the memory **A**ddress of `counter` into the register `t0`
**NOTE:** We must _load_ the address in memory into a register _before_ we can access the value stored in that address. Two steps!
2. `sw x0, (t0)`: Stores the value 0 (REMEMBER: 0 is always what the `x0` register holds) to the memory location of counter. This initializes the counter to zero.
Before any leet ASM prgrammers run me out of town, I am aware that this is NOT how one would do this in reality. If the counter needed to always start at 0, it is better to set it up in the `.section .data` than to do so in the code itself. I wrote or the purposes of showing how the registers worked with memory and how the `x0` register could be used. It's also not efficient to store the counter like we are doing, as opposed to using a temporary register or using the stack.
generate_compliment Function
# Check if we gave out enough compliments yet
1. `la t0, counter`: Loads the address of the counter into register `t0`.
2. `la t1, max_compliments`: Loads the address of `max_compliments` into register `t1`.
3. `lw t2, (t0)`: Loads the value of `counter` (how many times the compliment has been printed) into register `t2`.
4. `lw t3, (t1)`: Loads the value of `max_compliments` (which we set in read-only data to be 10) into register `t3`.
5. `bge t2, t3, exit`: This is a Branch, that jumps to some specified label is the condition is met. If not, we do nothing and move on to the next instruction line. For `bge`(**B**ranch if **G**reater **T**han): We compare the current count and the max compliments values that we just loaded into `t2` and `t3` respectively. If `t2` is greater than or equal to `t3`, we will jump to the `exit` function specified (in order to stop our program). If it's less than 10, we move on to the next instruction.
# Print out a compliment and increment the counter by one
If we didn't branch, then we'll need to print out a compliment to the user and increment the counter value by 1. We will set a bunch of values into registers starting with "a". These are "**A**rgument" registers and where we set the inputs for functions. In this case, the function is the UNIX system call to print something out.
6. `li a0, 1`: Load 1 into `a0` to specify stdout as the output (ie. File Descriptor #1).
7. `la a1, compliment`: Load the address of the compliment string into `a1` (the string to be printed).
8. `li a2, 32`: Specify the length of the string to print out (32 bytes = 31 characters in the compliment string + the `\0` terminating the string).
9. `li a7, 64`: Set `a7` to 64, which is the special system call number assigned to the print fucction in RISC-V. This will vary for different architectures. You can check out the different syscall numbers based on the arch in [a wonderful table resource](https://gpages.juszkiewicz.com.pl/syscalls-table/syscalls.html) maintained by Marcin Juszkiewicz.
10. `ecall`: Actually executes the system call we have worked so hard to set up in the last few instructions, printing the compliment out.
11. `addi t2, t2, 1`: Adds 1 to the current value of counter (stored in `t2`).
12. `sw t2, (t0)`: Stores the updated counter value back into memory at the address of `counter` data variable.
13. `j generate_compliment`: This is a `jump` which...jumps us back to the label specified. Unlike the branch instruction this will happen every single time, with no conditions that need to be met. Here, specifically, we're jumping back to the start of this very same function `generate_compliment`, forming a loop. This allows us to check once again if more compliments need to be printed.
exit Function
Remember the conditional branch instruction? When that condition is finally satisfied once `counter` is equal to 10, we will jump to the final area of the ASM code `exit` to exit gracefully.
1. `li a0, 0`: Load 0 into `a0` to set the exit status to 0, which in Unix-land means that the program successful exit. (A keen eye may note that we could refactor this to use the `x0` register here as well!)
2. `li a7, 93`: Set `a7` to 93, which is the system call number for program termination in RISC-V we looked up in the reference table mentioned above.
3. `ecall`: Executes the syscall to terminate the program.
Building and running the program
Step 0: Install needed local packages
Linux Flavors:
- Fedora: `sudo dnf install clang lld`
- Ubuntu: `sudo apt-get install clang lld`
- Arch: `sudo pacman -Su clang lld`
MacOS: `brew install llvm`*
Windows: Download the LLVM installer from [releases.llvm.org](http://releases.llvm.org/download.html).*
**\*NOTE:** Not tested on Mac or Windows! If you manage to get it fully working for either or if you hit issues with the above, please DM me on Mastodon. Thanks!
Step 1: Assemble and Link the code
We'll use `clang` to assemble our Assembly code into binary object code:
We'll link the binary output file (.o) into an executable file with the following the following straight forward `LLD` command:
Step 2: Run the program
We're going to cheat a bit for the sake of brevity and use RISC-V.org's slick, [ALE Emulator](https://riscv-programming.org/ale/#) to run our RISC-V program, bipassing the need to locally setup the RISC-V toolchain. Working smarter, not harder! 😎
Upload the `hallo-wurld.x` file to the emulator. A small notice should appear letting you know the file was uploaded successfully and how big the binary was.
When you run the executable file, by clicking the "Run" button in the top-left nav bar, you should see a console screen appear as a pop-up. The program should print out our compliment string to the std output 10 times and terminate with a status code of 0 if all goes well!
Congratulations on making it all the way through the walkthrough! You've taken your first step into a larger world. 🤓 🫶
© 2024-present by Katie Keller.
Content of this website is licensed under CC BY-NC-SA 4.0.
Crafted with 💖. Built with gempost.