Skip to content

Lab: RISC-V Assembly Part 7 — printf, Hello World, and Structs

Overview

This hands-on lab session ties together everything we have learned about RISC-V assembly so far and points it directly at Project 3. We start by learning to debug assembly with GDB so you can watch your code run one instruction at a time. We then learn to call C library functions from assembly — most importantly printf — which lets us write a classic "Hello, World" entirely in assembly while still respecting the calling convention. Finally, we lay the groundwork for the next major data structure: C structs. We look at how a struct is laid out in memory, how it compares to an array and a string, and how to read individual struct fields in assembly using lw with byte offsets. These struct-access patterns are exactly what Project 3 needs for the reverse-string problem and the upcoming linked-list work.

Learning Objectives

  • Drive GDB to debug a RISC-V assembly program: set breakpoints, step, examine registers and memory, and continue.
  • Distinguish the .data and .text sections and place strings and instructions correctly.
  • Use the la (load address) pseudo-instruction to load the address of a string into a register.
  • Call printf (and other C functions) from assembly, setting up a0a7 correctly.
  • Apply the calling convention: preserve caller-saved registers and keep sp 16-byte aligned across a call.
  • Describe how a C struct is laid out sequentially in memory, and why arrays, strings, and structs all reduce to "bytes at offsets."
  • Read struct fields in assembly using lw with the correct byte offset from the struct's base address.
  • Connect these techniques to Project 3 (reverse string and the introduction to structs / linked lists).

Prerequisites

  • RISC-V Assembly Parts 1–6: instructions, registers, labels, directives, branches, loops, and functions.
  • The RISC-V calling convention: caller-saved vs. callee-saved registers, the stack, and stack alignment (see the RISC-V guide).
  • C strings as null-terminated byte arrays, and byte-level memory access with lb/sb.
  • Pointer-based and index-based array access in assembly.
  • Comfort at a Linux shell on the RISC-V VM (assembling, compiling, linking with gcc).

1. Project 3 in Context

Project 3 continues the assembly work from Project 2 but shifts focus to strings and introduces composite data. Today's lab gives you the three tools you need to get started:

  1. GDB — so you can step through your assembly and see exactly what each instruction does to the registers and memory.
  2. printf from assembly — so you can print intermediate values while developing, and to satisfy any "print the result" requirement.
  3. Structs in memory — the conceptual bridge from arrays and strings to linked lists.

The first concrete Project 3 task is reversing a string. A string is just an array of bytes, so reversing it is analogous to reversing an integer array, with two differences:

  • Use lb (load byte) and sb (store byte) instead of lw/sw, because each character is one byte.
  • Do not reverse the null terminator. The \0 must stay at the end. Reverse only the characters before it.

The reversal can be done in place (swap the ends and walk inward) or by copying into a destination buffer (walk the source backward, write the destination forward). Either approach is fine; in-place is the classic two-pointer swap.

// In-place string reversal in C (the algorithm you will port to assembly)
void reverse_s(char *s) {
    int len = 0;
    while (s[len] != '\0') {     // find length, NOT counting '\0'
        len++;
    }
    int i = 0;
    int j = len - 1;
    while (i < j) {
        char tmp = s[i];
        s[i] = s[j];
        s[j] = tmp;
        i++;
        j--;
    }
    // s[len] == '\0' is never touched
}

Common mistake: including the \0 in the swap. If you swap s[0] with the terminator, the string becomes empty (the first byte is now \0). Always reverse the range [0, len-1] where len excludes the terminator.


2. Debugging Assembly with GDB

The instructor opened the lab on GDB because debugging assembly without a debugger is painful. With GDB you can watch your program execute one instruction at a time, inspect every register, and look at raw memory. The learning curve is real, but the payoff is a precise mental model of what the machine is doing.

Starting GDB

gdb prog

Here prog is your compiled/linked executable. Once inside, you work with a small set of commands. The handwritten notes list exactly these:

