박성범 Simon Park

A very concrete explanation of how computers read code

A walkthrough of MIPS assembly

KO | EN

Last semester, while studying Operating Systems, I got curious about how things work at a lower level. Based on the first half of Computer Organization and Design 5th Edition by David A. Patterson and John L. Hennessy, I organized what I learned about the MIPS instruction set.

Computer Abstractions and Technology

Studying computer architecture means understanding how the hardware and instructions that make up a computer work together. It deals with hardware, but not with circuits in detail. Circuits are low-level architecture, while what I cover here is higher-level architecture between circuits (hardware) and operating systems (software), such as ISA (Instruction Set Architecture) and microarchitecture.

Instructions: Language of the Computer

An ISA is a set of instructions such as add and load that defines the interface between hardware and software. Microarchitecture is an implementation of an ISA, meaning the organization of the processor and the I/O subsystem. Pipeline depth and cache size can be considered part of microarchitecture.

There are two important principles in machine design. First, because a computer understands everything as bits, it cannot distinguish instructions from data. Second, programs are stored in memory just like data. Dividing memory into sections is for humans; the computer ultimately accepts it all as binary.

From a High-Level Language to the Language of Hardware

int main(int argc, char *argv[]) {
  int s0 = 0, s1 = 1, s2 = 2;
  s0 = s1 + s2;
  return 0;
}

A high-level language is the kind of programming language closest to natural language. It offers high productivity, and most commonly used languages such as C, C++, and Java are high-level languages. High-level languages are converted to assembly by a compiler.

  .data
  .text
main:
  add $s0, $s1, $s2

Assembly language is a textual representation of a computer’s concrete behavior, and can be thought of as a set of instructions. At this stage, high-level code comes all the way down to jumps between instruction lines. Assembly is converted to machine code by an assembler.

00000000000000000000000000000100
00000000000000000000000000000000
00000010001100101000000000100000

Machine code is made up only of binary numbers the computer can understand. The computer fetches binary code from memory into the CPU and executes instructions.

Operations of the Computer Hardware

There are several kinds of ISAs. Well-known examples include x86 and AMD64 from Intel and AMD, and their CPU architectures are very complex because they use CISC (Complex Instruction Set Computer). On the other hand, RISC (Reduced Instruction Set Computer) is simpler. ARM, a RISC architecture, is used in mobile devices such as smartphones and tablets, and the M1 MacBook released in 2020 also uses an ARM-based CPU. MIPS (Microprocessor without Interlocked Pipeline Stages), which we will use, is also a RISC architecture. MIPS has a clean instruction set, so it is suitable for studying computer architecture, and in practice it has been used in digital home and networking equipment such as Blu-ray devices and PlayStation.

MIPS Instructions

Earlier, we saw that high-level languages are converted into assembly, and assembly is eventually converted into machine code. Assuming we already know high-level languages, let’s look at how instructions are actually written in MIPS and by what rules those instructions are converted to machine code.

Because reading values from main memory every time is expensive overhead, the CPU has a small and fast memory called registers. Registers are small but fast, so keeping data in registers allows instructions to run quickly. MIPS operations use 32 registers of 32 bits each, and 32-bit data is called a word. Some registers are given names in advance: $t0 through $t9 are temporary registers, and $s0 through $s7 are saved registers used continuously.

Arithmetic Operations

Arithmetic operations are not very complicated.

a = (b + c) - (d + e);

If a, b, c, d, and e correspond to registers $s0, $s1, $s2, $s3, and $s4, the MIPS code is:

add $t0, $s1, $s2 # $t0 = $s1 + $s2
add $t1, $s3, $s4 # $t1 = $s3 + $s3
sub $s0, $t0, $t1 # $s0 = $t0 - $t1

add performs addition, and sub performs subtraction.

Memory Operations

Memory operations in MIPS move data from memory to registers and from registers to memory.

a = b + A[8]

If a and b correspond to $s1 and $s2, and the base address of A corresponds to $s3, the MIPS code is:

lw $t0, 32($s3) # $t0 = $s3[8]
add $s1, $s2, $t0 # $s1 = $s2 + $t0

lw loads a value from memory into a register. In an integer array, each element takes 1 word (4 bytes) in memory. So A[8] exists in memory like this:

4byte   4byte     4byte          4byte
A[0],   A[1],     A[2],    ...,  A[8]
$s3,    4($s3),   8($s3),  ...,  32($s3)

So 32($s3) means accessing the location 32 bytes away from base address $s3. The number in front is called an offset, and the register pointing to the base address ($s3 in this case) is called a base register.

A[12] = a + A[8]

If a corresponds to $s2 and the base address of A corresponds to $s3, the MIPS code is:

lw $t0, 32($s3) # $t0 = $s3[8]
add $t0, $s2, $t0 # $t0 = $s2 + $t0
sw $t0, 48($s3) # A[12] = $t0

sw moves data from a register to memory. In the code above, lw loads the value at 32($s3) into register $t0, and then sw stores the value in $t0 into 48($s3) in memory.

Immediate Instructions

Use addi when adding a constant.

addi $s3, $s3, 4 # $s3 = $s3 + 4
addi $s2, $s2, -1 # $s2 = $s2 + (-1)

There is no separate subtraction instruction for constants, so you can add a negative number.

Constant

In MIPS, register $zero means constant 0.

add $t0, $s1, $zero # $t0 = $s1 + 0

You can use it to assign a value to another register as is.

Conditional Statement
if (i == j) {
  a = b + c;
} else {
  a = b - c;
}

If a, b, c, i, and j correspond to $s0 through $s4, the MIPS code is:

  bne $s3, $s4, Else # if ($s3 == $s4)
  add $s0, $s1, $s2 # { $s0 = $s1 + $s2 }
  j Exit
Else:
  sub $s0, $s1, $s2 # else { $s0 = $s1 - $s2 }
Exit:
  ...

bne compares two register values. If they are equal, it executes the next statement; if they are different, it jumps to the specified label. Above, bne compares $s3 and $s4, and if they differ, it jumps to label Else and executes sub $s0, $s1, $s2. Label names are arbitrary. They do not have to be named Else or Exit. The assembler also computes label positions.

Loop
while (save[i] == k) {
  i += 1;
}

If i and k correspond to $s3 and $s5, and the base address of save corresponds to $s6, the MIPS code is:

Loop:
  sll $t1, $s3, 2 # $t1 = $s3 << 2
  add $t1, $t1, $s6 # $t1 = $t1($s6)
  lw $t0, 0($t1) # $t0 = 0($t1)
  bne $t0, $s5, Exit # if ($t0 != $s5) { goto Exit }
  addi $s3, $s3, 1 # $s3 += 1
  j Loop
Exit:
  ...

This is quite complicated, but you can divide it into two big parts. From bne to j, it checks the loop condition and increments i. From sll to lw above that, it loads save[i] into a register.

sll itself is not an instruction central to control flow; it just left-shifts a value. Left-shifting $s3 by 2 is the same as multiplying by 4. This code multiplies by 4 whenever $s3 increases by 1 so the address accessed from $s6 moves by 4 bytes each time. Use srl for right shift.

MIPS Procedure

A procedure means a function. Defining and calling functions at the assembly level is quite different from doing so in high-level languages. The key points are register backup and jumps.

Leaf Procedure

Let’s assume a function that does not call another function inside it, that is, a leaf procedure:

int leaf_procedure(int a, b, c, d) {
  int e;
  e = (a + b) - (c -d);
  return e;
}

If a through d correspond to $a0 through $a3 and e corresponds to $s0, the MIPS code is:

main:
  jal leaf_procedure # call leaf_procedure
  j exit
leaf_procedure:
  addi $sp, $sp, -12 # move stack pointer by -12 to back up three 4-byte registers
  sw $t1, 8($sp) # 8($sp) = $t1
  sw $t0, 4($sp) # 4($sp) = $t0
  sw $s0, 0($sp) # 0($sp) = $s0
  add $t0, $a0, $a1
  add $t1, $a2, $a3
  sub $s0, $t0, $t1
  add $v0, $s0, $zero
  lw $s0, 0($sp) # $s0 = 0($sp)
  lw $t0, 4($sp) # $t0 = 4($sp)
  lw $t1, 8($sp) # $t1 = 8($sp)
  addi $sp, $sp, 12 # restore stack pointer
  jr $ra # jump to the address in $ra
exit:
  ...

…Why do we do this? Let’s go through it one by one.

main:
  jal leaf_procedure # call leaf_procedure
  j exit

First, jal moves execution to label leaf_procedure. At that moment, register $ra (return address) receives the program counter value. The program counter is the value used to track how far a process has executed its instructions. In this case, $ra points to j exit, the line immediately after jal leaf_procedure.

leaf_procedure:
  addi $sp, $sp, -12 # move stack pointer by -12 to back up three 4-byte registers

Inside leaf_procedure, we first add -12 to register $sp (stack pointer) to make room for backing up three 4-byte registers. $sp is the register that points to a specific address on the stack, and moving this position secures backup space.

