In order to implement a new CPU architecture in EnthusiASM, follow these steps:
This document goes through a very basic example of implementing a new architecture called “SimpleCPU”.
This step involves defining the registers available in the architecture and implementing the Registers trait from
Enthusiasm.
You should define a struct that holds the register values and implement the required methods.
All the information about this architecture’s registers should be encapsulated in this struct.
Registers ids should be addressable by a simple integer type (e.g., u8, u16, etc.).
use enthusiasm_core::prelude::{EnthusiasmResult, RegisterInfo, RegisterKind, Registers};
pub const R0: u8 = 0;
pub const R1: u8 = 1;
pub const PC: u8 = 32;
#[derive(Debug, Default)]
pub struct SimpleRegisters {
r0: u32,
r1: u32,
pc: u32,
}
impl Registers for SimpleRegisters {
type Address = u32;
type Word = u32;
type RegisterId = u8;
fn registers(&self) -> &[Self::RegisterId] {
&[R0, R1, PC]
}
fn info(&self, id: Self::RegisterId) -> RegisterInfo {
match id {
R0 => RegisterInfo {
name: "R0",
description: "Register 0",
read_only: false,
kind: RegisterKind::GeneralPurpose,
bits: 32,
},
R1 => RegisterInfo {
name: "R1",
description: "Register 1",
read_only: false,
kind: RegisterKind::GeneralPurpose,
bits: 32,
},
PC => RegisterInfo {
name: "PC",
description: "Program Counter",
read_only: false,
kind: RegisterKind::ProgramCounter,
bits: 32,
},
_ => panic!("Invalid register ID"),
}
}
fn read(&self, id: Self::RegisterId) -> Self::Word {
match id {
R0 => self.r0,
R1 => self.r1,
PC => self.pc,
_ => 0,
}
}
fn write(&mut self, id: Self::RegisterId, value: Self::Word) {
match id {
R0 => self.r0 = value,
R1 => self.r1 = value,
PC => self.pc = value,
_ => {}
}
}
fn set(&mut self, id: Self::RegisterId, value: Self::Word) -> EnthusiasmResult<()> {
Ok(self.write(id, value))
}
fn set_program_counter(&mut self, address: Self::Address) {
self.pc = address;
}
fn program_counter(&self) -> Self::Address {
self.pc
}
}
This step involves defining the instructions that the architecture supports, along with their encoding and decoding logic.
#[derive(Clone, Copy, Debug)]
pub enum SimpleOp {
Add = 0x01,
AddImmediate = 0x02,
}
impl From<SimpleOp> for u8 {
fn from(op: SimpleOp) -> Self {
op as u8
}
}
pub enum SimpleInstruction {
Add { dest: u8, src1: u8, src2: u8 },
AddImmediate { dest: u8, src: u8, immediate: i16 },
}
impl From<[u8; 4]> for SimpleInstruction {
fn from(bytes: [u8; 4]) -> Self {
let opcode = bytes[0];
match opcode {
0x01 => {
let dest = bytes[1];
let src1 = bytes[2];
let src2 = bytes[3];
SimpleInstruction::Add { dest, src1, src2 }
}
0x02 => {
let dest = bytes[1];
let src = bytes[2];
let immediate = i16::from_le_bytes([bytes[3], 0]);
SimpleInstruction::AddImmediate {
dest,
src,
immediate,
}
}
_ => panic!("Unknown opcode: 0x{opcode:02x}"), // consider using a `TryFrom` here instead
}
}
}
impl SimpleInstruction {
pub fn encode(&self) -> [u8; 4] {
match self {
SimpleInstruction::Add { dest, src1, src2 } => [SimpleOp::Add as u8, *dest, *src1, *src2],
SimpleInstruction::AddImmediate {
dest,
src,
immediate,
} => {
let imm_bytes = immediate.to_le_bytes();
[SimpleOp::AddImmediate as u8, *dest, *src, imm_bytes[0]]
}
}
}
}
The memory model represents the architecture’s main memory (aka RAM). You need to implement the Memory trait from
EnthusiASM.
Since this is an emulator, you should use a customizable memory size, represented by a vector on the heap.
use enthusiasm_core::prelude::{EnthusiasmError, EnthusiasmResult, Memory, MemoryError};
type Address = u32;
#[derive(Debug, Clone)]
pub struct SimpleMemory {
data: [u8; 1024], // 1KB of memory
}
impl Default for SimpleMemory {
fn default() -> Self {
SimpleMemory { data: [0; 1024] }
}
}
impl SimpleMemory {
fn read_at(&self, address: Address) -> EnthusiasmResult<u8> {
if address >= self.size() as Address {
return Err(EnthusiasmError::Memory(MemoryError::OutOfBounds {
address: address.into(),
size: self.size(),
}));
}
Ok(self.data[address as usize])
}
fn write_at(&mut self, address: Address, value: u8) -> EnthusiasmResult<()> {
if address >= self.size() as Address {
return Err(EnthusiasmError::Memory(MemoryError::OutOfBounds {
address: address.into(),
size: self.size(),
}));
}
self.data[address as usize] = value;
Ok(())
}
fn check_alignment(&self, address: Address, alignment: u32) -> EnthusiasmResult<()> {
if address % alignment != 0 {
Err(EnthusiasmError::Memory(MemoryError::UnalignedAccess {
address: address.into(),
alignment: size_of::<Address>() as u64,
}))
} else {
Ok(())
}
}
}
impl Memory for SimpleMemory {
type Address = Address;
fn size(&self) -> u64 {
self.data.len() as u64
}
fn read8(&self, address: Self::Address) -> EnthusiasmResult<u8> {
self.read_at(address)
}
fn read16(&self, address: Self::Address) -> EnthusiasmResult<u16> {
self.check_alignment(address, 2)?;
let low = self.read_at(address)? as u16;
let high = self.read_at(address + 1)? as u16;
Ok((high << 8) | low)
}
fn read32(&self, address: Self::Address) -> EnthusiasmResult<u32> {
self.check_alignment(address, 4)?;
let b0 = self.read_at(address)? as u32;
let b1 = self.read_at(address + 1)? as u32;
let b2 = self.read_at(address + 2)? as u32;
let b3 = self.read_at(address + 3)? as u32;
Ok((b3 << 24) | (b2 << 16) | (b1 << 8) | b0)
}
fn read64(&self, _address: Self::Address) -> EnthusiasmResult<u64> {
Err(EnthusiasmError::Memory(MemoryError::UnsupportedAccess {
size: 8,
}))
}
fn write8(&mut self, address: Self::Address, value: u8) -> EnthusiasmResult<()> {
self.write_at(address, value)
}
fn write16(&mut self, address: Self::Address, value: u16) -> EnthusiasmResult<()> {
self.check_alignment(address, 2)?;
self.write_at(address, (value & 0xFF) as u8)?;
self.write_at(address + 1, (value >> 8) as u8)?;
Ok(())
}
fn write32(&mut self, address: Self::Address, value: u32) -> EnthusiasmResult<()> {
self.check_alignment(address, 4)?;
self.write_at(address, (value & 0xFF) as u8)?;
self.write_at(address + 1, ((value >> 8) & 0xFF) as u8)?;
self.write_at(address + 2, ((value >> 16) & 0xFF) as u8)?;
self.write_at(address + 3, ((value >> 24) & 0xFF) as u8)?;
Ok(())
}
fn write64(&mut self, _address: Self::Address, _value: u64) -> EnthusiasmResult<()> {
Err(EnthusiasmError::Memory(MemoryError::UnsupportedAccess {
size: 8,
}))
}
}
This is the most important part, because it defines how the CPU fetches, decodes, and executes instructions.
Of course, instead of doing the entire execution inside a single method, it’s better to break it down into smaller methods or modules for better organization and maintainability.
The logic implemented inside of the step module though, should always be:
use enthusiasm_core::prelude::{Cpu, EnthusiasmResult, Memory as _, Registers as _};
#[derive(Default, Debug)]
pub struct SimpleCpu {
memory: SimpleMemory,
registers: SimpleRegisters,
}
impl SimpleCpu {
#[inline]
fn fetch(&self) -> EnthusiasmResult<u32> {
self.memory.read32(self.registers.program_counter())
}
#[inline]
fn decode(&self, data: u32) -> SimpleInstruction {
SimpleInstruction::from(data.to_le_bytes())
}
fn execute(&mut self, mock_instruction: SimpleInstruction) -> EnthusiasmResult<()> {
match mock_instruction {
SimpleInstruction::Add { dest, src1, src2 } => {
let val1 = self.registers.read(src1);
let val2 = self.registers.read(src2);
let result = val1.wrapping_add(val2);
self.registers.write(dest, result);
let pc = self.registers.program_counter();
self.registers.set_program_counter(pc.wrapping_add(4));
Ok(())
}
SimpleInstruction::AddImmediate {
dest,
src,
immediate,
} => {
let val = self.registers.read(src);
let result = val.wrapping_add(immediate as u32);
self.registers.write(dest, result);
let pc = self.registers.program_counter();
self.registers.set_program_counter(pc.wrapping_add(4));
Ok(())
}
}
}
}
impl Cpu for SimpleCpu {
type Registers = SimpleRegisters;
type Memory = SimpleMemory;
fn reset(&mut self) {
self.registers = SimpleRegisters::default();
self.memory = SimpleMemory::default();
}
fn step(&mut self) -> EnthusiasmResult<()> {
let data = self.fetch()?;
let instruction = self.decode(data);
self.execute(instruction)
}
fn registers(&self) -> &Self::Registers {
&self.registers
}
fn registers_mut(&mut self) -> &mut Self::Registers {
&mut self.registers
}
fn memory(&self) -> &Self::Memory {
&self.memory
}
fn memory_mut(&mut self) -> &mut Self::Memory {
&mut self.memory
}
}
The parser is responsible for reading assembly lines and converting them into tokens that can be assembled.
The parser is responsible only for syntax analysis, so it should not perform any semantic checks (e.g., verifying if a register exists, checking whether the label is defined, etc.). Those checks should be performed by the assembler.
In this example I’ve added support only for two instructions: add and addi, with the following formats:
# sum two registers and store the result in a destination register
add dest, src1, src2
# add an immediate value to a register and store the result in a destination register
addi dest, src, immediate
use enthusiasm_core::prelude::{
EnthusiasmError, InstructionParser, ParseError, ParsedInstruction, ParsedLine,
};
#[derive(Debug, Clone, Copy)]
pub enum SimpleToken {
Op(SimpleOp),
Register(u8),
Immediate(i16),
}
pub struct SimpleParser;
impl SimpleParser {
fn parse_reg(&self, r: &str, line: usize, column: usize) -> EnthusiasmResult<u8> {
match r {
"r0" => Ok(R0),
"r1" => Ok(R1),
"pc" => Ok(PC),
_ => Err(EnthusiasmError::Parse(ParseError::UnexpectedToken {
line,
column,
token: r.to_string(),
})),
}
}
fn parse_add<'a>(
&self,
line: usize,
mut iter: impl Iterator<Item=&'a str>,
) -> EnthusiasmResult<ParsedLine<SimpleToken>> {
let mut col = "add".len() + 1;
let dest_str = iter
.next()
.ok_or_else(|| EnthusiasmError::Parse(ParseError::Eof { line, column: col }))?;
let dest = self.parse_reg(dest_str, line, col)?;
col += dest_str.len() + 1;
let src1_str = iter
.next()
.ok_or_else(|| EnthusiasmError::Parse(ParseError::Eof { line, column: col }))?;
let src1 = self.parse_reg(src1_str, line, col)?;
col += src1_str.len() + 1;
let src2_str = iter
.next()
.ok_or_else(|| EnthusiasmError::Parse(ParseError::Eof { line, column: col }))?;
let src2 = self.parse_reg(src2_str, line, col)?;
// must be empty
if let Some(t) = iter.next() {
return Err(EnthusiasmError::Parse(ParseError::UnexpectedToken {
line,
column: col,
token: t.to_string(),
}));
}
Ok(ParsedLine {
line,
tokens: vec![ParsedInstruction {
line,
content: format!("add {dest_str}, {src1_str}, {src2_str}"),
tokens: vec![
SimpleToken::Op(SimpleOp::Add),
SimpleToken::Register(dest),
SimpleToken::Register(src1),
SimpleToken::Register(src2),
],
}],
})
}
fn parse_addi<'a>(
&self,
line: usize,
mut iter: impl Iterator<Item=&'a str>,
) -> EnthusiasmResult<ParsedLine<SimpleToken>> {
let mut col = "addi".len() + 1;
let dest_str = iter
.next()
.ok_or_else(|| EnthusiasmError::Parse(ParseError::Eof { line, column: col }))?;
let dest = self.parse_reg(dest_str, line, col)?;
col += dest_str.len() + 1;
let src_str = iter
.next()
.ok_or_else(|| EnthusiasmError::Parse(ParseError::Eof { line, column: col }))?;
let src = self.parse_reg(src_str, line, col)?;
col += src_str.len() + 1;
let imm_str = iter
.next()
.ok_or_else(|| EnthusiasmError::Parse(ParseError::Eof { line, column: col }))?;
let immediate: i16 = imm_str.parse().map_err(|_| {
EnthusiasmError::Parse(ParseError::InvalidLiteral {
line,
column: col,
literal: imm_str.to_string(),
})
})?;
// must be empty
if let Some(t) = iter.next() {
return Err(EnthusiasmError::Parse(ParseError::UnexpectedToken {
line,
column: col,
token: t.to_string(),
}));
}
Ok(ParsedLine {
line,
tokens: vec![ParsedInstruction {
line,
content: format!("addi {dest_str}, {src_str}, {imm_str}"),
tokens: vec![
SimpleToken::Op(SimpleOp::AddImmediate),
SimpleToken::Register(dest),
SimpleToken::Register(src),
SimpleToken::Immediate(immediate),
],
}],
})
}
}
impl InstructionParser for SimpleParser {
type Token = SimpleToken;
fn parse(&self, line: &str, line_number: usize) -> EnthusiasmResult<ParsedLine<Self::Token>> {
let trimmed = line.trim();
if line.starts_with('#') || trimmed.is_empty() {
return Ok(ParsedLine {
line: line_number,
tokens: vec![],
});
}
// split
let (op, args) = match trimmed.find(' ') {
Some(idx) => (&trimmed[..idx], &trimmed[idx + 1..]),
None => (trimmed, ""),
};
let args = args.split(',').map(|s| s.trim());
match op {
"add" => self.parse_add(line_number, args),
"addi" => self.parse_addi(line_number, args),
_ => Err(EnthusiasmError::Parse(ParseError::UnexpectedToken {
line: line_number,
column: 0,
token: op.to_string(),
})),
}
}
}
The assembler is responsible for taking the parsed tokens and assembling them into machine code.
The output produced by the assembler should be a Program struct containing the assembled bytes and the entry point
address.
The assembler takes the token produced by the parser and encodes them into the corresponding machine code bytes. In this case, the semantic should be verified here (e.g., checking if the registers used in the instructions are valid).
use enthusiasm_core::prelude::{
Assembler, AssemblyError, EnthusiasmError, EnthusiasmResult, ParsedInstruction, Program,
};
pub struct SimpleAssembler;
impl SimpleAssembler {
fn encode_add(&self, line: usize, tokens: Vec<SimpleToken>) -> EnthusiasmResult<Vec<u8>> {
// expect tokens: [Register(dest), Register(src1), Register(src2)]
if tokens.len() != 3 {
return Err(EnthusiasmError::Assembly(AssemblyError::InvalidOperand {
operand: format!("{tokens:?}"),
line,
column: 0,
}));
}
match (&tokens[0], &tokens[1], &tokens[2]) {
(SimpleToken::Register(dest), SimpleToken::Register(src1), SimpleToken::Register(src2)) => {
let instruction = SimpleInstruction::Add {
dest: *dest,
src1: *src1,
src2: *src2,
};
Ok(instruction.encode().to_vec())
}
_ => Err(EnthusiasmError::Assembly(AssemblyError::InvalidOperand {
operand: format!("{tokens:?}"),
line,
column: 0,
})),
}
}
fn encode_addi(&self, line: usize, tokens: Vec<SimpleToken>) -> EnthusiasmResult<Vec<u8>> {
// expect tokens: [Register(dest), Register(src), Immediate(imm)]
if tokens.len() != 3 {
return Err(EnthusiasmError::Assembly(AssemblyError::InvalidOperand {
operand: format!("{tokens:?}"),
line,
column: 0,
}));
}
match (&tokens[0], &tokens[1], &tokens[2]) {
(SimpleToken::Register(dest), SimpleToken::Register(src), SimpleToken::Immediate(imm)) => {
let instruction = SimpleInstruction::AddImmediate {
dest: *dest,
src: *src,
immediate: *imm,
};
Ok(instruction.encode().to_vec())
}
_ => Err(EnthusiasmError::Assembly(AssemblyError::InvalidOperand {
operand: format!("{tokens:?}"),
line,
column: 0,
})),
}
}
}
impl Assembler for SimpleAssembler {
type Address = u32;
type Token = SimpleToken;
fn assemble(
&self,
tokens: Vec<ParsedInstruction<Self::Token>>,
) -> EnthusiasmResult<Program<Self::Address>> {
let mut prog = vec![];
for instruction_tokens in tokens {
let mut line_tokens = instruction_tokens.tokens;
let op = line_tokens.remove(0);
match op {
SimpleToken::Op(op) => match op {
SimpleOp::Add => {
// encode add instruction
let encoded = self.encode_add(instruction_tokens.line, line_tokens)?;
prog.extend_from_slice(&encoded);
}
SimpleOp::AddImmediate => {
// encode addi instruction
let encoded = self.encode_addi(instruction_tokens.line, line_tokens)?;
prog.extend_from_slice(&encoded);
}
},
_ => {
return Err(EnthusiasmError::Assembly(AssemblyError::InvalidOperand {
operand: format!("{op:?}", ),
line: instruction_tokens.line,
column: 0,
}));
}
}
}
Ok(Program {
bytes: prog,
entry_point: 0,
})
}
}
With all these components implemented, you now have a basic CPU architecture called “SimpleCPU” that can be used within EnthusiASM.