How NanoCore Handles Its 256-Byte Memory Constraint and Addressing Architecture
NanoCore enforces its 256-byte memory limit by storing all addresses as u8 values and declaring RAM as a fixed [u8; 256] array, using wrapping arithmetic to handle overflow and explicit bounds checking for stack and program loading operations.
NanoCore is a minimal 8-bit CPU emulator implemented in Rust that operates within a deliberately constrained 256-byte memory space. Every addressable component—from the program counter to register indices—uses 8-bit unsigned integers, ensuring all memory references naturally wrap at the 0xFF boundary. This design choice simplifies the architecture while enforcing strict memory safety through Rust's type system and explicit bounds checks.
The Foundation: Fixed 256-Byte Memory Array
The memory constraint originates in the core CPU structure defined in src/cpu.rs. The CPU struct declares a fixed-size array that physically cannot exceed 256 bytes, alongside 8-bit address registers:
/// src/cpu.rs
pub struct CPU {
// ...
pub memory: [u8; 256], // 256-byte RAM
pub pc: u8, // 8-bit program counter
pub sp: u8, // 8-bit stack pointer
// ...
}
Because pc and sp are u8 types, they can only represent values 0x00 through 0xFF. This hardware-enforced limit means that even if arithmetic overflows, the value wraps naturally within the valid address space.
Program Loading with Overflow Protection
Before execution begins, NanoCore validates that the program fits within the 256-byte constraint. The load_program method in src/nanocore.rs checks that the combined size of the program and its start address does not exceed the memory array bounds:
/// src/nanocore.rs
pub fn load_program(&mut self, program: &[u8], start_address: u8) -> crate::EmulatorResult<()> {
if (start_address as usize + program.len()) > 256 {
return Err(crate::EmulatorError::ProgramTooLarge { /* ... */ });
}
for (i, &byte) in program.iter().enumerate() {
self.cpu.memory[start_address.wrapping_add(i as u8) as usize] = byte;
}
self.cpu.pc = start_address;
Ok(())
}
The use of wrapping_add ensures that if a program loads near the end of memory, the index rolls over to 0x00 rather than causing a panic or out-of-bounds access.
Fetch-Decode-Execute Cycle and PC Wrapping
During each instruction cycle, NanoCore reads three consecutive bytes from memory using the program counter. Because the PC is a u8, all increment operations use wrapping arithmetic to maintain the 256-byte boundary:
/// src/nanocore.rs
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];
// ...
if !pc_override {
self.cpu.pc = self.cpu.pc.wrapping_add(op.instruction_len());
}
This design guarantees that execution flows seamlessly from address 0xFF back to 0x00, creating a circular memory space without additional boundary checks during the fetch phase.
Stack Operations Within Bounds
The stack pointer operates within a constrained range defined by STACK_MIN (typically 0xEA) and STACK_MAX (0xFF). Push and pop operations explicitly validate these bounds before accessing memory:
/// src/nanocore.rs – PUSH operation
if self.cpu.sp == CPU::STACK_MIN {
return Err(crate::EmulatorError::StackOverflow { sp: self.cpu.sp });
}
self.cpu.memory[self.cpu.sp as usize] = value;
self.cpu.sp = self.cpu.sp.wrapping_sub(1);
/// src/nanocore.rs – POP operation
if self.cpu.sp == CPU::STACK_MAX {
return Err(crate::EmulatorError::StackUnderflow { sp: self.cpu.sp });
}
self.cpu.sp = self.cpu.sp.wrapping_add(1);
let value = self.cpu.memory[self.cpu.sp as usize];
These checks prevent stack corruption while the wrapping_add and wrapping_sub operations ensure the SP remains a valid u8 value.
Register-Indirect Addressing
Instructions such as LDR, STR, LDA, and STORE use registers to hold memory addresses. Since registers are defined as u8 values, they inherently cannot request addresses outside the 256-byte range:
/// src/nanocore.rs – LDR example
let addr = self.cpu.registers[rs as usize];
let value = self.cpu.memory[addr as usize];
self.cpu.registers[rd as usize] = value;
The cast from u8 to usize for array indexing is always safe because the maximum value (255) fits comfortably within the 256-element array bounds.
Error Handling for Memory Violations
When operations violate the 256-byte constraint, NanoCore returns specific errors defined in src/error.rs:
/// src/error.rs
/// The 8-bit architecture limits total addressable memory to 256 bytes.
ProgramTooLarge { size: usize, start: u8, max: usize },
Additional error variants handle stack violations and invalid memory accesses, providing clear diagnostics when the emulator encounters boundary conditions.
Summary
NanoCore maintains its strict 256-byte memory constraint through a combination of Rust type safety and explicit architectural decisions:
- Fixed-size array: The
memoryfield inCPUis declared as[u8; 256], physically preventing expansion. - 8-bit addressing: All pointers (
pc,sp, registers) useu8types, naturally limiting values to0x00..=0xFF. - Wrapping arithmetic: Operations use
wrapping_addandwrapping_subto handle overflow gracefully without panics. - Bounds validation: Program loading and stack operations explicitly check limits before accessing memory.
- Type-safe indexing: Casts from
u8tousizefor array access are always safe given the 256-byte constraint.
Frequently Asked Questions
How does NanoCore prevent programs from exceeding the 256-byte memory limit?
NanoCore validates program size during the loading phase in src/nanocore.rs. The load_program method checks if start_address + program.len() exceeds 256 bytes, returning EmulatorError::ProgramTooLarge if the program would overflow the fixed [u8; 256] array.
Why does NanoCore use wrapping arithmetic for the program counter?
The program counter (pc) is defined as a u8, which naturally overflows at 255. NanoCore uses wrapping_add when incrementing the PC to ensure that execution flows seamlessly from address 0xFF back to 0x00, creating a circular memory space without requiring additional boundary checks or risking panic conditions.
What happens when the stack pointer reaches its minimum or maximum bounds?
NanoCore defines STACK_MIN (typically 0xEA) and STACK_MAX (0xFF) in the CPU struct. When executing PUSH, the emulator checks if sp == STACK_MIN and returns StackOverflow if true. For POP, it checks sp == STACK_MAX and returns StackUnderflow if violated, preventing corruption outside the designated stack region.
How does register-indirect addressing stay within the 256-byte limit?
Registers in NanoCore are u8 values, meaning they can only hold addresses between 0x00 and 0xFF. When instructions like LDR or STR use a register as a memory pointer, the value is cast to usize for array indexing. Since the maximum register value (255) is always less than the memory array length (256), this operation is guaranteed safe without additional bounds checking.
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 →