sw $t1, 8($sp) # 8($sp) = $t1
sw $t0, 4($sp) # 4($sp) = $t0
sw $s0, 0($sp) # 0($sp) = $s0

Then sw stores $t1, $t0, and $s0 in each stack slot of $sp. (The reason it accesses from larger addresses first is that stack addresses go from larger to smaller.) The reason for doing this is that the caller of leaf_procedure (main here) might already be using $t1, $t0, or $s0. Without backing them up on the stack, we’d overwrite values that the caller was using.

add $t0, $a0, $a1
add $t1, $a2, $a3
sub $s0, $t0, $t1
add $v0, $s0, $zero

This performs the operation corresponding to e = (a + b) - (c -d).

lw $s0, 0($sp) # $s0 = 0($sp)
lw $t0, 4($sp) # $t0 = 4($sp)
lw $t1, 8($sp) # $t1 = 8($sp)

lw loads back the values saved at $sp. The stack pointer is also moved back by 12 bytes.

jr $ra # jump to the address in $ra

jr returns to $ra in the caller and executes j exit.

Here we also backed up $t1 and $t0, but in fact this is not necessary. $t registers are temporary registers, so they are meant to hold temporary values at all times. That means code should be written so that overwriting values in $t registers does not cause problems.

Non-Leaf Procedure

What about a non-leaf procedure that calls another function inside it? When calls are nested like this, there is a risk of falling into an infinite loop because the caller location saved in $ra gets overwritten. Let’s start with the high-level code:

int factorial(int n) {
  if (n < 1) {
    return 1;
  } else {
    return n * factorial(n - 1);
  }
}

This is a recursive factorial function. Assuming n maps to $a0 and the result maps to $v0, the MIPS code is:

factorial:
  addi $sp, $sp, -8 # move stack pointer by -8
  sw $ra, 4($sp)
  sw $a0, 0($sp)
  slti $t0, $a0, 1 # $t0 = ($a0 < 1)
  beq $t0, $zero, L1 # if ($t0 == 0) { goto L1 }
  addi $v0, $zero, 1
  addi $sp, $sp, 8 # move stack pointer by 8
  jr $ra
L1:
  addi $a0, $a0, -1 # $a -= 1
  jal factorial # factorial($a0)
  lw $a0, 0($sp)
  lw $ra, 4($sp)
  addi $sp, $sp, 8 # move stack pointer by 8
  mul $v0, $a0, $v0 # $v0 = $a0 * $v0
  jr $ra

It looks extremely complicated, but if you break it down… it is actually very complicated. Roughly speaking, it calls the function recursively while pushing the stack pointer and backing up values, then later pulls the stack pointer back, restores values, and computes the result. Assume $a0 is 3, and let’s go through every instruction step by step.

factorial:
  addi $sp, $sp, -8 # move stack pointer by -8
  sw $ra, 4($sp)
  sw $a0, 0($sp)

First, we enter factorial, move the stack pointer, and back up registers $ra and $a0. At this point, $ra will be the location that called factorial, and $a0 is the 3 we assumed earlier. The stack looks like this:

+--------+------------+
| $a = 3 | caller $ra |
+--------+------------+

Now let’s look at the next lines:

slti $t0, $a0, 1 # $t0 = ($a0 < 1)
beq $t0, $zero, L1 # if ($t0 == 0) { goto L1 }

It checks whether $a0 is less than 1 and stores the result in $t0. Then it checks whether $t0 equals $zero, and if so, it moves to label L1. Here, because $a0 is 3, it is not less than 1. So $t0 becomes 0, and since beq is satisfied, execution goes to L1.

L1:
  addi $a0, $a0, -1 # $a -= 1
  jal factorial # factorial($a0)

L1 defines the behavior corresponding to return n * factorial(n - 1); in high-level code. First it subtracts 1 from $a0. Then it calls factorial again.

factorial:
  addi $sp, $sp, -8 # move stack pointer by -8
  sw $ra, 4($sp)
  sw $a0, 0($sp)

factorial moves $sp by -8 again. Then it backs up $ra and $a0. Here, $ra is the line immediately after jal factorial in the previously executed L1, and $a0 becomes 2. If we represent the register values stored in the stack, it looks like this:

+--------+------------+--------+--------+
| $a = 3 | caller $ra | $a = 2 | L1 $ra |
+--------+------------+--------+--------+

Next, let’s inspect these lines:

slti $t0, $a0, 1 # $t0 = ($a0 < 1)
beq $t0, $zero, L1 # if ($t0 == 0) { goto L1 }