Command Short form What it does
break <line> b <line> Set a breakpoint at a line or label.
run [args] r [args] Run the program, passing optional command-line args.
step s Execute one source step, stepping into function calls.
next n Execute one source step, stepping over function calls.
print <expr> p <expr> Print a value (e.g., p $a0 for a register).
x/<fmt> <addr> x/... Examine raw memory at an address.
continue c Resume execution until the next breakpoint.

The notes abbreviate run as r and add ren as a reminder that r means "run." In GDB you can pass program arguments after run, e.g. run hello 42.

A typical debugging flow

flowchart TD
    A["gdb prog"] --> B["break main (or a label)"]
    B --> C["run args"]
    C --> D{"Stopped at breakpoint"}
    D --> E["step / next one instruction"]
    E --> F["print registers: p $a0"]
    F --> G["x/ examine memory"]
    G --> H{"Bug found?"}
    H -- no --> E
    H -- "skip ahead" --> I["continue"]
    I --> D
    H -- yes --> J["Fix the .s file, rebuild"]

Examining registers and memory

To print a register, prefix its name with $:

(gdb) p $a0          # value in a0 (decimal)
(gdb) p/x $a0        # value in a0 (hex)
(gdb) info registers # dump all registers

The x command examines memory. Its general form is x/NFU addr where N is a count, F is a format, and U is a unit size:

(gdb) x/4xw $sp      # 4 words, hex, starting at the stack pointer
(gdb) x/s $a0        # print the C string whose address is in a0
(gdb) x/8xb $a0      # 8 bytes in hex starting at a0

Examining memory is how you check that a string looks right (e.g., x/s $a0 should show your text), or that a struct's fields hold the values you expect.

A debugging strategy that scales

A naive approach is to step from the very first instruction, but real programs have far too many instructions for that. The instructor's strategy:

  1. Set a breakpoint at a meaningful point — for example, right where a particular array element is loaded, or just before a call.
  2. Use continue to fly through the uninteresting code and stop only where it matters.
  3. Step instruction-by-instruction only inside the region you care about.

The goal is not just to fix one bug; it is to build a mental model of how registers change and how the stack grows and shrinks. After a few sessions, GDB stops feeling like overhead and starts feeling like X-ray vision.

Tip: If your assembly was assembled with debug info (as -g / gcc -g), you can break on labels by name, e.g. break reverse_s.


3. The .data and .text Sections

Before we can call printf, we need somewhere to put the string we want to print. Assembly source is divided into sections that tell the assembler and linker how to place things in the final program's memory image:

  • .text holds your instructions (the machine code). It is read-only and executable.
  • .data holds initialized global data, such as strings and constants. It is readable and writable.

A string literal goes in .data and is given a label so we can refer to its address:

        .data
msg:    .string "Hello, World\n"   # bytes + automatic null terminator

Then your code goes in .text:

        .text
        .global main
main:
        # ... instructions ...

The memory layout of the program separates these regions. Conceptually:

high addresses
   +------------------+
   |      stack       |  grows down (locals, saved regs)
   +------------------+
   |       ...        |
   +------------------+
   |       heap       |  grows up (malloc)
   +------------------+
   |   .data / .bss   |  globals (our string lives here)
   +------------------+
   |      .text       |  our instructions
   +------------------+
low addresses

The .string directive emits each character byte followed by a \0 terminator automatically. .asciz is a synonym. .ascii does not add the terminator — use .string so printf knows where the text ends.


4. The la Pseudo-Instruction

To print a string with printf, we must put the address of that string into a0. The clean way to do that is the la (load address) pseudo-instruction:

        la a0, msg     # a0 = address of the label `msg`

la is a pseudo-instruction: it is not a single real RISC-V instruction. The assembler expands it into the real instructions needed to materialize a full 64-bit address into the register. On RV64 this is typically a lui/addi pair (PC-relative auipc/addi), and for some addresses the assembler emits a load (ld) from a constant pool. You do not have to worry about the expansion — you just write la a0, msg and the assembler does the rest.

# What you write:
la a0, msg

# What the assembler may produce (conceptually):
auipc a0, %pcrel_hi(msg)      # upper bits of the PC-relative address
addi  a0, a0, %pcrel_lo(msg)  # lower 12 bits

This mirrors other pseudo-instructions you have seen:

