RISC-V Assembly Functions¶
Due Wed Mar 11th by 11:59pm in your Lab04 GitHub repo
Links¶
Tests: https://github.com/USF-CS631-S26/tests
Overview¶
In this lab you will build on your RISC-V assembly skills from Lab03 by implementing functions that call other functions, use memory operations, and perform recursion. You will work with stack frames, callee-saved registers, and load/store instructions.
There are five programs to implement: max3, swap, sumabs, sort, and fibrec.
Programs¶
max3¶
Returns the maximum of three 32-bit integers. Your assembly function max3_s should call a helper function max2 (that you also write in the same file) which returns the max of two values. Use jal to call max2.
swap¶
Swaps two elements in an array by index. The function swap_s takes a pointer to an array of i32 values and two indices, and swaps the elements at those positions in memory. Uses lw and sw to load and store 32-bit values.
sumabs¶
Computes the sum of absolute values of all elements in an array. The function sumabs_s takes a pointer to an array of i32 values and a length, and returns the sum of the absolute values. You should write a helper function abs (in the same file) that returns the absolute value of a single integer, and call it from sumabs_s using jal. Since sumabs_s calls another function, it must save and restore callee-saved registers (s0-s2) and the return address (ra) using a stack frame.
sort¶
Sorts an array of integers in-place using insertion sort. The function sort_s takes a pointer to an array of i32 values and a length. It should call swap_s (which you also write in a separate file) to swap elements. This requires nested loops and function calls, so you will need a stack frame with callee-saved registers.
fibrec¶
Computes the nth Fibonacci number recursively. The function fibrec_s takes a single integer n and returns fib(n), where fib(0) = 0, fib(1) = 1, and fib(n) = fib(n-1) + fib(n-2). Since fibrec_s calls itself recursively, it must set up a proper stack frame, saving ra and any callee-saved registers it uses.
Given Code¶
Your Lab04 repo will have the following structure:
Cargo.toml
build.rs
src/bin/max3.rs
src/bin/swap.rs
src/bin/sumabs.rs
src/bin/sort.rs
src/bin/fibrec.rs
asm/max3_s.s (student fills in)
asm/swap_s.s (student fills in)
asm/sumabs_s.s (student fills in)
asm/sort_s.s (student fills in)
asm/fibrec_s.s (student fills in)
The Rust source files and build configuration are provided. Your job is to fill in the assembly files in the asm/ directory.
Cargo.toml¶
[package]
name = "lab04"
version = "0.1.0"
edition = "2021"
[build-dependencies]
cc = "1"
[[bin]]
name = "max3"
path = "src/bin/max3.rs"
[[bin]]
name = "swap"
path = "src/bin/swap.rs"
[[bin]]
name = "sort"
path = "src/bin/sort.rs"
[[bin]]
name = "fibrec"
path = "src/bin/fibrec.rs"
[[bin]]
name = "sumabs"
path = "src/bin/sumabs.rs"
The [build-dependencies] section includes the cc crate, which is used to compile the assembly files into a library that Rust can link against. Each [[bin]] section defines one of the five programs.
build.rs¶
fn main() {
cc::Build::new()
.file("asm/max3_s.s")
.file("asm/swap_s.s")
.file("asm/sort_s.s")
.file("asm/fibrec_s.s")
.file("asm/sumabs_s.s")
.compile("asm_functions");
println!("cargo:rerun-if-changed=asm/max3_s.s");
println!("cargo:rerun-if-changed=asm/swap_s.s");
println!("cargo:rerun-if-changed=asm/sort_s.s");
println!("cargo:rerun-if-changed=asm/fibrec_s.s");
println!("cargo:rerun-if-changed=asm/sumabs_s.s");
}
The build.rs file is a Cargo build script that runs before compilation. It uses the cc crate to compile all five assembly files into a static library called asm_functions. The cargo:rerun-if-changed lines tell Cargo to re-run the build script when any assembly file changes.
Rust Source Files¶
Here is the max3.rs source file as an example:
use std::cmp::max;
use std::env;
use std::process;
extern "C" {
fn max3_s(a: i32, b: i32, c: i32) -> i32;
}
fn max3(a: i32, b: i32, c: i32) -> i32 {
max(a, max(b, c))
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 4 {
println!("usage: max3 a b c");
process::exit(-1);
}
let a: i32 = args[1].parse().unwrap_or(0);
let b: i32 = args[2].parse().unwrap_or(0);
let c: i32 = args[3].parse().unwrap_or(0);
let rust_result = max3(a, b, c);
println!("Rust: {}", rust_result);
let s_result = unsafe { max3_s(a, b, c) };
println!("Asm: {}", s_result);
}
The key parts are:
extern "C" { fn max3_s(...) -> i32; }- This declares an external functionmax3_sthat follows the C calling convention. This is the assembly function you will implement.fn max3(...)- This is the Rust reference implementation. Your assembly should produce the same result.unsafe { max3_s(a, b, c) }- Calling the external assembly function requiresunsafebecause Rust cannot verify the safety of foreign functions.
The other source files follow the same pattern. Note that swap_s and sort_s take a mutable pointer (*mut i32) to modify array elements in place, while sumabs_s takes a read-only pointer (*const i32). The fibrec_s function takes a single integer and returns the Fibonacci result.
RISC-V Assembly Concepts¶
This lab builds on the instructions from Lab03. Here are the new concepts you will need.
Function Calls and Stack Frames¶
When a function calls another function (a "non-leaf" function), it must save the return address register ra before the call, and restore it before returning. The standard way to do this is with a stack frame:
my_function:
# Prologue: allocate stack frame and save registers
addi sp, sp, -32 # allocate 32 bytes on the stack
sd ra, 24(sp) # save return address
sd s0, 16(sp) # save callee-saved registers as needed
sd s1, 8(sp)
# ... function body ...
jal other_function # call another function (saves pc+4 in ra)
# Epilogue: restore registers and deallocate stack frame
ld s1, 8(sp) # restore callee-saved registers
ld s0, 16(sp)
ld ra, 24(sp) # restore return address
addi sp, sp, 32 # deallocate stack frame
ret
Callee-Saved Registers¶
Registers s0-s11 are callee-saved: if your function uses them, it must save their original values on the stack and restore them before returning. These are useful for values that must survive across function calls (since temporary registers t0-t6 and argument registers a0-a7 may be overwritten by called functions).
Memory Operations¶
| Instruction | Description | Example |
|---|---|---|
lw rd, offset(rs) |
Load 32-bit word from memory | lw t0, 0(a0) |
sw rs, offset(rd) |
Store 32-bit word to memory | sw t0, 0(a0) |
ld rd, offset(rs) |
Load 64-bit doubleword from memory | ld ra, 24(sp) |
sd rs, offset(rd) |
Store 64-bit doubleword to memory | sd ra, 24(sp) |
Use lw/sw for 32-bit i32 array elements (with 4-byte offsets). Use ld/sd for saving and restoring 64-bit registers (like ra, s0) on the stack (with 8-byte offsets).
To access array element i, compute the byte offset as i * 4 (since each i32 is 4 bytes):
Calling Another Function with jal¶
The jal (jump and link) instruction calls a function by saving the return address in ra and jumping to the target:
Before calling, place arguments in a0, a1, etc. The return value will be in a0.
Assembly File Format¶
Each assembly file should declare global functions and implement them:
The .global directive makes the function visible to the linker so Rust can call it. Helper functions that are only called within the same file do not need .global.
Autograder¶
To run the Autograder tests for Lab04:
Or if you are in your lab04-<gitid> repo:
Code Submission¶
You will submit your code in your Lab04 GitHub repo. You will be provided a link to create your repo. Your goal is to complete the five assembly files in the asm/ directory. There is a default .gitignore that should prevent the inclusion of any binary files into your repo.
Rubric¶
100% Lab04 autograder tests.