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.github.io/creator/schema/architecture.json.
The actual definition for an instruction is a simple JavaScript code block to manipulate the simulator state. Within this block, you have the registers variable to access the registers (e.g. registers.PC, or registers[value]), as well as CAPI, an API that 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 case-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, and we're executing an instruction at0x0, the "real" PC is0x4(because of the fetch performed at the start of the cycle), but the value ofregisters.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 a control register bank with a PC program counter register (we'll mark that with the program_counter property), and another integer register bank with a A and B register, as well as a SP stack pointer register (stack_pointer property).
[!NOTE] A floating point bank would be defined as:
# ... - name: Floating point registers type: float double_precision: true # or `false`, if single-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 all be unique.
register_files:
- name: Control registers
type: ctrl
double_precision: false
registers:
- name:
- PC
nbits: 8
encoding: 0
value: 0
default_value: 0
properties:
- read
- write
- program_counter
- name: Integer registers
type: int
double_precision: false
registers:
- 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);
You'll also need to add some directives in order to mark things like the data and text segments, or defining data types on the data segment. Here you have a complete example:
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
Pseudoinstructions
Pseudoinstructions are special instructions recognized by the assembler that get automatically expanded to a sequence of instructions recognized by the simulator during execution. Their definition in the architecture is very similar to real instructions:
pseudoinstructions:
base:
- name: mv
fields:
- field: reg1
type: INT-Reg
suffix: ","
- field: reg2
type: INT-Reg
definition: |
addi reg1, reg2, 0;
help: Copy the value in register rs2 into register rd.
The main difference with real instructions is their definition field. Instead of being used during execution of the program to manipulate the simulator state, it is used to define how the instruction should be transformed into a sequence of instructions. The definition can take one of two forms: a instruction sequence template, or a JavaScript function body. We'll describe each form below.
[!NOTE] While not recommended, other pseudoinstructions can be used in the definition of a pseudoinstruction. The pseudoinstructions are expanded recursively until all instructions are real.
Instruction Sequence Form
This is the simplest method to use, although it's still enough for most cases. This method allows transforming the pseudoinstruction into a single, fixed instruction sequence. In this case, the definition field should be a single string containing the resulting assembly instructions, separated by newlines or semicolons. The example above uses this method.
In order to forward the arguments of the pseudoinstruction to the resulting instructions, the name of the corresponding fields can be used as placeholders for the value. This works even in sub-expressions, allowing arithmetic manipulation of the arguments during the transformation. Modifiers can be very useful for doing bit manipulation (e.g extracting a range of bits out of an immediate value) during the expansion of a pseudoinstructions. For PC-relative address calculations, the . symbol always evaluates to the address in which the surrounding instruction will be loaded into. Using this, for example, the la RISC-V pseudoinstruction can be defined as follows:
definition: |
auipc rd, %hi(addr - .);
addi rd, rd, %lo(addr - (. - 4));
[!NOTE] This form allows the use of forward references by the user. For this reason, this form should be preferred when possible.
JavaScript Function Form
This method allows implementing complex transformations of the pseudoinstruction. It's mostly used when a pseudoinstruction can conditionally expand into multiple different instruction sequences, or when complex processing of its arguments is needed. In this case, the definition field should be the body of a JavaScript function prefixed by js:\n. The signature of this function should be:
(pc: bigint, args: Array<number | bigint | string | null>) => string
Where:
pc: address in which the pseudoinstruction is being assembled into.args: array of evaluated pseudoinstruction arguments in the order they appear in the assembly syntax, where:numberis used for expressions that evaluate to a float.bigintis used for expressions that evaluate to an integer.stringis used for single identifiers (typically register names).nullis used for other expressions that can't be evaluated (expressions containing undefined labels/forward references, or other errors like division by 0).
- The returned string must be the definition in instruction sequence form, as described in the previous section.
- The function is allowed to throw any
JSvalue that can be converted to a string, which will be displayed in error messages
[!NOTE] In this form, it's up to the definition whether to throw an error on
nullarguments or simply forward the expression using its field name in the returned string. When possible, the later should be preferred as it allows forward references and provides better errors for the user when the expression contains errors.
Example usage of this method:
pseudoinstructions:
base:
- name: li
fields:
- field: rd
type: INT-Reg
suffix: ","
- field: val
type: imm-signed
definition: |
js:
const val = args[1];
// If the value is small (1 byte signed), use a single instruction
if (val !== null && val >= -128 && val <= 127) return "addi rd, x0, val";
// Otherwise, use multiple instructions
return `
lui rd, val >> 8;
ori rd, rd, val & 0xFF;
`
help: Load the immediate, imm, into register rd.
Modifiers
Modifiers are a set of predefined operators which allow to easily perform bit manipulation on expressions. They allow easily taking a slice of bits from their input. In the assembly code, modifiers can be used in expressions as %<name>(<expression>) (e.g. lui rd, %hi(0xDEADBEEF)). An example definition is:
modifiers:
hi:
lower_signed: true
output_signed: false
range: [12, 32]
lo:
lower_signed: false
output_signed: true
range: [0, 12]
Where:
rangedefines the slice of bits to take, using a right-exclusive range.lower_signeddefines whether thestart - 1bit should be added to the result. This is mostly intended for cases where the modifiers are used to split a constant into multiple different parts for loading, when the lower bits will be added as a signed integer to the loaded upper bits.output_signeddefines whether resulting bit string should be interpreted as a signed or unsigned integer.
Plugins
Plugins are extra architecture-specific CAPI modules that are loaded with the architecture (CAPI.ARCH). These add extra custom functionalities that can be more easily achieved by being inside the engine.
config:
# ...
plugin: riscv
[!NOTE] For more information about the API and available functionalities, see CAPI Reference.
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:
register_files:
# ...
- name: Integer registers
# ...
registers:
# ...
- 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, we need to define how to determine if an interrupt happened. CREATOR has some predefined types of interrupts, and 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.
register_files:
# ...
- name: Control registers
# ...
registers:
# ...
- name:
- EIP
encoding: 1
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 instructions 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. To achieve that, we should modify the custom handler so that it sets kernel mode (CAPI.INTERRUPTS.setKernelMode();) and modify the reti instruction so that it goes 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;
Timers
Another way of generating interrupts is through the use of a timer. CREATOR, nevertheless, allows a custom timer handler, which gives many more possibilities.
The functioning of timers in CREATOR if that, each tick (a tick is defined as a number of clock cycles in tick_cycles), if the timer is enabled (is_enabled), it calls the handler (handler) and advances the timer value (advance). The handler is supposed to compare the timer value and perform whatever action.
As an example, we'll create a new TIME control register to keep time, and a user-configurable TIMECMP register to compare that timer to, allowing the user to create their own timers. We'll also add a register to enable timers:
register_files:
- name: Control registers
# ...
registers:
# ...
- name:
- TIME
nbits: 8
encoding: 2
value: 0
default_value: 0
properties:
- read
# ...
- name: Integer registers
# ...
registers:
# ...
- name:
- TIMECMP
encoding: 5
nbits: 8
value: 0
default_value: 0
properties:
- read
- write
- name:
- TE
encoding: 6
nbits: 1
value: 1
default_value: 1
properties:
- read
- write
Our timer will advance once per cycle, compare both TIME and TIMECMP and fire up a Timer interrupt. It will advance continuously, and overflow going back to 0. For the enabling/disabling we use the new TE register.
timer:
tick_cycles: 1 # one tick per cycle
advance: |
registers.TIME = (registers.TIME + 1n) % (2n**8n - 1n); // to prevent overflow
handler: |
if (registers.TIME === registers.TIMECMP) {
CAPI.INTERRUPTS.create(InterruptType.Timer);
registers.TIME = 0; // you might want to reset here
}
is_enabled: |
return !!registers.TE; // has to return bool
enable: |
registers.TE = 1;
disable: |
registers.TE = 0;
[!NOTE] We're generating a new type of interrupt (
InterruptType.Timer), but we're not handling it in the interrupt handler.This is left as an exercise for the reader.
Devices
Devices are memory-mapped I/O that the user can interact with (through memory). CREATOR exposes a set of devices with their own functionalities, and they are configurable in the architecture.
Each device has a string ID (id), a class - or type of device (cls), and the addresses of their control (ctrl_addr) and status registers (status_addr) and data segment (data). You can also enable or disable them (enable - by default, they are enabled)
Let's set up a console device at the end of the data segment. It will overlap, but the device has priority:
devices:
- id: console
cls: ConsoleDevice
ctrl_addr: 0x7FF0
status_addr: 0x7FF4
data:
start: 0x7FF8
end: 0x7FFF
# enabled: true
[!NOTE] Now the user can talk to the device by reading and writing to the end of the
datasegment. But the current architecture does not expose any instructions to do that.This is left as an exercise for the reader.
You can find more information about the implemented devices in Devices.