Pseudo-instruction Real expansion Purpose
li a0, 99 addi a0, zero, 99 Load a small immediate.
la a0, msg auipc/addi (or ld) Load a label's address.
mv a0, a1 addi a0, a1, 0 Copy a register.
j label jal zero, label Unconditional jump.
call f jal ra, f Call a function.
ret jalr zero, ra, 0 Return.

Why not li a0, msg? li is for integer constants, not for symbol addresses. Use la for "the address of this label." Using li with a label will not assemble the way you want.


5. Calling C Functions from Assembly

A huge benefit of following the standard calling convention is that your assembly can call C library functions, and C can call your assembly. To call an external function like printf you do two things:

  1. Declare it global/external so the linker can find it. In practice, printf lives in the C library you link against (via gcc), so you simply reference it. If you define your own entry point, mark it .global (e.g., .global main).
  2. Set up the argument registers and use call.

The RISC-V argument registers are a0a7. For printf:

  • a0 = pointer to the format string.
  • a1, a2, a3, … = the additional arguments referenced by %d, %s, etc.
# printf("x = %d\n", value);
la   a0, fmt       # a0 = address of "x = %d\n"
mv   a1, t0        # a1 = the integer to print (the first %d)
call printf

Preserving registers across a call

printf is a function, so it follows the calling convention — which means it is allowed to clobber all caller-saved registers: a0a7, t0t6, and ra. If you need any of those values after the call, you must save them first (typically on the stack) and restore them afterward.

flowchart TD
    A["Need a value after the call?"] --> B{"Is it in a caller-saved reg<br/>(a0-a7, t0-t6, ra)?"}
    B -- yes --> C["Save it to the stack before call"]
    B -- no --> D["It is callee-saved (s0-s11) —<br/>printf preserves it for you"]
    C --> E["call printf"]
    E --> F["Restore from stack after call"]
    D --> E

Two practical rules from the lab:

  • ra must be saved by any function that itself makes a call (a non-leaf function). printf will overwrite ra with its own return address. If your function called printf and then tried to ret without restoring ra, it would jump to the wrong place.
  • Keep sp 16-byte aligned at the call boundary. Allocate a frame in multiples of 16.

Worked example: register preservation

Suppose you are mid-computation with a running total in t0, and you want to print it without losing it:

        .data
fmt:    .string "total = %d\n"

        .text
        .global main
main:
        addi sp, sp, -16     # allocate a 16-byte frame (aligned)
        sd   ra, 0(sp)       # save ra (we are about to call printf)
        sd   t0, 8(sp)       # save t0 — printf may clobber it (caller-saved)

        la   a0, fmt         # a0 = format string
        mv   a1, t0          # a1 = the value to print
        call printf          # printf may trash a0-a7, t0-t6, ra

        ld   t0, 8(sp)       # restore t0 for later use
        ld   ra, 0(sp)       # restore ra so our ret works
        addi sp, sp, 16      # free the frame
        ret

Common mistake: forgetting to save t0 because "it's just a temporary." Temporaries are caller-saved, which means the caller (you) is responsible for preserving them across a call. If printf overwrites t0, that is allowed by the convention — the bug is in your code, not printf.

Note: You can write an entire application in assembly, including all the printf calls, but in this course that is rarely the point. Write the small piece that must be assembly, and let C handle I/O and glue.


6. "Hello, World" in Assembly

Putting Sections 3–5 together gives the classic program. This is a complete, runnable RISC-V (RV64) assembly file:

        # hello.s — Hello, World in RISC-V assembly
        .data
msg:    .string "Hello, World\n"

        .text
        .global main
main:
        addi sp, sp, -16      # 16-byte aligned frame
        sd   ra, 0(sp)        # save ra: we call printf (non-leaf)

        la   a0, msg          # a0 = address of the string
        call printf           # printf(msg)

        li   a0, 0            # return 0 from main
        ld   ra, 0(sp)        # restore ra
        addi sp, sp, 16       # free the frame
        ret

Assemble, link, and run it on the RISC-V VM:

gcc -o hello hello.s     # gcc assembles hello.s and links with libc
./hello
# Hello, World