Since $a0 is 2, it is not less than 1. $t0 becomes 0, beq is satisfied, and execution goes to L1 again.

L1:
  addi $a0, $a0, -1 # $a -= 1
  jal factorial # factorial($a0)

Just like before, L1 subtracts 1 from $a, making it 1, and jumps to factorial again.

factorial:
  addi $sp, $sp, -8 # move stack pointer by -8
  sw $ra, 4($sp)
  sw $a0, 0($sp)

In factorial, $sp is moved by -8 once more. At this point, $ra backed up is the line immediately after jal factorial in the previously executed L1, and $a0 is 1. The register values stored in the stack are now:

+--------+------------+--------+--------+--------+--------+
| $a = 3 | caller $ra | $a = 2 | L1 $ra | $a = 1 | L1 $ra |
+--------+------------+--------+--------+--------+--------+

Let’s move to the next lines:

slti $t0, $a0, 1 # $t0 = ($a0 < 1)
beq $t0, $zero, L1 # if ($t0 == 0) { goto L1 }

$a0 is still 1, so it is not less than 1, and beq is satisfied, so execution goes to L1.

L1:
  addi $a0, $a0, -1 # $a -= 1
  jal factorial # factorial($a0)

L1 subtracts 1 from $a, making it 0, then jumps to factorial.

factorial:
  addi $sp, $sp, -8 # move stack pointer by -8
  sw $ra, 4($sp)
  sw $a0, 0($sp)

In factorial, $sp is moved by -8 yet again. Here, $ra backed up is the line immediately after jal factorial in the previously executed L1, and $a0 is 0. The register values stored in the stack are now:

+--------+------------+--------+--------+--------+--------+--------+--------+
| $a = 3 | caller $ra | $a = 2 | L1 $ra | $a = 1 | L1 $ra | $a = 0 | L1 $ra |
+--------+------------+--------+--------+--------+--------+--------+--------+

Now look at these lines:

slti $t0, $a0, 1 # $t0 = ($a0 < 1)
beq $t0, $zero, L1 # if ($t0 == 0) { goto L1 }

Now $a0 is 0, so it is less than 1 and $t0 becomes 1. Since beq is not satisfied, it does not go to L1 and executes the next lines instead. That was the call process; now the return-value process repeats.

addi $v0, $zero, 1
addi $sp, $sp, 8 # move stack pointer by 8
jr $ra

It stores 1 in $v0 (0 plus 1). Then it moves the stack pointer back by 8 and jumps to the $ra location. Here, $ra is the L1 $ra location.

+--------+------------+--------+--------+--------+--------+
| $a = 3 | caller $ra | $a = 2 | L1 $ra | $a = 1 | L1 $ra |
+--------+------------+--------+--------+--------+--------+

Since we pulled the stack pointer by 8, the stack now looks like this.

lw $a0, 0($sp)
lw $ra, 4($sp)
addi $sp, $sp, 8 # move stack pointer by 8
mul $v0, $a0, $v0 # $v0 = $a0 * $v0
jr $ra

It restores $a0 and $ra from the stack. Looking at the stack above, $a0 is 1 and $ra is the L1 $ra location. Then it moves the stack pointer by 8 again. Next, it stores $a0 * $v0, meaning 1 * 1, into $v0, and jumps to $ra. $ra is still the L1 $ra location.

+--------+------------+--------+--------+
| $a = 3 | caller $ra | $a = 2 | L1 $ra |
+--------+------------+--------+--------+

Because we moved the stack pointer by 8 earlier, the stack now looks like this.

lw $a0, 0($sp)
lw $ra, 4($sp)
addi $sp, $sp, 8 # move stack pointer by 8
mul $v0, $a0, $v0 # $v0 = $a0 * $v0
jr $ra

Repeat this process. Restore $a0 and $ra. Now $a0 is 2 and $ra is the L1 $ra location. Move the stack pointer by 8 again. Then store $a0 * $v0, meaning 2 * 1, into $v0, and jump to $ra. $ra is the L1 $ra location.

+--------+------------+
| $a = 3 | caller $ra |
+--------+------------+

After the stack pointer moved by 8, the stack now looks like this.

lw $a0, 0($sp)
lw $ra, 4($sp)
addi $sp, $sp, 8 # move stack pointer by 8
mul $v0, $a0, $v0 # $v0 = $a0 * $v0
jr $ra

Now this is the final step. Restore $a0 and $ra from the stack. Now $a0 is 3 and $ra is the caller $ra location. Move the stack pointer by 8 again, and now the stack pointer returns to its initial value.

