Creating Custom Architectures
CREATOR supports defining custom architectures through YAML configuration files. This allows adding new instruction sets or modifying existing ones.
Creating an Architecture File
An architecture file is a YAML file that describes the architecture's properties, including its instruction set, registers, memory layout, and other relevant details. Instructions are defined with their binary encoding, assembly syntax, and semantics.
[!NOTE] We provide a JSON schema for the architecure file at https://creatorsim-community.github.io/creator-beta/schema/architecture.json.
The actual definition for an instruction is simple javascript code to manipulate the simulator state. Within this code, you also have a registers local value to access the registers (e.g. registers.PC, or registers[value]), as well as CAPI, which allows you to interact with the simulator.
[!IMPORTANT] The values stored in the registers are BigInt. Take that into account when reading or writing values:
const foo = registers.PC; // 420n registers.PC = foo + 1n; // 421n
Let's define a simple 8-bit architecture with a few instructions. The first step is to create a YAML file, e.g., simplearch.yml, and fill out the config. We want an architecture where the word size and byte size are both 8 bits. We'll also make it little-endian, although it doesn't matter in this specific case because a word contains only one byte. pc_offset will be 0. The entry point will be a function named main, or address 0x0 if it doesn't exist; we'll use the ; character to write comments, and the names of the registers won't be sensitive (PC == pc). We'll also enable memory alignment and passing convention checks.
[!IMPORTANT] The value of the program counter register (
program_counter) inside the instruction definitions is affected by thepc_offset.
pc_offsetis the offset that we'll add to the value of program counter the instruction "sees".E.g. if
pc_offsetis-4, while executing an instruction at0x0, the "real" PC is0x4(because of the fetch performed at the start of the cycle), andregisters.pc(the "virtual" PC) will be0x0.
version: 2.0.0
config:
name: Simple8Bit
description: A simple custom 8-bit architecture
word_size: 8
byte_size: 8
endianness: little_endian
pc_offset: 0
main_function: main
start_address: 0x0
comment_prefix: ;
sensitive_register_name: false
memory_alignment: true
passing_convention: true
For the registers, we'll make an control register bank with a PC program counter register (we'll mark that with the program_counter property), and another integer register bank with an A and B register, as well as an SP stack pointer register (stack_pointer property).
[!NOTE] A floating point bank would be defined as:
# ... - name: Floating point registers type: fp_registers double_precision: true # or `false`, if sinlge-precision
All of these registers will be 8 bits, be initialized (value) and have a default value (default_value) of 0, and will be both readable (read property) and writable (write property).
[!NOTE] The
encodingproperty will be used when decoding binary instructions, in this case we'll just make it sequential.
nameis a list because a register can have multiple values, e.g. in RISC-V registerzerocan be also calledx0, and so on. These names must be all unique.
components:
- name: Control registers
type: ctrl_registers
double_precision: false
elements:
- name:
- PC
nbits: 8
encoding: 0
value: 0
default_value: 0
properties:
- read
- write
- program_counter
- name: Integer registers
type: int_registers
double_precision: false
elements:
- name:
- A
encoding: 0
nbits: 8
value: 0
default_value: 0
properties:
- read
- write
- name:
- B
encoding: 1
nbits: 8
value: 0
default_value: 0
properties:
- read
- write
- name:
- SP
encoding: 2
nbits: 8
value: 0
default_value: 0
properties:
- read
- write
- stack_pointer
For the memory layout, we'll use a simple .text, .data, .stack layout:
memory_layout:
text:
start: 0x0000
end: 0x03FF
data:
start: 0x0400
end: 0x7FFF
stack:
start: 0x8000
end: 0xFFFF
Now it's time for the instructions. We want an architecture with two instructions: NOP and ADD in the base extension. The NOP instruction does nothing, while the ADD instruction adds the values of two registers and stores the result in a destination register.
As the instructions will have the same form, we can define an instruction template called standard defining that our instructions will be 1 word long (nwords), take 1 clock cycle (clk_cycles) and use the full word as an operation code (type co) field. We then override in each instruction the value of that field.
templates:
- name: standard
nwords: 1
clk_cycles: 1
fields:
- name: opcode
type: co
startbit: 7
stopbit: 0
order: 0
instructions:
base:
- name: nop
template: standard
fields:
- field: opcode
value: "0x00"
definition: ""
- name: add
template: standard
fields:
- field: opcode
value: "0x80"
definition: |
const oldValueA = registers.A;
registers.A = (oldValueA + registers.B) & 0xFFn;
registers.F = CAPI.ARCH.calculateFlags_ADD(oldValueA, registers.B);
directives:
- name: .data
action: data_segment
size: null
- name: .text
action: code_segment
size: null
- name: .bss
action: global_symbol
size: null
- name: .zero
action: space
size: 1
- name: .space
action: space
size: 1
- name: .align
action: align
size: null
- name: .balign
action: balign
size: null
- name: .globl
action: global_symbol
size: null
- name: .string
action: ascii_null_end
size: null
- name: .asciz
action: ascii_null_end
size: null
- name: .ascii
action: ascii_not_null_end
size: null
- name: .byte
action: byte
size: 1
- name: .half
action: half_word
size: 2
- name: .word
action: word
size: 4
- name: .dword
action: double_word
size: 8
- name: .float
action: float
size: 4
- name: .double
action: double
size: 8
Plugins
Interrupt Support
Now, let's take our architecture and add support for some simple maskable and nonmaskable interrupts.
Custom handler
We'll define two new 1-bit integer registers MIP (Maskable Interrupt Pending) and NIP (Nonmaskable Interrupt Pending) that will be set to 1 when an interrupt of the type is pending. We'll also define another 1-bit integer register IE to enable (value of 1) and disable (value of 0) maskable interrupts.
We just need to add them to simplearch.yml:
components:
# ...
- name: Integer registers
# ...
elements:
# ...
- name:
- MIP
encoding: 2
nbits: 1
value: 0
default_value: 0
properties:
- read
- write
- name:
- NIP
encoding: 3
nbits: 1
value: 0
default_value: 0
properties:
- read
- write
- name:
- IE
encoding: 4
nbits: 1
value: 1
default_value: 1
properties:
- read
- write
Now we have to define some functions to determine how interrupts work in this architecture.
First, how to determine if an interrupt happened. CREATOR has some predefined types of interrupts that you can use, but here we'll use InterruptType.Maskable and InterruptType.Nonmaskable. We have to write a function that returns the type of interrupt (InterruptType), or null if there is no interrupt:
[!NOTE] You don't have to check if interrupts are enabled here, we'll define that later.
interrupts:
check: |
if (registers.NIP) return InterruptType.Nonmaskable;
if (registers.MIP) return InterruptType.Maskable;
return null;
Then, we must define how different types of interrupts can be created and cleared. We'll receive the desired type (InterruptType) inside the type variable:
interrupts:
# ...
create: |
switch (type) {
case InterruptType.Maskable:
registers.MIP = 1n;
break;
case InterruptType.Nonmaskable:
registers.NIP = 1n;
break;
}
clear: |
switch (type) {
case InterruptType.Maskable:
registers.MIP = 0n;
break;
case InterruptType.Nonmaskable:
registers.NIP = 0n;
break;
}
global_clear: |
registers.MIP = 0n;
registers.NIP = 0n;
[!NOTE]
clearis optional, it gets overriten byglobal_clearif it's not defined
Next, how they can be enabled and disabled, per type (and globally), as well as how to check if they are enabled. For the sake of simplicity, we'll assume nonmaskable interrupts can't be disabled.
# ...
interrupts:
# ...
is_enabled: |
switch (type) {
case InterruptType.Maskable:
return registers.MIE === 1n;
case InterruptType.Nonmaskable:
// nonmaskable are always enabled
return true;
}
return false;
is_global_enabled: |
return true;
enable: |
switch (type) {
case InterruptType.Maskable:
return registers.MIE = 1n;
break;
// we don't need to do anything for nonmaskable
}
disable: |
switch (type) {
case InterruptType.Maskable:
registers.IE = 0n;
break;
// can't disable nonmaskable
}
global_enable: |
registers.IE = 1n;
global_disable: |
registers.IE = 0n;
[!NOTE]
enableanddisableare optional, they gets overriten byglobal_counterparts if it's not defined.is_global_enabledis optional, and defaults toreturn true.
Finally, we define the custom interrupt handler. This handler will disable interrupts, store PC in the stack, and jump to 0x0. To disable interrupts, we do it "manually" by clearing IE, but we can also use CAPI to reuse the code we already defined.
[!NOTE] Using these CAPI functions is recommended way of doing it, as it allows the application to (secretly) keep track of these interrupts.
interrupts:
handlers:
custom: |
// disable interrupt
CAPI.INTERRUPTS.disable(type);
// store PC in stack
registers.SP = (registers.SP - 1n) & 0xFFn;
CAPI.MEM.write(registers.SP, 1, registers.PC);
// jump to handler
registers.PC = 0n;
Many architectures have a specific instruction to return from an interrupt, so let's make one, reti. This instruction will clear and enable interrupts and jump back to the address stored in the stack:
instructions:
base:
# ...
- name: reti
template: standard
fields:
- field: opcode
value: "0x01"
definition: |
// enable interrupts
CAPI.INTERRUPTS.globalEnable();
// pop return address from stack
registers.PC = CAPI.MEM.read(registers.SP, 1);
registers.SP = (registers.SP + 1n) & 0xFFn;
// notify UI that handler has finished
CAPI.INTERRUPTS.clearHighlight();
CREATOR handler
As we mentioned in Interrupt Handling, CREATOR has two different interrupt handlers: the default "CREATOR" one, and a custom architecture-defined one. We also mentioned that the default handler is able to handle "architecture-defined system calls". Let's see a more concrete example, by implemening them in our architecture.
[!TIP] Why would we want this? Because we want to have our cake and eat it too.
Before interrupts were added to CREATOR, the definition of RISC-V's
ecallfunction didn't create an interrupt, it just executed the desired system call depending on the value of registera7. But we wanted to have "real" interrupts and a "real"ecall. The problem is that this required an interrupt handler, and we didn't want to force our users to use it, we didn't want to silently include a kernel, and we didn't want to have two architectures: one with interrupts and one without.So the solution (compromise) we found was this one, a second (default) interrupt handler that can be programmed in JS.
These system calls will generate a new type of interrupt (InterruptType.EnvironmentCall), so let's quickly modify the architecture to take them into account. We'll also add a new EIP register to signal that that type of interrupt is pending.
components:
# ...
- name: Integer registers
# ...
elements:
# ...
- name:
- EIP
encoding: 2
nbits: 1
value: 0
default_value: 0
properties:
- read
- write
# ...
interrupts:
check: |
if (registers.NIP) return InterruptType.Nonmaskable;
if (registers.MIP) return InterruptType.Maskable;
if (registers.EIP) return InterruptType.EnvironmentCall;
return null;
is_enabled: |
switch (type) {
case InterruptType.Maskable:
return registers.MIE === 1n;
case InterruptType.EnvironmentCall:
return registers.EIE === 1n;
case InterruptType.Nonmaskable:
// nonmaskable are always enabled
return true;
}
return false;
enable: |
switch (type) {
case InterruptType.Maskable:
// we don't need to do anything for nonmaskable
break;
default:
registers.IE = 1n;
break;
}
disable: |
switch (type) {
case InterruptType.Maskable:
// can't disable nonmaskable
break;
default:
registers.IE = 0n;
break;
}
create: |
switch (type) {
case InterruptType.Maskable:
registers.MIP = 1n;
break;
case InterruptType.Nonmaskable:
registers.NIP = 1n;
break;
case InterruptType.EnvironmentCall:
registers.EIP = 1n;
break;
}
clear: |
switch (type) {
case InterruptType.Maskable:
registers.MIP = 0n;
break;
case InterruptType.Nonmaskable:
registers.NIP = 0n;
break;
}
global_clear: |
registers.MIP = 0n;
registers.NIP = 0n;
registers.EIP = 0n;
# ...
Now we can define our syscall instruction:
# ...
instructions:
base:
# ...
- name: syscall
template: standard
fields:
- field: opcode
value: "0x02"
definition: CAPI.INTERRUPTS.create(InterruptType.EnvironmentCall);
The convention will be that the type of system call we want to use will be stored in register A, while register B will hold extra information. For example, a system call to print a number will print whatever B is holding.
To allow CREATOR's handler to handle them, as system calls depend on each architecture, we must define that in the architecture definition:
# ...
interrupts:
handlers:
# ...
creator_syscall: |
switch (registers.A) {
case 1n:
CAPI.SYSCALL.print(registers.B, 'int32');
break;
}
// notify UI that handler has finished
CAPI.INTERRUPTS.clearHighlight();
Privileged instructions
CREATOR also supports having privileged instuctions that can only be executed in kernel mode, by adding the privileged property. This is the reason for having system calls in the first place, we allow the user to ask doing things that require a higher privilege (e.g. accessing I/O) without giving them that privilege itself.
Let's say that in our architecture, an interrupt always triggers an execution mode change, for that we should modify the custom handler to set kernel mode (CAPI.INTERRUPTS.setKernelMode();) and the reti instruction to go back to user mode (CAPI.INTERRUPTS.setUserMode();). As reti should only be used while dealing with interrupts, we'll make it a privileged instruction.
instructions:
base:
# ...
- name: reti
# ...
properties:
- privileged
definition: |
// enable interrupts
CAPI.INTERRUPTS.globalEnable();
// pop return address from stack
registers.PC = CAPI.MEM.read(registers.SP, 1);
registers.SP = (registers.SP + 1n) & 0xFFn;
// notify UI that handler has finished
CAPI.INTERRUPTS.clearHighlight();
// set user mode
CAPI.INTERRUPTS.setUserMode();
# ...
interrupts:
handlers:
custom: |
// disable interrupt
CAPI.INTERRUPTS.disable(type);
// set kernel mode
CAPI.INTERRUPTS.setKernelMode();
// store PC in stack
registers.SP = (registers.SP - 1n) & 0xFFn;
CAPI.MEM.write(registers.SP, 1, registers.PC);
// jump to handler
registers.PC = 0n;