You can also assemble separately and link:

as -o hello.o hello.s    # assemble to object file
gcc -o hello hello.o     # link with the C library (provides printf)
./hello

Why does this work? main is the entry point the C runtime calls. We mark it .global so the linker finds it. We save ra because call printf overwrites it. We load the string address with la, put it in a0 (the format string argument), and call printf. We return 0 by placing it in a0 before ret.

Printing a value

To print a number, add a format specifier and an argument:

        .data
fmt:    .string "answer = %d\n"

        .text
        .global main
main:
        addi sp, sp, -16
        sd   ra, 0(sp)

        la   a0, fmt          # a0 = "answer = %d\n"
        li   a1, 42           # a1 = first %d argument
        call printf           # printf("answer = %d\n", 42)

        li   a0, 0
        ld   ra, 0(sp)
        addi sp, sp, 16
        ret
$ gcc -o ans ans.s && ./ans
answer = 42

Common mistakes when calling printf - Forgetting to save ra → your function returns to the wrong address and crashes. - Putting the value to print in a0 instead of a1a0 must be the format string. - Misaligning sp (frame not a multiple of 16) → undefined behavior, possible crash. - Using .ascii instead of .string → no null terminator, so printf reads past your text.


7. Structs in Memory — From Arrays to Structs

The second half of the lab introduces C structs by first revisiting two things you already understand: arrays and strings. The big idea the instructor emphasized is that all of these are just bytes laid out sequentially in memory — the type system tells the compiler how to interpret and index those bytes, but the memory itself has no metadata.

Arrays in memory

An int array stores its elements sequentially, lowest address first. With 4-byte ints:

int arr[3] = {70, 90, 99};
            memory (low -> high)
arr ----->  +--------+   offset 0
            |   70   |   arr[0]   (4 bytes)
            +--------+   offset 4
            |   90   |   arr[1]   (4 bytes)
            +--------+   offset 8
            |   99   |   arr[2]   (4 bytes)
            +--------+

The name arr is the base address — the address of arr[0]. To reach element i, add i * 4 bytes (because each int is 4 bytes). This is exactly the index-based array access you already wrote: addr = base + i * sizeof(element).

Strings in memory

A C string is an array of char, where each char is 1 byte, terminated by a \0:

char *s = "foo";
        memory (low -> high)
 s ---->  +------+   offset 0
          | 'f'  |   0x66
          +------+   offset 1
          | 'o'  |   0x6F
          +------+   offset 2
          | 'o'  |   0x6F
          +------+   offset 3
          | '\0' |   0x00   <- terminator marks the end
          +------+

