Functions, Calling Convention, and Stack Frames

TL;DR


1. The two instructions that define calls

1
jalr x0, x1, 0   # jump to ra; discard link

Pseudo-instruction form:

1
ret

2. Leaf vs non-leaf functions


3. A canonical stack frame

A typical (not universal) RV32 prologue/epilogue looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
addi sp, sp, -16
sw   ra, 12(sp)
sw   s0,  8(sp)
addi s0, sp, 16
...
# function body
...
lw   ra, 12(sp)
lw   s0,  8(sp)
addi sp, sp, 16
ret

What this is doing


4. Hands-on: force a clear stack frame

Create:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/stack_demo.c
#include "types.h"
#include "uart.h"

__attribute__((noinline))
u32 inner(u32 a, u32 b) {
  u32 x = a ^ 0xA5A5A5A5u;
  u32 y = b + 0x1234u;
  u32 z = x + y;
  return z;
}

__attribute__((noinline))
u32 outer(u32 v) {
  u32 local1 = v + 1u;
  u32 local2 = v + 2u;
  return inner(local1, local2);
}

int main(void) {
  uart_puts("outer=");
  uart_puthex32(outer(100u));
  uart_putc('\n');
  return 0;
}

Build with flags that help readability:

1
2
3
riscv64-unknown-elf-gcc -O0 -g -fno-omit-frame-pointer -ffreestanding -nostdlib \
  -march=rv32im -mabi=ilp32 -T src/link.ld \
  src/start.s src/uart.c src/stack_demo.c -o build/stack_demo.elf

Disassemble:

1
riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/stack_demo.elf | less

What to locate


5. Debug the stack with GDB (practical commands)

Run with a GDB stub:

1
2
qemu-system-riscv32 -S -M virt -nographic -bios none \
  -kernel build/stack_demo.elf -gdb tcp::1234

In another terminal:

1
2
3
4
5
gdb-multiarch ./build/stack_demo.elf
(gdb) set architecture riscv:rv32
(gdb) target remote :1234
(gdb) b outer
(gdb) c

Now inspect:

(gdb) info registers sp ra s0 a0 a1
(gdb) bt
(gdb) info frame
(gdb) x/32wx $sp
(gdb) x/16i $pc

Interpreting x/32wx $sp

You are dumping 32 words of memory starting at the current stack pointer.


6. Stack corruption: how it happens

Common causes:

Hands-on bug: off-by-one overwrite

Create:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/overflow.c
#include "types.h"
#include "uart.h"

__attribute__((noinline))
u32 victim(u32 x) {
  volatile u32 canary = 0xCAFEBABEu;
  volatile u8 buf[16];

  // BUG: writes 17 bytes into a 16-byte buffer
  for (int i = 0; i <= 16; i++) {
    buf[i] = (u8)i;
  }

  // return depends on whether canary got corrupted
  return (canary ^ x);
}

int main(void) {
  uart_puthex32(victim(0x12345678u));
  uart_putc('\n');
  return 0;
}

Build and run:

1
2
3
4
5
riscv64-unknown-elf-gcc -O0 -g -fno-omit-frame-pointer -ffreestanding -nostdlib \
  -march=rv32im -mabi=ilp32 -T src/link.ld \
  src/start.s src/uart.c src/overflow.c -o build/overflow.elf

qemu-system-riscv32 -M virt -nographic -bios none -kernel build/overflow.elf

Now debug it and watch the canary:

1
2
qemu-system-riscv32 -S -M virt -nographic -bios none \
  -kernel build/overflow.elf -gdb tcp::1234
(gdb) b victim
(gdb) c
(gdb) p/x canary
(gdb) next
(gdb) p/x canary

7. Arguments beyond a0..a7

RISC-V uses a0..a7 for the first 8 integer/pointer arguments. Extra arguments are passed on the stack.

You can demonstrate this by writing a function with 10 arguments and inspecting how the call site prepares them.


Exercises

  1. In stack_demo, identify which registers carry parameters into inner.
  2. In stack_demo, determine the stack offset (from sp or s0) of local1 and local2 by correlating disassembly with GDB memory dumps.
  3. Create a function with 9 integer parameters and find where parameter #9 lives.
  4. In the overflow example, change the loop back to i < 16 and confirm the canary stays unchanged.

How to test your answers


Summary

You now understand how RV32 function calls work: ra, sp, prologues/epilogues, and stack-frame structure-and you practiced debugging stack state directly.

Next: linker scripts and memory maps-we’ll control where code/data live, generate .map files, and explain why firmware images often start at addresses like 0x80000000.