Store $a0 * $v0, meaning 3 * 2, into $v0, then jump to $ra. At this point $ra is the caller location, and if the caller reads $v0, it will get 6. factorial worked correctly.

I feel like a human computer.

Memory Layout

+---------------+ High address
|     Stack     |
+-------+-------+
|       |       |
|       v       |
|               |
|       ^       |
|       |       |
+-------+-------+
|     Heap      |
+---------------+
| Static (Data) |
+---------------+
|  Text (Code)  |
+---------------+ Low address

In the diagram, the code segment is called Text, and the actual program code is stored there. The data segment is called Static Data, and stores constant arrays, strings, and static variables. Heap stores dynamically allocated array elements or objects, and Stack stores functions or methods and local variables.

The process of storing values in the stack and loading them back, which we saw earlier, is based on this memory structure. Since the stack grows from high addresses to low addresses, we moved the stack pointer in the negative direction, as in addi $sp, $sp, -8. When storing values too, we did sw $ra 4($sp) and then sw $a0, 0($sp). Unlike the stack, the heap grows from low addresses to high addresses.

MIPS Instruction Formats

When an assembler in MIPS reads instructions line by line and converts them to machine code, there are three formats for representing machine code: R (Register), I (Immediate), and J (Jump).

R-format

R-format is the format that holds instructions that operate using registers.

+----+----+----+----+-------+-------+
| op | rs | rt | rd | shamt | funct |
+----+----+----+----+-------+-------+

Suppose we have MIPS code like this:

add $t0, $s1, $s2

In R-format, it would look like this:

+---------+-----+-----+-----+---+-----+
| special | $s1 | $s2 | $t0 | 0 | add |
+---------+-----+-----+-----+---+-----+

If we convert that to decimal according to the register table:

+---+----+----+---+---+----+
| 0 | 17 | 18 | 8 | 0 | 32 |
+---+----+----+---+---+----+

The register values, as well as the opcode and funct values of the add instruction and the positions of rs, rt, and rd, are all predefined. You can refer to the MIPS Green Sheet. Then, if you convert each field into binary, you get the value that is actually placed in memory:

00000010001100100100000000100000

The computer executes instructions by reading binary numbers converted like this.

Looking at this, it might seem like op is unnecessary because you can just check funct right away. In fact, on a 32-bit system, aligning to 32 bits makes it easier to read instructions and helps reduce complexity, so this design accepts some memory overhead.

I-format

I-format is used for constant operations and memory operations.

+----+----+----+-----+
| op | rs | rt | IMM |
+----+----+----+-----+
J-format

J-format is used when jumping to another location.

+----+----------------+
| op | pseudo-address |
+----+----------------+

MIPS Addressing for 32-bit Immediates and Addresses

Among MIPS instruction formats, I-format is also used to express statements like bne $t0, $s5, Exit. But the last I-format field, constant or address, is only 16 bits, so it cannot contain the whole body of what runs at Exit. So this field stores how many lines away Exit is. Let’s look at the Loop code we used earlier:

Loop:                 # address: 8000
  sll $t1, $s3, 2     # R-format: | 0  | 0  | 19 | 9 | 4 | 0  |
  add $t1, $t1, $s6   # R-format: | 0  | 9  | 22 | 9 | 0 | 32 |
  lw $t0, 0($t1)      # I-format: | 35 | 9  | 8  |     0      |
  bne $t0, $s5, Exit  # I-format: | 5  | 8  | 21 |     2      |
  addi $s3, $s3, 1    # I-format: | 8  | 19 | 19 |     1      |
  j Loop              # J-format: | 2  |         2000         |
Exit:
  ...

Look at the I-format of bne $t0, $s5, Exit here. You can see 2 in the constant or address field, which means Exit is two lines away from that line.

In J-format as well, the pseudo-address field is only 26 bits, so it cannot store all instructions. In J-format, the pseudo-address field holds the value of the jump target address right-shifted by 2. Look at the J-format of the j Loop line above. The pseudo-address field has 2000, while the address of Loop, which is the jump target, is 8000. If you left-shift 2000 by 2 (2000 << 2), you get 8000.

To make this mechanism work, the assembler scans the entire code first and finds label positions before converting concrete instructions into machine code. Then it reads instructions in order and converts them to machine code. When it reaches a jump target, it converts that part of the instruction accordingly. Only after converting all instructions in order like this do you get a result the computer can understand.

That is the basic content of MIPS instructions. If we go deeper, we could also cover processor and memory behavior, or binary operations using digital circuits, but for now I only looked at an overview of computer architecture and the process by which computers interpret and execute code.