The pointer s holds the address of the first byte ('f'). Because each element is one byte, you advance one address at a time and access bytes with lb/sb. The \0 is how every C string function (and printf's %s) knows where the string ends — there is no separate length stored anywhere.

Structs in memory

A struct groups several fields. Like an array, the fields are placed sequentially in memory, lowest address first, but unlike an array the fields can have different types:

struct node_st {
    int x;     // 4 bytes
    int y;     // 4 bytes
};
              memory (low -> high)
 node ----->  +--------+   offset 0
              |   x    |   field x   (4 bytes)
              +--------+   offset 4
              |   y    |   field y   (4 bytes)
              +--------+

Key points the instructor stressed:

  • A struct is laid out field by field, lowest address first — just like an array, but with named fields of possibly different types.
  • A C struct carries no automatic metadata — there is no hidden length or type tag stored at runtime. It is just bytes. The compiler remembers the field offsets at compile time.
  • The compiler may insert padding between fields so each field meets its alignment requirement (e.g., an int is usually aligned to a 4-byte boundary, a double to 8). For struct node_st with two ints there is no padding: x at offset 0, y at offset 4, total size 8.
int main(void) {
    struct node_st node;   // one node on the stack
    node.x = 70;
    node.y = 99;
    return 0;
}

The whole point: arrays, strings, and structs are all contiguous bytes, and accessing any field or element is just base address + offset. That single idea unlocks struct access in assembly.


8. Accessing Struct Fields in Assembly

Because a struct is just bytes at known offsets, reading a field in assembly is lw with the byte offset of that field added to the struct's base address. Consider:

struct node_st {
    int x;     // offset 0
    int y;     // offset 4
};

int get_x(struct node_st *np) {
    return np->x;
}

int get_y(struct node_st *np) {
    return np->y;
}

The caller passes a pointer to the struct in a0 (its base address). The field offsets are fixed at compile time: x is at offset 0, y is at offset 4.

get_x — first field, offset 0

        # a0 - struct node_st *np
        .global get_x
get_x:
        lw a0, (a0)      # a0 = *(np + 0) = np->x
        ret

lw a0, (a0) loads the word at the address in a0 (offset 0). Since x is the first field, no offset is needed — the struct's base address is the address of x.

get_y — second field, offset 4

There are two equivalent ways to reach y. The first computes the address explicitly, matching the handwritten notes:

        # a0 - struct node_st *np
        .global get_y
get_y:
        addi a0, a0, 4   # a0 = &np->y  (base + 4 bytes)
        lw   a0, (a0)    # a0 = *(np + 4) = np->y
        ret

The cleaner, idiomatic form uses the load offset built into lw:

        # a0 - struct node_st *np
        .global get_y
get_y:
        lw a0, 4(a0)     # a0 = *(np + 4) = np->y  (one instruction)
        ret

Both produce the same result. lw a0, 4(a0) reads the word at address a0 + 4. This is exactly the offset syntax you used for arrays — accessing arr[1] is lw t0, 4(a0), and accessing the second struct field is also lw a0, 4(a0). An array index and a struct field offset are the same machine operation.

flowchart LR
    A["a0 = np (base address)"] --> B["lw a0, 0(a0)<br/>reads field x"]
    A --> C["lw a0, 4(a0)<br/>reads field y"]
    B --> D["offset 0 = x"]
    C --> E["offset 4 = y"]

Mapping C field access to assembly

C expression Field offset Assembly (np in a0)
np->x 0 lw a0, 0(a0) (or lw a0, (a0))
np->y 4 lw a0, 4(a0)
np->x = v (v in a1) 0 sw a1, 0(a0)
np->y = v (v in a1) 4 sw a1, 4(a0)

These offsets feed directly into linked lists, which we cover next session: a linked-list node is just a struct whose last field is a pointer to the next node, accessed with another fixed offset (and ld for a 64-bit pointer on RV64).

Common mistake: using the wrong offset because you forgot a field's size, or because padding changed the layout. Always work out the byte offset of each field. For int fields with no padding, the offsets are 0, 4, 8, … For a pointer field on RV64, it occupies 8 bytes and must be loaded with ld, not lw.


9. Preserving Registers When Calling C Functions (Recap with FindMax)

The lab revisited the calling convention through a FindMax-over-linked-list discussion. The takeaways are worth pinning down because they trip people up constantly:

  • s0s11 (saved registers) survive calls for you. If you put a value in s1 and then call something, s1 still holds your value afterward — but only because the callee promised to save and restore it. That promise has a cost: if you clobber an s register, you must save it on entry and restore it on exit, because your caller is relying on the same promise.
  • a0a7, t0t6, ra (caller-saved) do not survive calls. If you need them after a call, save them yourself first.

A function that calls itself (or another function) in the middle of using values illustrates the rule perfectly: any caller-saved register you still need after the recursive/other call must be spilled to the stack before the call and reloaded after.

# Sketch: a non-leaf function that must keep a running max across calls.
# Use an s-register for the value you want to survive the call, OR
# spill a caller-saved register around each call.

find_max:
        addi sp, sp, -16
        sd   ra, 0(sp)        # non-leaf: must save ra
        sd   s1, 8(sp)        # we will use s1, which is callee-saved -> save it

        # ... use s1 to hold the running max; it survives any inner call ...
        call helper           # ra clobbered, but we saved it; s1 preserved by callee rules

        ld   s1, 8(sp)        # restore the caller's s1
        ld   ra, 0(sp)
        addi sp, sp, 16
        ret

The decision tree:

flowchart TD
    A["I need register R to keep its value across a call"] --> B{"What class is R?"}
    B -- "a0-a7 / t0-t6 / ra (caller-saved)" --> C["Save R to stack before call,<br/>restore after"]
    B -- "s0-s11 (callee-saved)" --> D["The callee preserves R for me.<br/>But if I CLOBBER an s-reg,<br/>I must save/restore it on entry/exit"]

Key Concepts

Concept Definition Example
GDB Command-line debugger to run code step-by-step and inspect state gdb prog, then b main, r, s, p $a0
.data section Region for initialized global data (strings, constants) msg: .string "Hi\n"
.text section Region for executable instructions .global main / main:
la (load address) Pseudo-instruction that loads a label's address into a register la a0, msg
call Pseudo-instruction jal ra, f — calls a function, sets ra call printf
printf args a0 = format string, a1a7 = subsequent arguments la a0, fmt; mv a1, t0; call printf
Caller-saved a0a7, t0t6, ra — caller must save across calls Spill t0 before call printf
Callee-saved s0s11, sp — callee must preserve; survive calls Save s1 on entry, restore on exit
Struct layout Fields placed sequentially, lowest address first, no metadata x at offset 0, y at offset 4
Field access Read a field with lw offset(base) lw a0, 4(a0) reads np->y
Padding Extra bytes inserted so fields meet alignment requirements A double after an int may force a 4-byte gap

Practice Problems

Problem 1: Hello, World, fix the bug

The following assembly is supposed to print Hello, World but it crashes. Find and fix the bug.

        .data
msg:    .string "Hello, World\n"

        .text
        .global main
main:
        la   a0, msg
        call printf
        li   a0, 0
        ret
Click to reveal solution `main` calls `printf`, which overwrites `ra`. The code never saves and restores `ra`, so the final `ret` jumps to wherever `printf` left `ra` — a crash. Allocate a 16-byte aligned frame, save `ra` before the call, and restore it before `ret`:
        .data
msg:    .string "Hello, World\n"

        .text
        .global main
main:
        addi sp, sp, -16
        sd   ra, 0(sp)        # save ra (we are about to call printf)
        la   a0, msg
        call printf
        li   a0, 0
        ld   ra, 0(sp)        # restore ra
        addi sp, sp, 16
        ret

Problem 2: Print a register without losing it

You have a value in t0 that you still need after printing it. Write assembly that calls printf("v = %d\n", t0) and keeps t0 intact afterward. Assume fmt is defined in .data as "v = %d\n".

Click to reveal solution `t0` is caller-saved, so `printf` may clobber it. Save it across the call:
        addi sp, sp, -16
        sd   ra, 0(sp)        # ra: non-leaf
        sd   t0, 8(sp)        # save t0 across the call

        la   a0, fmt          # a0 = "v = %d\n"
        mv   a1, t0           # a1 = value to print
        call printf

        ld   t0, 8(sp)        # restore t0
        ld   ra, 0(sp)
        addi sp, sp, 16
        # t0 is now intact for further use
Putting `t0` in `a1` (not `a0`) is essential — `a0` must be the format string.

Problem 3: Read a struct field

Given struct point { int x; int y; }; and a pointer to a point in a0, write a leaf function get_y that returns p->y in a0. Give both the explicit-address form and the one-instruction form.

Click to reveal solution `y` is the second `int`, at byte offset 4. Explicit address computation:
        .global get_y
get_y:
        addi a0, a0, 4   # a0 = &p->y
        lw   a0, (a0)    # a0 = p->y
        ret
Idiomatic one-instruction form using the load offset:
        .global get_y
get_y:
        lw a0, 4(a0)     # a0 = *(p + 4) = p->y
        ret
Both are leaf functions (no calls), so no stack frame and no `ra` save are needed.

Problem 4: Set a struct field

Given the same struct point and a pointer in a0, write set_x that stores the value in a1 into p->x (i.e., p->x = v). Then write set_y.

Click to reveal solution `x` is at offset 0, `y` is at offset 4. Use `sw` (store word):
        # a0 - struct point *p, a1 - int v
        .global set_x
set_x:
        sw a1, 0(a0)     # p->x = v   (offset 0)
        ret

        # a0 - struct point *p, a1 - int v
        .global set_y
set_y:
        sw a1, 4(a0)     # p->y = v   (offset 4)
        ret
Note the operand order for `sw`: the **source register** (`a1`) comes first, then the **memory address** (`offset(base)`).

Problem 5: Reverse a string in place

Reverse the C string at the address in a0 in place, not touching the \0. Write the algorithm in C, then describe how each step maps to assembly (which load/store and which pointers).

Click to reveal solution
void reverse_s(char *s) {
    int len = 0;
    while (s[len] != '\0') len++;   // length excluding '\0'
    int i = 0, j = len - 1;
    while (i < j) {
        char tmp = s[i];
        s[i] = s[j];
        s[j] = tmp;
        i++; j--;
    }
}
Mapping to assembly: - Finding the length: walk a pointer with `lb` until you read `0` (the `\0`). `lb t0, (a0)`; `beq t0, zero, done_len`; `addi a0, a0, 1`; loop. Count the bytes before the terminator. - Two indices/pointers `i` and `j`: one at the front, one at the last character (`len - 1`), **not** at the `\0`. - Swap loop: `lb` the front byte and the back byte, `sb` them swapped, advance the front pointer (`+1`), retreat the back pointer (`-1`), continue while `i < j`. - Because each character is one byte, use `lb`/`sb` (not `lw`/`sw`) and step pointers by 1. The terminator at `s[len]` is never in the `[0, len-1]` range, so it stays put.

Problem 6: Which registers must be saved?

A function compute puts a partial result in t1, calls printf to log it, and then needs t1 again to finish. It also calls printf a second time. List every register that must be saved on entry and explain why.

Click to reveal solution Registers to save on entry: - **`ra`** — `compute` is non-leaf (it calls `printf`). Each `call printf` overwrites `ra`, so the original return address must be preserved or the final `ret` fails. - **`t1`** — it is caller-saved, and `printf` may clobber it. Since `t1` is needed *after* the first `printf`, it must be spilled before the call and restored after. Any other caller-saved register (`a*`, other `t*`) only needs saving if its value is needed across a call. Callee-saved registers (`s0`–`s11`) do **not** need saving unless `compute` itself writes to them. A correct, aligned frame:
compute:
        addi sp, sp, -16
        sd   ra, 0(sp)
        sd   t1, 8(sp)
        # ... first printf, then ld t1 to use it, etc. ...
        ld   t1, 8(sp)
        ld   ra, 0(sp)
        addi sp, sp, 16
        ret

Further Reading


Summary

  1. GDB lets you debug assembly precisely. Start with gdb prog; use b/r/s/n/p/x/c to set breakpoints, step, inspect registers and memory, and continue. Break at meaningful points and continue between them rather than stepping the whole program.

  2. Code and data live in different sections. Put strings and constants in .data; put instructions in .text. Use .string so string literals get a null terminator automatically.

  3. la loads a label's address. It is a pseudo-instruction the assembler expands into the real instructions that build a 64-bit address. Use la a0, msg to point a0 at a string before calling printf.

  4. Calling printf follows the convention. Put the format string address in a0 and additional arguments in a1a7, then call printf. Mark your entry point .global.

  5. Preserve caller-saved registers across calls. printf may clobber a0a7, t0t6, and ra. Save anything you still need (especially ra in a non-leaf function) on a 16-byte-aligned stack frame, and restore it afterward.

  6. "Hello, World" combines all of the above: a .data string, a .global main in .text, save ra, la a0, msg, call printf, restore ra, return 0.

  7. Arrays, strings, and structs are all just sequential bytes. A struct's fields are laid out lowest address first, with no runtime metadata; the compiler may add padding for alignment. For struct node_st { int x; int y; }, x is at offset 0 and y at offset 4.

  8. Reading a struct field is lw offset(base). With the struct pointer in a0, lw a0, (a0) reads x and lw a0, 4(a0) reads y. This is the same base+offset mechanic as array indexing, and it generalizes directly to linked-list nodes in the next session.