Loops
Part of Programming Fundamentals
Loops repeat a block of code, enabling programs to process collections, wait for conditions, and perform iterative calculations without duplicating instructions.
Why This Matters
Nearly everything a computer does involves repetition. Process each byte in a message. Wait until the hardware signals ready. Calculate a value iteratively until it converges. Sort a list by making repeated passes. Generate a waveform by continuously updating output. Without loops, none of this is possible — each repetition would require copying the instructions. With loops, a few instructions handle any number of repetitions.
For a rebuilding civilization’s programmers, understanding loop patterns and their assembly implementations is essential for both writing programs and analyzing what programs are doing. Loops are where most execution time is spent. An efficient loop does the same work in fewer instructions or clock cycles. Knowing how loops compile to assembly lets you write higher-level code that produces efficient results.
The key insight about loops: at the hardware level, a loop is nothing more than a conditional backward jump. Everything else — FOR, WHILE, DO-UNTIL, REPEAT — is just a variation in where the test occurs and how the counter is managed.
The FOR Loop
A FOR loop iterates a fixed number of times, controlled by a counter variable:
FOR I = 1 TO 10
PRINT I
NEXT I
The counter I starts at 1, increments by 1 each iteration, and the loop body executes while I ⇐ 10. After the NEXT, I = 11 and the loop ends.
In assembly (Z80), using the DJNZ instruction:
; for I = 1 to 10: print I
LD B, 10 ; B = loop count
LD A, 1 ; A = starting value
FOR_LOOP:
CALL PRINT_A ; print A
INC A ; A = A + 1
DJNZ FOR_LOOP ; decrement B, loop if B != 0
DJNZ (Decrement B and Jump if Not Zero) is a single 2-byte instruction that decrements the B register and conditionally branches. It is the Z80’s dedicated loop counter instruction, taking 13 clock cycles when the branch is taken and 8 when the loop ends — very efficient for tight loops.
For loops where the count is not a simple register: use any register as the counter, test it explicitly, and jump back:
LD HL, 1000 ; counter = 1000 (more than fits in 8-bit register)
LOOP:
CALL PROCESS
DEC HL
LD A, H ; test HL == 0
OR L
JP NZ, LOOP ; loop if HL != 0
Step value: A FOR loop with STEP -1 counts downward. FOR I = 10 TO 1 STEP -1. In assembly, decrement instead of increment, test whether the counter has gone below the lower bound.
The WHILE Loop
A WHILE loop tests its condition at the top. If false initially, the body never executes.
WHILE sensor_value > 0
PROCESS(sensor_value)
READ(sensor_value)
WEND
Assembly pattern:
WHILE_TOP:
LD A, (SENSOR_VALUE)
CP 0
JP Z, WHILE_END ; exit if value == 0
JP M, WHILE_END ; exit if value < 0
CALL PROCESS
CALL READ_SENSOR
JP WHILE_TOP
WHILE_END:
The condition test at the top means the jump out of the loop must also be at the top. Two instructions are needed: the comparison, then the conditional jump. The unconditional back-jump at the bottom closes the loop.
The DO-WHILE / REPEAT-UNTIL Loop
Tests the condition at the bottom. The body always executes at least once.
DO
READ_SENSOR(value)
LOOP UNTIL value > 0 ' repeat until valid reading
Assembly pattern:
REPEAT_TOP:
CALL READ_SENSOR ; body always executes first
LD A, (VALUE)
CP 0
JP Z, REPEAT_TOP ; loop if value = 0 (still invalid)
JP M, REPEAT_TOP ; loop if value < 0 (still invalid)
; valid reading obtained
The test at the bottom saves one instruction per loop iteration compared to WHILE when the body is always expected to execute at least once. For waiting loops — waiting for a hardware signal, waiting for user input — the DO-WHILE is the natural form.
Infinite Loops
Embedded systems typically run forever:
MAIN_LOOP:
CALL CHECK_SENSORS
CALL UPDATE_OUTPUTS
CALL HANDLE_SERIAL
JP MAIN_LOOP
An unconditional backward jump creates an infinite loop. This is the correct structure for any device that should run as long as it is powered. The loop exits only via reset or power loss.
For a timed infinite loop, add a delay subroutine:
MAIN_LOOP:
CALL READ_SENSORS
CALL OUTPUT_RESULTS
CALL DELAY_100MS ; wait 100 milliseconds
JP MAIN_LOOP
The delay subroutine typically implements a counted busy-wait: loop a calibrated number of times, spending known cycles per iteration, until the desired time has elapsed.
Nested Loops
Loops inside other loops are common for processing two-dimensional data:
; Fill 8x8 grid with zero
LD B, 8 ; 8 rows
LD HL, GRID ; pointer to start of grid
ROW_LOOP:
PUSH BC ; save row counter (DJNZ uses B)
LD B, 8 ; 8 columns per row
COL_LOOP:
LD (HL), 0 ; zero this cell
INC HL ; advance to next cell
DJNZ COL_LOOP ; inner loop: 8 columns
POP BC ; restore row counter
DJNZ ROW_LOOP ; outer loop: 8 rows
The critical detail: the inner loop uses B as its counter, and so does the outer loop (via DJNZ). The outer loop’s B value must be preserved across the inner loop — hence the PUSH BC before the inner loop and POP BC after. Forgetting to save registers used by outer loops is a common nested-loop bug.
Loop Optimization
In time-critical code, inner loops should be as tight as possible. Common optimizations:
Move invariant code outside the loop: If a calculation’s result is the same on every iteration, compute it once before the loop. LD HL, BASE + OFFSET — if BASE and OFFSET do not change, compute the sum once, not inside the loop.
Minimize memory accesses: Registers are faster than memory. Load a value into a register before the loop and store it back after, rather than loading and storing on every iteration.
Use specialized loop instructions: DJNZ, LDIR, CPIR — instructions designed for looping are always faster than equivalent general-purpose instruction sequences. Know which specialized loop instructions your CPU provides.
Loop unrolling: Execute the loop body 2, 4, or 8 times per iteration by replicating the body, reducing the number of loop counter updates and branch instructions. Trades code size for speed.
Count down instead of up: Testing for zero (after decrement) is simpler and often cheaper than testing for a specific value (after increment). DJNZ only exists for counting down.
Loop Termination
Every loop must eventually terminate (or be an intentional infinite loop). Common causes of infinite loops:
Counter never decremented: The loop counter variable is not updated inside the body.
Wrong exit condition: The condition that should end the loop is never met because of a logic error. Test the boundary values.
Exit path missing: A conditional break inside the loop should exit but due to a logic error does not.
Sentinel never found: A loop searching for a terminating value (null byte, end-of-file marker) in a buffer that does not contain it will run past the end of the buffer.
When a loop runs longer than expected, print the loop variable on each iteration. If it prints the same value repeatedly, the variable is not being updated. If it counts correctly but keeps going, the exit condition is wrong.
Practical Notes for Rebuilders
The most frequent loop bug is modifying the loop counter inside the body in an unexpected way. Functions called from inside the loop may also affect registers used as the loop counter. Always check what registers a called subroutine modifies; if it modifies your loop counter register, save and restore it with PUSH/POP.
Use counted loops (FOR, DJNZ) when the iteration count is known. Use condition-tested loops (WHILE, DO-UNTIL) when iteration continues until some event occurs. Mixing the patterns — a FOR loop that also conditionally breaks — is valid but should be documented clearly.
Document the loop invariant — what is always true at the top of the loop — for any non-trivial loop. This discipline catches bugs before they are written and makes the loop’s purpose obvious to anyone reading the code.