CMPUT-229 · Lesson 6 of 12 · 29 min

Assembly programming: control flow, arrays and memory

With the instruction set in hand, you can write real programs. This lesson covers the everyday work of assembly programming: laying out data in memory with assembler directives, loading and storing values, turning if statements, loops and switch statements into branches, walking through arrays with indices and with pointers, and processing strings byte by byte. Every program in the lesson was assembled with the LLVM assembler and run on the course's RISC-V simulator, so the outputs are real. Along the way you will see the patterns compilers use, which makes compiler output, the subject of lesson 8, much easier to read.

Assembly programming has a reputation for difficulty, but the difficulty is mostly bookkeeping. High-level languages keep track of where each variable lives, what type it has and which branch belongs to which if; in assembly you do all of that yourself. The best defense is discipline: write the algorithm in pseudocode or C first, decide which register holds each variable, comment every line with its high-level meaning, and test small pieces as you go.

The memory layout of a program

A running program's memory is divided into regions. The text segment holds the machine instructions. The static data segment holds global variables and constants whose size is known when the program is assembled. The heap grows upward from the end of the static data and holds memory allocated at run time, as malloc does in C. The stack grows downward from the top of the address space and holds procedure frames, which lesson 7 covers. RARS and this course's simulator use a layout descended from the one used for the MIPS simulator SPIM: text starts at address 0x00400000, static data at 0x10010000, the global pointer gp is set to 0x10008000 so that the first 64 KiB of data can be reached with a single offset from it, and the stack pointer sp starts at 0x7FFFEFFC.

A tall rectangle representing the address space, with address 0 at the bottom. From the bottom up: a reserved region, the text segment starting at 0x00400000 holding instructions, the static data segment starting at 0x10010000 with gp pointing into it, the heap growing upward, a large empty gap, and the stack growing downward from sp at 0x7FFFEFFC. Arrows show the heap and stack growing toward each other.
The memory layout used by RARS and this course's simulator

In an assembly source file, directives tell the assembler which segment to fill and what data to place there. They begin with a full stop and generate data or control assembly rather than producing instructions.

Common assembler directives
DirectiveEffectExample
.textFollowing lines go in the text segment.text
.dataFollowing lines go in the data segment.data
.word w1, w2, …Store 32-bit valuesscores: .word 90, 75, 82
.half, .byteStore 16-bit or 8-bit valuesflags: .byte 1, 0, 1
.asciz "text"Store a string with a terminating zero bytemsg: .asciz "hello"
.space nReserve n bytes of zerosbuffer: .space 64
.align nAdvance to an address that is a multiple of 2ⁿ.align 2
.globl nameMake a label visible to the linker.globl main

A label is a name followed by a colon. In the data segment it names the address of the data that follows; in the text segment it names the address of the next instruction, which branches and jumps can target. Labels are resolved by the assembler and linker into numbers, so they cost nothing at run time.

Loads, stores and addressing

RISC-V has exactly one addressing mode for data: a base register plus a signed 12-bit constant offset. To read the word at address A, a program puts an address near A in a register and uses lw rd, offset(rs1). There are no instructions that add a memory operand to a register, as x86-64 has; every computation on memory data is a load, one or more register operations and a store. This load-store design is the defining trait of RISC architectures.

Arrays sit in consecutive memory locations. Element i of an array of 4-byte integers starting at address base is at base + 4i. To access a[i] when i is in a register, a program multiplies i by 4 with a left shift by 2, adds the base address, and loads with offset 0. When an element's index is a constant, the offset field can do the work directly: a[3] is at 12(base). The program below finds the largest element of an array and prints it, using the index method.

Finding the maximum of an array by index
    .data
a:      .word 17, -4, 42, 8, 23, 42, -11, 5
n:      .word 8
msg:    .asciz "maximum = "
    .text
    .globl main
main:
    la   s0, a              # s0 = base address of a
    lw   s1, n              # s1 = n (la + lw through a pseudo-instruction)
    lw   s2, 0(s0)          # s2 = max = a[0]
    li   t0, 1              # t0 = i = 1
loop:
    bge  t0, s1, done       # while (i < n)
    slli t1, t0, 2          #   t1 = 4 * i
    add  t1, s0, t1         #   t1 = &a[i]
    lw   t2, 0(t1)          #   t2 = a[i]
    ble  t2, s2, skip       #   if (a[i] > max)
    mv   s2, t2             #       max = a[i]
skip:
    addi t0, t0, 1          #   i++
    j    loop
done:
    la   a0, msg
    li   a7, 4
    ecall
    mv   a0, s2
    li   a7, 1
    ecall
    li   a7, 10
    ecall
Output
maximum = 42

Translating if statements

