Getting Started With Risc V Assembly Language
I spent about three weeks trying to get a bare-metal LED blinker working on an SiFive FE310 board before I realized I was fighting the toolchain, not the hardware. Most people don't run into that problem right away, but it comes up. Once you have your environment set up, the actual syntax is straightforward enough. The instruction set is clean. That's the nice thing about it compared to some of the older architectures out there. You need the riscv64-unknown-elf-gcc cross-compiler. On Ubuntu, that's just sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf gdb-multiarch. On macOS with Homebrew, it's brew install riscv-tools, though I've found the Homebrew build sometimes pulls in outdated binutils that break certain linker scripts. If you hit weird linking errors, check your binutils version first before digging into your code. You'll also want a disassembler. objdump is fine for quick looks, but if you're reading through an entire compiled binary, gobjdump with color output saves time. I alias it to something short because I type it constantly.
The Instruction Set Basics
RISC-V uses a base integer instruction set called RV32I or RV64I depending on whether you're working with 32-bit or 64-bit registers. There are also extensions, but you don't need them to get started. The register naming is consistent: x0 is always zero, x1 is the return address, x2 is the stack pointer, and x8 is the frame pointer on most ABIs. Everything else is general purpose. Don't try to remember all thirty-two names. Just know which ones have special roles and which ones you can use freely. Lets look at something simple. Here's a minimal program that adds two numbers and exits.
.section .text
.global _start
_start:
li t0, 5
li t1, 3
add a0, t0, t1
li a7, 93
ebreak
That's about as basic as it gets. li loads an immediate value. add does what you expect. ebreak is the trap instruction, which on bare metal usually means you're done or something went wrong depending on how your exception handler is set up. a7 is syscall number on Linux, so 93 is the exit syscall. On bare metal without an OS, ebreak just halts the core in most simulators. Calling conventions matter more than people realize when they're just starting out. In RISC-V, argument registers are a0 through a7. Return values come back in a0 and a1. Callee-saved registers are s0 through s11. Caller-saved registers are t0 through t6 and a0 through a7. If you pass a value in a0 and the function you call modifies it, your value is gone. Save it on the stack if you need it afterward. I've seen this trip people up more than I can count, and it shows up as random subtle bugs in larger programs. Also worth noting: the stack grows downward. You decrement sp before storing, not after. It's counter-intuitive the first few times but once your muscle memory catches up, it stops being a problem.
Get the Full Details
A Real Problem I Ran Into
I was writing a small math library for a RISC-V embedded project a while back, and I kept getting incorrect results from a floating-point division routine. The code looked right. The instructions were correct. Turns out I hadn't enabled the F extension in my compiler flags. Without -march=rv32imf, the assembler would accept fdiv.s instructions, but they'd silently turn into NOPs or cause misalignment depending on the toolchain version. I spent about four hours chasing this before a colleague pointed out the mismatch between my inline assembly and my compile flags. Now I always verify my march flag matches exactly what my code uses, and I double-check it when switching between projects. One thing that catches people off guard is how RISC-V handles branching. Unlike ARM where you can attach a condition to almost any instruction, RISC-V branches are separate instructions. Beq, bne, blt, bge, bltu, bgeu. That's it for conditional branching in the base ISA. You don't get conditional moves in the base set either. Some newer extensions add them, but for bare-metal or kernel work you're usually stuck with branches. This means tight loops can be slower than you'd expect if you're coming from architectures with predicated execution. A simple loop that decrements a counter and jumps back takes five instructions on RISC-V: one for the decrement, one for the comparison, one for the branch, plus whatever the loop body contains. It's not terrible, but it adds up in performance-critical code.
Another thing: load and store instructions require 12-bit signed immediates. That limits you to offsets between -2048 and 2047 bytes from a base register. If you need to access data further away, you load the address into a register first using auipc plus addi, then load or store from that register. I wrote a macro for this because I do it constantly when working with large data structures in assembly.
When RISC-V Assembly Isn't The Right Call
Here's the honest part: most of the time you should just write C and let the compiler emit the assembly. The RISC-V GCC backend is good. It handles register allocation, instruction selection, and optimization passes that are hard to beat by hand. Writing assembly makes sense when you need deterministic timing, direct hardware access, or a tiny hot path where every cycle matters. It does not make sense for general application logic, and it definitely doesn't make sense if you're doing it to learn without a concrete reason. If you're targeting an OS environment, use C. If you're writing boot code, an interrupt handler, or a cryptographic primitive on a constrained device, that's where assembly earns its keep. I've seen people write entire drivers in assembly and it's almost always worse than the C version, including harder to maintain and sometimes slower because the compiler can do things like reordering and speculative loads that are painful to replicate by hand. The SiFive documentation is decent for reference, but it's not a tutorial. The RISC-V unprivileged spec is the real source of truth and it's free online. It's dense but accurate. I keep it bookmarked and only open it when I need to verify something about instruction encoding or privilege levels. The privileged spec is a separate document and it's where you go if you're working with traps, CSRs, or hypervisor extensions.
For download links, the official GNU toolchain builds are at https://github.com/riscv-collab/riscv-gnu-toolchain. The project has build instructions in the README and it takes about twenty minutes on a decent machine. There are prebuilt binaries available too if you don't want to compile from source, and they work fine for most development. If you want something lighter for education, Spike the reference simulator from RISC-V International is worth trying. It runs RISC-V binaries and gives you cycle-accurate output. Not fast, but useful for understanding exactly what your instructions are doing at the pipeline level.