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.
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.
| Directive | Effect | Example |
|---|---|---|
| .text | Following lines go in the text segment | .text |
| .data | Following lines go in the data segment | .data |
| .word w1, w2, … | Store 32-bit values | scores: .word 90, 75, 82 |
| .half, .byte | Store 16-bit or 8-bit values | flags: .byte 1, 0, 1 |
| .asciz "text" | Store a string with a terminating zero byte | msg: .asciz "hello" |
| .space n | Reserve n bytes of zeros | buffer: .space 64 |
| .align n | Advance to an address that is a multiple of 2ⁿ | .align 2 |
| .globl name | Make 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.
.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
ecallmaximum = 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
- Branch to the else-part when the condition is false, that is when x < 0: blt a0, zero, else.
- Then-part: mv a1, a0, followed by j endif to skip the else-part.
- else: sub a1, zero, a0 (that is, neg a1, a0).
- 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.
.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
ecall39 instructions executed: 81 ALU 48, loads 10, stores 0, branches 11 (1 taken), jumps 10, system 2
.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
ecall39 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.
.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
ecalltwo 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.
.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
ecallRISC-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.
.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
ecallarr: -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.
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;
}$ 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| Service (a7) | Effect | Arguments and results |
|---|---|---|
| 1 | Print integer | a0 = value |
| 4 | Print string | a0 = address of a zero-terminated string |
| 11 | Print character | a0 = character |
| 34 | Print integer in hexadecimal | a0 = value |
| 10 | Exit | none |
| 93 | Exit with status | a0 = 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.
Give the instructions to load a[5] into t0 when s0 holds the base of an int array.
Show answer
lw t0, 20(s0).
Translate: if (a0 == a1) a2 = 1;
Show answer
bne a0, a1, skip / li a2, 1 / skip: …
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: …
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.
How many instructions per iteration does the pointer loop in this lesson execute?
Show answer
Four: lw, add, addi and bne.
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.
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: …
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.
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.
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.
Create a free account to save your progress, take quizzes and earn a certificate.