You can read every line of a function and still not know how it talks to the rest of your program. That conversation is governed by the ABI β the Application Binary Interface β and on RISC-V it is refreshingly clean and well documented. Understanding it turns assembly from a puzzle into something you can read fluently.

What the ABI Actually Is
The ABI is the binary-level contract between pieces of code. While the ISA defines what instructions mean, the ABI defines the conventions that let independently compiled functions, libraries, and the operating system interoperate: how arguments are passed, what a function may clobber, how the stack grows, and how data is laid out. On RISC-V this is the psABI (processor-specific ABI), a public specification anyone can implement.
Register Roles
RISC-V has 32 integer registers, and the calling convention assigns each a role. The names you see in assembly are ABI names, not raw numbers:
| ABI name | Role | Preserved across call? |
|---|---|---|
zero | Hard-wired zero | β |
ra | Return address | No (caller-saved) |
sp | Stack pointer | Yes |
a0βa7 | Arguments / return values | No |
t0βt6 | Temporaries | No |
s0βs11 | Saved registers | Yes |
The split between caller-saved (t*, a*, ra) and callee-saved (s*, sp) is the heart of the convention: it decides who is responsible for preserving a value across a function call.
Passing Arguments and Returning Values
The rules are simple enough to memorize:
- The first eight integer arguments go in
a0βa7. - Additional arguments spill to the stack.
- Return values come back in
a0(anda1for a second word). - With a hard-float ABI, floating-point arguments use the FP registers
fa0βfa7β see the floating-point extensions for how those registers work.
# int add(int a, int b) { return a + b; }
add:
add a0, a0, a1 # a0 = a + b -> return value in a0
ret # pseudo-instruction for: jalr zero, 0(ra)Notice the result lands in a0 and the function returns by jumping to ra β exactly what the convention promises.
The Stack Frame
The stack grows downward and is kept 16-byte aligned. A function that calls others (a βnon-leafβ function) typically opens with a prologue that lowers sp and saves ra plus any s* registers it will use, and ends with an epilogue that restores them:
func:
addi sp, sp, -16 # allocate a frame
sd ra, 8(sp) # save return address
sd s0, 0(sp) # save a callee-saved register
# ... body ...
ld s0, 0(sp) # restore
ld ra, 8(sp)
addi sp, sp, 16 # free the frame
retThis discipline is what makes debugging and stack unwinding possible β a debugger can walk frames precisely because everyone follows the same layout.
Data Models: ILP32 vs LP64
The ABI also fixes the data model. On RV32 you typically use ILP32; on RV64, LP64. The letters describe type widths: under LP64, long and pointers are 64-bit while int stays 32-bit. There are float variants too β lp64d means LP64 with double-precision FP in registers. The crucial rule: every object you link must share the same ABI, which is why toolchain flags like -mabi=lp64d matter when you build a toolchain.
Why a Clean ABI Matters
A well-specified ABI is what lets a Debian package, a vendorβs closed library, and your own code all link together and run. It is the quiet foundation under the entire software ecosystem β and because the RISC-V psABI is open, every compiler and OS implements the same contract, avoiding the fragmentation that plagued earlier architectures.
The Bottom Line
The RISC-V ABI is the contract that turns isolated functions into working programs: register roles, the eight-argument convention, a downward-growing 16-byte-aligned stack, and a clear data model. Once you internalize who saves what and where arguments live, RISC-V assembly stops looking cryptic and starts looking obvious. That clarity β an open, single, well-documented convention β is one of the underrated reasons the RISC-V ecosystem came together so fast.
Part of my RISC-V series. See also the assembly tutorial and building a toolchain.



