Stepping Through Code
Part of Programming Fundamentals
Stepping through code means tracing execution instruction by instruction, tracking register and memory state at each step — the most reliable method for finding and understanding bugs.
Why This Matters
There is a gap between the program you think you wrote and the program that actually executes. The program you think you wrote has no bugs — in your mental model, every instruction does what you intended. The program that actually executes may do something subtly different. Finding that difference requires watching what the CPU actually does, not what you imagined it would do.
Stepping through code bridges this gap. By executing one instruction at a time and examining the state after each step, you can observe exactly what the program does. The first step where actual behavior differs from intended behavior is the location of the bug.
On modern systems with source-level debuggers, stepping is automatic — you set a breakpoint, click “step,” and see variable values update in a window. On primitive hardware without these tools, stepping is manual: you calculate what each instruction should do, check what actually happened, and compare. The principle is identical; only the tooling differs.
For rebuilders working on bare hardware or early computing systems, manual stepping is both a fundamental skill and often the only debugging option available.
Mental Simulation (Paper Stepping)
The most universally available form of stepping requires nothing but a pencil, paper, and the program listing. Create a state table with columns for the program counter (PC), each relevant register, and relevant memory locations. Trace execution line by line:
Start with the initial state:
PC | A | B | C | HL | (0x2000)
------+------+------+------+------+----------
0x100 | 00 | 00 | 00 | 0000 | 00
Execute the first instruction mentally: LD A, 0x0F (at address 0x100, opcode 0x3E, operand 0x0F, total 2 bytes):
PC | A | B | C | HL | (0x2000)
------+------+------+------+------+----------
0x100 | 00 | 00 | 00 | 0000 | 00 ← before
0x102 | 0F | 00 | 00 | 0000 | 00 ← after (PC advanced 2, A loaded)
Continue, instruction by instruction, updating each column. After any instruction you suspect is wrong, compare the actual effect with what you intended.
This method is slow (perhaps 20-30 instructions per hour for careful work) but reliable. For short routines of 10-50 instructions, paper stepping often finds bugs in less time than more elaborate methods because it requires you to understand every step precisely.
Hardware Single-Step
Many CPUs have a single-step mode: execute exactly one instruction and then stop, waiting for the programmer’s command to proceed. On the Z80, asserting the HALT pin after each instruction achieves this. On the 6502, the single-step function is sometimes available through development system hardware.
A simple single-step monitor:
- CPU executes one instruction
- Hardware generates a break condition (interrupt, halt, or debug pin)
- Monitor code runs: saves all registers, displays PC and register values, waits for user command
- User examines state, optionally modifies registers or memory
- User commands “step”: monitor restores registers and resumes execution
Building such a monitor for a new CPU is a valuable early investment. The ability to single-step through code on real hardware, seeing real register and memory values, dramatically accelerates debugging of timing-sensitive and hardware-interaction bugs that cannot be reproduced in paper simulation.
Software Breakpoints
Without hardware single-step, you can use software breakpoints: replace an instruction with a special instruction that calls the monitor, do your examination, then restore the original instruction and continue.
On Z80, the RST (restart) instructions are 1-byte subroutine calls to fixed addresses (RST 0x00 calls 0x0000, RST 0x08 calls 0x0008, etc.). To set a breakpoint at address X:
- Save the byte at address X
- Write 0xCF (RST 8) or another RST opcode to address X
- When execution reaches X, the RST fires and calls your monitor at 0x0008
- The monitor shows the PC (which will be X+1, after the RST), registers, and memory
- To continue: restore the original byte at X, decrement PC by 1, and resume
This software breakpoint technique works on any CPU that has a breakpoint-style instruction (INT, BRK, RST, SWI). The monitor code needs only to occupy the RST handler location in ROM or RAM.
Trace Logging
For programs where single-step is too slow (real-time systems, interrupt handlers) but you need to understand execution flow, trace logging inserts print statements (or buffer entries) at key points to leave a record of what happened.
At each significant step, write a short record to a log buffer:
; Log entry: write PC and register A to trace buffer
LOG_TRACE:
LD HL, (TRACE_PTR) ; pointer to next empty log entry
LD (HL), A ; store register A
INC HL
LD (HL), PCL ; store low byte of current PC (approximation)
INC HL
LD (TRACE_PTR), HL ; advance pointer
RET
After the program runs (or crashes), dump the trace buffer. Reading the sequence of log entries tells you which code paths executed and what values were in key registers at each point.
This technique is invaluable for interrupt-driven code where single-stepping disrupts the timing that causes the bug. The trace log captures behavior at full speed.
Bisection Debugging
When you know the program starts correctly and fails at some later point, but you do not know where the failure begins, use bisection: add a check in the middle of the suspect code region.
Is the state correct at the midpoint? If yes, the bug is in the second half. If no, the bug is in the first half. Repeat, halving the search space each time.
With 1,000 lines of code and a reproducible failure, bisection finds the bug in about 10 steps (log₂(1000)). Each step adds one check, runs the program, and examines one result. This is dramatically faster than linear search even with primitive tools.
Register Invariants
Experienced assembly programmers maintain mental invariants about what each register should contain at key points in the code. Documenting these explicitly — in comments — lets you step through and verify:
; At this point: HL = pointer into buffer, B = bytes remaining, A = last read byte
PROCESS_BYTE:
; assert: B > 0 (we only enter if bytes remain)
CP 0xFF ; check for end marker
JP Z, PROCESS_DONE
CALL HANDLE_BYTE ; A preserved, HL and B may change
; assert: HL advanced by appropriate amount, B decremented
DJNZ PROCESS_BYTE
PROCESS_DONE:
When stepping through and you find a register does not have its stated invariant value, you have found either a wrong assumption in the invariant or a bug that violated it. Either way, you have learned something precise.
Comparing Expected vs. Actual
The most direct stepping technique: calculate what each instruction should produce (using the CPU reference and the current state), then verify. Any instruction where actual != expected is either buggy code or wrong expectations.
Wrong expectations are themselves valuable: they mean you misunderstood how the instruction works (a reference card error, a subtlety about flag behavior, a misread of the addressing mode) or you misunderstood the data (the variable did not have the value you assumed).
Both types of discrepancy are bugs — either in the code or in your model of it — and both must be resolved before the program is correct.
Practical Notes for Rebuilders
Keep a notebook dedicated to debugging sessions. Write down: what the program should do, what it actually does, which instructions you have stepped through and their results, and your current hypothesis. The act of writing clarifies thinking and prevents re-checking things you already verified.
For beginners, paper-step through every non-trivial subroutine before running it on hardware. The time spent prevents the much longer time spent debugging mysterious failures later. As experience accumulates, the mental simulation becomes faster and you can skip the most obvious instructions.
Never assume you know what a register contains without verification, especially after calling a subroutine. Check the documentation for what registers each routine modifies. When in doubt, dump the registers at the entry and exit of suspect routines.