Program Counter
Part of Programming Fundamentals
The program counter is the CPU register that holds the address of the next instruction to execute — the pointer that makes sequential and controlled execution possible.
Why This Matters
The program counter (PC) is the most fundamental register in any CPU. It is what makes the CPU a sequential machine: after each instruction, the PC automatically advances to the next, creating the default flow of sequential execution. Jump instructions work by overwriting the PC with a new address. Subroutine calls work by saving the PC and loading a new address. The entire mechanism of control flow — conditionals, loops, function calls — reduces to manipulation of this single register.
Understanding the program counter demystifies how CPUs work at the deepest level. When you understand that JP LOOP_START simply loads the address of LOOP_START into the PC, and that the CPU then fetches from that address on the next cycle, you have a complete mental model of how any program executes. Everything higher-level — IF statements, function calls, recursion, exceptions — is ultimately this same mechanism.
For rebuilders designing or debugging at the hardware interface, the program counter is a constant reference point. A crashed program has jumped to an invalid PC value. A subroutine call stores the PC on the stack. An interrupt saves the PC and loads a new one. Understanding where the PC is and how it got there explains what the system is doing.
The Fetch-Decode-Execute Cycle
Every CPU operation is driven by the fetch-decode-execute cycle:
-
Fetch: Read the byte(s) at the address in the PC. This is the instruction opcode (and possibly its operands, read in subsequent fetch cycles).
-
Decode: Determine from the opcode what operation to perform and what operands are needed.
-
Execute: Perform the operation.
-
Advance PC: Add the instruction length to the PC, so the PC now points to the next instruction.
-
Repeat from step 1.
The automatic advance in step 4 creates sequential execution. Every instruction executes, then the PC moves forward. This is so fundamental that we do not think about it — the program just “runs forward” — but it is an explicit hardware mechanism repeated billions of times per second on modern hardware.
How Jump Instructions Work
An unconditional jump instruction (JP nn, JMP address) works by:
- Fetching the opcode (and the target address from the following bytes)
- Loading the target address into the PC instead of advancing it
On the next cycle, the fetch happens from the jump target address. From the programmer’s perspective: execution continues at the target. From the hardware perspective: the PC was overwritten with a new value.
A conditional jump (JP Z, nn — jump if zero flag is set):
- Fetch and decode
- Test the specified condition (is the zero flag set?)
- If yes: load the target address into PC (taken branch)
- If no: advance PC normally past the jump instruction (not-taken branch, fall-through)
The condition test determines whether the PC gets the target address or simply increments.
A relative jump (JR offset) adds a signed offset to the current PC:
PC = PC + instruction_length + signed_offset
Since instruction_length for JR is 2 (opcode + offset byte), and the offset is signed 8-bit (-128 to +127), relative jumps can reach addresses from 126 bytes before to 129 bytes after the current instruction. This is sufficient for most loop back-jumps and short conditional branches. Relative jumps produce position-independent code — the same bytes work correctly regardless of where the program is loaded in memory.
How Subroutine Calls Work
A CALL instruction (subroutine call) must both change the PC to the subroutine’s address and record where to return afterward. It does this using the stack:
- Decrement the stack pointer by 2 (making room for the return address)
- Write the PC value (pointing to the instruction after the CALL) to the memory at the new stack pointer
- Load the target address into the PC
After these steps, execution continues at the subroutine’s first instruction. The return address is preserved on the stack.
The RETURN instruction reverses this:
- Read the 2-byte return address from the memory at the stack pointer
- Load that address into the PC
- Increment the stack pointer by 2 (restoring the pre-call stack state)
After RETURN, execution continues at the instruction that followed the CALL.
This push/pop of the PC on the stack is what makes nested and recursive calls work correctly. Each CALL pushes a return address; each RETURN pops the most recent one. The stack naturally handles any depth of nesting.
PC in Interrupts
Hardware interrupts are the most complex PC manipulation. When an interrupt signal arrives:
- The CPU finishes the current instruction
- The current PC (and often the flags register) is pushed to the stack — saving the return point
- The PC is loaded with the address of the interrupt service routine (ISR) from a hardware-defined or software-configured interrupt vector table
- The ISR executes
- An RETI (return from interrupt) instruction pops the saved PC (and flags) from the stack, restoring the interrupted program
From the interrupted program’s perspective, execution seamlessly resumes exactly where it was. The interrupt was invisible except for any side effects the ISR may have had on shared data.
This automatic PC save-and-restore is why interrupts can be inserted transparently into a running program. The hardware takes care of the return mechanism; the programmer just writes the ISR.
The PC as Memory Pointer
Since the PC is just a register holding a memory address, anything that makes it point to valid program code causes that code to execute. This has several implications:
Self-modifying code: A program can write new instruction bytes to memory and then jump to them. This is how JIT (just-in-time) compilers work — they generate machine code at runtime and execute it. On early microcomputers, self-modifying code was used for optimization techniques and copy protection schemes.
Computed jumps (dispatch tables): Load the PC with an address read from a table. Used for CASE/SWITCH implementations and interrupt vectors.
; Jump table dispatch: choose which routine to call based on value in A
LD H, 0
LD L, A ; HL = routine index (0-N)
ADD HL, HL ; HL *= 2 (2 bytes per address)
LD DE, JUMP_TABLE
ADD HL, DE ; HL = &JUMP_TABLE[A]
LD E, (HL) ; load low byte of target address
INC HL
LD D, (HL) ; load high byte of target address
PUSH DE ; push target address
RET ; RET pops it into PC — jumps to target
Corrupted stack as PC attack: If an adversary (or a buffer overrun bug) overwrites a return address on the stack with a chosen value, the subsequent RET instruction loads that value into the PC. The program then executes from that address. This is how buffer overflow exploits work — but also how they need to be defended against in critical systems.
Reading the PC
On most 8-bit CPUs, the PC cannot be read directly into a general-purpose register. However, you can determine the current PC value indirectly:
; Get current PC value into HL (Z80)
CALL GET_PC
GET_PC:
POP HL ; pop return address (= address after CALL GET_PC) into HL
PUSH HL ; push it back so RET can use it
RET ; return to caller
; HL now contains the address after the CALL GET_PC instruction
This technique is used in position-independent code that needs to know its load address — for example, a startup routine that copies itself to a final location in RAM and jumps there.
Practical Notes for Rebuilders
When debugging a crash, the first question is “what was the PC when it failed?” A monitor or diagnostic program that displays the PC at crash time (either from hardware registers or from the value on the stack when the crash occurred) is invaluable. Without it, you are guessing where the runaway code came from.
Always verify that your assembler’s ORG directive (which sets the expected load address for address calculations) matches where the program is actually loaded. A mismatch means every jump target and data address is wrong by the difference. The program starts executing correctly, then fails immediately when it first takes a jump.
For subroutine libraries, document whether each routine must be called with a CALL (using the stack for the return address) or with a JP (not expected to return). Mixing these causes stack corruption when the called code does an unexpected RETURN and pops an incorrect value into the PC.