From Python to MIPS: What Low-Level Programming Taught Me About Computing
Most people learning to program start with Python or JavaScript — high-level languages that abstract away almost everything about what the computer is actually doing. That abstraction is useful. It lets you focus on solving problems rather than managing memory and registers.
But at some point, if you want to genuinely understand computing, you need to look under the hood. COMP1521 (Computer Systems Fundamentals) at UNSW does exactly that, and it's one of the most illuminating courses I've taken.
The Gap Between What You Write and What Runs
When you write x = a + b in Python, you're describing a computation at a level so abstract it hides almost everything interesting. By the time that line executes, it has been:
- Parsed and compiled into bytecode by the Python interpreter
- The bytecode has been evaluated by the CPython virtual machine
- The VM has issued machine code instructions to the CPU
- The CPU has fetched operands from memory into registers, executed an ALU operation, and written the result back
MIPS assembly strips away all but the last step. You are writing the instructions that the CPU executes directly. There is no interpreter, no virtual machine, no compiler doing clever things on your behalf. If you want to add two numbers, you load them into registers and issue an add instruction. If you want to access memory, you calculate the address explicitly and use a lw or sw instruction.
This directness is initially uncomfortable, then illuminating.
What MIPS Assembly Actually Looks Like
A simple example: computing the Gaussian sum of integers from 1 to n (i.e., n × (n+1) / 2).
In Python:
def gaussian_sum(n):
return n * (n + 1) // 2
In MIPS:
# $a0 = n (argument)
# $v0 = return value
gaussian_sum:
addi $t0, $a0, 1 # $t0 = n + 1
mul $v0, $a0, $t0 # $v0 = n * (n + 1)
srl $v0, $v0, 1 # $v0 = result / 2 (arithmetic right shift)
jr $ra # return
Every operation is explicit. You manage which register holds which value, you handle the calling convention (where arguments come from, where results go), and you are responsible for not accidentally overwriting a value you still need.
This forces a precision of thinking that high-level programming does not require.
Memory Operations and Addresses
One of the most important shifts is from "variables" to "memory addresses." In a high-level language, a variable is an abstraction — you give something a name and the language handles where it lives in memory. In MIPS, you work with memory addresses directly.
A string reversal (palindrome check) algorithm in MIPS requires:
- Calculating the address of each character in the string
- Loading characters from those addresses into registers
- Comparing and swapping characters by writing to the appropriate addresses
- Tracking array bounds explicitly to avoid reading past the end of allocated memory
Implementing this by hand — tracking two pointers, loading bytes, swapping, advancing — gives you a concrete understanding of why off-by-one errors are so common in C and why buffer overflows are a real class of vulnerability. You can see exactly how a mis-calculated address leads to reading or writing memory that doesn't belong to your data.
C and Data Structures (COMP2521)
COMP2521 moves from assembly to C — still low-level relative to Python, but with enough abstraction to express complex algorithms. The course focuses on data structures and algorithm design.
Huffman Tree Encoder
One of the major projects involved implementing a Huffman Tree encoder for lossless data compression. The algorithm:
- Counts character frequencies in the input
- Builds a priority queue (min-heap) of frequency nodes
- Repeatedly extracts the two lowest-frequency nodes and merges them into a parent node
- Generates variable-length binary codes from the resulting tree (frequent characters get shorter codes)
- Encodes the input using the generated codes
Implementing this in C means managing memory explicitly — allocating nodes with malloc, freeing them when done, and being careful that every code path either frees or transfers ownership of allocated memory. A memory leak in a Huffman encoder isn't a crash — it silently accumulates, which can be harder to debug than an outright failure.
The experience builds a very concrete understanding of why garbage-collected languages exist and what they're doing on your behalf.
Sorting Algorithm Analysis
The course includes experimental design assignments analysing the time-complexity of various sorting algorithms under different input distributions. Running quicksort, mergesort, insertion sort, and heapsort against:
- Random input of varying sizes
- Already-sorted input
- Reverse-sorted input
- Input with many repeated values
...and plotting the actual runtime curves against the theoretical Big O bounds reveals a lot about the difference between worst-case and average-case complexity. Quicksort's O(n log n) average-case performance degrades dramatically on already-sorted input with a naive pivot selection — something that's easy to understand theoretically but viscerally clear when you see the runtime curve spike.
What This Changes
Studying at this level changes how you think about code at every level above it. A few specific shifts:
Performance intuition: Understanding that cache lines exist, that memory access patterns matter, and that the CPU is doing real work to fetch operands makes performance claims legible. When someone says "avoid branch misprediction" or "prefer sequential memory access," you know why.
Security intuition: Buffer overflows, use-after-free, and off-by-one errors stop being abstract vulnerability categories and become specific, concrete failure modes you've seen happen in your own code. This context is directly applicable to the security focus of my degree.
Debugging precision: When something goes wrong in a low-level program, there is no stack trace, no helpful error message, and no interpreter to catch the mistake. You develop a methodical, hypothesis-driven approach to debugging that transfers to every other environment.
Appreciation for abstraction: Paradoxically, writing assembly and C makes you appreciate high-level languages more, not less. Every convenience a language provides — automatic memory management, bounds checking, expressive type systems — is a solution to a real problem that you've now encountered first-hand.
The Uncomfortable Part
Low-level programming is genuinely difficult at first. The debugging feedback loop is slow, the error modes are unfamiliar, and the gap between "this looks right" and "this works correctly" is often large.
The temptation to retreat to familiar abstractions is real. Resisting it — staying at the level of registers and addresses until the concept clicks — is where the learning happens.
The first time a MIPS program runs correctly end-to-end, without a segfault or an incorrect result, is more satisfying than almost anything I've produced at a higher level. Because you know exactly why it works.
Recommended Approach
If you're going through COMP1521 or equivalent:
- Read the MIPS instruction set reference regularly. You will forget which instructions exist and what they do. Having a reference open is not a crutch — it's normal.
- Use MARS or SPIM to step through execution. Watching the register values change with each instruction builds intuition faster than any other method.
- Write the high-level algorithm first. Knowing what you're trying to implement at an abstract level before translating to assembly reduces the cognitive load significantly.
- Test edge cases explicitly. Empty strings, zero values, maximum sizes — these are exactly the cases where off-by-one errors and memory mismanagement show up.
The effort is worth it. Understanding what your programs are actually doing — at the level of instructions and memory addresses — is foundational to being a serious engineer.