An if statement becomes a conditional branch that skips the then-part when the condition is false. The natural translation therefore tests the opposite condition: if (x < y) { body } becomes bge x, y, skip followed by the body and the label skip. For an if with an else, the then-part ends with an unconditional jump over the else-part. RISC-V's six branch conditions, equal, not equal, less than and greater or equal in signed and unsigned versions, together with operand swapping, cover every comparison; for example, x > y is y < x. Compound conditions use several branches: for if (a && b), a false a branches straight past the body without testing b, which is exactly C's short-circuit rule.

Worked problem

Translating an if–else

Translate: if (x >= 0) y = x; else y = -x; with x in a0 and y in a1.

Show solution
  1. Branch to the else-part when the condition is false, that is when x < 0: blt a0, zero, else.
  2. Then-part: mv a1, a0, followed by j endif to skip the else-part.
  3. else: sub a1, zero, a0 (that is, neg a1, a0).
  4. endif: the code after the statement.

Answer: blt a0, zero, else / mv a1, a0 / j endif / else: neg a1, a0 / endif: … — four instructions, one of them executed only on one path.

Branches are cheap but not free; lesson 10 shows how mispredicted branches stall a pipeline. RISC-V has no conditional move instruction in the base ISA, but compilers can sometimes avoid a branch with arithmetic: the absolute value above can be computed without branches as srai t0, a0, 31; xor a1, a0, t0; sub a1, a1, t0, because t0 is 0 for non-negative x and −1 (all ones) for negative x.

Loops

A while loop tests its condition at the top and jumps back at the bottom, as in the maximum program. Compilers usually rotate the loop so the test is at the bottom: one test before entering, then a single conditional backward branch per iteration instead of a forward branch plus an unconditional jump. That saves one instruction per iteration. A for loop is a while loop with its initialization and update written in one place. The same array can also be traversed with a pointer instead of an index: keep a register pointing at the current element, add 4 each time round, and stop when it reaches the end address. Pointer form removes the shift and the add from every iteration, which is why compilers prefer it.

Summing an array with an index: seven instructions per iteration
    .data
a:      .word 3, 1, 4, 1, 5, 9, 2, 6, 5, 3
    .text
    .globl main
main:
    la   s0, a
    li   s1, 10              # n
    li   a1, 0               # sum
    li   t0, 0               # i
iloop:                       # index form: shift and add each time
    bge  t0, s1, idone
    slli t1, t0, 2
    add  t1, s0, t1
    lw   t2, 0(t1)
    add  a1, a1, t2
    addi t0, t0, 1
    j    iloop
idone:
    mv   a0, a1
    li   a7, 1
    ecall
    li   a7, 10
    ecall
Output
39
instructions executed: 81
  ALU 48, loads 10, stores 0, branches 11 (1 taken), jumps 10, system 2
The same sum with a pointer and the test at the bottom: four instructions per iteration
    .data
a:      .word 3, 1, 4, 1, 5, 9, 2, 6, 5, 3
    .text
    .globl main
main:
    la   t0, a               # p = &a[0]
    addi t3, t0, 40          # end = &a[10]
    li   a1, 0               # sum
ploop:                       # pointer form, test at the bottom
    lw   t2, 0(t0)
    add  a1, a1, t2
    addi t0, t0, 4
    bne  t0, t3, ploop
    mv   a0, a1
    li   a7, 1
    ecall
    li   a7, 10
    ecall
Output
39
instructions executed: 49
  ALU 27, loads 10, stores 0, branches 10 (9 taken), jumps 0, system 2

Both programs print 39, but the second executes far fewer instructions because its loop body is four instructions instead of seven, and only one of them is a branch. For a loop that runs millions of times, that difference is the difference between a slow program and a fast one, and it is exactly the transformation clang performed on the C loop in lesson 1. Note that the pointer version assumes the array is not empty; a compiler adds a test before the loop for that case.

Switch statements and jump tables

A switch statement with a few cases becomes a chain of comparisons. With many dense cases, compilers build a jump table instead: an array of code addresses, one per case value. The program checks that the value is in range, loads the address from the table at index × 4, and jumps there with jalr. The cost is the same for every case, rather than growing with the number of comparisons. The same mechanism, an indirect jump through a register, also implements function pointers, virtual method calls in object-oriented languages and the dispatch loop of an interpreter.

A switch statement compiled as a jump table, with a range check
    .data
table:  .word case0, case1, case2, case3     # addresses of the code for each case
names:  .asciz "zero\n"
n1:     .asciz "one\n"
n2:     .asciz "two\n"
n3:     .asciz "three\n"
nother: .asciz "other\n"
tests:  .word 2, 0, 7, 3
    .text
    .globl main
main:
    la   s0, tests
    li   s1, 4               # number of test values
