Loading learning content...
Imagine reading a book and being interrupted. You need a bookmark to remember where you left off—otherwise, when you return, you'd have to start from the beginning or waste time searching for your place.
Processes face the same problem. A modern operating system runs hundreds of processes on just a few CPUs, constantly switching between them. Each switch interrupts a process mid-execution—potentially mid-instruction. When that process runs again, it must resume from the exact point where it stopped.
The Program Counter (PC), also called the Instruction Pointer (IP), is that bookmark. It's a CPU register that holds the memory address of the next instruction to execute. Before a process is suspended, the kernel saves its program counter to the PCB. When the process resumes, the kernel restores that address, and execution continues seamlessly.
Without the program counter and its preservation in the PCB, multitasking would be impossible.
By the end of this page, you will understand the Program Counter at the deepest level: its role in the CPU instruction cycle, its architecture-specific implementations, how it's saved and restored during context switches, the relationship between PC and call stacks, and how PC corruption leads to crashes. This knowledge is fundamental to understanding low-level execution and debugging.
The Program Counter (PC) is a special-purpose CPU register that contains the memory address of the next instruction to be fetched from memory and executed. It is the CPU's way of tracking progress through a program.
Formal Definition:
The Program Counter is a processor register that indicates the address in memory of the next instruction in the current program's instruction stream.
Different Names, Same Concept:
| Architecture | Register Name | Width |
|---|---|---|
| x86 (32-bit) | EIP (Extended Instruction Pointer) | 32 bits |
| x86-64 | RIP (64-bit Instruction Pointer) | 64 bits |
| ARM (32-bit) | PC (R15) | 32 bits |
| ARM64 (AArch64) | PC | 64 bits |
| MIPS | PC | 32/64 bits |
| RISC-V | pc | 32/64 bits |
The Program Counter is a direct consequence of the stored-program concept. In the von Neumann architecture, instructions are data stored in memory alongside program data. The PC points to these instructions sequentially, enabling the CPU to execute arbitrary programs without hardware changes.
What the PC Contains:
The PC holds a virtual memory address in user processes—an address in the process's address space that the CPU will translate to a physical address via the page tables. This address points to:
mov rax, rbx)The PC doesn't contain the instruction itself—it contains the address of the instruction. The CPU must fetch the instruction from memory at that address before it can execute.
The program counter is central to the instruction cycle—the fundamental sequence of operations by which a CPU executes instructions. Understanding this cycle reveals exactly how the PC is used and modified.
PC Modification During Execution:
For most instructions, the PC simply increments by the instruction size. But several instruction types modify the PC in special ways:
| Instruction Type | PC Behavior | Example |
|---|---|---|
| Sequential | PC += instruction_size | add rax, rbx |
| Jump (unconditional) | PC = target_address | jmp 0x401050 |
| Conditional branch | PC = taken ? target : PC+size | jz error_handler |
| Call | Push PC, then PC = target | call printf |
| Return | Pop saved PC into PC | ret |
| Interrupt | Save PC, then PC = ISR address | Hardware interrupt |
The Critical Insight:
The PC represents the single thread of execution. At any moment, the CPU is at one specific point in the instruction stream. This is why saving and restoring the PC is sufficient to pause and resume a program—the entire execution state boils down to "which instruction comes next."
Modern CPUs use pipelining, executing multiple instructions simultaneously at different stages. The 'architectural PC' (what the programmer sees) still represents a single instruction, but internally the CPU may have dozens of instructions in flight. Context switches and interrupts must handle this complexity, often by 'draining' the pipeline.
When a process is preempted or blocked, the kernel must save its program counter to the PCB. When the process resumes, the kernel restores it. This save/restore mechanism is the essence of context switching.
What Gets Saved:
The PC at the moment of preemption becomes the "resume address." But there's a subtlety: the exact PC value depends on when in the instruction cycle the interrupt occurred.
Precise Interrupts: The saved PC points to the next instruction after the one that completed. This is the ideal behavior—the process resumes at a clean instruction boundary.
Imprecise Interrupts: On some older architectures, the PC might point mid-instruction. The processor must either complete or roll back the interrupted instruction. Modern CPUs guarantee precise interrupts through techniques like retirement queues.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Conceptual view of PC save/restore in context switching // Architecture-specific context structure (simplified x86-64)struct cpu_context { uint64_t rip; // Program Counter (Instruction Pointer) uint64_t rsp; // Stack Pointer uint64_t rflags; // Status flags uint64_t rax, rbx, rcx, rdx; uint64_t rsi, rdi, rbp; uint64_t r8, r9, r10, r11, r12, r13, r14, r15; // ... floating point state, etc.}; // In the PCBstruct task_struct { // ... other fields struct cpu_context context; // Saved CPU state including PC // ... other fields}; // Context switch: save old, load new// This is typically written in assembly for atomicity and precisionvoid context_switch(struct task_struct *prev, struct task_struct *next) { // Save current registers to prev's PCB // The RIP (PC) value saved is the return address from this function asm volatile( "movq %%rsp, %0" // Save stack pointer "movq $1f, %1" // Save return address as PC (label 1:) : "=m"(prev->context.rsp), "=m"(prev->context.rip) ); // Save other registers... save_general_registers(&prev->context); save_fpu_state(&prev->context); // Switch to next process's address space switch_mm(next->mm); // Restore next's registers restore_general_registers(&next->context); restore_fpu_state(&next->context); // Jump to next's saved PC asm volatile( "movq %0, %%rsp" // Restore stack pointer "jmpq *%1" // Jump to saved PC : : "m"(next->context.rsp), "m"(next->context.rip) ); // Never reaches here for 'prev' - execution continues at label "1:" asm volatile("1:"); // 'prev' resumes here when scheduled again}The Elegant Trick:
Notice the technique above: the "saved PC" for the outgoing process is actually the address within the context_switch function itself. When that process is later resumed, it returns from context_switch and continues executing as if it had just returned from a normal function call.
This is more practical than saving the PC of the interrupted user-space instruction directly, because the context switch itself happens in kernel code. The actual user-space PC is on the kernel stack, pushed there by the interrupt that triggered the switch.
The PC alone doesn't tell the full story. A process might be deep in nested function calls. The stack contains return addresses for each level of nesting. Together, the PC (where we are now) and the stack (how we got here and where to return) define the complete control flow state.
The Program Counter tracks the current instruction. But programs have function calls, and each call creates a need to remember where to return. This is where the stack becomes essential—it's the memory of the PC's history.
How Function Calls Work:
When a call instruction executes:
This creates a call stack—a chain of return addresses representing the current function call hierarchy.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// C code demonstrating call stackvoid function_c() { // PC is here (in function_c) // Stack contains: // - Return addr to function_b (0x401030) // - Return addr to function_a (0x401020) // - Return addr to main (0x401010) int x = 42; // Execute some code // When ret executes, PC -> 0x401030 (back to b)} void function_b() { // Stack: return to a, return to main function_c(); // Push return addr, PC -> function_c // 0x401030: After call returns, continue here} void function_a() { // Stack: return to main function_b(); // Push return addr, PC -> function_b // 0x401020: After call returns, continue here} int main() { function_a(); // Push return addr, PC -> function_a // 0x401010: After call returns, continue here return 0;} /*Stack during function_c execution: High addresses+------------------+| Return to main | 0x401010+------------------+| Return to func_a | 0x401020+------------------+| Return to func_b | 0x401030+------------------+| Local vars of c |+------------------+ <- RSP (Stack Pointer)Low addresses Each 'ret' pops one address and jumps there.*/Buffer overflow attacks often target return addresses on the stack. By overwriting a return address, attackers can redirect the PC to malicious code. Modern defenses like stack canaries, ASLR, and DEP/NX bits protect against this by detecting stack corruption or preventing execution of injected code.
Interrupts and exceptions are mechanisms that divert the CPU from its current instruction stream. In both cases, the program counter must be saved so the original code can resume later (or, for exceptions, so the handler knows where the problem occurred).
Interrupt Handling and PC:
When an interrupt occurs (e.g., timer, disk, network):
iret (interrupt return)Example: Timer Interrupt Triggering Context Switch:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Simplified timer interrupt handler that may trigger context switch // Timer interrupt entry point (from assembly stub)void timer_interrupt_handler(struct pt_regs *regs) { // regs->rip contains the interrupted code's PC // regs->rsp contains the interrupted code's stack pointer // Acknowledge interrupt acknowledge_lapic_timer(); // Update timing statistics update_process_times(current); // Check if current process's time slice expired if (current->time_slice-- <= 0) { // Time to switch processes // regs already contains saved PC/SP - they're on the kernel stack // These will be restored when we return from this interrupt // BUT, we can change WHICH process we return to! // Select next process struct task_struct *next = pick_next_task(); if (next != current) { // Save current's kernel context and switch to next's // When 'current' runs again later, it will return from HERE // and eventually return from this interrupt to user space context_switch(current, next); } } // Return from interrupt // The CPU will restore PC (rip) and flags from the stack // If we switched, 'next's saved state is now on the kernel stack // So we return to next's interrupted code, not current's!} // The assembly stub that calls the C handler:// timer_interrupt_stub:// push all registers ; save user state// mov rdi, rsp ; pointer to saved regs as argument// call timer_interrupt_handler// pop all registers ; restore (possibly different process's) state// iretq ; return from interrupt// ; restores RIP and RFLAGS from stackNotice the elegance: user code never knows it was interrupted. From the process's perspective, one instruction followed another. The entire interrupt, handler execution, and potential context switch happened invisibly. The PC was saved, potentially replaced with another process's PC, and the CPU seamlessly continued—now running different code.
The program counter is central to debugging. When a program crashes or behaves unexpectedly, the PC tells you exactly where the problem occurred. Debuggers manipulate the PC to implement stepping, breakpoints, and inspection.
When a program crashes, the PC points to the faulting instruction. Core dumps and crash reports include this information.
1234567891011121314151617181920212223242526272829303132333435363738
# Analyzing a crash using PC information # When segfault occurs, kernel logs the PC:dmesg | tail -5# program_name[12345]: segfault at 0 ip 0x00005555555551a0 # sp 0x00007fffffffe3b0 error 4 # in program_name[555555555000+1000] # ip = Instruction Pointer (PC) at time of fault# sp = Stack Pointer# error = page fault error code # Using GDB to analyze the crashgdb ./program core(gdb) info registers riprip 0x5555555551a0 0x5555555551a0 <main+32> (gdb) x/i $rip=> 0x5555555551a0 <main+32>: mov eax,DWORD PTR [rax] # The crash happened trying to dereference a pointer# rax contains the address being accessed:(gdb) info registers raxrax 0x0 0 # Null pointer dereference! PC = 0x5555555551a0, dereferencing NULL # Get source line from PC(gdb) list *0x5555555551a00x5555555551a0 is in main (program.c:10).5 int *ptr = NULL;6 ...7 8 int main() {9 int *ptr = get_pointer();10 int value = *ptr; // <-- CRASH HERE11 return value;12 }Converting a PC address to a source file and line number requires debug information, typically stored in DWARF format. The compiler embeds a mapping from instruction addresses to source lines. Without debug info, you see only raw addresses, not function names or line numbers.
While the concept of a program counter is universal, the implementation details vary by architecture. Let's examine the specifics for major CPU families.
x86-64 Instruction Pointer (RIP)
mov; use lea rax, [rip] or call/pop trickRIP-Relative Addressing:
; Access global variable without knowing its absolute address
mov rax, [rip + global_var] ; Address = RIP + offset to global_var
This is essential for position-independent code (PIC) used in shared libraries.
12345678910111213141516171819202122232425262728
; x86-64 Program Counter Examples ; Get current PC into RAX (call/pop trick)call .get_rip.get_rip:pop rax ; RAX now contains address of .get_rip ; Or using LEA (modern way)lea rax, [rip] ; RAX = address of this instruction + instruction size ; Jump modifies PCjmp target ; PC = target address (unconditional)jz target ; PC = target if ZF==1, else PC += instruction_size ; Call pushes return address (next PC) and jumpscall function ; push RIP+5 (size of call), then PC = function ; Ret pops into PCret ; pop into RIP, continue at popped address ; Interrupt handling saves PC on stack; When INT 3 (breakpoint) or interrupt occurs:; 1. Push SS (if privilege change); 2. Push RSP; 3. Push RFLAGS; 4. Push CS; 5. Push RIP <-- This is the saved PC; 6. RIP = IDT[vector].handler_addressWe've explored the Program Counter from its fundamental role in the instruction cycle to its critical importance for multitasking. Let's consolidate the key insights:
What's Next:
The Program Counter is just one part of the CPU context saved in the PCB. The next page examines CPU Registers—the complete set of general-purpose registers, status flags, and special-purpose registers that must be saved and restored for seamless context switching.
You now understand the Program Counter at the deepest level: how it drives instruction execution, how it's preserved during context switches, how it interacts with the call stack, and how it's used in debugging. This knowledge is foundational for understanding low-level systems programming and operating system internals.