NanoCore is a meticulously crafted emulator for a custom 8-bit CPU. Designed with extreme minimalism in mind, this CPU operates within a strict 256-byte memory space, with all registers, the Program Counter (PC), and the Stack Pointer (SP) being 8-bit.
This project serves as an educational exercise in understanding the fundamental principles of computer architecture, low-level instruction set design, memory management under severe constraints, and assembly language programming.
Website: nanocore.afaan.dev ย |ย DeepWiki: AfaanBilal/NanoCore
- True 8-bit Architecture: All general-purpose registers (R0โR15), Program Counter (PC), and Stack Pointer (SP) are 8-bit.
- 256-byte Memory: The entire addressable memory space is limited to 256 bytes (
0x00to0xFF). - Variable-Length Instruction Set: 1-byte, 2-byte, and 3-byte instructions to maximize opcode efficiency within the limited address space.
- Modular Design: CPU cycle broken down into distinct Fetch, Decode, and Execute phases.
- Inbuilt Two-Pass Assembler: Write NanoCore Assembly (
.nca) instead of raw machine code. - Terminal User Interface: Fully functional TUI with breakpoints for interactive debugging.
- Typed Error Handling: Stack overflow/underflow, division by zero, invalid operands, and invalid opcodes all surface as structured Rust errors.
Download pre-built binaries for your platform from the GitHub Releases page.
cargo install nanocoreRequires Rust (stable).
git clone https://github.com/AfaanBilal/NanoCore.git
cd NanoCore
cargo build --release# Run a pre-assembled binary
cargo run -- programs/test.ncb
# Auto-assemble and run a .nca source file
cargo run -- programs/fib.nca
# Print CPU state after each cycle
cargo run -- programs/test.nca -s
# Print each instruction as it executes
cargo run -- programs/test.nca -icargo run --bin nca -- -i example.nca -o example.ncbcargo run --bin tui -- programs/counter.ncacargo test| Component | Details |
|---|---|
| Registers | R0โR15, all 8-bit |
| Program Counter | 8-bit (0x00โ0xFF) |
| Stack Pointer | 8-bit (stack: 0xEBโ0xFF; 0xEA is the overflow guard) |
| Flags | Zero (Z), Carry (C), Negative (N) |
| Memory | 256 bytes total |
| Stack size | 21 bytes (0xEBโ0xFF) |
| Max cycles | 1024 per run |
All 256 bytes are one flat, unprotected address space. The layout below is convention โ nothing enforces it.
| Range | Size | Purpose |
|---|---|---|
0x00โ0xA9 |
170 bytes | Program and data โ programs load at 0x00 by default |
0xAAโ0xE9 |
64 bytes | Screen โ the TUI debugger renders this as an 8ร8 grid |
0xEA |
1 byte | Stack overflow guard โ never written |
0xEBโ0xFF |
21 bytes | Stack |
The stack grows downward from the top of memory. SP starts at 0xFF and always points at the next free slot, so the value on top is at memory[SP + 1] and an empty stack is SP == 0xFF.
| Instruction | Effect |
|---|---|
PUSH Rd |
memory[SP] = Rd, then SP -= 1 |
POP Rd |
SP += 1, then Rd = memory[SP] |
CALL/CALLR |
Pushes the return address (PC + 2), then jumps |
RET |
Pops into PC |
SP = 0xFF ; empty
PUSH R0 ; memory[0xFF] = R0, SP = 0xFE
PUSH R1 ; memory[0xFE] = R1, SP = 0xFD
POP R2 ; SP = 0xFE, R2 = memory[0xFE] (the old R1)
PUSH on a full stack (SP == 0xEA) raises StackOverflow; POP on an empty one (SP == 0xFF) raises StackUnderflow. Both are checked before the access, so 0xEA is never written โ hence the guard byte.
Important
Return addresses and pushed data share the same 21 bytes. Nesting CALLs 21 deep exhausts the stack on its own, and an unbalanced PUSH inside a subroutine leaves RET popping a data byte and jumping to it.
Warning
Nothing protects the stack region. load_program only checks that the program fits in 256 bytes, so a program longer than 235 bytes overwrites the stack (and one over 170 bytes overwrites the screen). STORE/STR into 0xEBโ0xFF corrupts it silently โ there is no fault.
Tip
See programs/ for example programs and compiled binaries.
NanoCore features a small but complete instruction set across 7 categories.
- 1-byte: Opcode only.
- 2-byte: Opcode + one 8-bit operand (register or address).
- 3-byte: Opcode + two 8-bit operands (register + immediate or address).
| Opcode | Bytes | Mnemonic | Description |
|---|---|---|---|
0x00 |
1 | HLT |
Halt execution |
0x01 |
1 | NOP |
No operation |
0x02 |
3 | LDI Rd val |
Load immediate val into Rd |
0x03 |
3 | LDA Rd addr |
Load from memory address into Rd |
0x04 |
2 | LDR Rd Rs |
Load from address in Rs into Rd |
0x05 |
2 | MOV Rd Rs |
Copy Rs into Rd |
0x06 |
3 | STORE addr Rd |
Store Rd into memory address |
0x07 |
2 | PUSH Rd |
Push Rd onto stack |
0x08 |
2 | POP Rd |
Pop top of stack into Rd |
0x09 |
2 | ADD Rd Rs |
Rd = Rd + Rs |
0x0A |
3 | ADDI Rd val |
Rd = Rd + val |
0x0B |
2 | SUB Rd Rs |
Rd = Rd - Rs |
0x0C |
3 | SUBI Rd val |
Rd = Rd - val |
0x0D |
2 | INC Rd |
Rd = Rd + 1 |
0x0E |
2 | DEC Rd |
Rd = Rd - 1 |
0x0F |
2 | AND Rd Rs |
Rd = Rd & Rs |
0x10 |
2 | OR Rd Rs |
Rd = Rd | Rs |
0x11 |
2 | XOR Rd Rs |
Rd = Rd ^ Rs |
0x12 |
2 | NOT Rd |
Rd = ~Rd |
0x13 |
2 | CMP Rd Rs |
Set Z/N/C from Rd - Rs (no store; C=borrow if Rd < Rs) |
0x14 |
2 | SHL Rd |
Rd = Rd << 1 (C = old bit 7) |
0x15 |
2 | SHR Rd |
Rd = Rd >> 1 (C = old bit 0) |
0x16 |
2 | JMP addr |
Unconditional jump |
0x17 |
2 | JZ addr |
Jump if Zero flag set |
0x18 |
2 | JNZ addr |
Jump if Zero flag clear |
0x19 |
2 | PRINT Rd |
Print Rd as ASCII character |
0x1A |
2 | MUL Rd Rs |
Rd = Rd * Rs |
0x1B |
3 | MULI Rd val |
Rd = Rd * val |
0x1C |
2 | DIV Rd Rs |
Rd = Rd / Rs |
0x1D |
3 | DIVI Rd val |
Rd = Rd / val |
0x1E |
2 | MOD Rd Rs |
Rd = Rd mod Rs |
0x1F |
3 | MODI Rd val |
Rd = Rd mod val |
0x20 |
2 | CALL addr |
Call subroutine (push return address) |
0x21 |
1 | RET |
Return from subroutine |
0x22 |
2 | ROL Rd |
Rotate Rd left by 1 (C = old bit 7) |
0x23 |
2 | ROR Rd |
Rotate Rd right by 1 (C = old bit 0) |
0x24 |
2 | IN Rd |
Read byte from stdin into Rd |
0x25 |
2 | JMPR Rd |
Jump to address in Rd |
0x26 |
2 | CALLR Rd |
Call subroutine at address in Rd |
0x27 |
2 | STR Rd Rs |
Store Rd to address held in Rs |
Tip
All arithmetic is wrapping. R0 = 0x00, R1 = 0x01, ..., R15 = 0x0F.
Opcodes 0x28โ0xFF are unassigned. Executing one raises EmulatorError::InvalidOpcode, reporting the byte and the PC โ so running off the end of your code into a .DB block or jumping to a bad address fails loudly instead of drifting through it.
The rule: an instruction that writes a register sets Z and N; one that writes memory or the stack sets nothing. CMP is the only exception โ it sets flags without writing a register, which is its entire job.
Z is set when the result is 0x00, N from bit 7 of the result. C is written only where marked โ every other instruction leaves the previous C intact.
| Instruction | Z | C | N | Notes |
|---|---|---|---|---|
LDI, LDA, LDR, MOV, IN, POP |
โ | โ | โ | From the loaded / popped value |
STORE, STR, PUSH |
โ | โ | โ | Write memory โ no register result to flag |
INC, DEC |
โ | โ | โ | Wraps silently โ no C on 0xFF โ 0x00 |
ADD, ADDI, SUB, SUBI |
โ | โ | โ | C = carry-out (ADD) / borrow (SUB) |
MUL, MULI |
โ | โ | โ | C set when the product exceeds 0xFF |
DIV, DIVI, MOD, MODI |
โ | โ | โ | C always cleared โ 8-bit division cannot overflow |
AND, OR, XOR, NOT |
โ | โ | โ | |
CMP |
โ | โ | โ | From Rd - Rs; C = borrow. No register written |
SHL, ROL |
โ | โ | โ | C = old bit 7 |
SHR, ROR |
โ | โ | โ | C = old bit 0 |
JMP, JMPR, JZ, JNZ |
โ | โ | โ | JZ / JNZ read Z, never write it |
CALL, CALLR, RET |
โ | โ | โ | |
PRINT |
โ | โ | โ | |
HLT, NOP |
โ | โ | โ |
Note
Loads set Z/N, so a MOV or LDI between a CMP and a JZ clobbers the comparison. The flip side is useful: since there is no TST, MOV Rd Rs doubles as a test-for-zero on Rs.
Tip
PUSH and STORE preserve flags, so a subroutine prologue can save registers without destroying the caller's comparison. POP does not โ it loads a register.
NanoCore Assembly (.nca) files are plain text. The assembler performs two passes โ first to map labels and constants, then to emit bytecode.
; Comment
.CONST MAX 10 ; Named constant
.DB 0x01 0x02 0x03 ; Embed raw bytes
.STRING "Hello" ; Embed ASCII string (null-terminated)
start: ; Label
LDI R0 0
LDI R1 MAX ; Use constant
loop:
ADD R0 R1
DEC R2
JNZ loop
HLT| Directive | Description |
|---|---|
.CONST name val |
Define a named constant |
.DB byte ... |
Embed raw bytes at current position |
.STRING "text" |
Embed a null-terminated ASCII string |
| File | Description |
|---|---|
src/cpu.rs |
CPU state โ registers, PC, SP, memory, flags |
src/nanocore.rs |
Main emulator โ load, run, cycle, fetch/decode/execute |
src/assembler.rs |
Two-pass assembler core |
src/lib.rs |
Library exports and Op enum (instruction set) |
src/error.rs |
Typed error definitions |
src/bin/nca.rs |
nca assembler binary |
src/bin/tui.rs |
tui debugger binary entry point |
src/tui/ |
TUI implementation (ratatui) |
programs/ |
Example .nca source files and .ncb binaries |
; Print the fibonacci sequence (two-digit)
start:
LDI R0 0
LDI R1 1
LDI R2 12
LDI R12 32
loop:
JMP print_digits
post_print:
MOV R3 R1
ADD R1 R0
MOV R0 R3
DEC R2
JNZ loop
end:
HLT
print_digits:
PUSH R10
PUSH R11
MOV R10 R0
DIVI R10 10
JZ unit_digit
ADDI R10 48
PRINT R10
unit_digit:
MOV R11 R0
MODI R11 10
ADDI R11 48
PRINT R11
print_space:
PRINT R12
POP R11
POP R10
JMP post_printAll contributions are welcome. Please create an issue first for any feature request or bug. Then fork the repository, create a branch, make your changes, and open a pull request.
NanoCore is released under the MIT License. See LICENSE for details.