next:
    beqz s1, finish
    lw   a1, 0(s0)           # the switch value
    li   t0, 4
    bgeu a1, t0, default     # unsigned test also rejects negatives
    la   t1, table
    slli t2, a1, 2
    add  t1, t1, t2
    lw   t1, 0(t1)           # t1 = table[value]
    jr   t1                  # indirect jump
case0: la a0, names
       j  print
case1: la a0, n1
       j  print
case2: la a0, n2
       j  print
case3: la a0, n3
       j  print
default:
       la a0, nother
print:
    li   a7, 4
    ecall
    addi s0, s0, 4
    addi s1, s1, -1
    j    next
finish:
    li   a7, 10
    ecall
Output
two
zero
other
three

The range check uses an unsigned comparison, a standard trick: a negative value, viewed as unsigned, is enormous, so one bgeu rejects values below 0 and values above the last case at the same time. jr t1 is a pseudo-instruction for jalr x0, 0(t1).

Working with bytes and strings

Strings are arrays of bytes, so string code uses lbu and sb and steps pointers by 1. The program below converts a string to upper case in place. It relies on the ASCII layout from lesson 3: lower-case letters are 'a' (97) to 'z' (122), and each upper-case letter is 32 less. Two comparisons test whether a byte is a lower-case letter; an unsigned trick could reduce that to one, by subtracting 'a' and comparing the result unsigned with 26.

Converting a string to upper case in place
    .data
s:      .asciz "RISC-v rocks, 2026!\n"
    .text
    .globl main
main:
    la   t0, s
    li   t3, 'a'
    li   t4, 'z'
loop:
    lbu  t1, 0(t0)           # load one byte
    beqz t1, done            # end of string
    blt  t1, t3, keep        # below 'a'?
    bgt  t1, t4, keep        # above 'z'?
    addi t1, t1, -32         # convert to upper case
    sb   t1, 0(t0)           # store it back
keep:
    addi t0, t0, 1
    j    loop
done:
    la   a0, s
    li   a7, 4
    ecall
    li   a7, 10
    ecall
Output
RISC-V ROCKS, 2026!

A complete example: insertion sort

Insertion sort, which CMPUT-204 lesson 4 analyzed, makes a good test of everything so far: nested loops, array indexing, comparisons and stores. The register plan comes first: s0 holds the array base, s1 the length, t0 the outer index i, t1 the key and t2 the inner index j. The program sorts eight integers in place and then dumps the array, which the simulator reads from memory after the program ends.

Insertion sort in RV32I, with the sorted array read back from memory
    .data
arr:    .word 29, 10, 14, 37, 13, -5, 21, 3
    .text
    .globl main
main:
    la   s0, arr
    li   s1, 8
    li   t0, 1               # for (i = 1; i < n; i++)
outer:
    bge  t0, s1, sorted
    slli t3, t0, 2
    add  t3, s0, t3
    lw   t1, 0(t3)           #   key = a[i]
    addi t2, t0, -1          #   j = i - 1
inner:
    blt  t2, zero, place     #   while (j >= 0 && a[j] > key)
    slli t4, t2, 2
    add  t4, s0, t4
    lw   t5, 0(t4)
    ble  t5, t1, place
    sw   t5, 4(t4)           #     a[j + 1] = a[j]
    addi t2, t2, -1          #     j--
    j    inner
place:
    addi t4, t2, 1
    slli t4, t4, 2
    add  t4, s0, t4
    sw   t1, 0(t4)           #   a[j + 1] = key
    addi t0, t0, 1
    j    outer
sorted:
    li   a7, 10
    ecall
Output
arr: -5 3 10 13 14 21 29 37
instructions executed: 255
  ALU 118, loads 30, stores 25, branches 56 (8 taken), jumps 25, system 1

Look at the store sw t5, 4(t4), which writes a[j + 1] using the address of a[j] plus a constant offset of 4 instead of computing a second address, and how the while condition with && becomes two branches to the same label. The instruction counts show the pattern you would expect from insertion sort on a jumbled input: the inner loop dominates.

It is worth comparing hand-written assembly with what a compiler produces from the equivalent C. Here is clang's optimized output for a function that finds the maximum of an array. The compiler has rotated the loop, switched to a pointer, and kept everything in registers, much as the improved hand-written loop did.

clang's RISC-V code for an array maximum
int max_of(const int *a, int n) {
    int m = a[0];
    for (int i = 1; i < n; i++)
        if (a[i] > m)
            m = a[i];
    return m;
}
Output
$ clang --target=riscv32 -march=rv32im -O1 -S max.c -o -
max_of:
    lw      a2, 0(a0)
    li      a3, 2
    blt     a1, a3, .LBB0_5
    addi    a3, a0, 4
    slli    a1, a1, 2
    add     a1, a0, a1
    j       .LBB0_3
.LBB0_2:
    addi    a3, a3, 4
    mv      a2, a0
    beq     a3, a1, .LBB0_6
.LBB0_3:
    lw      a0, 0(a3)
    blt     a2, a0, .LBB0_2
    mv      a0, a2
    j       .LBB0_2
.LBB0_5:
    mv      a0, a2
.LBB0_6:
    ret
The RARS system services used in this course's programs (requested with ecall)
Service (a7)EffectArguments and results
1Print integera0 = value
4Print stringa0 = address of a zero-terminated string
11Print charactera0 = character
34Print integer in hexadecimala0 = value
10Exitnone
93Exit with statusa0 = status

Common mistakes

  • MistakeForgetting to multiply the index by the element size.

    InsteadFor 4-byte words, compute base + (i << 2); for bytes no scaling is needed; for halfwords shift by 1.

  • MistakeBranching on the condition itself rather than its opposite when skipping a then-part.

    InsteadTo skip the body when the condition is false, branch on the negated condition: if (x < y) becomes bge x, y, skip.

  • MistakeUsing lw on a byte array or lb on unsigned text.

    InsteadUse lbu for text and unsigned bytes, lb for signed bytes, lw only for 4-byte words.

  • MistakeComparing a signed switch value only against the upper bound.

    InsteadA negative index would read outside the table; use bgeu, which rejects negatives too, or test both bounds.

  • MistakeKeeping a loop variable in a t register across an ecall or a call.

    InsteadIn real code calls may overwrite t registers; use s registers for values that must survive, and save them as lesson 7 explains.

Summary and key terms

  • Programs occupy text, static data, heap and stack regions; RARS places text at 0x00400000, data at 0x10010000 and the stack below 0x7FFFF000.
  • Directives such as .data, .text, .word, .byte, .asciz and .space lay out memory; labels name addresses.
  • RISC-V addresses memory only as base register plus 12-bit offset; element i of a word array is at base + 4i.
  • If statements branch on the negated condition; if–else adds a jump over the else-part; && and || become chains of branches.
  • Loops are fastest with the test at the bottom and a pointer that advances, rather than an index scaled every iteration.
  • Dense switch statements compile to jump tables reached with jalr after an unsigned range check.
  • Strings are processed byte by byte with lbu and sb until the zero terminator.
Text segment
The region of memory holding a program's instructions.
Static data segment
The region holding global variables and constants laid out at assembly time.
Assembler directive
A line beginning with a full stop that controls assembly or generates data rather than instructions.
Label
A name for an address in the program, used as a branch target or data address.
Load-store architecture
An ISA in which only load and store instructions access memory.
Base-plus-offset addressing
Forming an address by adding a constant offset to a register.
Loop rotation
Moving a loop test to the bottom so that each iteration needs only one branch.
Jump table
An array of code addresses indexed by a switch value and used with an indirect jump.
Indirect jump
A jump whose target address comes from a register, as with jalr.
Null terminator
The zero byte that marks the end of a C string.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Give the instructions to load a[5] into t0 when s0 holds the base of an int array.

    Show answer

    lw t0, 20(s0).

  2. Translate: if (a0 == a1) a2 = 1;

    Show answer

    bne a0, a1, skip / li a2, 1 / skip: …

  3. Translate: while (t0 != 0) t0 = t0 - 1; with the test at the bottom.

    Show answer

    beqz t0, done / loop: addi t0, t0, -1 / bnez t0, loop / done: …

  4. What does .space 40 reserve, and why might it be followed by nothing else?

    Show answer

    40 zero bytes, enough for ten words; it is typically used for an array filled at run time.

  5. How many instructions per iteration does the pointer loop in this lesson execute?

    Show answer

    Four: lw, add, addi and bne.

  6. Why does the jump-table range check use bgeu?

    Show answer

    Viewed as unsigned, negative values are huge, so one unsigned comparison rejects both negatives and values that are too large.

  7. Write a loop body that counts the negative numbers in an array, with the element in t2 and the count in a1.

    Show answer

    bge t2, zero, skip / addi a1, a1, 1 / skip: …

  8. How would you test whether the byte in t1 is a decimal digit with one comparison?

    Show answer

    addi t2, t1, -48 then sltiu t3, t2, 10; t3 is 1 exactly for 0 to 9.

  9. Where does the RARS stack start, and in which direction does it grow?

    Show answer

    sp starts at 0x7FFFEFFC and the stack grows toward lower addresses.

  10. In the insertion sort, why can the shift store use 4(t4)?

    Show answer

    t4 holds the address of a[j], so a[j + 1] is 4 bytes further on.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz