How the NanoCore CPU Implements the Fetch-Decode-Execute Cycle
The NanoCore CPU implements a classic 8-bit fetch-decode-execute pipeline where each clock tick reads three bytes from memory, decodes the opcode into an Op enum with Operands, executes the instruction via pattern matching, and updates the program counter unless overridden by control flow.
The afaanbilal/nanocore repository provides a Rust-based emulator that models a complete 8-bit CPU architecture. Understanding its fetch-decode-execute cycle requires examining how the NanoCore struct orchestrates instruction processing through memory access, opcode translation, and state mutation.
The Cycle Orchestration in src/nanocore.rs
The entry point for every clock tick is the cycle() method, which coordinates the three pipeline stages and manages program counter advancement.
pub fn cycle(&mut self) -> crate::EmulatorResult<()> {
let (op, operands) = self.fetch_decode(); // ← FETCH & DECODE
let pc_override = self.execute(op, operands)?; // ← EXECUTE
// ...
if !pc_override {
self.cpu.pc = self.cpu.pc.wrapping_add(op.instruction_len());
}
self.cycle += 1;
Ok(())
}
This implementation in src/nanocore.rs (lines 71-104) demonstrates the classic pipeline: fetch and decode happen atomically in fetch_decode(), while execute processes the instruction and returns a boolean indicating whether the instruction manually modified the program counter.
Stage 1: Fetch and Decode
The fetch_decode() method handles both memory retrieval and opcode interpretation in a single operation.
Reading Bytes from Memory
The fetch stage reads three consecutive bytes starting at the current program counter (PC):
let opcode = self.cpu.memory[self.cpu.pc as usize];
let byte_2 = self.cpu.memory[self.cpu.pc.wrapping_add(1) as usize];
let byte_3 = self.cpu.memory[self.cpu.pc.wrapping_add(2) as usize];
Decoding to Operation Enums
The decode stage translates the raw u8 opcode into type-safe enums. The Op enum represents the operation type, while Operands captures the instruction format:
let op: Op = opcode.into();
let operands = match op {
Op::HLT | Op::NOP | Op::RET => Operands::None,
Op::LDI | Op::ADDI | ... => Operands::RegImm(byte_2, byte_3),
Op::LDA | Op::STORE => Operands::RegAddr(byte_2, byte_3),
Op::PUSH | Op::POP | ... => Operands::Reg(byte_2),
Op::LDR | Op::MOV | ... => Operands::RegReg((byte_2 >> 4) & 0x0F, byte_2 & 0x0F),
Op::JMP | Op::JZ | Op::JNZ | Op::CALL => Operands::Addr(byte_2),
};
This logic resides at src/nanocore.rs#L40-L53 and supports multiple addressing modes including register-immediate, register-address, and single-register formats.
Stage 2: Execute
The execute() method contains a large pattern match on the Op enum, with each arm implementing specific semantics for arithmetic, memory access, or control flow.
Arithmetic Operations
Arithmetic instructions read register values, compute results, and update status flags:
Op::ADD => {
let Operands::RegReg(rd, rs) = operands else { ... };
let v1 = self.cpu.registers[rd as usize];
let v2 = self.cpu.registers[rs as usize];
let (result, carry) = self.execute_arithmetic(op, v1, v2)?;
self.cpu.registers[rd as usize] = result;
self.cpu.update_zn_flags(result);
if carry { self.cpu.set_flag(CPU::FLAG_C); } else { self.cpu.clear_flag(CPU::FLAG_C); }
}
Control Flow Handling
Branching instructions override the default PC advancement by returning true:
Op::JMP => {
let Operands::Addr(a) = operands else { ... };
self.cpu.pc = a; // PC set directly
pc_override = true; // Prevent auto-increment
}
The full execution logic spans src/nanocore.rs#L84-L188 and handles all 8-bit operations including jumps, calls, returns, and halts.
CPU State Architecture
The underlying hardware state resides in src/cpu.rs, defining the 8-bit architecture's registers and memory:
pub struct CPU {
pub registers: [u8; 16],
pub pc: u8,
pub sp: u8,
pub memory: [u8; 256],
pub flags: u8,
pub is_halted: bool,
}
This struct maintains sixteen general-purpose registers, an 8-bit program counter addressing 256 bytes of memory, a stack pointer, and flag bits. Helper methods for flag manipulation appear in src/cpu.rs#L21-L49.
Practical Example: Running a Program
The following example demonstrates three complete fetch-decode-execute cycles:
use nanocore::nanocore::NanoCore;
let program = vec![
0x02, 0x00, 0x05, // LDI R0, 5
0x02, 0x01, 0x03, // LDI R1, 3
0x08, 0x00, 0x01, // ADD R0, R1
0x00, // HLT
];
let mut emu = NanoCore::new();
emu.load_program(&program, 0x00).unwrap();
emu.run().unwrap();
assert_eq!(emu.cpu.registers[0], 8); // 5 + 3 = 8
For debugging, you can step through individual cycles:
let mut emu = NanoCore::new();
emu.load_program(&[0x02, 0x00, 0x0A, 0x00], 0x00).unwrap();
emu.cycle().unwrap(); // Fetch, decode, execute LDI
assert_eq!(emu.cpu.registers[0], 10);
emu.cycle().unwrap(); // Execute HLT
assert!(emu.cpu.is_halted);
Summary
- Fetch: The
fetch_decode()method reads three bytes frommemory[PC]insrc/nanocore.rs. - Decode: Raw opcodes convert to the
Openum andOperandsvariants based on instruction format. - Execute: A comprehensive
matchstatement inexecute()performs operations, updates registers, and sets flags. - PC Management: Most instructions advance the program counter by their length, while control-flow instructions set
pc_overrideto handle jumps and calls manually. - State: The
CPUstruct insrc/cpu.rsholds all registers, memory, and status flags for the 8-bit architecture.
Frequently Asked Questions
How does NanoCore handle instruction operands of different sizes?
NanoCore reads three bytes for every instruction regardless of actual length. The Operands enum variant selected during decode determines which bytes are significant—Operands::None ignores both extra bytes, while Operands::RegImm uses both as register index and immediate value.
What happens when the program counter reaches the end of memory?
The PC is an 8-bit value (u8) that wraps around using wrapping_add() for auto-increment operations. Since memory is exactly 256 bytes, the PC naturally wraps from 0xFF to 0x00, creating a circular address space unless control-flow instructions redirect execution.
How does the emulator distinguish between arithmetic and control-flow instructions?
Both types execute within the same match arms in src/nanocore.rs, but control-flow instructions (like JMP, CALL, RET) return true to set pc_override, preventing the automatic PC increment in the cycle() method. Arithmetic instructions return false, allowing the standard PC advancement by op.instruction_len().
Where is the stack implemented in the NanoCore CPU?
The stack uses the sp field in the CPU struct defined in src/cpu.rs. Stack operations like PUSH and POP manipulate this 8-bit pointer and interact with the same 256-byte memory array used for program storage, creating a unified memory architecture typical of 8-bit microprocessors.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →