Basic RISC-V Assembly Instructions
1. Goal
Learn the core RV32I instruction families and practice with a tiny hand-written program.
2. Instruction families
- Arithmetic:
add,addi,sub - Loads/stores:
lw,sw,lb,sb - Branches:
beq,bne,blt,bge - Jumps:
jal,jalr
3. Example: add two numbers and store the result
File: src/basic_add.s
| |
4. Minimal linker script (and why it matters)
When you run bare-metal assembly, there is no operating system or C runtime to decide where your code and data live in memory. The C Runtime (CRT) is the code that runs before your main() function and after it finishes.
It prepares the execution environment of a C program: stack, arguments, global variables, libraries, and only then calls main().
Short CRT note (what it is and is not):
- CRT is not libc. It is a tiny startup/shutdown layer, typically split into objects like
crt1.o,crti.o, andcrtn.o. - The kernel never calls
main(). The entry point is_start; the CRT provides_startand then callsmain()for you. - No CRT means no automatic startup. If you omit CRT (like in bare-metal), you must implement
_startyourself.
Without CRT (C Runtime):
- There is no automatic
main() - You must provide
_start - You talk directly to the kernel via syscalls (System Calls)
The linker needs explicit instructions about:
- Entry point: where execution starts (
_start). - Memory address: where the image should be placed (QEMU’s
virtmachine expects RAM at0x80000000). - Section layout: where
.text,.rodata,.data, and.bssgo.
4.1. Create a tiny linker script:
File: src/minimal_link.ld
| |
4.2. The Linker Script
This is the instructions for the Linker. Think of the Linker as a builder who takes piles of raw materials (your compiled code lines) and arranges them into a finished building (the executable binary).
ENTRY(_start)- The Front Door: This tells the linker: “When the computer loads this program, the very first instruction to execute is at the label
_start.” - Without this, the processor wouldn’t know where to begin running your code.
- The Front Door: This tells the linker: “When the computer loads this program, the very first instruction to execute is at the label
SECTIONS { ... }- The Blueprint: This block defines the memory map. It tells the linker exactly where to place different parts of your program in the computer’s RAM.
. = 0x80000000;- The Current Location Counter (
.): Think of the dot.as a “cursor” pointing to a specific memory address. 0x80000000: These lines set the cursor to this specific address.- Why this number? In many RISC-V systems (like the QEMU
virtmachine), physical RAM starts at address0x80000000. Lower addresses usually hold hardware registers or flash memory. This line says: “Start putting our program right at the beginning of RAM.”
- The Current Location Counter (
.text : { *(.text .text.*) }- The Code Section:
.textis where your actual executable code (assembly instructions) lives. { *(.text) }: This means “Take the.textsection from all input files (*) and put them right here.”- Since the cursor was just set to
0x80000000, your code goes first.
- The Code Section:
.rodata : { *(.rodata .rodata.*) }- Read-Only Data: This is for constants, like string literals (“Hello World”) that shouldn’t change.
- They are placed immediately after the code.
.data : { *(.data .data.*) }- Initialized Data: This is for global variables that have a starting value (e.g.,
int score = 10;). - They are placed after the read-only data.
- Initialized Data: This is for global variables that have a starting value (e.g.,
.bss : { *(.bss .bss.* COMMON) }- Uninitialized Data:
bssstands for “Block Started by Symbol” (a historical term). Ideally, it holds global variables that don’t have a value yet (e.g.,int buffer[100];). - The linker doesn’t store zeros in the file for this to save space; it just marks that space needs to be reserved in RAM.
- Uninitialized Data:
Why Order Matters
When you are creating a linker script for bare-metal assembly, always place .text first! The CPU jumps blindly to the base address (0x80000000). If you put data (like .rodata) first, the CPU will try to “execute” your data bytes as instructions, causing an immediate crash. While smart loaders (like QEMU with -kernel) might handle this by reading the ELF entry point, raw binary payloads on real hardware will fail.
4.3. Build and run in QEMU
| |
The terminal will hang because of the infinite loop at the end of the program. This is expected. To verify the result, open another terminal and use gdb-multiarch to inspect memory as explained in the next section.
5. Connect with gdb-multiarch
To debug, start QEMU with its GDB stub enabled and paused at reset:
| |
In another terminal, manually connect using gdb-multiarch and follow each step below:
| |
If you prefer a single command, you can run:
| |
Now you can inspect the result:
| |
Output should show 0x0000002a, which is 42 in decimal (40 + 2).
| |
- To quit GDB, type
quitorq. - To stop QEMU, use
Ctrl+a xin its terminal.
6. Exercises
- Change the constants to compute
7 + 13and verify in memory. - Replace the
addwithsuband observe the new result. - Add a loop that increments
g_resultfive times.
7. Summary
- RV32I instructions are small and orthogonal.
- A minimal program can run without libc by defining
_start. - QEMU + GDB lets you inspect results in memory directly.