Skip to content

RISC-V Assembly Functions

Due Wed Mar 11th by 11:59pm in your Lab04 GitHub repo

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.

$ cargo run --bin max3 -- 1 2 3
Rust: 3
Asm: 3
$ cargo run --bin max3 -- 3 2 1
Rust: 3
Asm: 3

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.

$ cargo run --bin swap -- 0 1 22 33
Rust: 33 22
Asm: 33 22
$ cargo run --bin swap -- 4 3 11 22 33 55 66 77
Rust: 11 22 33 66 55 77
Asm: 11 22 33 66 55 77

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.

$ cargo run --bin sumabs -- -3 5 -7 2
Rust: 17
Asm: 17
$ cargo run --bin sumabs -- 0 -1 0
Rust: 1
Asm: 1

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.

$ cargo run --bin sort -- 33 22 11
Rust: 11 22 33
Asm: 11 22 33
$ cargo run --bin sort -- 2 7 4 1
Rust: 1 2 4 7
Asm: 1 2 4 7

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.

$ cargo run --bin fibrec -- 1
Rust: 1
Asm: 1
$ cargo run --bin fibrec -- 10
Rust: 55
Asm: 55

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 function max3_s that 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 requires unsafe because 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):

slli t0, a1, 2        # t0 = i * 4
add  t0, a0, t0       # t0 = &arr[i]
lw   t1, 0(t0)        # t1 = arr[i]

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:

jal  my_helper         # call my_helper, ra = pc + 4

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:

.global function_name

function_name:
    # your instructions here
    ret

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:

$ grade test -p lab04

Or if you are in your lab04-<gitid> repo:

$ grade test

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.